This page sits within Frontend Error Boundary Architecture & Fundamentals, which defines the isolation boundaries this cleanup protocol depends on.

The failure mode #

A render-phase exception inside an error boundary leaves the application in a partially mutated state: network requests are mid-flight, event listeners point at unmounted DOM nodes, Zustand or Redux slices hold values that were never committed, and IndexedDB transactions straddle an aborted write. The boundary swaps in a fallback UI, but the underlying state graph is now inconsistent. When the user retries and the component remounts, it reads that stale or corrupted state — producing either a blank screen, an infinite render loop, or silently wrong data. The user-visible consequence is a component that appears to recover but immediately re-crashes, or form inputs that revert to pre-session values.

Prerequisites #


Deterministic State Reset Sequence Flowchart showing the four stages of state reset after an error boundary captures a render-phase exception: (1) lock mutation scope, (2) flush cleanup queue by priority, (3) persist session snapshot, (4) gate hydration and mount fallback UI. Error Captured getDerivedStateFromError Lock Mutations StateMutationLock .lock(scopeId) Flush Queue critical → normal → deferred Persist Snapshot IndexedDB / session Storage + checksum Gate Hydration await RecoveryTx → mount fallback UI render phase synchronous parallel tracks async gate

Core implementation #

The reset controller below is the load-bearing piece. It accepts ICleanupTask items from anywhere in the component tree, sorts them by priority, and executes them in a single flush() pass when the boundary fires. The lock flag prevents new tasks from being enqueued mid-teardown, which would otherwise cause re-entrant cleanup on concurrent renders.

// cleanup-controller.ts
export type CleanupPriority = 'critical' | 'normal' | 'deferred';

export interface ICleanupTask {
  id: string;
  priority: CleanupPriority;
  execute: () => void | Promise<void>;
  rollback?: () => void;
}

const PRIORITY_ORDER: Record<CleanupPriority, number> = {
  critical: 0, // AbortController.abort(), removeEventListener
  normal: 1,   // store slice resets, timer clears
  deferred: 2, // telemetry flushes, cache invalidation
};

export class ResetController {
  private queue: ICleanupTask[] = [];
  public isLocked = false;

  enqueue(task: ICleanupTask): void {
    if (this.isLocked) {
      // Boundary already flushing — drop non-critical work
      if (task.priority !== 'critical') return;
    }
    this.queue.push(task);
    this.queue.sort((a, b) => PRIORITY_ORDER[a.priority] - PRIORITY_ORDER[b.priority]);
  }

  async flush(): Promise<void> {
    this.isLocked = true;
    const snapshot = [...this.queue];
    this.queue = [];

    for (const task of snapshot) {
      try {
        await task.execute();
      } catch (err) {
        // A failing cleanup must not abort remaining tasks
        console.warn(`[ResetController] task ${task.id} failed:`, err);
        task.rollback?.();
      }
    }

    this.isLocked = false;
  }

  drain(): void {
    // Synchronous-only path for getDerivedStateFromError
    const sync = this.queue.filter((t) => t.priority === 'critical');
    this.queue = this.queue.filter((t) => t.priority !== 'critical');
    sync.forEach((t) => {
      try { t.execute(); } catch { t.rollback?.(); }
    });
  }
}

// Singleton per boundary tree — exported so boundary class can import it
export const globalResetController = new ResetController();
// error-boundary.tsx  (React class component — hooks cannot catch render errors)
import React from 'react';
import { globalResetController } from './cleanup-controller';

interface Props { children: React.ReactNode; scopeId: string; }
interface State { hasError: boolean; errorId: string | null; }

export class ErrorBoundary extends React.Component<Props, State> {
  state: State = { hasError: false, errorId: null };

  static getDerivedStateFromError(): Partial<State> {
    // Synchronous path: drain only critical tasks immediately
    globalResetController.drain();
    return { hasError: true, errorId: crypto.randomUUID() };
  }

  componentDidCatch(error: Error, info: React.ErrorInfo): void {
    // Async path: flush remaining tasks after render cycle completes
    globalResetController.flush().catch(console.error);
  }

  handleRetry = (): void => {
    this.setState({ hasError: false, errorId: null });
  };

  render() {
    if (this.state.hasError) {
      return (
        <div role="alert" aria-live="assertive">
          <p>Something went wrong. Your data has been saved.</p>
          <button onClick={this.handleRetry}>Try again</button>
        </div>
      );
    }
    return this.props.children;
  }
}

Architecture note #

React’s getDerivedStateFromError is a static, synchronous method called during the render phase — it cannot await anything and must not produce side-effects that trigger re-renders. That is why the pattern above splits cleanup into two tracks. The drain() call inside getDerivedStateFromError runs synchronously and only touches critical-priority tasks (listener detachment, request abortion). The flush() call in componentDidCatch runs after the boundary has committed its error state to the DOM and can safely await async work — persistence writes, telemetry batches, cache invalidation. This respects React’s execution model without blocking the render cycle.

The mutation lock in enqueue() prevents the pattern seen in Zustand and Redux middleware where a state subscription fires during teardown and tries to enqueue additional cleanup, creating a re-entrant loop. Non-critical work arriving while the lock is held is silently dropped; critical tasks (such as an AbortController.abort() arriving from a newly intercepted network response) are still accepted.

Edge cases and failure modes #

Failure mode Root cause Mitigation
Concurrent route transition during flush Navigation fires while flush() is awaiting async tasks Implement a RouterGuard that queues navigation events until flush() resolves
SSR hydration mismatch triggers premature reset Boundary activates before client state initialises Gate drain() behind an afterHydration flag; check window.__NEXT_DATA__ or framework hydration signals
Shared global store partially reset Boundary clears all store slices, not just its own Restrict enqueue() to scoped store selectors; mark only locally owned slices as eligible for reset
Synchronous teardown misses pending microtasks try/catch in getDerivedStateFromError cannot catch in-flight promise rejections Handle unhandled rejections via the approach described in Error Propagation Strategies
Stale snapshot applied on remount Cached recovery payload contains pre-crash corrupted values Validate checksum and version tag before applying; discard if mismatched
Race between async reset and component hydration gate Component remounts before flush() resolves Block remount behind RecoveryTransactionManager — see advanced variant below

Advanced variant: idempotent async recovery with hydration gate #

The core implementation handles the synchronous teardown contract. Production applications also need coordinated async recovery — persistent session snapshots, deduplication across rapid retry attempts, and a gate that blocks premature remounting. The classes below extend the pattern:

// recovery-transaction-manager.ts
export class RecoveryTransactionManager {
  private pending = new Map<string, Promise<void>>();

  /**
   * Ensures the same operation (identified by `id`) is never run twice concurrently.
   * A second call with the same id awaits the in-flight promise instead of re-running.
   */
  async execute(id: string, operation: () => Promise<void>): Promise<void> {
    if (this.pending.has(id)) return this.pending.get(id)!;

    const tx = operation().finally(() => this.pending.delete(id));
    this.pending.set(id, tx);
    return tx;
  }

  isSettled(id: string): boolean {
    return !this.pending.has(id);
  }
}

// session-snapshot.ts
export interface SessionSnapshot {
  version: string;
  timestamp: number;
  payload: Record<string, unknown>;
  checksum: string; // SHA-256 hex of JSON.stringify(payload)
}

async function sha256hex(data: string): Promise<string> {
  const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(data));
  return Array.from(new Uint8Array(buf)).map((b) => b.toString(16).padStart(2, '0')).join('');
}

export async function persistSessionSnapshot(
  state: Record<string, unknown>,
  key: string
): Promise<void> {
  const payload = JSON.stringify(state);
  const snapshot: SessionSnapshot = {
    version: '1',
    timestamp: Date.now(),
    payload: state,
    checksum: await sha256hex(payload),
  };
  try {
    sessionStorage.setItem(key, JSON.stringify(snapshot));
  } catch {
    // Quota exceeded — fall back to in-memory cache
    (window as any).__recoveryCache ??= {};
    (window as any).__recoveryCache[key] = snapshot;
  }
}

export async function restoreSessionSnapshot(
  key: string
): Promise<Record<string, unknown> | null> {
  const raw = sessionStorage.getItem(key)
    ?? (window as any).__recoveryCache?.[key];
  if (!raw) return null;

  const snapshot: SessionSnapshot = typeof raw === 'string' ? JSON.parse(raw) : raw;
  const expected = await sha256hex(JSON.stringify(snapshot.payload));
  if (expected !== snapshot.checksum) {
    console.warn('[Recovery] Checksum mismatch — discarding snapshot');
    return null;
  }
  return snapshot.payload;
}

// usage in componentDidCatch (extends the ErrorBoundary above)
const recoveryTxManager = new RecoveryTransactionManager();

async function runFullRecovery(
  scopeState: Record<string, unknown>,
  snapshotKey: string
): Promise<void> {
  await recoveryTxManager.execute('boundary-recovery', async () => {
    await globalResetController.flush();
    await persistSessionSnapshot(scopeState, snapshotKey);
  });
}

For more on the promise-cancellation side of this pattern — specifically aborting fetch calls and IndexedDB transactions that are in-flight when the boundary fires — see Managing state reset after uncaught promise rejections.

Testing and CI/CD validation #

Automated validation of the cleanup protocol needs two things: (1) a way to inject a controlled render error, and (2) assertions on what state exists after recovery. The harness below uses Vitest and @testing-library/react:

// error-boundary.test.tsx
import { render, screen, act } from '@testing-library/react';
import { ErrorBoundary } from './error-boundary';
import { globalResetController } from './cleanup-controller';

function Bomb({ shouldThrow }: { shouldThrow: boolean }) {
  if (shouldThrow) throw new Error('Controlled test error');
  return <div>OK</div>;
}

beforeEach(() => {
  // Reset controller state between tests
  (globalResetController as any).queue = [];
  (globalResetController as any).isLocked = false;
});

test('flushes critical cleanup tasks before fallback mounts', async () => {
  let criticalRan = false;
  globalResetController.enqueue({
    id: 'abort-fetch',
    priority: 'critical',
    execute: () => { criticalRan = true; },
  });

  render(
    <ErrorBoundary scopeId="test">
      <Bomb shouldThrow />
    </ErrorBoundary>
  );

  // Fallback is visible
  expect(screen.getByRole('alert')).toBeInTheDocument();
  // Critical task ran synchronously during getDerivedStateFromError
  expect(criticalRan).toBe(true);
});

test('recovery queue is empty after flush', async () => {
  render(
    <ErrorBoundary scopeId="test">
      <Bomb shouldThrow />
    </ErrorBoundary>
  );

  // Wait for async flush in componentDidCatch
  await act(async () => {});
  expect((globalResetController as any).queue).toHaveLength(0);
  expect(globalResetController.isLocked).toBe(false);
});

test('retry clears error state and remounts children', async () => {
  const { rerender } = render(
    <ErrorBoundary scopeId="test">
      <Bomb shouldThrow />
    </ErrorBoundary>
  );

  const retryBtn = screen.getByRole('button', { name: /try again/i });
  await act(async () => retryBtn.click());

  rerender(
    <ErrorBoundary scopeId="test">
      <Bomb shouldThrow={false} />
    </ErrorBoundary>
  );
  expect(screen.getByText('OK')).toBeInTheDocument();
});

In CI, pair this test suite with a Playwright smoke test that asserts aria-live announcements fire and that performance.getEntriesByType('measure') shows teardown completing under 16 ms. Use page.on('pageerror', ...) to detect any uncaught secondary exceptions that slip past the boundary.

Integrations and deep-dives #

The cleanest integration points for this protocol are described across several related pages. For how the fallback UI component should avoid layout shift while the cleanup queue flushes, see Fallback UI Rendering Patterns. For preserving form input across a boundary-triggered remount, the draft persistence techniques in Draft Auto-Save Recovery Workflows apply directly to persistSessionSnapshot. For coordinating IndexedDB writes under quota pressure, LocalStorage & IndexedDB Sync Strategies covers the fallback chain in detail.

The one deep-dive under this page addresses the async dimension directly:

Frequently Asked Questions #

How do I differentiate between recoverable state resets and fatal application crashes?

Define a severity classifier that inspects the error’s stack depth, the affected module paths, and whether the exception propagated past the root boundary or wrote to the global store’s auth or routing slices. Leaf-component failures (UI widgets, data grids) are recoverable via local rollback and a retry affordance. Errors that escape three boundary levels, originate in router initialisation, or corrupt the root store warrant a full session reset and a redirect to a static recovery page — the classifier should call window.location.replace('/error') directly, bypassing the SPA router, to avoid re-running any corrupted route guards.

What is the recommended approach for preserving form data during a boundary-triggered reset?

Debounce state flushes to sessionStorage at 150 ms intervals via a FormDataProxy that intercepts input events outside the component tree. On boundary activation, halt the debounce timer and commit the final snapshot synchronously — sessionStorage.setItem is synchronous and safe to call inside getDerivedStateFromError. On remount, check restoreSessionSnapshot before initialising form state. If the checksum is invalid, fall back to the last valid committed snapshot rather than an empty form.

How can QA teams automate validation of cleanup protocols?

Use Playwright to intercept window.onerror and unhandledrejection, inject controlled exceptions at varying component depths, and assert: PerformanceObserver teardown latency under 16 ms, zero detached DOM nodes after flush(), and aria-live regions announcing recovery state. Run chrome.debugger-based heap-snapshot diffs (or Puppeteer’s page.queryObjects) in CI to detect listener leaks before framework upgrades land in production.

Does flushing the cleanup queue block the main thread?

The synchronous drain() path in getDerivedStateFromError blocks only for critical tasks, which should be sub-millisecond operations (AbortController.abort(), clearTimeout, removeEventListener). The asynchronous flush() path in componentDidCatch runs off the render cycle. Any task that does heavy serialisation or DOM traversal should be tagged deferred and handled via requestIdleCallback or a Web Worker to avoid jank.

How do I prevent the cleanup queue from running twice on fast retry?

Wrap all async recovery in RecoveryTransactionManager.execute() with a stable operation ID (e.g. 'boundary-recovery-${scopeId}'). If the user clicks “Try again” before the first flush() resolves, RecoveryTransactionManager returns the already-in-flight promise rather than starting a duplicate run. This prevents double-writes to sessionStorage and duplicate telemetry events.