How to Optimize Worker Usage in Odoo 19 for Performance
Performance optimization in Odoo 19 comes from properly configuring worker processes — not from blindly adding more memory. Workers are the separate operating-system processes that Odoo spawns to handle requests concurrently in multiprocessing mode (enabled whenever workers is greater than zero). This complete tutorial is both a beginner guide and a detailed step by step guide that explains exactly how to size your worker pool, set sane memory and time limits, keep scheduled jobs running with dedicated cron workers, and front everything with an Nginx reverse proxy. By the end you will know how to tune odoo.conf for your specific hardware, read the logs to confirm workers are healthy, and avoid the most common mistakes that quietly destroy throughput.
What You'll Learn:
- The Odoo workers formula (CPU cores × 2) + 1 and why the +1 exists
- How to build a complete
[options]block in odoo.conf for a real server - What each parameter does —
workers,max_cron_threads,limit_memory_hard,limit_memory_soft,limit_time_cpu,limit_time_real, andlimit_request - How to plan worker memory so total usage fits comfortably within available RAM
- Why cron workers matter and what breaks when you set
max_cron_threads = 0 - How to configure an Nginx reverse proxy so slow clients never tie up Odoo workers
- How to read Odoo 19 logs to tell a healthy worker pool from one being killed for memory
- The common worker-tuning mistakes and how to fix each one
Why Worker Configuration Drives Odoo 19 Performance
When workers = 0, Odoo runs in single-process (threaded) mode — fine for development, but unable to use more than one CPU core effectively under load. Setting workers to a value greater than zero switches Odoo into multiprocessing mode, where the master process forks several independent worker processes that each handle HTTP requests in parallel. This is what lets a multi-core server actually serve many concurrent users without one slow request blocking everyone else.
The goal of tuning is balance. Too few workers and requests queue up behind each other. Too many workers and the server spends its time context-switching between processes and runs out of RAM, which forces the kernel to swap memory to disk — the single fastest way to make a healthy server feel broken. Good worker tuning means matching the number of processes to your CPU cores, capping the memory each process may use, and recycling workers periodically so leaks never accumulate.
The Workers Formula
Odoo recommends (CPU cores × 2) + 1 HTTP workers as a starting point. The +1 accounts for one worker frequently waiting on I/O — disk, database, or network — so the other workers stay busy on the CPU. On a 4-core server that is 9 workers. Treat the number as a baseline to measure and tune from, not an absolute.
Memory Limits
Each worker gets a soft and a hard memory cap. When a worker crosses limit_memory_soft it finishes the current request and is recycled gracefully; if it ever crosses limit_memory_hard it is killed immediately. Together with limit_request, these caps stop a leaking worker from eating the whole server.
Cron Workers
Cron workers, controlled by max_cron_threads, run scheduled tasks — invoicing, email queueing, inventory moves, automated actions. One or two are usually enough. Setting the value to 0 disables every scheduled job, so emails and automations stop silently. Increase it only when you run heavy batch operations.
Nginx Reverse Proxy
An Nginx reverse proxy sits in front of Odoo and buffers slow client connections, so a worker is freed the instant it finishes generating a response instead of waiting for a slow browser to download it. Matching the proxy timeouts to Odoo's limit_time_real keeps long requests from being cut off mid-flight.
The Workers Formula Explained
The starting point for sizing your HTTP worker pool is a simple formula that Odoo has recommended for years:
workers = (CPU cores x 2) + 1
# Plus 1 to 2 dedicated cron workers:
max_cron_threads = 1 to 2
# Example on a 4-core server:
# (4 x 2) + 1 = 9 HTTP workers
# + 2 cron workers
Why multiply by two and add one? Real requests are not pure CPU work — they spend a meaningful slice of their time waiting on PostgreSQL, the filesystem, or remote services. While one worker is blocked on I/O, another can use that idle CPU core. Doubling the core count gives the scheduler enough processes to keep every core busy, and the trailing +1 covers the worker that is, at any given moment, likely parked on I/O. It is a heuristic that gets you close on the first try — then you measure and adjust.
Key Insight: The Formula Is a Starting Point, Not a Final Answer
(CPU cores × 2) + 1 is a baseline to measure-and-tune from, never a number to set and forget. Your real workload — report generation, e-commerce traffic, heavy imports, the number of installed apps — changes the ideal count. Deploy with the formula, then watch CPU utilisation, RAM headroom, and request latency under genuine load. If cores sit idle while requests queue, add a worker or two. If RAM is tight or context-switching is high, pull one back. The right number is the one your own metrics confirm.
Example odoo.conf for a 4-Core Server
Here is a complete, production-oriented [options] block for a 4-core machine. Copy it into your odoo.conf and adjust the numbers to your own hardware. Every value below is explained in the table that follows.
[options]
workers = 9
max_cron_threads = 2
limit_memory_hard = 2684354560
limit_memory_soft = 2147483648
limit_time_cpu = 60
limit_time_real = 120
limit_request = 8192
A quick read-through of each line: workers = 9 is the HTTP worker count from the formula. max_cron_threads = 2 dedicates two handlers to scheduled jobs. The two limit_memory_* values cap each worker's memory at roughly 2 GB soft and 2.5 GB hard. The two limit_time_* values cap how long a single request may run. And limit_request = 8192 recycles a worker after it has served that many requests, so any slow memory leak is reset before it matters.
Worker Configuration Parameters
| Parameter | Example Value | Purpose |
|---|---|---|
workers |
9 | Number of HTTP worker processes that handle web requests in parallel — here (4×2)+1. Any value above 0 enables multiprocessing mode. |
max_cron_threads |
2 | Number of scheduled-task handlers reserved for cron jobs. Setting this to 0 disables all scheduled actions. |
limit_memory_hard |
2684354560 | ~2.5 GB hard memory cap per worker, in bytes. A worker that exceeds this is killed immediately. |
limit_memory_soft |
2147483648 | ~2 GB soft cap per worker, in bytes. The worker finishes its current request, then is recycled gracefully. |
limit_time_cpu |
60 | Maximum CPU seconds a single request may consume before it is aborted. |
limit_time_real |
120 | Maximum wall-clock seconds a request may run, including time spent waiting on I/O. Match this to your Nginx proxy timeout. |
limit_request |
8192 | Number of requests a worker serves before it is recycled, preventing slow memory leaks from accumulating over time. |
Planning Worker Memory
Worker count and memory limits are two sides of the same coin: the total memory your workers can consume must fit inside the RAM you actually have, with room left over for PostgreSQL and the operating system. The arithmetic is simple but easy to forget.
With the example config above — 9 HTTP workers, each allowed up to ~2.5 GB hard — the worst-case worker memory is 9 × 2.5 GB = 22.5 GB. If you also count the two cron workers, you are reserving headroom for eleven processes. If that total approaches or exceeds physical RAM, the kernel starts swapping to disk, and the server thrashes: requests that took 200 ms now take 20 seconds. The fix is never "add more workers" — it is "make sure the workers you have fit in memory."
(workers + max_cron_threads) x limit_memory_hard
should be comfortably UNDER total RAM,
leaving headroom for PostgreSQL and the OS.
Example (4-core server, 9 workers):
9 workers x 2.5 GB = 22.5 GB worker memory
+ PostgreSQL + OS overhead
=> plan for a server with well above 24 GB RAM
| Component | Calculation | Example |
|---|---|---|
| HTTP worker memory | workers × limit_memory_hard | 9 × 2.5 GB = 22.5 GB |
| Cron worker memory | max_cron_threads × limit_memory_hard | 2 × 2.5 GB = 5 GB |
| PostgreSQL | shared_buffers + work_mem × connections | ~2 to 4 GB (workload dependent) |
| Operating system | base OS + filesystem cache headroom | ~1 to 2 GB reserved |
| Total required RAM | sum of all rows above, with margin | Comfortably above 30 GB recommended |
Cron Workers: Keeping Scheduled Jobs Alive
Cron workers are the unsung heroes of an Odoo deployment. They run every scheduled task in the system: generating recurring invoices, flushing the outgoing email queue, processing inventory moves, and firing automated actions. These jobs are separate from the HTTP workers that serve the web interface, which is why they need their own setting, max_cron_threads.
For most deployments, one or two cron workers are plenty. Scheduled jobs are usually short and infrequent, so they rarely compete for resources. You only need to raise the count when you regularly run heavy, long-running batch operations — large data imports, bulk recomputations, or nightly synchronisations — that would otherwise queue behind one another.
Setting max_cron_threads = 0 Disables ALL Scheduled Jobs
If you set max_cron_threads = 0, Odoo stops running every scheduled action — and it does so silently. Outgoing emails never leave the queue, recurring invoices are never generated, inventory automations never fire, and your team gets no error. The system simply appears "quiet" while critical business processes stall for days. Unless you have deliberately moved cron handling to a separate dedicated instance, always keep max_cron_threads at 1 or higher.
Step by Step Guide: Optimizing Worker Usage in Odoo 19
Follow these six steps in order. They take you from counting your hardware to confirming, in the logs, that your tuned worker pool is healthy and stable under load.
Count Your CPU Cores
Before you can apply the formula you need an accurate core count. On Linux run nproc or lscpu to see the number of logical CPUs available to the server. Use the count of cores the Odoo host can actually schedule on — on a virtual machine or cloud instance this is the number of vCPUs assigned to that instance, not the physical host. Write this number down; it is the single input that drives your entire worker calculation. A typical small production box has 4 cores; larger deployments scale from there.
Apply the Workers Formula
Plug your core count into (CPU cores × 2) + 1 to get the HTTP workers value. On a 4-core server that is (4 × 2) + 1 = 9. Then add 1 to 2 dedicated cron workers via max_cron_threads. Set workers = 9 and max_cron_threads = 2 in the [options] section of odoo.conf. Remember this is a measured starting point: you will revisit it once you can observe real traffic, not a number to set in stone.
Set Memory Limits
Add limit_memory_soft (~2 GB / 2147483648 bytes) and limit_memory_hard (~2.5 GB / 2684354560 bytes) so a single worker can never run away with the server's RAM. Then add limit_time_cpu = 60 and limit_time_real = 120 to cap how long any request may run, and limit_request = 8192 to recycle each worker periodically. Crucially, verify the math: (workers + max_cron_threads) × limit_memory_hard must sit comfortably below total RAM, with room reserved for PostgreSQL and the OS. Adjust the limits or the worker count until it fits.
Configure Cron Workers
Confirm max_cron_threads is set to 1 or 2 — never 0 on an instance that handles scheduled jobs, or every automation stops silently. If your deployment runs particularly heavy nightly batch jobs that overlap, bump the value up so they do not queue behind one another. Remember to include these cron processes in your memory planning from Step 3: they consume RAM just like HTTP workers do, so they count toward your total.
Add an Nginx Reverse Proxy
Put Nginx in front of Odoo to buffer slow client connections so a worker is freed the moment it finishes a response. Set the proxy read, connect, and send timeouts to 720s and tune the proxy buffers as shown below. Importantly, align proxy_read_timeout with Odoo's limit_time_real — set the proxy equal to or slightly higher than Odoo so legitimately long requests are not cut off by the proxy before Odoo's own limit decides. A reverse proxy is one of the highest-leverage performance changes you can make.
Restart and Monitor Logs
Save odoo.conf, then restart the service — for example sudo systemctl restart odoo — and immediately tail the logs. Confirm you see the expected number of WorkerHTTP processes spawn and "live on" with their PIDs. Then watch under real load: if you see repeated Worker (N) max memory reached, killing messages, your memory limits are too low for the work your requests do, so raise the limits or reduce the worker count. Healthy, stable PIDs that survive load mean your tuning is correct.
Nginx Reverse Proxy Configuration
Without a reverse proxy, an Odoo worker stays occupied for the entire time a slow client takes to receive its response — a user on a weak mobile connection can hold a worker hostage for seconds. Nginx solves this by accepting the full response from Odoo quickly, freeing the worker, and then dribbling the bytes out to the slow client itself. Add the following directives to the relevant location block of your Nginx site config.
proxy_read_timeout 720s;
proxy_connect_timeout 720s;
proxy_send_timeout 720s;
proxy_buffer_size 64k;
proxy_buffers 8 64k;
The three timeout directives keep long-running operations (large report exports, bulk actions) from being terminated by the proxy. The buffer settings give Nginx enough memory to hold a full Odoo response so it can release the worker right away. A practical rule: set proxy_read_timeout equal to — or just slightly above — Odoo's limit_time_real, so that Odoo's own limit is the authority that decides when a request is genuinely too long, not the proxy.
Reading the Logs: Healthy vs. Problem Workers
After any change, the logs are your source of truth. Odoo 19 improved worker recycling — it now removes zombie workers more aggressively and replaces them to keep the configured worker count intact, and its debug logs expose memory-related recycling events so you can see exactly why a worker was retired. A healthy pool looks like this, with each worker reporting a stable PID it "lives on":
INFO odoo odoo.service.server: WorkerHTTP (4) lives on pid=31204
INFO odoo odoo.service.server: WorkerHTTP (5) lives on pid=31205
If, on the other hand, you repeatedly see workers being killed for memory, your limit_memory_hard is too low for what your requests actually do — or you have too many workers competing for too little RAM. This is the signal to raise the limit or reduce the worker count:
WARNING odoo odoo.service.server: Worker (4) max memory reached, killing (pid=31204)
Applying the Changes
Once odoo.conf is edited and your Nginx config is in place, apply everything by restarting the Odoo service and then watching the logs to confirm workers spawn cleanly and are not being killed.
sudo systemctl restart odoo
# Then watch the logs and confirm workers spawn:
sudo journalctl -u odoo -f
Common Mistakes and How to Fix Them
Most Odoo performance problems trace back to a handful of recurring tuning mistakes. Recognise these patterns and the fix is usually quick.
| Mistake | Consequence | Fix |
|---|---|---|
| Excessively high worker counts (30 to 40) on a low-core machine | Heavy context switching and RAM pressure with no real throughput gain — often slower than fewer workers. | Return to (cores × 2) + 1 and tune up only if metrics justify it. |
| Raising limit_time_real excessively | Masks underlying slow queries instead of fixing them; long requests keep tying up workers. | Keep a sane limit and fix the slow query or report behind the timeout. |
| Forgetting cron workers, or setting max_cron_threads = 0 | Scheduled jobs silently stop — no emails, no invoicing, no automations. | Always set max_cron_threads to 1 or 2 on instances that run cron. |
| Not accounting for PostgreSQL and OS memory when planning workers | Total memory exceeds RAM, the server swaps to disk, and everything thrashes. | Reserve RAM for PostgreSQL and the OS before sizing the worker pool. |
Maintenance: Re-Tune as You Grow
Worker tuning is not a one-time task. As your dataset grows, user counts rise, and new modules are installed, the ideal configuration drifts. Plan to review your worker settings every few months: re-measure CPU utilisation, RAM headroom, and request latency under real load, then re-tune workers, the memory limits, and max_cron_threads accordingly. A configuration that was perfect for 50 users may be undersized at 200. Treat the formula as your reset point and let live metrics guide each adjustment from there.
Frequently Asked Questions
How many workers should I set for Odoo 19?
Start with (CPU cores × 2) + 1 HTTP workers plus 1 to 2 cron workers — for example, 9 workers on a 4-core server. Treat this as a measured starting point, then watch CPU, RAM, and request latency under real traffic and adjust up or down. The right number is whatever your own metrics confirm keeps cores busy without exhausting RAM.
What is the difference between limit_memory_soft and limit_memory_hard?
When a worker crosses limit_memory_soft, Odoo lets it finish the current request and then recycles it gracefully — no request is interrupted. If a worker ever crosses limit_memory_hard, it is killed immediately to protect the server. The soft limit is the planned, gentle recycle point; the hard limit is the emergency ceiling.
What happens if I set max_cron_threads to 0?
All scheduled actions stop running — and they stop silently. Outgoing emails are never sent, recurring invoices are never generated, and automated actions never fire, with no error shown. Unless cron is handled by a separate dedicated instance, always keep max_cron_threads at 1 or higher.
Why do I keep seeing "max memory reached, killing" in the Odoo logs?
That warning means a worker exceeded limit_memory_hard and was terminated. It usually signals that your hard limit is too low for the memory your requests genuinely need, or that too many workers are competing for too little RAM. Raise limit_memory_hard, reduce the worker count, or add RAM — and check for memory-heavy reports or queries.
Do I really need an Nginx reverse proxy in front of Odoo 19?
For any production deployment, yes. An Nginx reverse proxy buffers slow client connections so a worker is freed the instant its response is generated, instead of waiting for a slow browser to finish downloading. Match proxy_read_timeout to Odoo's limit_time_real and it is one of the highest-leverage performance improvements available.
Need Help Tuning Your Odoo 19 Server for Performance?
Our Odoo hosting and DevOps experts can audit your worker configuration, right-size memory and cron limits, set up an Nginx reverse proxy, and keep your deployment fast and stable as you scale.
About the author
Odoo Practice Lead, Braincuber Technologies
Leads the Odoo practice at Braincuber. Has delivered Odoo ERP implementations, NetSuite/Tally migrations, and Shopify–Odoo integrations for US mid-market and D2C brands. Owns scoping, data migration, and go-live for every Odoo engagement.
