Offline-First and Background Sync (PWA)
In short: User actions (e.g. “add to cart”) aren’t discarded when there’s no internet connection, but are stored locally in the browser and automatically replayed as soon as the connection is back.
In more detail: IndexedDB (instead of localStorage) is used because a service worker has to be able to access it in the background, even when the actual page isn’t open at all — localStorage can’t be reached from the service worker context. The Background Sync API (Chrome/Edge/Android only) lets the service worker trigger the replay even when the page is closed; as a universal fallback (also works on Safari/iOS), a simple 'online' browser event listener is used in addition.
Our context: app/utils/offlineQueue.ts at Emzett (CART_QUEUE_STORE) for the shopping cart — with an idempotent sync via /api/cart/sync, so that a duplicate sync attempt (Background Sync AND the 'online' event) doesn’t create duplicate entries.
In Depth
Idempotency as the core requirement
“Idempotent” is the key word here: since both the Background Sync API and the 'online' event fallback can trigger the same sync (and theoretically both could even fire at the same time if the browser comes online at exactly that moment), the server has to be built so that sending the same action twice has NO duplicate effect — typically via a unique client-generated ID per action (usually a UUID, generated locally when the action is created), which the server recognises on the second arrival and ignores, instead of adding the item to the cart a second time. This ID must be generated BEFORE the actual sync (not only when sending), so that it stays identical on every sync attempt — if a new ID were generated on each attempt, the idempotency check would be ineffective.
Typical flow
The user clicks “add to cart” without a connection → the action lands in the IndexedDB queue immediately AND is shown optimistically in the UI as if it had already gone through → as soon as navigator.onLine becomes true again (or the service worker is woken up via Background Sync), a sync handler processes the queue in order and sends each action to the server → on success the queue entry is deleted, on failure it’s kept for a later attempt. The optimistic UI update is a deliberate choice: a user shopping offline shouldn’t get the feeling that the app is “broken” — instead the action appears successful immediately, with a subtle hint (“will be synced once you’re back online”) if something actually fails.
self.addEventListener("sync", (event) => {
if (event.tag === "cart-sync") {
event.waitUntil(processQueuedCartActions());
}
});Why IndexedDB instead of localStorage
localStorage is synchronous and briefly blocks the main thread, which can cause noticeable stutters with larger amounts of data; IndexedDB, on the other hand, is asynchronous and designed for structured, larger amounts of data (object stores with indexes, transactions). But the decisive point here is a different one: localStorage is bound to the document’s main thread and simply can’t be reached from a service worker context — a service worker runs in its own thread, independent of the document, precisely so that it can also work when the page itself isn’t open at all. IndexedDB is one of the few storage APIs that is equally accessible from both contexts (document AND service worker).
Limits of the Background Sync API
The Background Sync API is deliberately no guarantee of immediate execution — the browser decides by itself when it actually triggers the sync (depending on network quality, battery level, etc.), but typically within a few seconds of reconnecting. Browser support is also inconsistent: Safari (and therefore all browsers on iOS, since Apple enforces its own WebKit engine there) still doesn’t support the API — which is why the 'online' event fallback isn’t an optional extra but strictly necessary for cross-platform reliability.
See also: proxy.ts, Connectionless protocol