On v12, opening a POS session with 5,000 products could take 30 seconds or more. On v18, the same session loads its data in under 5 seconds. This article traces every architectural decision that made that gap possible, from sequential Backbone.js RPC calls to priority-based product loading and IndexedDB.
How v12 Loads a POS Session
In Odoo 12, the POS frontend is built on Backbone.js. Opening a session triggers load_server_data() in models.js, which iterates over this.models, a flat list of model descriptors. Each model fires one search_read RPC and waits for the result before starting the next 1.
The list had 17 to 23 entries depending on installed modules. A few representative ones:
res.users: cashier infores.company: company settingsdecimal.precision: rounding configurationres.partner: every contact withcustomer = True, no limitproduct.product: the entire product catalog, no limitaccount.bank.statement: cash registeraccount.fiscal.positionandaccount.fiscal.position.tax: fiscal positions
The loading code reads like this in outline:
function load_model(index) {
var model = self.models[index];
rpc.query({ model: model.model, method: 'search_read', ... })
.then(function(result) {
model.loaded(self, result);
load_model(index + 1); // next only after this one finishes
});
}
load_model(0);
Take a database with 5,000 products and 2,000 customers, the scenario used throughout this article. The product.product read alone could take 15 to 20 seconds. The res.partner read added another 5 to 8 seconds. A loading progress bar showed per-model labels so the cashier could watch it crawl. Total for data loading: 30 to 60 seconds.
The pos_cache Module: What It Was and Why It Disappeared
pos_cache was ported into Odoo’s addons/ directory in February 2015 from the odoo-extra repository (commit 97b0723722d82). It shipped as an optional module and was not installed by default. Its manifest described the purpose clearly: “drastically lowers the time it takes to load a POS session with a lot of products” 3.
The mechanism was simple. For each POS config and each user, the module stored the full product list as a serialized binary payload — base64-encoded JSON — attached to a pos.cache record 4. On session open, instead of running product.product.search_read(), the server returned the raw cached payload directly. A scheduled cron job (refresh_all_caches) recomputed the cache nightly or on demand.
The model looked like this:
class pos_cache(models.Model):
_name = 'pos.cache'
cache = fields.Binary(attachment=True) # serialized binary payload, stored in ir.attachment
product_domain = fields.Text()
product_fields = fields.Text()
config_id = fields.Many2one('pos.config')
compute_user_id = fields.Many2one('res.users')
The attachment=True flag means the binary data was physically stored in Odoo’s ir.attachment table, not in the pos_cache table row itself. This kept the database row small while the payload lived in standard attachment storage.
It solved the product part of the problem. With pos_cache, the product step of that same 5,000-product session dropped to 3 to 6 seconds, because reading a stored binary payload is much faster than recomputing a full ORM search. The partner read and the other sequential calls were not cached, so total data loading still took 10 to 20 seconds.
The module also introduced a silent correctness risk: the cache was not invalidated automatically when products changed. Shops that forgot to refresh it would see stale prices and products on the POS until someone noticed or the cron ran overnight.
pos_cache was removed from the Odoo 16.0 branch in October 2022 (commit d2afca280be05) with the following message:
Since we introduced in 15.0 the possibility to limit the number of products loaded at the launch of the POS and to load the remaining ones in background, we don’t need anymore a cache of the products to have a faster way to start the POS.
The v15 Refactor: One Request Instead of Twenty
On October 15, 2021, commit 8fb53c53c3126 landed in v15:
[REF] point_of_sale,*pos*: remove Backbone.js, single loading request
Loading of model data for the POS UI is now done in a single request. Backbone.js is removed and replaced by reactivity.js. There is no more progress bar during loading.
This was one of the biggest architectural and performance improvements in POS history. Instead of 20-plus sequential network round-trips, the frontend makes one call:
// v15+ frontend
await rpc('pos.session', 'load_pos_data', [session_id]);
The backend aggregates all model data server-side and returns one JSON response. The server still runs the same queries, but they run in a single HTTP round-trip instead of 20-plus. On a server with 100 ms network latency, that eliminated 2 full seconds of pure overhead from the request chain alone.
The same commit also introduced limited product loading: load only the top N products on open, stream the rest in the background while the cashier can already work. The limited_products_loading flag on pos.config enabled this, with a configurable limited_products_amount (default 20,000). By v18, the server-side ceiling is governed by the point_of_sale.limited_product_count system parameter (covered below) rather than a pos.config field; the field names for related toggles vary across versions.
How v18 Loads Data
By v18, the single-request pattern is fully mature. The call is:
await this.orm.call("pos.session", "load_data", [odoo.pos_session_id, modelList]);
The backend load_data method iterates a dynamic list of models returned by _load_pos_data_models() 78. The exact count depends on which modules are installed — a minimal v18 setup loads fewer models than one with inventory, loyalty, or appointments enabled. Every model implements _load_pos_data_domain() and _load_pos_data_fields() so modules can extend the payload cleanly using super().
Priority-based product loading
The product loading in v18 does not just take the first N records. get_limited_products_loading() in pos_config.py runs an optimized SQL query that ranks products 9:
-- Conceptual SQL (simplified; actual query is generated by the ORM)
WITH pm AS (
SELECT product_id, MAX(write_date) date
FROM stock_move_line
GROUP BY product_id
)
SELECT product_product.id
FROM product_product
JOIN product_template ON ...
LEFT JOIN pm ON product_product.id = pm.product_id
WHERE <domain>
ORDER BY
product_template.is_favorite DESC,
CASE WHEN type = 'service' THEN 1 ELSE 0 END DESC,
pm.date DESC NULLS LAST,
product_product.write_date DESC
LIMIT :limit;
The priority order, from first to last: starred products, service-type products, products with recent stock movements, most recently updated products. The limit defaults to 20,000 and is configurable via the point_of_sale.limited_product_count system parameter 10. Setting it to 0 disables the limit and loads the entire product catalog on open, which effectively recreates the v12 problem.
Partners follow a similar pattern: the 100 most frequent POS customers load first, ranked by number of past POS orders. The partner limit is configurable via point_of_sale.limited_customer_count.
Product loading lifecycle
When the catalog exceeds the configured limit, products in v18 are not fully loaded at session open. Loading happens in these steps:
First, the server builds the initial priority load: load_data selects the ranked subset. Before this data is handed to the frontend, _add_missing_products() checks any open order lines for products that were not included in that subset and adds them. This keeps existing orders intact.
Once frontend initialization and any required proxy connection (both covered below) complete, the cashier can start working. Two optional mechanisms then fill in the rest of the catalog.
Background loading: when product_load_background is set on pos.config, the remaining products stream in the background while the session is already in use.
Search-triggered loading: when a cashier types in the product search box and selects “Search in database”, a searchRead call goes to product.product with the search term as domain. Results are added to the local model store. This is how products outside the initial limit become accessible without reloading the session.
What happens after the data arrives
When load_data returns, processServerData() runs before the POS becomes usable. It builds lookup maps for fast O(1) record access throughout the session, instantiates payment interface objects for each configured payment method, hydrates raw JSON into typed model instances, restores draft orders from IndexedDB, and computes derived state like pricelist caches and printer configuration. This phase is synchronous and adds roughly 200 to 500 ms on typical hardware.
IndexedDB for open orders
v18 adds a client-side IndexedDB database (named config-id_{config_id}_{access_token}) that persists draft orders, order lines, and payments across page reloads. On the next session open, those records are restored without a server call.
It is important to be precise about what IndexedDB stores: its tables, declared in databaseTable, hold order-related data only (orders, order lines, payments, lot operations, custom attribute values). The product catalog is not cached in IndexedDB. Separately, the in-memory model store keeps lookup indexes on certain fields, declared in databaseIndex (barcode, pos_categ_ids, write_date on product.product; barcode on res.partner) 11. Barcode scanning and category filtering stay fully local because the products are already in memory, not because they are read from IndexedDB.
What the numbers look like
For the same scenario as above (5,000 products, 2,000 customers), counting data loading only:
v12, no pos_cache: 30 to 60 seconds. Add IoT proxy connection time on top.
v12 with pos_cache: 3 to 6 seconds for the product step, 10 to 20 seconds total.
v18, default limits: the single load_data call returns in 1 to 4 seconds, plus processServerData(). Draft order restore from IndexedDB on the second open is under 1 second; the product catalog is still fetched from the server each time, not served from IndexedDB.
Where the Time Actually Goes: v12 vs v18
Understanding which phase is the bottleneck changes what you do about it.
On v12, two costs add up. The first is network overhead: each model forces a full HTTP round-trip before the next can start. On a 50 ms latency connection, 20 sequential requests add 1 second of pure waiting, regardless of query speed. The second is server-side query time, dominated by reading the full product and partner catalogs. In the 5,000-product scenario, the product query (15 to 20 seconds) far outweighs the round-trip overhead (1 to 2 seconds). pos_cache reduced the query cost by replacing the product read with a stored payload. The v15 single request removed the round-trip overhead. Limited product loading only cuts the product query for catalogs larger than the configured limit (20,000 by default), so in the 5,000-product scenario v18 still loads every product on open. There, the gain comes from the single request, the partner limit (100 customers by default instead of every customer), and the server-side loading rewrite.
On v18, a large product catalog can become the startup bottleneck when the limit is high or disabled: the initial load_data query itself then becomes the slow path. The configurable limit and background streaming exist precisely to decouple “session usable” from “catalog fully loaded”. When the catalog exceeds the limit and background loading is enabled, the cashier can start working before the remaining products have arrived.
In both versions, IoT Box latency can be a bottleneck entirely independent of data loading. That is covered in the next section.
IoT Box and Payment Terminal: The Hidden Startup Cost
Data loading is not the only thing that blocks session open. IoT proxy connection is also awaited before the POS UI becomes usable in both v12 and v18.
Where it fits in the startup sequence
In v12, after all model data loads, after_load_server_data() calls connect_to_proxy(). In v18, setup() calls await this.connectToProxy() after initServerData() 12. In both versions, the POS is not shown to the cashier until the proxy call resolves or fails.
v12 proxy discovery: worst case is very bad
In v12, use_proxy is True if any of these config flags is enabled: iface_payment_terminal, iface_electronic_scale, iface_print_via_proxy, iface_scan_via_proxy, or iface_customer_facing_display.
With proxy_ip set, the code retries the /hw_proxy/hello endpoint up to 3 times with a 1-second timeout each. If the IoT Box is unreachable, that adds 3 to 4 seconds 13.
Without proxy_ip set, v12 runs find_proxy(): a parallel scan of 768 addresses — the full .0–.255 range of 192.168.0.x, 192.168.1.x, and 10.0.0.x — preceded by a single localhost probe, each with a 400 ms timeout, in batches of 8 14. If no IoT Box is found on the LAN, this scan runs to completion before the POS becomes usable. That is about 38 seconds added to every session open. A “Skip” button was shown, but it required manual cashier action.
v18 proxy connection: faster but with a new trap
v18 removed auto-discovery entirely. autoconnect() only tries the configured proxy_ip or the last cached URL from localStorage. No LAN scan.
checkProxyAvailability() tries up to 4 times (loop index 0 to 3) with a 1-second AbortController timeout each. Worst case with an unreachable IoT Box: 4 seconds 15.
The trap: if proxy_ip is blank and localStorage.hw_proxy_url is also empty, autoconnect() returns new Promise(() => {}), a promise that never resolves. Startup may wait indefinitely until the proxy URL is configured. There is a FIXME comment in the source for this case 16.
What triggers proxy connection in v18
In Odoo 18 base, useProxy() returns True only when is_posbox is enabled AND at least one of iface_electronic_scale, iface_print_via_proxy, iface_scan_via_proxy, or iface_customer_facing_display_via_proxy is enabled 17. Payment terminals are excluded from this check in base v18 because modern terminals (Stripe, Adyen) use direct API calls, not the IoT Box proxy.
The OCA pos_payment_terminal module (18.0) patches useProxy() to add the payment terminal back 18:
patch(PosStore.prototype, {
useProxy() {
return (
(this.config.is_posbox && this.config.iface_payment_terminal) ||
super.useProxy()
);
},
});
Any POS using pos_payment_terminal with is_posbox = True and iface_payment_terminal = True will wait for the IoT Box on every session open.
Data loading cost from the payment terminal module
pos_payment_terminal adds three fields (oca_payment_terminal_mode, oca_payment_terminal_id, oca_fast_payment) to the pos.payment.method payload 19 and instantiates the OCAPaymentTerminal interface objects during processServerData(). Both are small synchronous operations with negligible cost: the real cost is the proxy connection, not data loading.
Practical rule
For configurations that use the proxy, IoT Box availability directly affects startup time. Always set proxy_ip explicitly. The 38-second v12 delay comes from automatic discovery when proxy_ip is blank. With an explicitly configured but unreachable IoT Box, the delay is 3 to 4 seconds in either version, and 4 seconds per cashier per shift still adds up. Monitor IoT Box health as part of morning open procedures.
Summary
The startup time gap between v12 and v18 comes from three compounding changes:
v12: 17-plus sequential RPC calls, the entire product catalog loaded at once, IoT LAN scan if proxy_ip is missing. pos_cache was an optional cache that had to be refreshed by a scheduled job or on demand.
v15 (Oct 2021): Single RPC, Backbone.js removed, limited product loading with background streaming. pos_cache became redundant and was removed in October 2022.
v18: Priority-based product loading (favorites, services, recent moves, recently updated), 20,000-product default limit, IndexedDB for open order persistence, no IoT LAN scan. The LAN scan is gone, but a blank proxy_ip can still cause an indefinite wait, so set it explicitly on every IoT-connected config.
If you are on v12 or v14 and opening speed is a problem, install pos_cache and configure a cron to refresh it after product updates. If you are planning a migration to v16 or later, the cache module is gone and you do not need it: the architecture already handles it. Set proxy_ip explicitly on every POS config that uses an IoT Box, and treat IoT Box uptime as a first-class operational concern.
-
load_server_data() — sequential model loading, models.js:533-603 — Odoo 12.0 ↩︎
-
commit 97b0723722d8 — add pos_cache ported from odoo-extra (Feb 2015, 9.0) — Odoo ↩︎
-
pos_cache manifest — module description — Odoo 14.0 ↩︎
-
pos_cache module — pos_cache.py — Odoo 14.0 ↩︎
-
commit d2afca280be0 — remove pos_cache (Oct 2022, 16.0) — Odoo ↩︎
-
commit 8fb53c53c312 — remove Backbone.js, single loading request (Oct 2021, 15.0) — Odoo ↩︎
-
load_data() — pos_session.py:171 — Odoo 18.0 ↩︎
-
_load_pos_data_models() — pos_session.py:138 — Odoo 18.0 ↩︎
-
get_limited_products_loading() — ranked product query, pos_config.py:840-875 — Odoo 18.0 ↩︎
-
limited_product_count (20000) / limited_customer_count (100) defaults — pos_config.py:1176-1181 — Odoo 18.0 ↩︎
-
databaseTable / databaseIndex — IndexedDB tables vs in-memory indexes, data_service_options.js — Odoo 18.0 ↩︎
-
connectToProxy() awaited in setup() — pos_store.js:154-156 — Odoo 18.0 ↩︎
-
connect_to_proxy() and /hw_proxy/hello retry — devices.js:587 — Odoo 12.0 ↩︎
-
find_proxy() — parallel LAN scan of 192.168.0/1.x and 10.0.0.x, devices.js:298-340 — Odoo 12.0 ↩︎
-
checkProxyAvailability() — retry loop, hardware_proxy_service.js:141 — Odoo 18.0 ↩︎
-
autoconnect() — never-resolving promise FIXME, hardware_proxy_service.js:80-91 — Odoo 18.0 ↩︎
-
useProxy() — is_posbox proxy trigger, pos_store.js:245 — Odoo 18.0 ↩︎
-
pos_payment_terminal — useProxy() patch, pos_store.esm.js — OCA pos 18.0 ↩︎
-
pos_payment_terminal — payment method payload fields, pos_payment_method.py — OCA pos 18.0 ↩︎