Every ERP was fast on day one. Six months and forty custom fields later, the sales team is complaining that Sales Order list views take eight seconds to load, and someone has quietly started keeping a spreadsheet on the side "until it’s fixed." This is one of the most common support tickets we get, and it’s almost never one single cause — it’s three or four small ones stacking on top of each other.
This guide covers the diagnostic approach and the fixes, for both Odoo and ERPNext (Frappe), because the two systems share more performance failure modes than their different tech stacks would suggest: both are Python ORMs sitting on a relational database, both run behind a small pool of worker processes, and both get slow in the same three places — the database, the worker layer, and the customizations layered on top.
1. Diagnose before you tune
The instinct is to jump straight to "add more RAM" or "increase workers." Resist it. Adding resources to a system that’s slow because of a missing index just gives you a faster way to hit the same wall.
Find out where the time is actually going:
- Is it one page or everything? A single slow report points at a query or a script. Site-wide slowness points at infrastructure — workers, database connections, or disk I/O.
- Is it slow for one user or all users? One user with a huge personal dashboard or a saved filter with 50,000 rows is a different problem than everyone being slow at 9am on Monday.
- Odoo: run with
--log-level=debug_sql(or use the built-in Performance/Query Count in developer mode) to see query counts and duration per request. A view that fires 200+ queries is a customization problem, not an infrastructure one. - ERPNext/Frappe: use the built-in Recorder (
bench --site [site] set-config recorder 1, then Developer Settings → Recorder in the UI) to capture SQL query counts and durations per request. The MariaDB slow query log (slow_query_log = 1,long_query_time = 1) catches the rest.
If you only do one thing before reaching for infrastructure changes, do this: find the top five slowest queries or requests, and trace each one back to a specific DocType/model, report, or script.
2. The database layer
Both systems put almost all their weight on the database — Odoo on PostgreSQL, ERPNext/Frappe on MariaDB by default (Frappe also supports PostgreSQL from v14+). The database is disproportionately often the actual bottleneck.
Missing indexes on custom fields. This is the single most common cause of ERP slowdown we see. Every custom Link/Select field that gets used in a filter, a list view column, or a report needs an index — and custom fields don’t get one automatically just because the built-in ones do.
- Odoo: set
index=Trueon the field definition, or add it via SQL/migration for existing data. - Frappe: set
"in_list_view": 1alone does not index a field — you need"search_index": 1in the DocField, or add the index manually withbench --site [site] add-index.
Stale statistics and bloat. PostgreSQL needs regular VACUUM ANALYZE (usually handled by autovacuum, but check it’s actually running and not falling behind on high-write tables like stock_move or account_move_line). MariaDB tables benefit from periodic ANALYZE TABLE and OPTIMIZE TABLE on heavily-updated tables — the Frappe Version and Comment tables in particular grow enormous and rarely get cleaned up.
Connection exhaustion. Every Odoo worker and every Frappe gunicorn worker holds a database connection. On a modest database tier, it’s easy to run out of max_connections under load, which shows up as intermittent timeouts rather than uniform slowness. A connection pooler (PgBouncer for Odoo/PostgreSQL) is standard practice on any install past a handful of concurrent users.
Read-heavy reporting on the transactional database. A scheduled report that scans a year of Sales Invoice Item rows will lock resources that the sales team needs right now. If you have recurring heavy reports or BI dashboards, point them at a read replica rather than the primary.
3. The worker layer
Both Odoo and Frappe are WSGI applications behind a fixed pool of worker processes — this pool size is the single biggest infrastructure lever, and it’s usually wrong.
Odoo workers. The standard formula is workers = (CPU cores × 2) + 1, with roughly 20% of workers reserved for cron jobs (max_cron_threads). Each worker is a separate process with its own memory footprint (--limit-memory-soft / --limit-memory-hard protect against runaway requests), so undersized RAM per worker causes workers to get killed and restarted mid-request — which looks like random slowness or occasional 502s. Long-running requests (large imports, PDF generation) should go through a separate longpolling/queue mechanism rather than tying up a regular worker.
Frappe/ERPNext workers. bench runs a gunicorn pool for web requests plus separate RQ (Redis Queue) workers for background jobs, split into short, default, and long queues. A common failure pattern: someone schedules a heavy job (bulk email, large data import, PDF batch generation) onto the default queue, and it starves every other background job behind it. Route long jobs explicitly to the long queue, and make sure you’re running enough worker processes per queue for your background job volume — this is set in Procfile/supervisor.conf, not something that scales automatically.
Socket/realtime layer. Frappe’s realtime updates and Odoo’s longpolling/bus both depend on Redis and a dedicated process (node for Frappe’s socketio, the longpolling worker for Odoo). If this process falls over, the symptom users report is usually "the page feels frozen" rather than an obvious error.
4. Caching, correctly separated
Both systems use Redis for more than one purpose — typically cache, background job queue, and pub/sub for realtime — and a common misconfiguration is pointing all three at the same Redis instance/database number with no memory limit. A cache eviction under memory pressure can then take out queued background jobs. Use separate Redis databases (or separate instances) for cache vs. queue, and set a sane maxmemory + eviction policy on the cache instance specifically.
For ERPNext, confirm redis_cache, redis_queue, and redis_socketio are actually pointed at distinct database numbers in site_config.json — on a lot of self-managed installs they were left at the bench defaults, which is fine for a single-site dev setup and wrong for anything in production with real load.
5. The customization anti-patterns that don’t show up until scale
This is the category that catches teams off guard, because everything looks fine in testing with ten sample records and falls apart with fifty thousand.
- N+1 queries in server scripts / custom scripts. A loop that calls
frappe.get_doc()orself.env['res.partner'].browse()once per row instead of batching is invisible at 10 rows and catastrophic at 10,000. Always batch-fetch withfrappe.get_all()/search_read()before looping. - Client scripts that query the server on every keystroke or every field change, especially on List Views with many visible rows — each one is a round trip.
- Overly broad list view/report queries: pulling all columns of a wide DocType/model when only three are displayed, or fetching child table rows eagerly when they’re rarely opened.
- Permission and sharing rules that require row-by-row evaluation at scale — user permissions and record rules are powerful, but a rule with a subquery evaluated per row on a 100,000-row list will be exactly as slow as that sounds.
- Print formats and PDF generation running synchronously in the request cycle instead of as a background job — fine for one invoice, painful for a "print 200 invoices" batch action.
The fix for all of these is the same: profile first (Section 1), find the specific script or query, then fix it — not "throw a bigger server at it and hope."
6. Filestore and attachments
Both systems default to storing attachments on local disk. This is fine until it isn’t: local disk on the app server doesn’t scale horizontally, backups get slow, and on some cloud/network-attached storage setups, filestore I/O latency directly slows down every document that has an attachment preview. For anything beyond a single-server deployment, move attachments to object storage (S3-compatible) — both Odoo (via filestore add-ons) and Frappe (native S3 backup/attachment support) handle this natively.
7. Infrastructure sizing and horizontal scaling
Once the database is indexed correctly and customizations are profiled clean, infrastructure sizing is a much simpler conversation:
- Disk matters more than most people expect. Database performance on spinning disk or under-provisioned cloud IOPS tiers will bottleneck everything else you fix. SSD-backed storage with adequate IOPS is not optional for a production ERP database past a small team.
- Horizontal scaling requires shared state. Adding a second app server means the filestore and session/cache layer need to be shared (object storage + shared Redis), or users will see inconsistent behavior depending on which node they land on.
- Static assets belong behind a CDN or at minimum a reverse proxy with aggressive caching (nginx serving
/assetsdirectly rather than proxying every JS/CSS request through the app workers).
Diagnostic checklist
- Reproduce the slowness and identify: one page or all pages, one user or all users.
- Turn on query profiling (Odoo
debug_sql, Frappe Recorder) and find the top five slowest requests. - Check the MariaDB/PostgreSQL slow query log for the same window.
- For each slow query: is there a missing index on a custom field used in the
WHEREclause? - Check worker/queue health: are Odoo workers being OOM-killed, or is a Frappe background queue backed up?
- Check Redis: is cache competing with the job queue for memory?
- Check for N+1 patterns in any custom script involved in the slow path.
- Only after 1–7: consider adding CPU/RAM/workers.
Most ERP slowness is fixable without a hardware upgrade. The database and the customization layer cause the majority of real-world tickets — infrastructure sizing is usually the last five percent, not the first fix to reach for.
Sources:
- Odoo Deployment Documentation — Worker configuration
- Frappe Framework — Background Jobs
- Frappe Framework — Recorder
- PostgreSQL Documentation — Routine Vacuuming
Latest Posts
- Passkey Enrollment Is Your New Attack Surface: What Pass-the-Passkey Means for Your IdP August 22, 2026
- Your Attacker Might Already Be an AI Agent: What the OpenAI–Hugging Face Incident Means for Your SOC August 17, 2026
- Implementing OCPI 2.2.1: A Developer’s Guide to Locations, Sessions, and CDRs August 7, 2026
- OCPI Explained: What CPOs and eMSPs Actually Need to Build for EV Roaming August 7, 2026
- The Landlocked Sea Bass: Building an Automated Feeding System for Marine Fish Far From the Ocean July 31, 2026
- Your Shop Floor Speaks Five Dialects: Why OPC UA Doesn’t Solve Protocol Fragmentation July 30, 2026