Recipes: app flows

Copy-and-adapt patterns for common product features. Each one is complete enough to paste, and each calls out the one thing people usually get wrong.

The recipes assume a queue from Getting started and a small post() helper that sends JSON with an idempotency key and throws NonRetryableError on 4xx (see the REST helper).

Offline-first form

The user fills a long form in a lift with no signal. It must not be lost and should not feel broken.

Why it works

The UI treats "saved" as success. The network is an implementation detail.

Chat messages

A message needs a stable client id, a visible status and (usually) order.

Ordering

If message 1 fails and message 2 succeeds, the server sees 2 before 1. QueueKit orders by priority and time but does not hold later jobs behind a failing one. Include a seq (or client timestamp) and let the server order by it. To make this extremely rare, set limits: { sendMessage: { concurrency: 1 } }.

Likes and other toggles

The user taps like, unlike, like, unlike. Only the last intent matters.

Do not use dedupeKey here

dedupeKey would keep the first request and ignore the newer one. For "latest wins", remove the pending one and enqueue the new one. A request already in flight (active) is left alone, and the new job runs after it.

A reusable "latest wins" helper

Autosaving drafts, typing indicators, settings sliders: same problem, so wrap it once.

Because each call removes the previous pending job and starts a new delayMs timer, this is a true debounce: the request is sent 1.5 s after the last keystroke.

Throttle: at most one sync pending

Use this when you want to coalesce requests rather than restart a timer. A request made while a sync is already running is absorbed by that run, so changes made after it started wait for the next sync.

Orders and payments

The classic nightmare is a double charge. Make the order id yours, and the server idempotent.

Undo send

Delay the job and let the user cancel while it is still pending.

If the job has already started, remove() aborts it through ctx.signal (the request may still reach the server, so keep the idempotency story in mind).

A "Sync issues" screen

Failed jobs are never silently dropped. Give the user (and your support team) a place to see them.

"Sync now" button and pull to refresh

Offline-first CRUD

Write to your local database first so the UI is instant, then enqueue a sync job. The handler reads the current local record, so the server always gets the latest state no matter how many edits happened while offline.

latestOnly (above) removes a not-yet-started push for the same note before enqueueing, and leaves a push that is already running alone. So an edit made while a push is in flight still gets its own push afterwards. Plain dedupeKey would hand the new edit to the running job, which has already read the old text.

Actions that go stale

Delayed follow-ups

Not a scheduler

The job runs two hours later only if the app is alive then, or soon after it next opens or is woken. For reminders that must fire at an exact time, use scheduled local notifications instead.

Remote kill switch and maintenance mode

Useful when your backend is down or a bad release is sending broken requests. Jobs accumulate safely and drain when you flip it back.

Wi-Fi only