Enable Dark Mode!
how-to-perform-load-testing-in-odoo-19.jpg
By: Sayed Mahir Abdulla KK

How to Perform Load Testing in Odoo 19

Technical Odoo 19 Odoo Enterprises Odoo Community

Odoo 19 makes architectural improvements to the core that significantly impact load characteristics: rewritten HTTP layer, improved OWL 3 rendering engine, more aggressive asset bundling. While all these changes improve the performance of a typical daytime request, they also shift potential performance bottlenecks into different layers. In other words, an instance that performs reasonably well in a staging environment may experience major slowdowns when subjected to concurrent database writes, large-scale API reads, or accounting reconciliation runs.

A smoke test at the beginning of a load test is a valuable but insufficient indicator: most failures occur when 200+ concurrent sessions hammer the Sales dashboard, or Inventory moves API, or when Accounting reconciliation runs at the same time as cron jobs.

Conditions in Which Load Testing is Essential

  • More than 50 concurrent active users
  • Heavy cron jobs (e.g. multiple jobs)
  • High API inbound/outbound usage (webhooks, EDI integration)
  • Multi-company mode
  • Modules with large computations

This guide covers tools for load testing, realistic scenario design, the primary metrics, failure patterns, and mitigation techniques.

Odoo 19 Concurrency Overview

Odoo 19 uses a Multi-worker Gevent-based concurrency model. The relevant parameters are defined in odoo.conf:

workers = 8               # Number of worker processes
max_cron_threads = 2      # Dedicated cron workers (separate from HTTP workers)
limit_memory_hard = 2684354560   # 2.5 GB per worker before kill
limit_memory_soft = 2147483648   # 2 GB soft limit (triggers GC)
limit_time_cpu = 60        # CPU seconds per request
limit_time_real = 120      # Wall-clock seconds per request
limit_request = 8192       # Requests a worker handles before recycling

The CPU-bound ORM operations executed in the main thread of each worker process impose a hard concurrency limit dictated by $workers. See below the recommendations for configuring this parameter based on the workload.

Tools

1. Locust (Recommended)

Locust is a Python framework that can be used to implement Odoo load testing scenarios and view the results in a convenient web UI. It supports asynchronous execution of JSON-RPC calls, which is critical for simulating concurrent database operations in Odoo.

pip install locust

A simple script for Odoo 19 performance testing is given below:

from locust import HttpUser, task, between
import json

class OdooUser(HttpUser):
   wait_time = between(2, 8)
   def on_start(self):
       """Authenticate and establish session."""
       payload = {
           "jsonrpc": "2.0",
           "method": "call",
           "params": {
               "db": "your_database",
               "login": "test_user@example.com",
               "password": "test_password"
           }
       }
       resp = self.client.post(
           "/web/session/authenticate",
           json=payload,
           headers={"Content-Type": "application/json"}
       )
       data = resp.json()
       self.uid = data["result"].get("uid")
   @task(3)
   def browse_sale_orders(self):
       """Simulate sales dashboard load."""
       payload = {
           "jsonrpc": "2.0",
           "method": "call",
           "params": {
               "model": "sale.order",
               "method": "search_read",
               "args": [[["state", "in", ["sale", "done"]]]],
               "kwargs": {
                   "fields": ["name", "partner_id", "amount_total", "state"],
                   "limit": 80,
                   "offset": 0
               }
           }
       }
       self.client.post(
           "/web/dataset/call_kw",
           json=payload,
           headers={"Content-Type": "application/json"},
           name="sale.order/search_read"
       )
   @task(1)
   def create_sale_order(self):
       """Simulate order creation — heavier write path."""
       payload = {
           "jsonrpc": "2.0",
           "method": "call",
           "params": {
               "model": "sale.order",
               "method": "create",
               "args": [{
                   "partner_id": 7,
                   "order_line": [[0, 0, {
                       "product_id": 15,
                       "product_uom_qty": 1,
                       "price_unit": 100.0
                   }]]
               }],
               "kwargs": {}
           }
       }
       self.client.post(
           "/web/dataset/call_kw",
           json=payload,
           headers={"Content-Type": "application/json"},
           name="sale.order/create"
       )

To run this example, execute the command below:

locust -f locustfile.py --host=https://your-odoo-instance.com \
 --users=100 --spawn-rate=10 --run-time=5m --headless \
 --csv=results/run_01

2. k6

k6 is a modern CLI tool for performance testing applications. It can be a good choice for load testing of Odoo when CI/CD integration is needed (it does not require Python) or when specific assertions need to be made at the request level.

A simple k6 script for Odoo 19 login and search_read is given below:

import http from 'k6/http';
import { check, sleep } from 'k6';
export let options = {
 stages: [
   { duration: '2m', target: 50 },   // ramp up
   { duration: '5m', target: 50 },   // steady state
   { duration: '1m', target: 0 },    // ramp down
 ],
 thresholds: {
   http_req_duration: ['p(95)<2000'],  // 95th percentile under 2s
   http_req_failed: ['rate<0.01'],     // error rate under 1%
 },
};
const BASE_URL = 'https://your-odoo-instance.com';
const DB = 'your_database';
export default function () {
 // Authenticate
 let loginRes = http.post('${BASE_URL}/web/session/authenticate', JSON.stringify({
   jsonrpc: '2.0', method: 'call',
   params: { db: DB, login: 'load_test@example.com', password: 'password' }
 }), { headers: { 'Content-Type': 'application/json' } });
 check(loginRes, { 'login OK': (r) => r.json('result.uid') !== null });
 sleep(1);
 // Read partner list
 let readRes = http.post('${BASE_URL}/web/dataset/call_kw', JSON.stringify({
   jsonrpc: '2.0', method: 'call',
   params: {
     model: 'res.partner', method: 'search_read',
     args: [[['customer_rank', '>', 0]]],
     kwargs: { fields: ['name', 'email'], limit: 80 }
   }
 }), { headers: { 'Content-Type': 'application/json' } });
 check(readRes, { 'read OK': (r) => r.status === 200 });
 sleep(Math.random() * 5 + 2);
}

3. Apache JMeter

Apache JMeter is primarily designed for testing web applications and is particularly useful for UI testing. It can be used for testing Odoo through JSON-RPC, with the following recommended steps:

Use the JSON Extractor post-processor to extract the session cookie from https://your-odoo-instance.com/web/session/authenticate.

Thread groups can be used to simulate the behavior of different classes of users

The choice between these tools depends on the specific requirements:

CriteriaLocustk6JMeter
Compatibility for Odoo JSON-RPCExcellentGoodModerate
CI/CD integrationGoodExcellentModerate
Real-time metricsBuilt-in UIGrafana/InfluxDBBuilt-in UI
Distributed testingNativeNativeNative
Learning curveLow (Python)Low (JS)Medium (GUI)

Designing Realistic Test Scenarios

A generic performance test for Odoo 19 can be built using Locust or similar Python tools, but it is critical to avoid naive designs such as uniformly distributed requests to the login page.

Instead, a performance tester or technical manager should design a set of scenarios reflecting the actual user behavior in the target instance. The examples below can help identify the scenarios and define their weights.

Scenario 1: Office Day Simulation

The objective is to reproduce a realistic 8-hour day in the life of an Odoo user. It is important to reflect actual time spent in different operations.

The typical Odoo user spends most of their time in list views, only occasionally opening form views. About 40% of users only browse list views and do not make any changes, while another 25% only open form views and browse their contents. 20% of users edit records, and 10% perform server actions. The remaining 5% only use wizards and reports. The weights should be adjusted depending on the use case.

Scenario 2: Month-End Accounting Spike

Users of the accounting module do not access it uniformly throughout the month. A realistic scenario should reflect a surge of activity towards the end of the month when invoices are due. It can be simulated as a burst of requests to validate invoices (account.move -> action_post), match bank reconciliation statements (account.bank.statement.line), and view aged receivables reports.

This scenario should also reflect potential bottlenecks in the database: account.move.line tables often grow to tens of millions of rows on mature databases, and month-end operations are often limited by PostgreSQL write contention.

Scenario 3: API Batch Processing

External systems (e-commerce, WMS, 3PL, etc.) typically send requests in bursts rather than isolated requests. This can be simulated by sending 50-100 concurrent JSON-RPC calls to stock.picking -> button_validate , sale.order -> action_confirm and res.partner -> write to update addresses.

This scenario helps identify worker exhaustion: a burst of API requests from external systems often competes for connections with actual users, potentially degrading the UI experience.

Scenario 4: Cron Jobs + Concurrent Users

Cron jobs run during the same time window as regular UI traffic. A plausible scenario is to execute a bulk compute operation in the background (e.g., stock.warehouse.orderpoint > button_recompute) while 80 concurrent users access the system. The resource contention will be visible in PostgreSQL and will impact general performance.

Key Metrics to Monitor

Application-Level Metrics (from Locust/k6)

MetricHealthy TargetWarningCritical
p50 response time< 500 ms500–1000 ms> 1000 ms
p95 response time< 2000 ms2000–5000 ms> 5000 ms
p99 response time< 5000 ms5000–10000 ms> 10000 ms
Error rate< 0.1%0.1–1%> 1%
Throughput (RPS)Stable plateau—Declining under steady load

Server-Level Metrics (commands to run)

 # CPU and memory per worker process
pidstat -u -r -p $(pgrep -d',' odoo) 5
# PostgreSQL active connections and wait events
psql -c "
 SELECT wait_event_type, wait_event, count(*)
 FROM pg_stat_activity
 WHERE state = 'active'
 GROUP BY 1, 2
 ORDER BY 3 DESC;
"
# Slow query log (set in postgresql.conf)
# log_min_duration_statement = 500   # log queries over 500ms

PostgreSQL-Specific Metrics

Top slow queries during the load run:

SELECT
   left(query, 80) AS query_snippet,
   calls,
   round(mean_exec_time::numeric, 1) AS avg_ms,
   round(total_exec_time::numeric, 1) AS total_ms
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 15;

Cache hit ratio (should be > 99% for hot data):

SELECT
   sum(heap_blks_hit) * 100.0 /
   nullif(sum(heap_blks_hit) + sum(heap_blks_read), 0) AS cache_hit_ratio
FROM pg_statio_user_tables;

Table bloat on high-churn tables:

SELECT relname, n_dead_tup, n_live_tup,
      round(n_dead_tup * 100.0 / nullif(n_live_tup + n_dead_tup, 0), 1) AS dead_pct
FROM pg_stat_user_tables
WHERE n_dead_tup > 10000
ORDER BY n_dead_tup DESC;

Common Performance Issues in Odoo 19

1. Job Fatigue (HTTP 504 Errors)

When job execution time starts to increase, Odoo logs will contain timeout errors from werkzeug or gevent. This usually happens when all the workers of 'The Process' are busy, and new requests are placed in a queue managed by Nginx until they time out.

Possible solutions: add more workers, add more RAM, optimize the query. Use profile with ‘--log-level=debug’ and ‘–limit-time-real=120’ to find the request that consumes the most time.

2. PostgreSQL Connection Pool Exhaustion.

An increase in connection pool exhaustion occurs when the number of connections is not sufficient for the current load. This problem often occurs in Odoo for the same reason when the limit is reached for concurrent database connections.

Possible solution: increasing the connection limit for PostgreSQL:

# postgresql.conf
max_connections = 200
# odoo.conf — use PgBouncer in transaction pooling mode for > 20 workers
db_maxconn = 64

It is possible to reduce the connection pool size for the Odoo instance if the number of workers is higher than 20:

# pgbouncer.ini
pool_mode = transaction
max_client_conn = 500
default_pool_size = 20

3. Long-Running '@api.depends' Compute Chains

Symptoms: some form views or wizard submissions take 10-30 seconds to load under concurrent database writes. This is not visible in a single-user test.

The concurrent writes lead to excessive PostgreSQL row-level locking and serialization of ORM writes for models with stored fields that have a deep dependency tree.

Possible solution: analyze the code for the slow model to see which @api.depends methods have the broadest access to fields:

# Identify compute methods with broad dependencies
# Look for patterns like:
@api.depends('order_line.price_unit', 'order_line.product_uom_qty',
            'order_line.discount', 'order_line.tax_id')
def _compute_amounts(self):
   ...

Stored fields that are only displayed in the form view are suitable for store=False if they are not used for filtering or grouping.

4. Autovacuum Delay on Heavily-Updated Tables

Slowdowns can occur when querying the mail_message, stock_move, account_move_line, or bus_bus tables under a sustained load. EXPLAIN ANALYZE will indicate that PostgreSQL is using sequential scans for queries that should normally use indexes.

The reason for this is that autovacuum is unable to keep up with the rate of concurrent INSERT / UPDATE / DELETE operations.

Possible solution: configure aggressive autovacuum settings for tables with a high-write load:

ALTER TABLE mail_message SET (
   autovacuum_vacuum_scale_factor = 0.01,
   autovacuum_analyze_scale_factor = 0.005,
   autovacuum_vacuum_cost_delay = 2
);
ALTER TABLE stock_move SET (
   autovacuum_vacuum_scale_factor = 0.01,
   autovacuum_analyze_scale_factor = 0.005
);

5. Nginx Upstream Timeout Configuration

Symptoms: 502/504 errors are returned by the browser, but the Odoo workers are able to respond to requests. The error message in the Nginx logs indicates that upstream has timed out:

Possible solution:

upstream odoo {
   server 127.0.0.1:8069;
   keepalive 32;
}
server {
   location / {
       proxy_pass http://odoo;
       proxy_read_timeout 720s;
       proxy_connect_timeout 720s;
       proxy_send_timeout 720s;
       proxy_buffering off;
   }
   location /longpolling {
       proxy_pass http://odoo;
       proxy_read_timeout 3600s;  # Long-poll bus needs long timeout
   }
}

Preparation Checklist

Before testing

  • Use a database backup of production data with anonymization for testing, not the clean installation
  • Pre-warm PostgreSQL buffer cache with the cold start of testing
  • Prevent email sending (ir.mail_server > no outgoing server configured or use a dummy SMTP server such as MailHog)
  • Do not hard-code passwords; instead use ir.config_parameter or fixtures to configure test users' credentials
  • Set workers, max_cron_threads, and the limit_ parameter in odoo.conf as per production configuration

During testing

  • Check CPU, RAM, and swap usage on the Odoo server every 30 seconds
  • Monitor Odoo logs for Python tracebacks and request warnings about performance
  • Collect the pg_stat_activity every 60 seconds
  • Store the output of the pg_stat_statements at the start and end of the test

After testing

  • Compare the p95 latency metric with your service level objectives
  • Relate slow queries with the time when the test was running
  • Check the dead tuples count for large tables
  • Document the user load at which the error rate is more than 1%

Load testing of Odoo 19 requires identification of the weak points of the application - those use cases, API calls, or ORM actions which would become problematic first. Scripts for load testing using tools like Locust or k6 should simulate realistic behavior of a user - no need to send the same requests on the login page. Performance of PostgreSQL is of the same level of importance as any other part of the Odoo stack. Thus, queries should be checked and improved in case the response time or error rate thresholds are not met.

Issues that are seen in most productions are associated with one of these three reasons: lack of workers, exhaustion of the PostgreSQL connection pool, or delays of autovacuum operations. None of these issues is visible in isolation in Odoo. Using 50+ concurrent users in testing is a guarantee of encountering some of these issues.

It is recommended to start with 50 concurrent users and gradually raise the amount until the first performance problem is encountered. Then, testing with the same number of users should be repeated to get an understanding of the behavior of the system. In most cases, the reason for the issue would be found in PostgreSQL.

To read more about How to Perform Integration Test in Odoo19, refer to our blog How to Perform Integration Test in Odoo19.


Frequently Asked Questions

How many concurrent users should I simulate in my first load test?

Start with 50 concurrent users. A slow increase in the load (5-10 users per minute) is necessary to identify the first performance bottleneck. After that, it is recommended to increase the user count by 25-50% to see if the next set of issues emerges. There is no universal formula or recommended value for the initial user count in load testing.

Can I perform load testing directly on the production database?

The production database must never be used for performance testing. A recent replica should be created on a separate server for this purpose. It is critical to anonymize the user data to avoid sending emails to real customers and disrupting live operations.

My load test shows high p95 latency for specific form views, but not all list views. What could be the reason?

If a particular form view is consistently slow under concurrent database writes while other list views are unaffected, the problem is likely caused by a stored computed field and a long @api.depends chain. Each time the form is opened, Odoo checks if any of the fields have become stale since the last refresh and initiates a recompute if necessary.

How can I load test Odoo’s new REST API endpoints introduced in version 17 using Locust?

Odoo 17+ provides a set of standard REST API endpoints at /api/, for example, GET /api/sale.order or POST /api/sale.order. These endpoints use Bearer token authentication instead of cookies. Locust supports cookies and tokens for authenticating API requests in Odoo.

After resolving the obvious performance bottlenecks, my load test still shows gradually increasing latency over a 30-40 minute period. What are the possible reasons?

The first reason is the prolonged load. When autovacuum lags, PostgreSQL will resort to sequential scans for queries that would normally use an index. The second reason is a memory leak: after several hours of continuous operation, Odoo workers will increase their memory consumption to reach the soft_memory limit, thereby reducing the number of available workers. The third reason is insufficient shared_buffers memory. This can be resolved by allocating a larger portion of available RAM to this subsystem: 25%-50% for PostgreSQL in Odoo 19, depending on the workload.

If you need any assistance in odoo, we are online, please chat with us.



0
Comments



Leave a comment



WhatsApp