# POS Session Startup Time: How Odoo Evolved from v12 to v18

> 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.

**Date:** 2026-09-29
**Source:** <https://trobz.com/insights/pos-performance-cache-history/>

---


## 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 [ref](https://github.com/odoo/odoo/blob/12.0/addons/point_of_sale/static/src/js/models.js#L533-L603).

The list had 17 to 23 entries depending on installed modules. A few representative ones:

- `res.users`: cashier info
- `res.company`: company settings
- `decimal.precision`: rounding configuration
- `res.partner`: every contact with `customer = True`, no limit
- `product.product`: the entire product catalog, no limit
- `account.bank.statement`: cash register
- `account.fiscal.position` and `account.fiscal.position.tax`: fiscal positions

The loading code reads like this in outline:

```javascript
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 `97b0723722d8`](https://github.com/odoo/odoo/commit/97b0723722d8)). 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" [ref](https://github.com/odoo/odoo/blob/14.0/addons/pos_cache/__manifest__.py).

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 [ref](https://github.com/odoo/odoo/blob/14.0/addons/pos_cache/models/pos_cache.py). 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:

```python
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 `d2afca280be0`](https://github.com/odoo/odoo/commit/d2afca280be0)) 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 [`8fb53c53c312`](https://github.com/odoo/odoo/commit/8fb53c53c312) 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:

```javascript
// 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:

```javascript
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()` [ref](https://github.com/odoo/odoo/blob/18.0/addons/point_of_sale/models/pos_session.py#L171)[ref](https://github.com/odoo/odoo/blob/18.0/addons/point_of_sale/models/pos_session.py#L138). 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 [ref](https://github.com/odoo/odoo/blob/18.0/addons/point_of_sale/models/pos_config.py#L840-L875):

```sql
-- 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 [ref](https://github.com/odoo/odoo/blob/18.0/addons/point_of_sale/models/pos_config.py#L1176-L1181). 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`) [ref](https://github.com/odoo/odoo/blob/18.0/addons/point_of_sale/static/src/app/models/data_service_options.js). 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()` [ref](https://github.com/odoo/odoo/blob/18.0/addons/point_of_sale/static/src/app/store/pos_store.js#L154-L156). 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 [ref](https://github.com/odoo/odoo/blob/12.0/addons/point_of_sale/static/src/js/devices.js#L587).

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 [ref](https://github.com/odoo/odoo/blob/12.0/addons/point_of_sale/static/src/js/devices.js#L298-L340). 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 [ref](https://github.com/odoo/odoo/blob/18.0/addons/point_of_sale/static/src/app/hardware_proxy/hardware_proxy_service.js#L141).

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 [ref](https://github.com/odoo/odoo/blob/18.0/addons/point_of_sale/static/src/app/hardware_proxy/hardware_proxy_service.js#L80-L91).

### 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 [ref](https://github.com/odoo/odoo/blob/18.0/addons/point_of_sale/static/src/app/store/pos_store.js#L245). 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 [ref](https://github.com/OCA/pos/blob/18.0/pos_payment_terminal/static/src/app/pos_store.esm.js):

```javascript
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 [ref](https://github.com/OCA/pos/blob/18.0/pos_payment_terminal/models/pos_payment_method.py) 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.


## References

Odoo 12.0, "load_server_data() — sequential model loading, models.js:533-603": https://github.com/odoo/odoo/blob/12.0/addons/point_of_sale/static/src/js/models.js#L533-L603
Odoo, "commit 97b0723722d8 — add pos_cache ported from odoo-extra (Feb 2015, 9.0)": https://github.com/odoo/odoo/commit/97b0723722d8
Odoo 14.0, "pos_cache manifest — module description": https://github.com/odoo/odoo/blob/14.0/addons/pos_cache/__manifest__.py
Odoo 14.0, "pos_cache module — pos_cache.py": https://github.com/odoo/odoo/blob/14.0/addons/pos_cache/models/pos_cache.py
Odoo, "commit d2afca280be0 — remove pos_cache (Oct 2022, 16.0)": https://github.com/odoo/odoo/commit/d2afca280be0
Odoo, "commit 8fb53c53c312 — remove Backbone.js, single loading request (Oct 2021, 15.0)": https://github.com/odoo/odoo/commit/8fb53c53c312
Odoo 18.0, "load_data() — pos_session.py:171": https://github.com/odoo/odoo/blob/18.0/addons/point_of_sale/models/pos_session.py#L171
Odoo 18.0, "_load_pos_data_models() — pos_session.py:138": https://github.com/odoo/odoo/blob/18.0/addons/point_of_sale/models/pos_session.py#L138
Odoo 18.0, "get_limited_products_loading() — ranked product query, pos_config.py:840-875": https://github.com/odoo/odoo/blob/18.0/addons/point_of_sale/models/pos_config.py#L840-L875
Odoo 18.0, "limited_product_count (20000) / limited_customer_count (100) defaults — pos_config.py:1176-1181": https://github.com/odoo/odoo/blob/18.0/addons/point_of_sale/models/pos_config.py#L1176-L1181
Odoo 18.0, "databaseTable / databaseIndex — IndexedDB tables vs in-memory indexes, data_service_options.js": https://github.com/odoo/odoo/blob/18.0/addons/point_of_sale/static/src/app/models/data_service_options.js
Odoo 18.0, "connectToProxy() awaited in setup() — pos_store.js:154-156": https://github.com/odoo/odoo/blob/18.0/addons/point_of_sale/static/src/app/store/pos_store.js#L154-L156
Odoo 12.0, "connect_to_proxy() and /hw_proxy/hello retry — devices.js:587": https://github.com/odoo/odoo/blob/12.0/addons/point_of_sale/static/src/js/devices.js#L587
Odoo 12.0, "find_proxy() — parallel LAN scan of 192.168.0/1.x and 10.0.0.x, devices.js:298-340": https://github.com/odoo/odoo/blob/12.0/addons/point_of_sale/static/src/js/devices.js#L298-L340
Odoo 18.0, "checkProxyAvailability() — retry loop, hardware_proxy_service.js:141": https://github.com/odoo/odoo/blob/18.0/addons/point_of_sale/static/src/app/hardware_proxy/hardware_proxy_service.js#L141
Odoo 18.0, "autoconnect() — never-resolving promise FIXME, hardware_proxy_service.js:80-91": https://github.com/odoo/odoo/blob/18.0/addons/point_of_sale/static/src/app/hardware_proxy/hardware_proxy_service.js#L80-L91
Odoo 18.0, "useProxy() — is_posbox proxy trigger, pos_store.js:245": https://github.com/odoo/odoo/blob/18.0/addons/point_of_sale/static/src/app/store/pos_store.js#L245
OCA pos 18.0, "pos_payment_terminal — useProxy() patch, pos_store.esm.js": https://github.com/OCA/pos/blob/18.0/pos_payment_terminal/static/src/app/pos_store.esm.js
OCA pos 18.0, "pos_payment_terminal — payment method payload fields, pos_payment_method.py": https://github.com/OCA/pos/blob/18.0/pos_payment_terminal/models/pos_payment_method.py

