Core Concepts

The Mutation Pipeline

Not every write a user makes should turn into a network request the moment it happens. The Mutation Pipeline sits between your UI and Flux's offline queue, deciding whether a mutation should fire immediately, wait to be merged with what comes next, or be dropped outright — before any of it touches the network.

Why mutations need shaping before they're queued

A text field that autosaves on every keystroke, a button a nervous user double-taps, a slider dragged across fifty intermediate values in half a second — none of these represent fifty separate intentions. They represent one intention, expressed noisily. Sending each one straight to the offline queue means fifty queue entries, fifty replay attempts, and fifty requests hitting your server the moment connectivity returns.

The Mutation Pipeline is where that noise gets cleaned up — per channel, using whichever strategy fits the shape of that particular write. It runs before a mutation reaches the queue, so the queue only ever sees mutations that are actually worth persisting and replaying.

1

Strategy: queue — no shaping

The default. Every mutation is queued immediately, in the order it arrived, with nothing merged or discarded. Use this for writes where every single one genuinely matters on its own — a chat message, an order line item, an audit log entry. If you don't set a pipeline config at all, this is the behavior you get.
2

Strategy: drop — first write wins

Within a configurable windowMs window, only the first mutation is accepted — every subsequent one arriving before the window closes is discarded. This is the right shape for anything a user might accidentally fire twice: a submit button, a "like" toggle, a checkout confirmation. It protects against double-taps and rapid re-clicks without needing any UI-side debounce logic of your own.
lib/flux.ts (excerpt)
1// A payment button a user might tap twice in a row.
2flux.register({
3 channel: 'wire_transfer_submit',
4 idbKey: 'wire_transfer_submit:pending',
5 ttl: 'short',
6 event: 'CREATE',
7 store: transferStoreAdapter,
8
9 optimistic: { rollbackOnError: true },
10
11 pipeline: {
12 strategy: 'drop',
13 windowMs: 4000, // second tap within 4s is ignored, first tap wins
14 },
15
16 // No queueConfig here — see the callout below.
17})
3

Strategy: debounce — only the last write survives

The inverse of drop: within the window, every incoming mutation resets the timer, and only the final payload — the one still standing once the window finally closes with no new arrivals — is queued. Suited to things like a search-as-you-type filter being persisted, or a settings toggle a user might flip back and forth before settling on a value. Intermediate states are never queued at all, so they can never be replayed as stale writes.
4

Strategy: coalesce — merge, don't discard

Where debounce throws intermediate payloads away, coalesce folds them into one another using a coalesceHandler you provide. Every mutation that arrives inside an open window is merged into the one already waiting, rather than replacing it outright — so fields the latest payload didn't touch survive from an earlier one in the same window. This is the shape for autosave: fifty keystrokes over two seconds become exactly one queued write, carrying the combined result of all fifty.
lib/flux.ts (excerpt)
1// Every keystroke calls this — but only one request ever leaves the device.
2flux.register({
3 channel: 'draft_notes',
4 idbKey: 'draft_notes:current',
5 ttl: 'short',
6 event: 'UPDATE',
7 store: notesStoreAdapter,
8
9 pipeline: {
10 strategy: 'coalesce',
11 windowMs: 1500,
12
13 // Called every time a new mutation arrives inside an open window —
14 // you decide how the next payload merges with the one already waiting.
15 coalesceHandler: (current, next) => ({
16 ...current,
17 ...next,
18 wordCount: next.body.split(' ').length,
19 }),
20
21 onShaped: (result) => {
22 // 'queued' | 'dropped' | 'coalesced'
23 if (result === 'coalesced') markAutosaveIndicator('pending');
24 },
25 },
26})
onShaped tells you what happened to a mutation

Every strategy can take an optional onShaped callback, firing with 'queued', 'dropped', or 'coalesced'. This is how an autosave indicator knows to show "saving…" versus quietly doing nothing on a keystroke that got folded into the next one.

All four strategies are configured the same way — a pipeline block on register(), scoped to that one channel. A dashboard with an autosaving notes field and a one-shot export button can run coalesce on one channel and drop on the other, with no interaction between them.
Shaping happens before the queue, not instead of it

Every strategy shapes mutations before they ever reach the network — none of this is a security or rate-limiting layer, and none of it stops a direct API call made outside your app. It exists purely to stop your own UI from generating more requests than the user actually intended.

Never put ledger writes through this pipeline

A wire transfer, a direct debit, or any write that moves real money must never be shaped by debounce or coalesce, and should not sit behind an offline queueConfig at all. If you need double-submit protection on an endpoint like this, use strategy: 'drop' paired with optimistic.rollbackOnError: true and no queue — so a duplicate tap is blocked, but a failed request rolls back in the UI immediately instead of waiting for reconnection.

Non-ledger operations on the same screen — saving a beneficiary's nickname, updating a notification preference, a KYC form draft — are unaffected by this and are safe to queue and shape normally.

The pipeline only shapes what happens before a mutation is queued — see core-concepts/offline-queue for what happens after it's queued and offline, and core-concepts/conflict-resolution for what happens on reconnect if the server's record changed while a shaped mutation was waiting. Full field reference is on api/register.