Appearance
The offline write-ahead log
A queued message reached the server, but the browser closed before deleting its local queue entry. On the next launch, the client sent the same message again. The database rejected the duplicate message ID, yet a second background job was created and the user received two replies.
The queue had preserved the write. It had not made replay safe.
An offline queue is best understood as a small write-ahead log, or WAL. The client records intent before it depends on the network. Later, a flusher submits entries in order and removes each entry only after the server confirms it.
The shape of the mechanism
text
interaction
|
v
append durable entry -> overlay pending result
|
v
wait for connected state
|
v
single flusher -> server transaction -> confirmation -> delete entry
|
+-> idempotency guardThe WAL is not a second application database. It does not answer arbitrary queries or hold the final shared state. It records operations that have not crossed the authority boundary.
Vocabulary
Log entry. A serializable operation with a stable ID, operation name, arguments, and creation time.
Replay. Submitting a stored entry after connectivity returns.
Idempotency. Repeating the same logical operation has the same effect as applying it once.
Overlay. A derived view that applies pending entries over the synchronized replica.
Coalescing. Rewriting or removing queued operations when a later action makes them redundant.
Poison entry. An operation that will never succeed and blocks later entries if the flusher cannot classify it.
Acknowledgement boundary. The point after which the client may safely remove the entry.
Store operations, not screenshots of state
A useful entry names a domain action:
ts
type PendingWrite =
| {
id: string;
kind: "notes.create";
args: {noteId: string; title: string};
createdAt: number;
}
| {
id: string;
kind: "notes.rename";
args: {noteId: string; title: string};
createdAt: number;
}
| {
id: string;
kind: "notes.delete";
args: {noteId: string};
createdAt: number;
};Do not store a whole UI snapshot. A snapshot makes replay dependent on the page version that created it and obscures the user's actual request. Domain operations are easier to validate, migrate, coalesce, and make idempotent.
Keep the format narrow. If arbitrary function names and arguments can enter the log, old clients can invoke code that no longer exists or bypass new validation.
Flush one confirmed entry at a time
The safest first implementation is FIFO. It is slower than batch replay, but its failure behavior is obvious.
ts
async function flushPending() {
if (connection.state !== "connected") return;
await navigator.locks.request("pending-write-flush", async () => {
for (;;) {
const entry = await queue.peek();
if (!entry) return;
try {
await api.apply(entry, {idempotencyKey: entry.id});
await queue.remove(entry.id);
} catch (error) {
if (isTransient(error)) return;
if (isPermanent(error)) {
await queue.remove(entry.id);
await failures.record(entry, error);
continue;
}
throw error;
}
}
});
}A Web Lock keeps two tabs from flushing the same head concurrently. It is coordination, not the final correctness guard. A browser can crash after the server commits and before queue.remove. The server still needs an idempotency key or a unique constraint.
The acknowledgement must cover all side effects. If one request inserts a message and schedules a job, both writes belong in one server transaction. Deduplicating only the visible row still allows duplicate work.
Coalesce before replay
Queue order should preserve user intent, but literal replay can do useless work.
Suppose a user creates a note offline, renames it twice, then deletes it before reconnecting. The server never needs to see that note. Remove the create and every dependent entry.
Useful rules include:
- A rename of an unflushed create rewrites the title in the create entry.
- A delete of an unflushed create removes the whole local history for that record.
- A delete of an existing record drops later queued edits for that record.
- Repeated "set value" operations may keep only the last value.
- Increment operations must not collapse into "set total" unless the merge rule supports it.
Coalescing belongs in tested pure functions. It is easy to produce resurrection bugs when delete handling is scattered across UI components.
Error classes are part of the protocol
The flusher needs more than success or failure.
A transient failure means "keep the head and retry later." Network loss, a timeout, and a temporary server error fit.
A permanent failure means "this operation cannot become valid." A target deleted on another device or a rejected old schema may fit. Record the failure for the UI, remove that entry, and continue. One poison entry should not block a week of later work.
Authentication expiry needs separate treatment. Keep the queue, stop flushing, and ask the user to sign in. Deleting drafts because a session expired turns an access problem into data loss.
Rate limiting is also distinct. Keep the entry and retry after the server's stated delay. A tight retry loop makes the outage worse.
Why not use Background Sync?
Background Sync can wake a service worker later, but support and scheduling vary. It also does not solve ordering, idempotency, coalescing, or domain errors.
A reliable baseline flushes when the app opens and the sync connection becomes ready. Add background execution only when users need delivery while the app stays closed and the target browsers support it.
Other rejected choices have similar limits:
Trust the sync library's temporary buffer. Some buffers live only in memory or expire after a disconnect timeout.
Delete before awaiting confirmation. A failed request then loses the action.
Send the whole queue in parallel. Independent records may allow it later. Starting there makes dependencies and partial failures harder to explain.
Treat every server error as retryable. A malformed entry can block the queue forever.
Operational failures to expect
- A schema deployment removes an operation name still stored by old clients.
- The queue contains valid JSON that exceeds the current request limit.
- Two tabs use different queue databases because their origins differ.
- A stable hostname changes, producing a new IndexedDB namespace.
- Storage pressure evicts IndexedDB.
- A client retries after the server committed only part of a non-transactional operation.
- A read-only capability receives queued writes created before the role changed.
- Clock-based ordering puts a later action before an earlier action.
Version entries if their interpretation can change. Keep a bounded dead-letter record for permanent failures. Show users which action did not sync and why.
Field checklist
The WAL preserves user intent, but the app itself still has to start when the network is absent. Chapter 13, Service workers without superstition separates shell caching from data synchronization. For the user-facing model behind this chapter, see Chapter 11.