This page implements one half of the teardown contract in State Reset & Cleanup Protocols — the network half, where the consequences outlive the component.

The exact problem #

A dashboard panel crashes while three requests are in flight. The boundary catches it, the user clicks retry, and the subtree remounts and issues three fresh requests. Then the old requests start landing. Two of them resolve into handlers that still hold references to the old component’s setters; one is a POST that quietly commits a second time.

Symptoms that look unrelated but share this root cause: a chart that flickers back to old data a second after refreshing, a duplicate order, a spinner that never clears because the new request finished first and the old one reset the loading flag afterwards.

Zero-to-working cancellation #

// useAbortableEffect.ts
export function useAbortable(): AbortSignal {
  const ref = useRef<AbortController>();
  if (!ref.current) ref.current = new AbortController();

  useEffect(() => {
    const controller = ref.current!;
    return () => {
      // Runs on unmount — including the unmount caused by a boundary reset.
      controller.abort(new DOMException('subtree reset', 'AbortError'));
      ref.current = undefined;
    };
  }, []);

  return ref.current.signal;
}
// RevenuePanel.tsx
export function RevenuePanel({ range }: { range: DateRange }) {
  const signal = useAbortable();
  const [data, setData] = useState<Revenue | null>(null);
  const [error, setError] = useState<Error | null>(null);

  useEffect(() => {
    // A second controller scoped to THIS query, combined with the mount-level
    // one, so a range change cancels the query without tearing down the mount.
    const queryController = new AbortController();
    const combined = AbortSignal.any([signal, queryController.signal]);

    (async () => {
      try {
        const res = await fetch(`/api/revenue?from=${range.from}&to=${range.to}`, { signal: combined });
        if (!res.ok) throw new Error(`revenue responded ${res.status}`);
        const json = (await res.json()) as Revenue;

        // Re-check AFTER every await: abort rejects the fetch, but code that
        // was already past its await keeps running.
        if (combined.aborted) return;
        setData(json);
      } catch (err) {
        if (isAbort(err)) return;         // deliberate cancellation, not a failure
        if (combined.aborted) return;
        setError(err instanceof Error ? err : new Error('revenue failed'));
      }
    })();

    return () => queryController.abort();
  }, [range.from, range.to, signal]);

  if (error) throw error;                 // hand to the boundary
  return data ? <RevenueChart data={data} /> : <PanelSkeleton />;
}

export function isAbort(err: unknown): boolean {
  return err instanceof DOMException && err.name === 'AbortError';
}
Request lifetimes across a boundary reset Two stacked timelines share a vertical reset marker. In the upper timeline three request bars cross the marker and resolve afterwards, labelled stale writes. In the lower timeline the same three bars stop at the marker, labelled aborted, and two new bars start after it and resolve cleanly. boundary reset + remount without abort resolve into a tree that no longer exists with abort aborted at the reset line only fresh requests write state

Step-by-step #

  1. One controller per mount, created lazily. Creating it in a ref keeps the same controller across re-renders while giving each mount its own — which is exactly the granularity a boundary reset operates on.
  2. Abort in cleanup, not in componentWillUnmount equivalents scattered around. A single cleanup point means a future contributor cannot add a request that escapes cancellation.
  3. Combine signals with AbortSignal.any. Query-level changes (a new date range) and mount-level teardown are different reasons to cancel; combining them avoids a bespoke bookkeeping layer.
  4. Filter AbortError first. An abort is a success from the app’s point of view. Reporting it as a crash pollutes the dashboards described in Deduplicating Error Noise with Fingerprinting and Sampling.
  5. Re-check aborted after every await. This is the step teams skip. Rejecting the fetch does not unschedule continuations that were already queued.
  6. Never blanket-abort writes. A cancelled POST may already have committed server-side. Give writes idempotency keys so the retry after reset is safe, then abort freely.
Request type Safe to abort on reset? Why
GET list/detail reads Yes No side effect; the remount refetches
Polling or streaming reads Yes The new mount opens its own subscription
POST with an idempotency key Yes A repeat carries the same key; the server dedupes
POST without a key Risky The server may have committed; abort only hides the response
PUT/PATCH (idempotent by design) Yes Repeating the same payload converges
File upload in progress Prefer pausing Aborting discards potentially minutes of transfer

Idempotency, because abort is not cancellation #

// safeWrite.ts
export async function safeWrite<T>(url: string, body: unknown, signal: AbortSignal): Promise<T> {
  // Stable per logical operation — reused verbatim by any retry after a reset.
  const key = idempotencyKeyFor(body);
  const res = await fetch(url, {
    method: 'POST',
    signal,
    headers: { 'Content-Type': 'application/json', 'Idempotency-Key': key },
    body: JSON.stringify(body),
  });
  if (!res.ok) throw new Error(`write failed: ${res.status}`);
  return (await res.json()) as T;
}

With a key on the request, the sequence “abort mid-write → boundary resets → user retries” produces at most one committed effect, whichever leg the server saw first. Without it, abort is a coin flip. This is the same guarantee the offline queue relies on in Queuing Failed POST Requests with the Background Sync API.

Why an aborted write still needs an idempotency key Two lanes labelled client and server. The client lane shows a write starting, being aborted at the reset marker, then a retry. The server lane shows the first write committing before the abort and the retry being recognised by its idempotency key and returning the same record instead of creating a second one. client server abort + boundary reset POST /orders · key A committed before the abort retry · same key A deduped: returns the same order

Verification #

  1. Throttle and reset. Set the network to Slow 3G, crash the panel mid-request, retry, and confirm the DevTools network entry for the old request shows (cancelled).
  2. Count state writes. Log every setData call with the controller’s identity; after a reset, only the new mount’s identity may appear.
  3. Check crash reports. Aborting must produce zero reports. If AbortError shows up in telemetry, the isAbort filter is in the wrong place.
  4. Test the double-write. Fire a write, abort mid-flight, retry, and assert the backend holds exactly one record — the idempotency guarantee.
  5. Look for listener leaks. After fifty crash-and-retry cycles, getEventListeners(window) in DevTools should show a flat count; growth means something outside this hook is not cleaning up — see Clearing Timers and Subscriptions When a Boundary Unmounts.
Four guards every abortable request needs Four stacked rows, each naming a guard and the bug it prevents: signal passed to fetch prevents an uncancellable request, AbortError filtered prevents false crash reports, re-checking aborted after each await prevents stale state writes, and an idempotency key prevents duplicate side effects. signal passed to fetch without it the request cannot be cancelled at all AbortError filtered stops deliberate cancels becoming crash reports aborted re-checked stops resolved-but-obsolete responses writing state idempotency key on writes stops a retry duplicating a committed effect

One subtlety worth knowing: AbortSignal.timeout() produces a signal that aborts on its own schedule, and combining it with a mount-scoped signal through AbortSignal.any() gives you request timeouts and teardown cancellation in a single object without any bookkeeping of your own.

Frequently asked questions #

Does aborting a fetch stop the server from processing the request?

No. Abort closes the client’s side; the server may already have committed. Write requests need idempotency keys — cancellation prevents your UI acting on the response, not the effect happening.

Why do I still see stale-update bugs after adding abort?

Because abort only rejects the fetch promise; code already past an await still runs. Check signal.aborted immediately before every state update, not just at the top of the function.

Should I share one AbortController across the whole app?

No — a shared controller cancels unrelated work. Scope one per mounted subtree and combine signals with AbortSignal.any when a request has several cancellation reasons.