Cancellation
Every async handler receives an AbortSignal as ctx.signal. This page covers where the signal originates, the idiomatic cleanup patterns, and the interop story for code that needs its own AbortController.
If you haven’t read Async handlers and Concurrency strategies yet, start there — they cover the basics of signal.throwIfAborted() and abort behaviour per strategy.
The three sources of abort
Section titled “The three sources of abort”Triggery flips signal.aborted to true for one of three reasons. Every one has a stable string in signal.reason that you can also read from the inspector.
| Source | When it happens | signal.reason |
|---|---|---|
take-latest supersedes | A newer event for the same trigger fires while this run is still in flight, and the trigger uses concurrency: 'take-latest'. | 'superseded-by-latest' |
| Runtime / scope disposed | runtime.dispose() is called, the hosting <TriggerScope> unmounts, or the trigger is removed via trigger.dispose(). | 'disposed' |
| HMR reload | Vite / webpack HMR replaced the module that defined the trigger. The old instance is disposed before the new one boots. | 'hmr' |
That’s the complete list. There is no “user clicked cancel” source — if a user-driven cancel is what you need, expose it by firing a follow-up event with take-latest, or wire your own AbortController (see Interop).
Inspector records the reason
Section titled “Inspector records the reason”Every abort lands in the inspector ring buffer with status: 'aborted' and the reason string. The buffer is on in DEV by default; you can also enable it in PROD via createRuntime({ inspector: true }).
Inside React, useInspectHistory(trigger) from @triggery/react gives you a live list. The DevTools Bridge (@triggery/devtools-bridge) serialises the same snapshots over postMessage to a Redux DevTools-compatible extension.
Manual signal use in long-running operations
Section titled “Manual signal use in long-running operations”For anything that takes more than one I/O call — chunked downloads, paginated APIs, file reads, worker round-trips — check signal at every boundary. The runtime can’t interrupt your code between awaits; that’s still on you.
Two patterns worth pointing out:
- The reader uses
try { … } finally { reader.releaseLock(); }. Thefinallyruns whether the loop exits cleanly, throws (includingAbortErrorfromthrowIfAborted), or is interrupted by an outer abort. Resource cleanup belongs infinally, not insignal.addEventListener('abort'), because the latter doesn’t fire if the abort happens before the listener is attached. actions.throttle(100).reportProgress?.(...)makes the per-chunk action calls cheap. The throttled timer is cancelled with the trigger if the runtime disposes.
The signal.addEventListener('abort', cleanup) pattern
Section titled “The signal.addEventListener('abort', cleanup) pattern”When you own a resource that is not AbortSignal-aware (a database client, a WebSocket you opened, a setInterval), attach a one-shot abort listener:
Three things to notice:
{ once: true }is mandatory — without it, you leak the listener on every run that completes normally.- The matching
removeEventListenerruns infinally, covering both the success path and the abort path. - The cleanup is idempotent: calling
ws.close()twice is harmless. Cleanup callbacks should always be idempotent because the runtime may invoke abort, yourfinallymay fire, and any explicit retry in the catch path may all run.
Resource cleanup checklist
Section titled “Resource cleanup checklist”Use this list when reviewing an async handler that owns resources:
- Every
fetch(...)receives{ signal }. - Every
setTimeout / setIntervaleither accepts a signal (AbortSignal.timeout) or is cleared infinally. - Every
addEventListenereither passes{ signal }or is matched byremoveEventListenerinfinally. - Every reader / writer (
ReadableStream, file handle, DB transaction) is closed / released infinally. - Every
EventSource,WebSocket,BroadcastChannelyou created in the handler is closed. - You do not store a reference to the run’s
signalor any of its abort listeners outside the handler closure.
If everything on this list is satisfied, the only way to leak is to write fetch(url) without { signal } — and the eslint rule fetch-signal from @triggery/eslint-plugin flags exactly that case.
AbortController interop
Section titled “AbortController interop”You may want to chain your own AbortController to ctx.signal — usually for one of three reasons:
- A per-request timeout on top of the trigger’s lifetime.
- A user-driven cancel button.
- A fan-out where you start several parallel requests and want one of them to also be cancellable independently.
The standard browser API for “abort if either of these signals abort” is AbortSignal.any.
If the trigger is superseded → signal.aborted is true, throwIfAborted throws, run is classified 'aborted'. If the timeout fires first → signal.aborted is false, fetch rejects with AbortError from the timeout, the catch path can branch on composite.reason if you saved it.
Here conditions.userCancel is a { signal: AbortSignal } wrapper exposed by your cancel-button component as a condition. The trigger’s signal and the user’s signal are merged for the fetch call, then the catch path disambiguates.
Each request has its own controller; the trigger signal propagates to all of them via a single abort listener. You could call ctrls[2].abort() mid-handler to cancel just one request without affecting the rest of the run.
Common pitfall: race conditions in writes after abort
Section titled “Common pitfall: race conditions in writes after abort”The runtime aborts the handler function. It does not unwind work the handler already dispatched. Anything that landed in your store before the abort point is still there.
If the run is superseded between setData and markReady, your UI shows the data but the “ready” flag is still false. Two common fixes:
-
Tag writes with a run id (
meta.runId). Reactors discard writes whose run id doesn’t match the latest one for that trigger. -
Collect locally; dispatch at the end. Pay the latency, get atomic transitions:
There is no “right” answer — the trade-off is incremental UX vs atomic semantics. The wrong answer is to leave the gap unaddressed.
Common pitfall: missing throwIfAborted
Section titled “Common pitfall: missing throwIfAborted”What happens under take-latest:
- Run A starts a
fetch. Run B fires, aborts A’ssignal, starts its own fetch. - A’s
fetchrejects withAbortError. But your handler never sees that —.then(r => r.json())is on the resolved branch only. - Wait —
fetchrejects, so the chain rejects. Yes. So the handler rejects withAbortError, the runtime seessignal.abortedis true, classifies as'aborted'. Cleaner than you’d think.
Now the actually broken variant:
The catch-all swallows AbortError from the abort, and now actions.show?.({ items: [] }) clobbers whatever the newer run wrote. Always check signal.aborted in catch blocks:
Common pitfall: swallowed AbortError corrupting the run status
Section titled “Common pitfall: swallowed AbortError corrupting the run status”AbortError is a real Error (a DOMException with name === 'AbortError', technically). If you log it as a generic failure, your error reporter will think the run failed — when it actually completed cleanly.
The same logic applies anywhere you have a try/catch: aborts re-throw, real errors are handled.
Common pitfall: caching the signal for a later run
Section titled “Common pitfall: caching the signal for a later run”ctx.signal is owned by this run and is invalidated when the run ends. Storing it in a module-scope variable, a closure that outlives the handler, or a returned promise produces wrong behaviour and can leak.
If you need cross-run cancellation, expose a cancel mechanism as a condition (an external AbortController whose signal you read via useCondition) and merge it with the per-run signal using AbortSignal.any inside the handler. Don’t try to thread ctx.signal outside its run.