This page is a focused deep-dive within State Reset & Cleanup Protocols, which covers deterministic recovery from async failures in the Frontend Error Boundary Architecture Fundamentals section.

The problem: partial mutations left by an unhandled rejection #

A promise that rejects without a .catch() or enclosing try/catch propagates to the JavaScript engine’s global unhandled-rejection queue. By the time the engine fires the unhandledrejection event, any state mutations that ran before the rejection threw have already been committed. The component that triggered the async operation may have incremented a loading counter, cleared a cache key, or written optimistic UI data β€” all of which now point to an in-progress operation that will never complete. The result is a session stuck in a half-mutated state: spinners that never resolve, empty lists where cached data should be, and request queues that keep replaying cancelled operations.

The fix is not to swallow the rejection. It is to treat the unhandledrejection event as an explicit state-transition signal that triggers rollback, request cancellation, and session validation in one atomic pass.

Zero-to-working code: global rejection handler with atomic rollback #

The snippet below wires a window.unhandledrejection listener to a StateSnapshotManager, rolls back the store slice that was mutated before the rejection, aborts any in-flight request tied to the failing operation, and emits a structured telemetry event β€” all within a single microtask boundary.

// state-reset-on-rejection.ts
// Drop this module into your app entry point (main.tsx / _app.tsx).

interface StoreSlice {
  [key: string]: unknown;
}

// ─── 1. Snapshot manager ──────────────────────────────────────────────────────
class StateSnapshotManager<T extends StoreSlice> {
  private history: T[] = [];
  private index = -1;

  /** Call before every async mutation to capture a clean baseline. */
  snapshot(state: T): void {
    // Discard any forward history if we branched from an earlier rollback.
    this.history = this.history.slice(0, this.index + 1);
    this.history.push(JSON.parse(JSON.stringify(state)) as T);
    this.index++;
  }

  /** Returns the previous snapshot and moves the cursor back one step. */
  rollback(): T | null {
    if (this.index > 0) {
      this.index--;
      return this.history[this.index];
    }
    return null;
  }
}

// ─── 2. Request registry ──────────────────────────────────────────────────────
// Keyed by correlation ID so the rejection handler can cancel the exact request.
const inFlightControllers = new Map<string, AbortController>();

export function registerRequest(correlationId: string): AbortController {
  const controller = new AbortController();
  inFlightControllers.set(correlationId, controller);
  return controller;
}

export function cancelRequest(correlationId: string): void {
  const controller = inFlightControllers.get(correlationId);
  if (controller) {
    controller.abort();
    inFlightControllers.delete(correlationId);
  }
}

// ─── 3. Global rejection listener ─────────────────────────────────────────────
export function installRejectionHandler(
  store: { getState: () => StoreSlice; dispatch: (action: unknown) => void },
  snapshotManager: StateSnapshotManager<StoreSlice>
): void {
  window.addEventListener('unhandledrejection', (event) => {
    // Suppress the default console noise β€” we handle it ourselves.
    event.preventDefault();

    const correlationId: string =
      (event.reason as Record<string, unknown>)?.__correlationId as string
      ?? crypto.randomUUID();

    // Cancel any in-flight request tied to this operation.
    cancelRequest(correlationId);

    // Roll back to the pre-mutation snapshot.
    const previousState = snapshotManager.rollback();
    if (previousState) {
      store.dispatch({ type: 'ROLLBACK_COMPLETE', payload: previousState });
    }

    // Emit a structured telemetry event for downstream APM correlation.
    window.dispatchEvent(
      new CustomEvent('app:recovery:rejection', {
        detail: {
          correlationId,
          reason: event.reason instanceof Error ? event.reason.message : String(event.reason),
          timestamp: Date.now(),
          rolledBack: previousState !== null,
        },
      })
    );
  });
}

Step-by-step walkthrough #

  1. StateSnapshotManager.snapshot() β€” called by your async action creator immediately before dispatching any mutation. It deep-clones the current store slice with JSON.parse(JSON.stringify()), which is safe for serialisable Redux/Zustand state. For non-serialisable state, swap in structuredClone().

  2. registerRequest(correlationId) β€” called at the start of every fetch (or equivalent). Attach the returned signal to your fetch options. The correlation ID must be threaded through the rejection reason so the global handler can look up the matching controller.

  3. installRejectionHandler() β€” called once at app boot. The listener calls cancelRequest() before rolling back state, preventing a race where the aborted fetch’s stale response overwrites the restored snapshot.

  4. ROLLBACK_COMPLETE dispatch β€” your root reducer must handle this action by returning action.payload directly, bypassing the normal action-routing logic. One case in a switch is sufficient.

  5. app:recovery:rejection custom event β€” downstream listeners (your APM adapter, session-replay tool, or error-reporting service) subscribe to this instead of the raw unhandledrejection, receiving an already-enriched payload with no PII and a stable correlation ID.

Recovery flow diagram #

State recovery flow after an uncaught promise rejection Flowchart showing: async action dispatched β†’ snapshot saved β†’ fetch registered β†’ rejection fires β†’ cancel request β†’ rollback state β†’ emit telemetry event β†’ UI re-renders from restored snapshot Async action dispatched snapshot() saves pre-mutation state registerRequest() stores AbortController Promise rejects β†’ unhandledrejection fires cancelRequest() aborts fetch rollback() dispatches ROLLBACK_COMPLETE Telemetry event app:recovery:rejection

Edge cases #

Scenario Risk Mitigation
React 18 concurrent rendering β€” component unmounts mid-fetch Resolved-promise callback writes state after rollback, overwriting the restored snapshot Check signal.aborted inside the .then() before any dispatch call; use startTransition to defer low-priority state writes
Multiple overlapping rejections in the same microtask batch rollback() called twice, stepping back two snapshots instead of one Debounce the global handler with a Set of seen correlation IDs; deduplicate within the same event-loop tick
SSR hydration β€” snapshot taken server-side doesn’t match client initial state Rollback restores a snapshot the client never had, causing a hydration mismatch Skip the first snapshot until typeof window !== 'undefined'; validate restored state against the Hydration Mismatch State Recovery guard before dispatching
Service Worker intercepting a failing fetch and returning a cached fallback The fetch resolves rather than rejects; no unhandledrejection fires but state is stale Add an X-SW-Fallback: true response header in your SW fetch handler; check for this header in your action creator and treat it as a soft rejection

Verification steps #

  1. DevTools heap snapshot: Take a baseline before triggering the async operation, then a second snapshot immediately after the rejection fires. Filter by Detached β€” no HTMLElement nodes from the failed component should appear. If they do, an event listener is holding a reference; remove it in the component’s cleanup function.

  2. Store assertion in tests: After calling installRejectionHandler() in a Jest/Vitest environment, dispatch a thunk that rejects, then assert store.getState() equals the pre-thunk snapshot object. Use jest.spyOn(window, 'dispatchEvent') to verify the telemetry custom event fires with rolledBack: true.

  3. Console error audit: With the handler installed, no Uncaught (in promise) message should appear in the console β€” event.preventDefault() suppresses the default browser log. If one does appear, a rejection is escaping the handler (likely in a Web Worker or nested iframe context).

  4. Network panel check: After the rejection, confirm the aborted request shows status (canceled) in the Network panel. If it shows a completed request, the AbortController.signal was not passed to the fetch call.

Frequently Asked Questions #

Can uncaught promise rejections bypass React Error Boundaries?

Yes. React Error Boundaries exclusively catch synchronous errors during the render phase, lifecycle methods, and constructors. Async rejections in event handlers, setTimeout callbacks, or fetch chains bypass boundary componentDidCatch entirely. The window.addEventListener('unhandledrejection') handler described above is the correct interception point for those errors; it can then programmatically trigger a boundary fallback by updating a piece of state that the boundary reads via getDerivedStateFromError.

What is the safest way to reset global store state without a full page reload?

Replace only the corrupted store slice rather than clearing the entire store. ROLLBACK_COMPLETE replaces one slice atomically in the root reducer. Force reconciliation on components that read that slice by keying them on a recoveryNonce value incremented at rollback time β€” this triggers a clean remount without reloading the page. Wrap the dispatch in requestAnimationFrame to avoid layout thrashing when multiple components re-render simultaneously.

How do I differentiate a transient network rejection from fatal state corruption?

Transient failures show recoverable HTTP status codes (429, 503), low error frequency, and consistent retry semantics. Fatal state corruption manifests as validation checksum failures across multiple components, persistent memory growth after recovery attempts, and divergent telemetry that fails three consecutive retry cycles. Apply exponential backoff with jitter for transient failures; trigger full atomic rollback only when the validation guard (see State Reset & Cleanup Protocols) reports checksum mismatch on the third attempt.