Core Concepts

Offline Write Queue

What happens to a write when the network is not there to receive it, what happens when the server says no, how deletions fit the same model as creates and updates, and the one category of write that should never go through this system at all.

The core idea: a write is never lost, only delayed

When a channel is registered with a queueConfig, any write to it that cannot reach the server is captured and held rather than discarded. It leaves the queue in only three ways: the server accepts it, the server confirms it already has it, or the user explicitly discards it. If the server answers with an error, the write is kept and shown to the user as a failed change they can retry or discard. Your UI code for "save this note" does not need an offline branch; the same call works identically online or off.

1

Turning a channel into an offline safe write path

Add a queueConfig to any registration and every write against that channel becomes offline safe. Nothing else about the registration changes.
lib/flux.ts (excerpt)
1flux.register({
2 channel: 'notes',
3 idbKey: 'notes:mine',
4 ttl: 'medium',
5
6 ingestionType: 'COLLECTION_ALL',
7 diffBeforeUpdate: true,
8 event: 'UPDATE',
9 store: notesStoreAdapter,
10
11 queueConfig: {
12 storeName: 'notes_mutation_queue',
13 replayEndpoint: '/api/notes',
14 method: 'POST',
15 },
16})
replayEndpoint is where the queued write gets sent once it is safe to send. Until then, it sits captured locally — surviving a page refresh, a browser crash, and even a tab freeze, so nothing is lost between the moment the user hits save and the moment the network is actually available to hear about it.
Writes made while the device is online are sent directly and are not queued. The queue only holds writes that could not be sent.
2

Duplicate writes are handled for you

A user submitting the same form twice — a double tap on a slow connection, a retried request after a timeout — is a normal offline scenario, not an edge case. dedupeField and dedupeStrategy let you decide what happens when that occurs while an earlier write for the same identifier is still queued.
lib/flux.ts (excerpt)
1flux.register({
2 channel: 'waitlist_signups',
3 idbKey: 'waitlist_signups:latest',
4 ttl: 'long',
5
6 ingestionType: 'COLLECTION_ALL',
7 event: 'UPDATE',
8 store: waitlistStoreAdapter,
9
10 queueConfig: {
11 storeName: 'waitlist_queue',
12 replayEndpoint: '/api/waitlist',
13 method: 'POST',
14
15 dedupeField: 'email',
16 dedupeStrategy: 'replace',
17 },
18})
'reject' blocks the second write outright — the first one queued wins. 'replace' merges the new payload into the still queued entry, so the most recent input wins without producing two separate requests once the connection returns.
Dedupe also applies to a write that has already failed and is waiting for the user:
typescript
1// Dedupe also applies to a write that has already FAILED and is
2// waiting in your failed panel for the user:
3//
4// dedupeStrategy: 'replace'
5// A new write for the same dedupeKey merges into the failed entry
6// and makes it pending again. The user's newer input supersedes
7// whatever the server rejected, so the old failure is cleared and
8// the entry is sent again on the next reconnect.
9//
10// dedupeStrategy: 'reject'
11// A new write for the same dedupeKey is still rejected while the
12// failed entry is waiting. The user must retry or discard the
13// failed one first.
3

Storage is resilient across three tiers

Queued writes do not rely on a single storage mechanism succeeding. If the primary layer is not available — some private browsing modes restrict it — Flux automatically falls back to the next most durable option it has, rather than losing the write outright. You can observe this happening if you want to reflect it in your UI:
lib/flux.ts (excerpt)
1const flux = createFlux({
2 adapter: createSupabaseAdapter(supabase),
3
4 onStorageFallback: (layer, reason) => {
5 console.warn('[flux] Offline writes now backed by', layer, '—', reason)
6 },
7})
In practice this callback rarely fires — it exists for the narrow set of restrictive browser environments where the primary storage layer genuinely is not usable, not as something every app needs to handle.
4

Multiple open tabs never duplicate a write

If a user has your app open in two tabs and queues a write in one of them, the other tab is made aware of that queued entry without independently writing its own copy to storage. This means the queue contents — and its count, if you are displaying one — stay consistent across every open tab, and reconnecting in any one tab does not risk sending the same write twice because two tabs both thought they owned it.
Failed writes are shared the same way. A change that fails in one tab appears in the failed list of every open tab, and discarding or retrying it in one tab updates the others.
5

Queuing deletions

A DELETE is a queue entry like any other — the same three layer storage, the same dedupe machinery, the same cross tab sync, and the same failed state if the server rejects it. The only thing that changes is the method field on the entry:
typescript
1// A DELETE is a queue entry exactly like a CREATE or an UPDATE — the
2// only difference is the method field. addQueueEntrySafe() accepts
3// 'POST' | 'PUT' | 'PATCH' | 'DELETE' interchangeably, and the replay
4// engine batches and sends them the same way regardless of method.
5import { addQueueEntrySafe } from '@tsworldtech/flux'
6
7async function queueItemDelete(id: string): Promise<void> {
8 await addQueueEntrySafe(
9 {
10 channel: 'items',
11 storeName: 'items_mutation_queue',
12 endpoint: '/api/items',
13 method: 'DELETE',
14 payload: { id },
15 timestamp: Date.now(),
16
17 // Deduping a delete against itself: if the user taps delete
18 // twice while offline, 'replace' keeps a single queued DELETE
19 // entry instead of two identical ones.
20 dedupeKey: `item:${id}`,
21 dedupeStrategy: 'replace',
22 } as any,
23 'MyApp',
24 )
25}
The one case that needs its own handling is a record that was also created offline and never replayed. In that situation there is nothing server side to delete yet — the correct move is to purge the still queued create rather than queue a delete for a record the server has never seen:
typescript
1// A subtler case: the record was ALSO created offline and never
2// replayed. Now there are two queued entries for the same id — a
3// pending CREATE and a pending DELETE. Left alone, replay could send
4// the CREATE, then the DELETE, and the record would still end up
5// deleted — but it is an unnecessary extra round trip against a
6// record the server would only exist for a moment.
7//
8// The correct move is to purge the pending CREATE locally and skip
9// queuing the DELETE entirely — there is nothing on the server to
10// delete yet. This also covers a CREATE that already failed and is
11// sitting in the failed panel: removing it clears it from the panel.
12import { getAllQueueEntries, removeQueueEntry } from '@tsworldtech/flux'
13
14async function deleteItem(id: string): Promise<{ queued: boolean }> {
15 const isTempId = id.startsWith('temp_item_')
16
17 if (isTempId) {
18 const entries = await getAllQueueEntries('MyApp', 'items_mutation_queue')
19 for (const entry of entries) {
20 const isCreate = (entry.method ?? 'POST') !== 'DELETE'
21 if (isCreate && String(entry.payload?.id ?? '') === id) {
22 await removeQueueEntry(entry.id, 'MyApp', 'items_mutation_queue')
23 }
24 }
25 return { queued: false } // nothing to send — it never reached the server
26 }
27
28 await queueItemDelete(id)
29 return { queued: true }
30}
On the replay side, onReplaySuccess receives the entry method, so you can dispatch the right confirmation shape once a queued delete actually lands:
typescript
1// On the register() side, onReplaySuccess receives the entry that
2// just replayed — including its method — so you can branch on
3// whether this was a queued create/update or a queued delete finally
4// landing, and dispatch the right kind of confirmation frame.
5flux.register({
6 channel: 'items',
7 idbKey: 'items:all',
8 ttl: 'long',
9 ingestionType: 'COLLECTION_ALL',
10 store: itemsStoreAdapter,
11
12 queueConfig: {
13 storeName: 'items_mutation_queue',
14 replayEndpoint: '/api/items',
15 method: 'POST',
16 dedupeField: 'id',
17 dedupeStrategy: 'replace',
18
19 onReplaySuccess: async (responseBody, entry) => {
20 const method = (entry?.method ?? 'POST').toUpperCase()
21
22 if (method === 'DELETE') {
23 await flux.dispatchLocalUpdate('items', { op: 'DELETE', id: entry.payload?.id })
24 return
25 }
26
27 const confirmed = responseBody?.item
28 if (confirmed) await flux.dispatchLocalUpdate('items', confirmed)
29 },
30 },
31})
Dedupe a delete against itself, not against creates

Give the delete its own dedupeKey (the record id is usually enough) so a repeated tap collapses into one queued DELETE rather than two. This is independent from — and does not replace — the pending create purge above, which is about a different record lifecycle stage entirely.

6

When the server says no: failed writes

A write is failed when the server answered with an error. Flux never deletes it in response. The entry stays on the device with a record of what happened, and waits for the user to retry it or discard it. Being offline, a timed out request, or a dropped connection is not a failure — there is no answer from the server, so the write stays pending and is sent again on the next reconnect without bothering the user.
typescript
1// A queued write is always in one of two states:
2//
3// pending — never sent yet, OR a send was interrupted because there
4// was no response (device offline, request timed out,
5// connection dropped). Nothing is shown to the user.
6// It is sent again automatically on the next reconnect.
7//
8// failed — the server ANSWERED with an error (any 4xx, any 5xx,
9// 429, and 409). The write stays on the device, nothing is
10// deleted, and it waits for the user to retry or discard it.
11//
12// A write leaves the queue in exactly three ways:
13// 1. the server accepts it (2xx)
14// 2. the server says it already has it (see idempotency in api/replay)
15// 3. the user discards it
16//
17// Being offline is never a failure. A failure always has a status code.
Two callbacks on queueConfig let your app respond. onFailed tells you an entry just became failed. onRollback tells you the user gave up on one, which is your cue to revert the optimistic change in the UI:
typescript
1flux.register({
2 channel: 'notes',
3 idbKey: 'notes:mine',
4 ttl: 'medium',
5 ingestionType: 'COLLECTION_ALL',
6 store: notesStoreAdapter,
7
8 queueConfig: {
9 storeName: 'notes_mutation_queue',
10 replayEndpoint: '/api/notes',
11 method: 'POST',
12
13 // Fires every time an entry becomes failed, including when a
14 // manual retry fails again. The entry is already saved with its
15 // failure record by the time this runs.
16 onFailed: (entry, failure) => {
17 // failure.status -> HTTP status the server answered with
18 // failure.message -> short message from the response body, if any
19 // failure.kind -> 'rejected' | 'server' | 'auth' | 'rate_limited' | 'revalidation'
20 // failure.attempts -> how many times this entry has failed
21 // failure.retryAfterSec -> set when the server sent Retry-After on a 429
22 showToast('A change could not be saved. Review it in your unsent changes.')
23 },
24
25 // Fires ONLY when the user discards a failed entry. Revert the
26 // optimistic change here. It does not fire when a send fails,
27 // because failed entries are kept.
28 onRollback: (storeName, error) => {
29 revertOptimisticNote()
30 },
31 },
32})
onRollback no longer fires when a send fails

Earlier versions deleted a write on a 4xx response and called onRollback immediately. Failed writes are now kept, so onRollback fires only when the user discards one through discardFailed(). If you reverted optimistic UI inside onRollback before, that still works, it just happens at discard time instead of at failure time. The older onFailure callback still fires alongside onFailed, but onFailed is the one to use.

7

Building a failed changes panel

Flux provides the data and the actions; your app provides the panel. subscribeToFailedWrites() hands you the full list of failed entries on subscribe and again on every change, including changes made in other tabs and after a page reload. Each entry carries its failure record, so you can show what the server said.
components/FailedWritesPanel.tsx
1import { useEffect, useState } from 'react'
2import { flux } from '@/lib/flux'
3import type { QueueEntry } from '@tsworldtech/flux'
4
5export function FailedWritesPanel({ channel }: { channel: string }) {
6 const [failed, setFailed] = useState<QueueEntry[]>([])
7 const [busyId, setBusyId] = useState<string | null>(null)
8 const [notice, setNotice] = useState<string | null>(null)
9
10 // Called once on subscribe with the current list, then again on every
11 // change — including changes made in other tabs and after a reload.
12 useEffect(() => {
13 return flux.subscribeToFailedWrites(setFailed, { channel })
14 }, [channel])
15
16 if (failed.length === 0) return null
17
18 async function retry(entryId?: string) {
19 setBusyId(entryId ?? '*')
20 setNotice(null)
21 try {
22 const outcome = await flux.retryFailed(channel, entryId ? [entryId] : undefined)
23 if (outcome?.status === 'offline') {
24 setNotice("You're offline. Try again once you're connected.")
25 }
26 // Anything that fails again stays in the list automatically.
27 } finally {
28 setBusyId(null)
29 }
30 }
31
32 async function discard(entryId: string) {
33 // Removes the entry and fires onRollback so you can revert the
34 // optimistic change. refetch: true also reloads the channel's cached
35 // copy from the server (use it while online).
36 await flux.discardFailed(channel, entryId, { refetch: true })
37 }
38
39 return (
40 <section role="alert">
41 <h3>{failed.length} change{failed.length === 1 ? '' : 's'} could not be saved</h3>
42 {notice && <p>{notice}</p>}
43
44 <ul>
45 {failed.map((entry) => (
46 <li key={entry.id}>
47 <p>
48 The server answered {entry.failure?.status}
49 {entry.failure?.message ? ': ' + entry.failure.message : ''}
50 </p>
51 {entry.failure?.retryAfterSec ? (
52 <p>The server asked you to wait {entry.failure.retryAfterSec}s before retrying.</p>
53 ) : null}
54
55 <button disabled={busyId !== null} onClick={() => retry(entry.id)}>Try again</button>
56 <button disabled={busyId !== null} onClick={() => discard(entry.id)}>Discard</button>
57 </li>
58 ))}
59 </ul>
60
61 <button disabled={busyId !== null} onClick={() => retry()}>Try all again</button>
62 </section>
63 )
64}
typescript
1// One-shot reads, for when you don't want a live subscription:
2
3const failed = await flux.getFailedWrites('notes') // one channel
4const allFailed = await flux.getFailedWrites() // every channel
5
6// The failed list's length is your count. For a badge across the
7// whole app, use the summary:
8const { pending, failed: failedCount } = await flux.getQueueSummary()
discardFailed() with refetch: true also reloads that channel from the server, because the locally cached copy still contains an edit the server never accepted. Use it while online, since it clears the cached copy before the fresh one arrives.
8

How failed writes get retried

Retries are deliberately limited. There are no timers and no endless retry loops. A failed write is retried automatically only when the device reconnects, and only when the failure is the kind that could succeed later.
typescript
1// Failed entries are retried in exactly two ways.
2//
3// 1. Automatically, on a reconnect only — and only when the failure
4// could plausibly succeed later:
5// 5xx server errors, 401/403 auth, 429 rate limits (after the
6// server's Retry-After window), 408 timeouts, and handshake
7// revalidation errors.
8// A definitive rejection (400, 409, 422, ...) would fail the same way
9// again, so it waits for the user instead of hitting your server on
10// every network flap.
11//
12// 2. Manually, when the user presses your Retry button:
13// await flux.retryFailed('notes', [entry.id]) // one entry
14// await flux.retryFailed('notes') // every failed entry on the channel
15// A manual retry sends exactly what the user chose, whatever the
16// failure was.
17//
18// There is no timer, no backoff loop, and no retry on window focus.
19// A failed entry that is not retryable stays put until the user acts.
20//
21// After a page reload, failed entries are not retried automatically.
22// They reappear in your panel and wait for a reconnect or for the user.
For the full list of what triggers an automatic replay, and exactly which entries each trigger sends, see api/replay.
9

Confirm before logout

Signing a user out clears their queued writes, failed ones included. With a failed changes panel on screen, an unguarded logout becomes a visible way to lose work. Check the summary first:
typescript
1// clearUserSession() deletes the signed-in user's queued writes —
2// pending ones AND failed ones. Confirm first, so a logout never
3// silently throws away a change the user thinks is saved.
4async function handleLogout() {
5 const { pending, failed } = await flux.getQueueSummary()
6
7 if (pending + failed > 0) {
8 const ok = window.confirm(
9 failed > 0
10 ? 'You have changes that have not been saved to the server. Logging out will discard them.'
11 : 'You have changes waiting to sync. Logging out will discard them.'
12 )
13 if (!ok) return
14 }
15
16 await flux.clearUserSession()
17 router.push('/login')
18}
19
20// getQueueSummary() counts the signed-in user's writes plus writes that
21// belong to no user (global-scope queues, like a public contact form).
22// Global-scope writes are not removed by logout.
What should never go through this queue

The offline write queue is built for writes where "send it a little later" is an acceptable outcome — a saved draft, a preference change, a beneficiary added to an account. It is deliberately not built, and should never be used, for financial ledger writes: payments, wire transfers, direct debits, or any operation where a duplicate send or a delayed send against changed conditions has real financial consequences.

There is no configuration on the queue that makes this safe. A queued payment that replays after reconnecting could execute against a balance, a rate, or an authorization that is no longer valid the moment it was queued under. A failed payment kept for a Retry button has the same problem. The correct pattern is to keep payment endpoints entirely outside the queue system and instead block the action at the UI level while the app is offline — failing fast and visibly, rather than deferring silently.

Register the channel with no queueConfig at all:
lib/flux.ts (excerpt)
1flux.register({
2 channel: 'billing_account',
3 idbKey: 'billing_account:mine',
4 ttl: 'short',
5 ingestionType: 'SNAPSHOT',
6 diffBeforeUpdate: true,
7 event: 'UPDATE',
8 store: billingStoreAdapter,
9 // No queueConfig — a failed or offline payment attempt is never
10 // silently retried later. It fails immediately and visibly.
11})
Then gate the write action itself on live connectivity, using isOnline from useFlux() — the same flag every other part of your app can read to know whether the device currently has a connection:
components/CheckoutButton.tsx
1import { useFlux } from '@tsworldtech/flux_react'
2
3export function CheckoutButton() {
4 const { isOnline } = useFlux()
5
6 if (!isOnline) {
7 return (
8 <div role="status">
9 <p>You're offline right now.</p>
10 <p>Payments cannot be made without a connection. Please reconnect to check out.</p>
11 <button disabled>Pay now</button>
12 </div>
13 )
14 }
15
16 return <button onClick={submitPayment}>Pay now</button>
17}
This pattern applies to any endpoint where the cost of a delayed or duplicated write outweighs the convenience of it happening automatically later — payment submission is the clearest example, but the same reasoning applies to any one shot, consequential action your app exposes, including an irreversible delete against a financial ledger adjacent record.

See api/replay for exactly when queued writes are sent, how batching and idempotency work, and the retry rules, core_concepts/conflict_resolution for what happens when a queued write for a non financial record conflicts with a change made on the server while the user was offline, core_concepts/mutation_pipeline for shaping rapid fire writes — like autosave — before they ever reach the queue, and core_concepts/lib_flux_structure for a full reference lib/flux.ts file showing all of this wired together.

Powered by Flux