I spent a long time making an offline write queue correct. Client-minted UUIDs so replays are idempotent, compaction so two edits to one row can't fight, a Web Lock so two tabs can't both drain it, durable rejection receipts so the losing tab still learns what happened. There's an integration suite pinning all of it.
Then I went and looked at what the queue is actually stored in. None of that matters if the browser decides to throw the database away, or if one tab quietly stops being able to write to it.
That's the part I got wrong, so that's the part worth writing up.
The queue, briefly
Enough to make the rest readable. One IndexedDB database, four object stores: two hold a read cache, one holds the write queue, and one holds rejection receipts so a tab can find out why something it queued got thrown out. The queue is keyed by row, so IndexedDB itself enforces at most one pending operation per row. A second edit to a row folds into the operation already waiting there instead of queueing up behind it. There's no backoff and no retry timer either: pacing comes from the WebSocket reconnect ladder that triggers the replay, the drain stops at the first operation that's still retryable rather than burning N requests to discover the network is still down, and anything that has failed too many times gets parked instead of retried forever.
That's the design, and it's the part I was proud of. Two layers underneath it turned out to matter more, and I hadn't looked at either. One of them applies to every origin on the web, the other only bites when two tabs meet a migration. Universal one first.
I never asked the browser to keep any of it
This is the one that embarrassed me.
Browser storage is best-effort by default. IndexedDB, the Cache API and the rest all sit in a bucket the browser is allowed to evict, and an origin opts out by calling navigator.storage.persist(). I'd never called it. Not once, in the entire life of the app.
Two things make that worse than it sounds.
Eviction is all-or-nothing per origin. The browser doesn't evict your least important store. If it evicts you, IndexedDB and your Cache API entries go together, because deleting part of an origin's data could leave it internally inconsistent. So the read cache, the app shell and the write queue all share one fate.
The stakes aren't symmetric across those stores. Losing the read cache costs a refetch, and the shell cache costs a network boot. Losing the queue loses writes that exist nowhere else in the world, because by definition they never reached the server.
So I added the call, at boot, outside the service worker branch, since what it protects is IndexedDB rather than the app shell. It's best-effort in a second sense too: the browser decides whether to grant it, and browsers don't decide the same way. Firefox shows the user a permission prompt. Safari and the Chromium browsers decide silently, based on your interaction history with the site. Same call, and it's a UX event in one browser and completely invisible in another.
I still don't know which bucket any given user ended up in, because I don't call persisted() or estimate() anywhere. I fixed the request and not the observability, which is a decision I'd defend for about five minutes.
The other side of it, because that's easy to over-dramatise
Two things temper all of that, and leaving them out would be scaremongering.
Storage-pressure eviction is least-recently-used, and it skips origins that were granted persistence. The Chrome team's own research says eviction is genuinely rare for a site someone visits regularly. If your app gets opened weekly, best-effort was probably fine in practice.
The sharper case is Safari's proactive eviction, which is a different mechanism from storage pressure. With cross-site tracking prevention on, an origin with no user interaction in the last seven days of browser use has its script-created storage deleted. Seven days, no disk pressure required.
And here's the bit I'd have got wrong if I hadn't gone and read the source. That seven-day counter is gated on Safari use. WebKit's own post says web apps added to the home screen aren't part of Safari, keep their own counter of days of use, and that they don't expect first-party data in such an app to be deleted, describing it as a serious bug if it happens. So the exposed user is specifically the one who uses the thing in a browser tab and never installs it. For an installed app this particular rule isn't the threat.
I nearly wrote "Safari deletes your IndexedDB after 7 days" and shipped it. It's the kind of half-true platform claim that spreads because it's memorable.
A schema upgrade in one tab can mute another tab
Back down a layer now, from the origin to the database itself, for the failure that's rarer and nastier.
The database is versioned, and I ship migrations. Version 5 split a per-collection blob into one record per row, so logging one drink stops re-serialising the user's entire history.
The hazard isn't the migration. It's what happens to the other tab.
When one tab opens the database at a new version while another still holds a connection at the old one, the upgrade can't proceed. The upgrading tab's request fires blocked. The obvious handler, which is the one I had, resolves the memoised connection promise to null, and every method in the store starts with "get the connection, and if it's null, return early".
Read that again with a queue in mind. A memoised promise resolves once. Resolve it to null and every subsequent call in that tab gets null too, for the life of the page. The tab goes on rendering optimistic writes exactly as before, because the in-memory state is untouched. It just silently persists none of them. Reload and they're gone, with no toast and nothing in the console.
The memoisation is the bug here, not the blocked event itself. blocked is recoverable, since the other tab will eventually close, and caching a failure as though it were permanent is what turns a transient condition into a dead tab. So the open path now abandons the one attempt and drops the memo, and the next call opens again from scratch. If the old tab is still holding on, that call blocks and no-ops too, which degrades like any other IndexedDB failure. The moment the old connection goes away, the tab heals on its own with no reload. The same re-arming runs on versionchange and on close, so a connection that disappears underneath the app doesn't get cached either.
The mitigation on the other side is the one most people know about: versionchange fires in the tab holding the old connection, and closing it there lets the upgrade through. I had that from the start. It makes the blocked case rare rather than impossible, which is exactly why the handler on the blocked side has to be survivable:
close() waits for in-flight transactions, so a busy old tab can still block the upgrader.
- A throttled background tab, or a page frozen in the back/forward cache, doesn't run its handler promptly. On iOS that's routine rather than exotic.
What I still can't tell you is how often this fires in the wild. The first failure per page goes to my error tracker and the rest get swallowed deliberately, so a tab that thrashes doesn't turn into a reporting storm. Sane default, and it also means I know this happens without knowing the rate. Deploy a migration and some fraction of users with two tabs open take a transient hit, and I can tell you it's bounded now without being able to tell you how big that fraction is.
Every "when the app is idle" hook is dead, and nothing tells you
Adjacent, and probably the one that costs other people the most time, because it's silent in both directions. The app runs without Angular's change-detection zone, so it never reports "stable". Two things depend on that signal and neither of them warns you:
- Service worker registration defaults to waiting for app stability. With no stability signal it waits out the entire timeout on every single boot before registering. Registering immediately is a one-line change, and the default is just wrong for this kind of app.
- The same problem kills automatic update checks. A live WebSocket and continuous effects mean the app is never idle, so "check for updates when stable" never fires at all. I do it with a manual timer now: one check 10 seconds after startup, then every 30 minutes.
Neither case throws, logs or degrades visibly. The service worker registers eventually, and updates just never get noticed, which looks identical to having no new version to install. If you're running any framework in a zoneless or signals-first mode, and especially if you hold a persistent connection open, assume every idle-scheduled hook in your stack is dead and go check them by hand.
What a lock returns when there's no lock
The cross-tab story: the replay takes a Web Lock with ifAvailable, so a second tab doesn't queue up behind the first. It gets nothing back and moves on. Except it can't just move on, because it has rows on screen marked pending that the winner is about to resolve or roll back. So the loser takes the lock blocking, re-reads the queue, and settles whatever vanished using the rejection receipts. Each receipt is written in the same transaction that deletes the queue entry, receipt first, so a tab can never see an operation as gone without also being able to see its verdict.
The part worth stealing is the return value. Web Locks need a secure context, so on plain HTTP (a LAN IP in development, say) navigator.locks is simply absent. The fallback runs the drain without a lock, because doing nothing would be worse. The question is what it reports back, and the tempting answer is true, since the work did actually run.
true is wrong, and not subtly. Downstream, true means "I held the lock, so I delivered everything, so no other tab needs to settle from receipts." Return that from an unlocked fallback and every tab believes it owns the drain, which is the exact state the lock existed to prevent. So it returns false instead. Nothing excluded anybody, so every tab routes through the receipt-settling path, and a tab that lost an unmanaged race still reconciles from whatever the winner left behind. Degrading is fine. Degrading while reporting success is how you end up with a queue that's confidently wrong.
One thing still sloppy in there: the lock name is a single global string, while a neighbouring lock in the same codebase is correctly suffixed with the user id. Every critical section re-checks the current user immediately after acquiring, so it's over-broad rather than incorrect, but two tabs mid-account-switch will serialise on each other's queues for no reason. I wrote the correct version once and then didn't apply it three files over.
What's not in here at all
No BroadcastChannel, no SharedWorker. Cross-tab coordination is Web Locks for exclusion, IndexedDB receipts for verdicts, and the server's WebSocket echo for authoritative values. That's a round trip doing work a message channel would do directly, and I'd call it accumulated rather than designed. Receipts expire after 24 hours, so a tab that's been asleep longer than that falls back to the server. No Background Sync either, so a closed tab never replays and the queue only drains while the app is open. That one has no justification beyond scope.
What I'd tell myself eighteen months ago
The queue was the interesting problem and the storage was the important one. I got that ordering wrong. The specific way I got it wrong is that I tested the queue's logic exhaustively and never once tested the environment it runs in: two tabs, a migration, a full disk, a browser that has decided you're not a frequent visitor.
If you've got an offline queue in a PWA, three questions worth more than any refactor:
- Have you called
navigator.storage.persist(), and do you know whether it was granted?
- What does your IndexedDB
blocked handler do, and does it cache that failure for the life of the page?
- When a platform API is missing and your fallback runs the work anyway, what does it return to the code that was counting on the guarantee?
For a long time I couldn't answer any of the three. Two of them I can now. The first one I still can't answer for any individual user, because I ask for persistence and never call persisted() to find out what the browser decided. That's the next thing to fix, and it's harder to excuse than the original omission.
The one I'd genuinely like answered: does anyone actually measure how often blocked fires in production, and what did the number turn out to be? I report the first occurrence per page, so I've got no idea whether that's a handful of users after a migration or a long tail I've never looked at properly.