A shop manager messaged us mid-Saturday: “There’s a Recovery Session on the dashboard, what do I do?” The consultant on call spent twenty minutes checking network logs before realizing the shop’s connection was fine. The problem was a closed session on the server, not a network drop in the browser. Same symptom, completely different fix.
This article explains what Odoo POS Recovery Sessions are, how they work across Odoo 16, 17, and 18, and how to resolve them. It also covers offline mode separately, because conflating the two is the most reliable way to waste time on the wrong diagnosis.
Recovery Session vs Offline Mode
These are two separate mechanisms. They have different triggers, different symptoms, and different resolutions.
Offline mode (browser-side)
Offline mode is a browser state. The trigger is the browser firing a native offline event, which happens at the OS network level, not from a failed HTTP request.
When the browser fires offline, the POS frontend sets an internal flag (network.offline = true). Every subsequent RPC call immediately throws a ConnectionLostError before any network request is made. The cashier sees a “Connection Lost” dialog once, then a sync icon appears in the POS navbar. Non-critical operations are queued for retry.
When the network returns, the browser fires online. The frontend calls syncData() automatically and flushes the queue. The cashier can continue taking orders locally while offline. In Odoo 16 and 17, draft orders are kept in browser memory and localStorage. In Odoo 18, they are mirrored to IndexedDB and survive a full page refresh.
Recovery session (server-side)
A recovery session (called a rescue session internally) is a pos.session record with rescue = True. It is a server-side safety net created when orders are pushed to a session that is already closed or closing.
The cashier typically sees nothing unusual on the POS frontend at the time. The evidence appears later: the POS backend dashboard shows a “Recovery Sessions” counter on the relevant POS config card.
Recovery sessions are resolved from the backend dashboard. They cannot be closed from the POS UI.
The diagnostic question
When a shop reports a problem, ask one question first: “Is the POS showing a ‘Connection Lost’ message in the browser, or are you looking at the backend dashboard?”
Connection Lost dialog in the browser: offline mode. Check network connectivity, wait for auto-sync.
Recovery Session counter on the backend dashboard: rescue session. Follow the handling steps in Section 4.
What Triggers a Recovery Session
The primary trigger is an order being pushed to a session that is in closing_control or closed state on the server. The most common scenario:
- Terminal A and Terminal B are both in the same POS session.
- Terminal A’s operator closes the session from the POS UI.
- Terminal B still has a pending order (the cashier has not yet validated it, or the validation RPC was in flight when the session closed).
- Terminal B attempts to push the order to the server.
What happens next depends on the Odoo version.
v16 and v17: silent rescue session creation
In Odoo 16 and 17, the server’s _get_valid_session() method auto-creates a new pos.session with rescue = True when it detects the original session is closed. The new session is named (RESCUE FOR Shop/00003) (using the original session’s name). The order is saved into this rescue session. Terminal B receives a success response and the cashier sees no error.
# pos_order.py:84-114 (v17, identical in v16)
def _get_valid_session(self, order):
PosSession = self.env['pos.session']
closed_session = PosSession.browse(order['pos_session_id'])
rescue_session = PosSession.search([
('state', 'not in', ('closed', 'closing_control')),
('rescue', '=', True),
('config_id', '=', closed_session.config_id.id),
], limit=1)
if rescue_session:
_logger.warning('reusing recovery session %s for saving order %s', rescue_session.name, order['name'])
return rescue_session
_logger.warning('attempting to create recovery session for saving order %s', order['name'])
new_session = PosSession.create({
'config_id': closed_session.config_id.id,
'name': _('(RESCUE FOR %(session)s)', session=closed_session.name),
'rescue': True, # avoid conflict with live sessions
})
new_session.action_pos_session_open()
return new_session
The rescue session then appears on the backend dashboard, waiting to be reviewed and posted.
v18: no auto-creation
In Odoo 18, _get_valid_session() was simplified. It searches for any non-closed session on the same POS config. If it finds one (an open live session or a previously existing rescue session), it uses it. If nothing is open, it raises a UserError:
No open session available. Please open a new session to capture the order.
This is a breaking behavioral change from v16/v17. In v18, rescue sessions do not appear automatically. If a UserError appears instead of a rescue session, there is no open session at all on that POS config. The fix is to open a new session from the dashboard, not to reload the browser.
Note: the exact production scenario that generates a rescue session in v18 (as opposed to raising a UserError) is worth confirming on your specific environment. The most likely paths are: a live session was still open on a different terminal, or a previously created rescue session had not been posted yet.
Other trigger scenarios
- Admin closes the session from the Odoo backend while a terminal is mid-transaction.
- Network drop causes the frontend to lose sync, then reconnect to find the session was closed by another user while it was offline.
In both cases, when any user closes a session, a websocket notification (CLOSING_SESSION) is broadcast to all connected POS terminals. Each terminal attempts a final syncAllOrders, then reloads the page and redirects to the backend dashboard.
How It Works Under the Hood
Python flow (server-side, v18)
The frontend calls pos.order.sync_from_ui() (this replaced create_from_ui from v16/v17). Inside _process_order(), the server checks the session state:
# pos_order.py:80-82 (v18)
pos_session = self.env['pos.session'].browse(order['session_id'])
if pos_session.state == 'closing_control' or pos_session.state == 'closed':
order['session_id'] = self._get_valid_session(order).id
If the session is closed or closing, the server silently reassigns the order to a different session. The order is not lost.
_get_valid_session() in v18:
# pos_order.py:36-55 (v18)
open_session = PosSession.search([
('state', 'not in', ('closed', 'closing_control')),
('config_id', '=', closed_session.config_id.id)
], limit=1)
if open_session:
return open_session
raise UserError(
_('No open session available. Please open a new session to capture the order.')
)
In v16/v17, this same method created a rescue session automatically:
# pos_order.py:84-114 (v17) — the block that no longer exists in v18
new_session = PosSession.create({
'config_id': closed_session.config_id.id,
'name': _('(RESCUE FOR %(session)s)', session=closed_session.name),
'rescue': True,
})
new_session.action_pos_session_open()
return new_session
In v18, that creation block is gone. If no open session is found, the call fails with a UserError.
Frontend session swap (v18)
When sync_from_ui returns a pos.session record (because the order was moved to a different session), syncAllOrders() in the POS store detects this and swaps the local session object:
// pos_store.js:1299-1331 (simplified)
if (newSession) {
// discard old session, reassign all local draft orders to new session
session.delete();
draftOrders.forEach(order => order.session_id = this.session);
}
The cashier sees no interruption. The next order continues in the new session.
Rescue session properties
Rescue sessions have three important properties regardless of version:
Uniqueness constraint bypass: the constraint that prevents two open sessions for the same POS config is filtered to rescue = False. A rescue session can coexist with a live session.
No sequence name: rescue sessions skip the ir.sequence assignment. They keep a static name (e.g. (RESCUE FOR Shop/00003) in v16/v17) rather than getting a new sequence number.
No opening cash count: the opening balance is inherited from the last closed session. At closing, the expected cash balance is auto-computed from actual payment lines recorded in the rescue session.
IndexedDB (v18 only)
Odoo 18 introduced continuous IndexedDB mirroring: draft orders, order lines, and payments are written to the browser’s local database every 200ms. On startup, the frontend loads IndexedDB data before the server response.
The practical effect: a browser crash or forced page reload no longer requires a rescue session. The draft order is already in IndexedDB and reloads immediately. This eliminates a whole class of rescue session scenarios that existed in v16/v17, where a browser crash mid-order would leave an orphan order that needed a rescue session to recover.
Handling a Recovery Session Step by Step
Standard flow
- Open the POS backend. On the POS config card, look for the “Rescue Session” badge or counter. Click it to open the rescue session form.
- Review each order in the rescue session. Check the state (
paid,invoiced, ordraft) and verify that the payment lines match the amounts actually collected. - For any order still in
draftstate: check whether payment was physically collected (cash drawer, payment terminal log). If yes, add the payment line manually. If no, cancel the order. - Once all orders are in a final state, click “Close” on the rescue session from the backend. This posts all orders to accounting.
The rescue session cannot be closed from the POS frontend. It must be handled from the backend dashboard.
Edge case: cash control enabled
If the POS config has cash control enabled, rescue sessions behave differently from regular sessions:
- The opening cash count is skipped entirely. There is no prompt for the opening balance.
- At closing, the expected closing balance is auto-computed from the actual cash payments recorded in the rescue session. No manual count is required from the operator.
- Action: proceed directly to reviewing order states and posting. Do not expect or ask for a manual cash count.
Edge case: duplicate order risk
v18: safe by default. Orders are matched by UUID server-side. If the same UUID already exists and the order is non-draft, the sync is silently ignored. No duplicate is created.
v16/v17: verify manually. Deduplication uses a pos_reference string match plus the _is_the_same_order() heuristic. After a recovery, check the session for duplicate paid orders before posting, particularly if the original sync failed mid-way.
Edge case: “Session already closed by another user”
This message appears when a terminal tries to close a session that was already closed. The backend returns {'redirect': True} and the frontend auto-reloads to the backend dashboard.
Next steps: check the dashboard for a rescue session (v16/v17) or verify whether there is an open session at all (v18). If v18 shows no rescue session and no open session, open a new session before accepting further orders.
Version Reference
Frontend order push method
- v16 and v17:
create_from_ui - v18:
sync_from_ui
Rescue session auto-creation
- v16 and v17: auto-created by
_get_valid_session()with name(RESCUE FOR Shop/00003). Terminal B receives a success response. - v18: no auto-creation. The server finds any open session or raises
UserError. No rescue session appears unless one was already open.
Frontend session awareness after session swap
- v16 and v17: the frontend has no session swap logic. It does not detect that orders were moved to a different session.
- v18:
syncAllOrders()detects thenewSessionflag and replaces the local session object. Draft orders are reassigned to the new session automatically.
Offline data persistence
- v16 and v17: orders stored in browser memory and localStorage. A page refresh loses any unsaved draft orders.
- v18: continuous IndexedDB mirroring. Draft orders survive a page refresh or browser crash without a rescue session.
Order deduplication
- v16 and v17:
pos_referencestring matching plus_is_the_same_order()heuristic. - v18: UUID-based lookup. More reliable and less prone to false matches.
“Session already closed” user message
- v16: plain error, no mention of rescue session or dashboard.
- v17: richer message mentioning the Rescue Session and directing the user to the dashboard. First appearance of this UX.
- v18: same as v17.
Upgrade watch points (v17 to v18)
The removal of rescue session auto-creation is the most significant behavioral change. Any workflow that relied on orders silently landing in a rescue session after the main session closed will now raise a UserError instead. The correct operational response is to ensure at least one session remains open while orders are being pushed.
The positive side: fewer rescue sessions in practice on v18 stable deployments, because IndexedDB eliminates the browser-crash recovery scenario.
Reproducing the Scenario for Testing
Use these methods to simulate both mechanisms in a dev or staging environment before a client call or training session.
Method 1: Browser DevTools (offline mode)
- Open POS in Chrome or Firefox.
- Open DevTools. Go to the Network tab.
- Set the throttling to “Offline”.
- Attempt to validate an order. The “Connection Lost” dialog appears after the first blocked RPC.
- Restore throttling to “No throttling” (online). The
onlineevent fires,syncAllOrdersDebounced()runs, and pending orders sync.
This tests the offline mode path only. It does not create a rescue session.
Method 2: Tour test helper (automated)
The point_of_sale module ships a test utility at static/tests/tours/utils/offline_util.js that can be used in browser console or Playwright tours:
setOfflineMode(); // monkey-patches window.fetch to throw ConnectionLostError
// run test steps here
setOnlineMode(); // restores originals
Use this for repeatable automated tests or when the DevTools approach is inconvenient.
Method 3: Rescue session scenario (two browser tabs)
This reproduces the rescue session path in v16/v17. For v18, the outcome depends on whether another session is open.
- Open two browser tabs logged into the same POS session.
- On Tab 2: navigate to close the session from the POS UI. Complete the closing flow.
- On Tab 1: add items to an order and attempt to validate.
- Tab 1 receives the
CLOSING_SESSIONwebsocket notification, attempts a final sync, and reloads to the backend dashboard. - On v16/v17: check the dashboard for the rescue session. On v18: check whether a session swap occurred or a
UserErrorwas raised.
Method 4: IndexedDB inspection (v18 only)
This confirms that draft orders survive a page reload without a rescue session.
- Open POS in Chrome. Create a draft order with one or more items.
- Open DevTools. Go to Application, then IndexedDB. Find the database named
config-id_<N>_<token>. - The draft order, its lines, and any payments are mirrored there.
- Close the browser tab entirely. Reopen the POS.
- The draft order loads from IndexedDB before the server response. No rescue session is created.
Key Takeaways
Three facts that change how you respond to a rescue session incident:
Rescue session is not offline mode. Ask whether the problem is a browser “Connection Lost” message or a backend dashboard badge. The answer determines the entire response path.
v18 removed auto-creation. In v16/v17, orders hitting a closed session silently create a rescue session. In v18, that same condition raises a UserError. The fix in v18 is to open (or confirm the existence of) an open session.
Rescue sessions close from the backend, not the POS UI. Review order states, handle payment gaps, post. There is no close button in the POS frontend for rescue sessions.
Sources
[1] point_of_sale/models/pos_session.py:85-88 (v18): rescue field definition on pos.session
[2] point_of_sale/models/pos_session.py:295-302 (v18): uniqueness constraint filtered to rescue = False
[3] point_of_sale/models/pos_session.py:377-385 (v18): opening cash balance skipped for rescue sessions
[4] point_of_sale/models/pos_session.py:401-412 (v18): closing cash balance auto-computed for rescue sessions
[5] point_of_sale/models/pos_session.py:657-676 (v18): _cannot_close_session returning redirect with rescue session message
[6] point_of_sale/models/pos_order.py:36-55 (v18): _get_valid_session finds open session or raises UserError
[7] point_of_sale/models/pos_order.py:80-82 (v18): _process_order session state check
[8] point_of_sale/models/pos_order.py:1110-1158 (v18): sync_from_ui main entry point
[9] point_of_sale/models/pos_order.py:1000-1001 (v18): UUID-based order deduplication
[10] point_of_sale/static/src/app/store/pos_store.js:1299-1331 (v18): syncAllOrders session swap logic
[11] point_of_sale/static/src/app/store/pos_store.js:259-296 (v18): closingSessionNotification websocket handler
[12] point_of_sale/static/src/app/models/data_service.js:38-66 (v18): network state and browser online/offline event listeners
[13] point_of_sale/static/src/app/models/data_service.js:336-338 (v18): RPC short-circuit when network.offline is true
[14] point_of_sale/static/src/app/models/data_service.js:118-188 (v18): syncDataWithIndexedDB continuous mirroring
[15] point_of_sale/static/src/app/errors/error_handlers.js:34-49 (v18): single-shot offline dialog
[16] point_of_sale/static/tests/tours/utils/offline_util.js (v18): setOfflineMode() and setOnlineMode() test helpers
[17] point_of_sale/models/pos_order.py:76-111 (v16): _get_valid_session with rescue session auto-creation
[18] point_of_sale/models/pos_order.py:84-114 (v17): _get_valid_session with rescue session auto-creation — the PosSession.create(...) block that was removed in v18
[19] point_of_sale/models/pos_session.py:513-523 (v16): plain “session already closed” message
[20] point_of_sale/models/pos_session.py:522-541 (v17): first appearance of rescue session mention in closing message