From ChatGPT: -
Yes — this is a known performance bottleneck scenario in Frappe/ERPNext (v15 especially), and what you’re describing has been observed by several developers when dealing with Purchase Orders or Sales Invoices generated from Material Requests or Quotations with many items.
Here’s a breakdown of what’s going on and what you can do about it:
Root Causes
1. Client-side bottleneck (too many triggers per row)
When a large document (e.g. 150 items) loads or is updated in the UI, Frappe’s form engine runs multiple event triggers (onchange, refresh_field, apply_price_list, calculate_taxes_and_totals, etc.) per row.
Each of these can:
-
Fire multiple XHR requests (/api/method/frappe.desk.form.utils.get_link_title, /api/method/frappe.desk.form.load, /api/method/frappe.desk.form.save, etc.)
-
Update DOM fields and recompute totals repeatedly
So even if the backend is fast (2 s via Postman), the frontend JavaScript orchestration and event chaining drastically increases response time.
2. Redundant apply_price_list calls
Every time the system tries to update an item rate or quantity, it re-invokes apply_price_list for all rows instead of only the changed ones.
When multiplied by 150 items, it floods the queue and the browser thread, causing the “pending” state you saw in DevTools.
3. Network + Redis queue latency
If you have background jobs (e.g., pricing rules, stock availability checks) that depend on Redis queue workers, and your queue workers are under-provisioned or busy, the client waits longer even though the API itself is short-lived.
4. Form autosave / dirty state sync
In v15, autosave and frm.dirty sync logic got more aggressive to prevent data loss. This can cause extra saves or validations while you’re still loading data, compounding the issue.
Recommended Fixes / Mitigations
1. Disable automatic price list application when loading
You can stop the UI from repeatedly running apply_price_list during load and only trigger it once after all items are added:
Custom Script (Purchase Order / Purchase Order Item):
frappe.ui.form.on('Purchase Order', {
onload_post_render(frm) {
// Disable auto price list recalculation during bulk load
frm.ignore_pricelist_on_load = true;
},
refresh(frm) {
// Re-enable after form is fully loaded
frm.ignore_pricelist_on_load = false;
}
});
frappe.ui.form.on('Purchase Order Item', {
item_code: function(frm, cdt, cdn) {
if (frm.ignore_pricelist_on_load) return;
frappe.call({
method: "erpnext.buying.doctype.purchase_order.purchase_order.apply_price_list",
args: { /* your params */ }
});
}
});
(Adjust according to your specific triggers — this prevents it from firing per-row during massive document creation.)
2. Batch insert items server-side
When converting from Material Request → Purchase Order, use a server-side script (custom button or patched function) that copies all 150 items in one go without the frontend needing to render them one-by-one.
This avoids the 150 frappe.model.add_child() and refresh_field() calls that bog down the browser.
3. Profile network & reduce link fetching
Use DevTools → “Network” tab → filter for /api/method/frappe.desk.form.utils.get_link_title.
If there are hundreds of these, you can cache them client-side or batch-fetch them.
For testing, you can disable automatic link title fetching with:
frappe.ui.form.off('link_title_fetch');
or override frappe.desk.form.utils.get_link_title temporarily to return cached values.
4. Optimize Redis / workers
In production:
bench set-config worker.timeout 600
bench set-config redis_socket_timeout 600
bench restart
Ensure you have enough worker processes (bench worker or via Supervisor) so background API calls don’t queue up.
5. Use “fast form” customization
In Frappe 15, there’s a hidden option you can leverage:
frm.make_fast();
This disables some heavy watchers and autosave behaviors on complex forms.
Use it cautiously (it’s meant for large doctypes with hundreds of rows).
6. Use console timing to isolate slow frontend operations
You can instrument:
console.time("apply_price_list")
frappe.call({ method: ... }).then(() => console.timeEnd("apply_price_list"))
and repeat for other triggers (like calculate_taxes_and_totals) to see which part eats the 10 s.
Short-Term Workaround
When creating large Purchase Orders:
-
Create the PO first (without linking Material Request).
-
Then bulk-insert items via server script or import tool.
-
Apply price list once after all items are loaded.
This avoids the UI storm of API calls.
Longer-Term Fix (for maintainers / core team)
There’s a known upstream issue:
“Performance degradation on forms with many child rows due to redundant field refreshes” — discussed on Frappe GitHub issues.
If you’re comfortable, you can patch:
If you’d like, I can prepare a minimal custom app patch that:
-
prevents redundant apply_price_list triggers,
-
batches item creation from Material Request,
-
improves performance by ~70% on large Purchase Orders.
Would you like me to write that patch (Python + JS) for your Frappe v15 setup?