The background delivery from the previous chapter is convenient, but you cannot build a whole product on it: Background Sync exists in Chromium and is absent, for instance, on iOS. A reliable app must flush deferred operations without it too - using the open tab itself. This is foreground sync: the same queue (outbox) in IndexedDB, but triggered by ordinary page events rather than a system scheduler.
There are several reasons to start a flush, and relying on one is risky. A sensible set: app start, the online event, the tab returning to a visible state, a Retry button, and the moment right after a successful API response. That said, online is only a reason to try, not proof of connectivity: a captive portal or a dead mobile channel may sit between you and the server. So online does not change the queue state directly - it triggers an attempt whose outcome the real request decides.
There are many triggers, and many tabs and workers too - so you need protection against parallel sending, or two tabs will grab the same operation and create a duplicate on the server. A single-flight lock guarantees that only one participant runs flushOutbox() at any moment. It is practical to use the Web Locks API (navigator.locks.request) with a fallback to a lease record in IndexedDB - a short expiring lease the holder renews, so a hung tab does not hold the queue forever.
Inside the lock, before sending, reread the operation and make sure it was not cancelled or already sent in another tab. Attach an Idempotency-Key to every request so a retry after a drop does not create a second note. Responses fall into classes: 4xx is usually permanent or a conflict and is not fixed by retrying, while 408, 429 and 5xx are retryable - and for 429 and 503 you must respect Retry-After. Between attempts, use exponential backoff with full jitter, or all clients hit the server at once (a thundering herd).
Delivery solves transport, not agreement. As soon as one note is edited on two devices, a conflict appears, and the simplest answer - last-write-wins, whoever wrote last is right - is more dangerous than it looks. It is trivial in code and silently destroys someone's work: an edit vanishes without a trace. For low-value data that is tolerable; for notes and orders it is not.
To discuss a conflict, the client must remember the base - the snapshot of the record and its version (baseVersion), or you see two different texts but cannot tell what changed and on whose side. The server answers a version mismatch with 409 Conflict and returns its version and the common ancestor, and instead of a scary Error 409 the app shows a conflict screen: here is your version, here is the server's, here is the original. Deletion needs care: you cannot physically erase a record, or an old client resurrects it on the next sync - instead you set a tombstone, a record marked as deleted.
There is no single correct policy - the choice depends on the value of the data and the collaboration model. Server wins and client wins are simple, but each loses one side. LWW by timestamp fits only where data is cheap and the server controls the clock. For ordinary CRUD entities, optimistic concurrency by version is reasonable - at the cost of a conflict-resolution UI. Field merge across independent fields helps when edits do not overlap, and CRDTs honestly solve collaborative editing at the cost of complexity and metadata. The telling failure is the same: two people worked offline, synced - and one silently lost an hour of work because the system decided for them.
| Policy | When it fits | Risk |
|---|---|---|
| Server wins | Local edit is not critical | Loss of local intent |
| Client wins | Single owner, full replacement acceptable | Overwriting the remote update |
| LWW timestamp | Low value, clock controlled by the server | Loss of causality |
| Optimistic concurrency | Ordinary CRUD entities | Requires a conflict UI |
| Field merge | Independent fields | Semantic conflicts |
| CRDT | Collaborative editing | Complexity and metadata |
PUT /notes/n-42
X-Base-Version: 7
Idempotency-Key: 7bf4...
409 Conflict
{
"serverVersion": 8,
"server": { "title": "Remote", "body": "..." },
"base": { "title": "Old", "body": "..." }
}const triggerFlush = debounce(() => flushOutbox(), 300);
addEventListener('online', triggerFlush);
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') triggerFlush();
});
function retryDelay(attempt) {
const cap = Math.min(60_000, 1000 * 2 ** attempt);
return Math.random() * cap; // full jitter
}