Async Task UI
When to use this
Any time a "start" button appears in the interface and the work takes more than a few seconds: report
or document generation, batch imports, long-running queries, AI generation, file conversion. The
physics here is fixed: HTTP is stateless, the frontend is mortal (reloads, closed tabs, dropped
connections), and users click things — and the task has to outlive all three. The industry has a
highly consistent standard skeleton for this (submit → return a task ID immediately → run in the
background → poll a status endpoint → terminal state → claim the result); this skill is that skeleton
plus the iron rules bought with real incidents. The failure modes are extremely fixed, and so are the
standard fixes — nothing here needs inventing, it needs not deviating from.
Core rules
Rule 1: The state machine comes before the interface — settle life and death, then draw
Before writing any task UI component, settle the task state machine. The minimum set is
queued → running → succeeded / failed / cancelled (terminal), plus an orthogonal delivered flag.
- Write the transition table into the code as a table (which state can reach which), and forbid
scattered
ifs; give the table its own tests — an illegal transition must bark.
- Progress percentages and time estimates are decoration; the state is the skeleton. Only once you
have honest states do you get to have a progress bar (if you cannot produce an honest percentage,
do not produce one — a state label is more trustworthy than a fake percentage).
- Writing the interface before settling the state machine means every component invents its own notion
of a task's life and death, and from then on every bug is "two places disagree about task state."
Rule 2: Terminal states are irreversible — completed tasks go to an inbox and never come back to life
Terminal states (succeeded/failed/cancelled) are one-way: no automatic mechanism (reconnect, polling,
recovery) may ever reopen a terminal task as a working view.
- A task that completed but was not claimed (succeeded and not delivered) → goes to the
inbox / task center: listed passively, opened only when the user clicks it; it never seizes a
tab or the layout on its own.
- Leftover historical tasks (from days ago, from testing) all go to the inbox; "on load, open every
task you can find as a tab" is this skill's most classic crime scene (see below).
- The inbox also solves the "the user was not present when the task finished" delivery problem — the
result has somewhere to live, and you are not betting that their window is still open.
Rule 3: Views are separate from tasks — the server is the single source of truth, the frontend is only a telescope
A task's survival must never depend on the survival of any window, tab, or frontend object:
- After submission the task belongs to the server; the frontend holds a task_id and polls the status
endpoint — close the page, reload, drop the connection, and the task keeps running.
- A reload is the telescope refocusing: on load, query for running and queued tasks and reattach
the views; it is not the task being reborn, and certainly not the task dying.
- The reconnect mechanism recognizes non-terminal states only (Rule 2); and "reattach" means
updating an existing view or reattaching to an existing container — never open a new view just
because polling found something. A view may be born from exactly two legitimate causes: a user
action, or reattaching on load to a task that is genuinely running.
- With multiple views (multiple tabs), each view holds its own state container (a per-tab context);
global singletons are forbidden — global state swapped across an await will always produce a
re-entrancy collision.
Rule 4: Idempotency is an obligation of submission — double-clicks, retries, and reloads must never spawn a second job
Background work is inherently triggered more than once (double-clicks, network retries, pressing the
button again after a reload) — the same logical job may run any number of times and must still
count once:
- Frontend: disable the button on submit / debounce it (the first line of defense, against fast fingers).
- Server: an idempotency key (a content key or a request key) — the same key arriving twice returns the
same task_id and does not open a new task (the second line, against everything the frontend cannot
catch). You need both; frontend-only is none.
- Cancellation is idempotent too: hammering cancel is harmless; cancelling mid-run goes through a
cancel_requested intermediate state (the UI shows "cancelling…") and the worker converges to
cancelled at a checkpoint — it neither pretends to die instantly nor invites frantic clicking.
Rule 5: Make concurrency semantics explicit — never make the user guess; blankness is itself a defect
Can I start another one? What position am I in? What is the limit? — the answers must be written on
the interface:
- If there is a queue, show the position ("queued, N ahead of you"), not a fake "running 0%."
- If there is a limit, block at the limit and say so ("limit of N reached, close one first"),
rather than failing silently.
- A user's core anxiety about background work is control: is reloading safe, can I run these in
parallel, has the system actually updated? If the UI does not say, the user starts experimenting,
and experimenting triggers exactly what Rule 4 exists to block.
- A newly opened workspace must be immediately usable or must explain why it is not; rendering a
blank and letting the user guess is a defect, not "a feature that isn't finished yet."
Rule 6: A dirty environment is the default — all green in a clean environment ≠ all green in a real one
Test fixtures for a task UI must have two tracks: a clean environment and a dirty one (preload N
historical terminal tasks plus 1 running task, then load).
- Real users' environments always carry history: yesterday's tasks, last week's leftovers, orphans from
a half-finished test. Tests that only start from a clean environment have zero coverage of loading,
the single most everyday action there is.
- The minimum assertion set for the dirty track: after loading, exactly the genuinely-running task is
reattached; 0 terminal tasks revive automatically; the inbox lists every unclaimed task.
- For the pre-delivery self-walk, the environment is an equivalent copy of the real user's
environment (all leftovers included); walking a clean environment is not walking it at all.
Case files from this project (supporting evidence, not required for the general rules)
- Historical tasks coming back from the dead (Rules 2/3/6 all at once): one reporting system added
a reconnect mechanism for "recover after a reload," but it never filtered on task state and the
polling loop opened a new tab for every task it found — the user uploaded a single file and batches
completed days earlier let themselves in, one tab at a time, until the bar was full. Three
violations: terminal states resurrected (Rule 2), polling opening new views (Rule 3), and every
journey test starting from a clean environment so everything was green (Rule 6). The fix: add
terminal states to the state machine + route completed-but-unclaimed work to an inbox + reconnect
recognizes non-terminal states only + dual-track dirty-environment tests.
- Finished but nowhere to be found (Rules 1/3): a multi-item batch wrote its results into the
multi-item results container, while the delivery window read the single-item
state['result'] → the
progress bar showed a checkmark and opening the window gave a 404. The root cause was the absence of
a unified state machine: the single-item and multi-item paths each invented their own shape for
"finished." The 5 finished artifacts were sitting perfectly safely on disk — the address was wrong,
not the building; but to the user "I cannot get it" means "it was never produced," and the
severity is counted that way.
- Global state trampling itself (Rule 3): multiple tabs shared module-level globals and a single
DOM container, so switching tabs clobbered state and running views vanished; a timer swapping globals
across an await is re-entrancy. The fix: thread a per-tab context through everywhere, binding state
to the tab rather than to the module.
- Double-click protection (Rule 4): pressing "start" 3 times produced 3 tasks. The fix: frontend
debounce + server-side idempotency, so 3 submissions execute exactly 1 time, with tests pinning it.
- Sister skills: the verification approach for state machines and terminal states is the same as
verification-discipline's (independent expected values, breaking tests that bark); for when to check
external practice before starting, see engineering-economy Rule 1; for delivery stations and
journey-level acceptance, see ui-station-delivery; the data-layer counterpart of dual-track dirty
environments and cache resurrection is in cache-discipline (a cache seeded by a broken reader is
structurally invalidated).
1---2name: async-task-ui3description: Async Task UI4---56# Async Task UI78## When to use this910Any time a "start" button appears in the interface and the work takes more than a few seconds: report11or document generation, batch imports, long-running queries, AI generation, file conversion. The12physics here is fixed: **HTTP is stateless, the frontend is mortal (reloads, closed tabs, dropped13connections), and users click things** — and the task has to outlive all three. The industry has a14highly consistent standard skeleton for this (submit → return a task ID immediately → run in the15background → poll a status endpoint → terminal state → claim the result); this skill is that skeleton16plus the iron rules bought with real incidents. The failure modes are extremely fixed, and so are the17standard fixes — nothing here needs inventing, it needs not deviating from.1819---2021## Core rules2223### Rule 1: The state machine comes before the interface — settle life and death, then draw2425Before writing any task UI component, settle the task state machine. The minimum set is26`queued → running → succeeded / failed / cancelled (terminal)`, plus an orthogonal `delivered` flag.27- **Write the transition table into the code as a table** (which state can reach which), and forbid28 scattered `if`s; give the table its own tests — an illegal transition must bark.29- Progress percentages and time estimates are decoration; **the state is the skeleton**. Only once you30 have honest states do you get to have a progress bar (if you cannot produce an honest percentage,31 do not produce one — a state label is more trustworthy than a fake percentage).32- Writing the interface before settling the state machine means every component invents its own notion33 of a task's life and death, and from then on every bug is "two places disagree about task state."3435### Rule 2: Terminal states are irreversible — completed tasks go to an inbox and never come back to life3637Terminal states (succeeded/failed/cancelled) are one-way: **no automatic mechanism (reconnect, polling,38recovery) may ever reopen a terminal task as a working view.**39- A task that completed but was not claimed (succeeded and not delivered) → goes to the40 **inbox / task center**: listed passively, opened only when the user clicks it; it never seizes a41 tab or the layout on its own.42- Leftover historical tasks (from days ago, from testing) all go to the inbox; "on load, open every43 task you can find as a tab" is this skill's most classic crime scene (see below).44- The inbox also solves the "the user was not present when the task finished" delivery problem — the45 result has somewhere to live, and you are not betting that their window is still open.4647### Rule 3: Views are separate from tasks — the server is the single source of truth, the frontend is only a telescope4849A task's survival **must never depend on the survival of any window, tab, or frontend object**:50- After submission the task belongs to the server; the frontend holds a task_id and polls the status51 endpoint — close the page, reload, drop the connection, and the task keeps running.52- **A reload is the telescope refocusing**: on load, query for running and queued tasks and reattach53 the views; it is not the task being reborn, and certainly not the task dying.54- The reconnect mechanism recognizes **non-terminal** states only (Rule 2); and "reattach" means55 updating an existing view or reattaching to an existing container — **never open a new view just56 because polling found something**. A view may be born from exactly two legitimate causes: a user57 action, or reattaching on load to a task that is genuinely running.58- With multiple views (multiple tabs), each view holds its own state container (a per-tab context);59 global singletons are forbidden — global state swapped across an await will always produce a60 re-entrancy collision.6162### Rule 4: Idempotency is an obligation of submission — double-clicks, retries, and reloads must never spawn a second job6364Background work is inherently triggered more than once (double-clicks, network retries, pressing the65button again after a reload) — **the same logical job may run any number of times and must still66count once**:67- Frontend: disable the button on submit / debounce it (the first line of defense, against fast fingers).68- Server: an idempotency key (a content key or a request key) — the same key arriving twice returns the69 same task_id and does not open a new task (the second line, against everything the frontend cannot70 catch). You need both; frontend-only is none.71- Cancellation is idempotent too: hammering cancel is harmless; cancelling mid-run goes through a72 `cancel_requested` intermediate state (the UI shows "cancelling…") and the worker converges to73 cancelled at a checkpoint — it neither pretends to die instantly nor invites frantic clicking.7475### Rule 5: Make concurrency semantics explicit — never make the user guess; blankness is itself a defect7677Can I start another one? What position am I in? What is the limit? — the answers must be **written on78the interface**:79- If there is a queue, show the position ("queued, N ahead of you"), not a fake "running 0%."80- If there is a limit, **block at the limit and say so** ("limit of N reached, close one first"),81 rather than failing silently.82- A user's core anxiety about background work is control: is reloading safe, can I run these in83 parallel, has the system actually updated? If the UI does not say, the user starts experimenting,84 and experimenting triggers exactly what Rule 4 exists to block.85- A newly opened workspace must be immediately usable or must explain why it is not; **rendering a86 blank and letting the user guess is a defect**, not "a feature that isn't finished yet."8788### Rule 6: A dirty environment is the default — all green in a clean environment ≠ all green in a real one8990Test fixtures for a task UI must have **two tracks**: a clean environment and a dirty one (preload N91historical terminal tasks plus 1 running task, then load).92- Real users' environments always carry history: yesterday's tasks, last week's leftovers, orphans from93 a half-finished test. Tests that only start from a clean environment have zero coverage of loading,94 the single most everyday action there is.95- The minimum assertion set for the dirty track: after loading, exactly the genuinely-running task is96 reattached; 0 terminal tasks revive automatically; the inbox lists every unclaimed task.97- For the pre-delivery self-walk, the environment is **an equivalent copy of the real user's98 environment** (all leftovers included); walking a clean environment is not walking it at all.99100---101102## Case files from this project (supporting evidence, not required for the general rules)103104- **Historical tasks coming back from the dead (Rules 2/3/6 all at once)**: one reporting system added105 a reconnect mechanism for "recover after a reload," but it never filtered on task state and the106 polling loop opened a new tab for every task it found — the user uploaded a single file and batches107 completed days earlier let themselves in, one tab at a time, until the bar was full. Three108 violations: terminal states resurrected (Rule 2), polling opening new views (Rule 3), and every109 journey test starting from a clean environment so everything was green (Rule 6). The fix: add110 terminal states to the state machine + route completed-but-unclaimed work to an inbox + reconnect111 recognizes non-terminal states only + dual-track dirty-environment tests.112- **Finished but nowhere to be found (Rules 1/3)**: a multi-item batch wrote its results into the113 multi-item results container, while the delivery window read the single-item `state['result']` → the114 progress bar showed a checkmark and opening the window gave a 404. The root cause was the absence of115 a unified state machine: the single-item and multi-item paths each invented their own shape for116 "finished." The 5 finished artifacts were sitting perfectly safely on disk — the address was wrong,117 not the building; but to the user "I cannot get it" means "it was never produced," and the118 severity is counted that way.119- **Global state trampling itself (Rule 3)**: multiple tabs shared module-level globals and a single120 DOM container, so switching tabs clobbered state and running views vanished; a timer swapping globals121 across an await is re-entrancy. The fix: thread a per-tab context through everywhere, binding state122 to the tab rather than to the module.123- **Double-click protection (Rule 4)**: pressing "start" 3 times produced 3 tasks. The fix: frontend124 debounce + server-side idempotency, so 3 submissions execute exactly 1 time, with tests pinning it.125- **Sister skills**: the verification approach for state machines and terminal states is the same as126 verification-discipline's (independent expected values, breaking tests that bark); for when to check127 external practice before starting, see engineering-economy Rule 1; for delivery stations and128 journey-level acceptance, see ui-station-delivery; the data-layer counterpart of dual-track dirty129 environments and cache resurrection is in cache-discipline (a cache seeded by a broken reader is130 structurally invalidated).