This page is part of the Framework-Specific Crash Recovery & Error Handlers section, which covers how each framework’s lifecycle and rendering model shapes the error interception strategies you can realistically deploy.

The problem: async failures that escape the component lifecycle #

A user submits a form. The request times out after 8 seconds. The component that fired the request has since remounted because the router re-navigated. The promise rejects — but there is no catch handler attached in the current render cycle, so the rejection surfaces as an unhandled PromiseRejectionEvent on window, stripped of all component context. Your monitoring tool logs a stack trace that points to a bundler shim, not to the operation that failed.

This is the async error propagation gap. Synchronous crashes halt React’s render pipeline immediately and are catchable by boundaries wired into componentDidCatch. Asynchronous failures — network timeouts, deferred promise chains, Web Worker messages, third-party SDK callbacks — occur outside the render cycle entirely. Without an explicit interception layer between the async operation and the component’s state, failures either vanish silently or land in a global handler that has lost every piece of useful context.

User-visible consequence: loading spinners that never resolve, form fields that appear to submit but produce no feedback, and retry buttons that replay the same broken request because no error state was ever set.

Prerequisites #


Core implementation: useAsyncCatch #

The hook below is the foundational pattern. It wraps any async function in a deterministic state machine: idle → loading → (data | error) → idle. It cancels in-flight requests on unmount and on re-execution, and it normalizes every thrown value into a typed AsyncErrorPayload before touching component state.

import { useState, useRef, useCallback, useEffect } from 'react';

// ─── Normalized error shape ────────────────────────────────────────────────
export interface AsyncErrorPayload {
  /** Machine-readable code, e.g. "HTTP_503" or "NETWORK_TIMEOUT" */
  code: string;
  message: string;
  stack?: string;
  /** Arbitrary key/value pairs: endpoint, userId, operationId … */
  context: Record<string, unknown>;
  timestamp: number;
  severity: 'info' | 'warning' | 'critical';
}

export const normalizeError = (
  err: unknown,
  context: Record<string, unknown> = {}
): AsyncErrorPayload => {
  // Detect Axios errors without importing Axios types
  const isAxiosError = (
    e: unknown
  ): e is {
    response?: { status: number; data?: { message?: string } };
    config?: { url?: string };
    stack?: string;
  } => typeof e === 'object' && e !== null && 'isAxiosError' in e;

  if (isAxiosError(err)) {
    const status = err.response?.status ?? 500;
    return {
      code: `HTTP_${status}`,
      message: err.response?.data?.message ?? 'Network request failed',
      stack: err.stack,
      context: { ...context, endpoint: err.config?.url },
      timestamp: Date.now(),
      severity: status >= 500 ? 'critical' : 'warning',
    };
  }

  if (err instanceof DOMException && err.name === 'AbortError') {
    // Intentional cancellation — never surface this as an error state
    throw err; // re-throw so the caller's abort guard can handle it
  }

  return {
    code: 'UNKNOWN_ERROR',
    message: err instanceof Error ? err.message : String(err),
    stack: err instanceof Error ? err.stack : undefined,
    context,
    timestamp: Date.now(),
    severity: 'critical',
  };
};

// ─── State shape ───────────────────────────────────────────────────────────
export interface AsyncState<T> {
  data: T | null;
  isLoading: boolean;
  error: AsyncErrorPayload | null;
}

export interface AsyncCatchOptions {
  /** Fire immediately on mount */
  immediate?: boolean;
  /** Side-effect called after error state is set — use for telemetry or boundary escalation */
  onError?: (err: AsyncErrorPayload) => void;
}

// ─── Hook ──────────────────────────────────────────────────────────────────
export function useAsyncCatch<T>(
  asyncFn: (signal: AbortSignal) => Promise<T>,
  options: AsyncCatchOptions = {}
) {
  const [state, setState] = useState<AsyncState<T>>({
    data: null,
    isLoading: false,
    error: null,
  });

  // Tracks the active AbortController across re-renders
  const abortControllerRef = useRef<AbortController | null>(null);
  // Guards against setState on an unmounted component
  const isMountedRef = useRef(true);
  // Stable ref to onError so execute's useCallback doesn't re-create on every render
  const onErrorRef = useRef(options.onError);
  useEffect(() => { onErrorRef.current = options.onError; });

  useEffect(() => {
    isMountedRef.current = true;
    return () => {
      isMountedRef.current = false;
      abortControllerRef.current?.abort();
    };
  }, []);

  const execute = useCallback(async () => {
    // Cancel any previous in-flight request before starting a new one
    abortControllerRef.current?.abort();
    const controller = new AbortController();
    abortControllerRef.current = controller;

    setState((prev) => ({ ...prev, error: null, isLoading: true }));

    try {
      const result = await asyncFn(controller.signal);
      // Only update state if not cancelled and still mounted
      if (isMountedRef.current && !controller.signal.aborted) {
        setState({ data: result, isLoading: false, error: null });
      }
    } catch (err) {
      // AbortError from our own controller means intentional cancel — ignore
      if (err instanceof DOMException && err.name === 'AbortError') return;
      let normalized: AsyncErrorPayload;
      try {
        normalized = normalizeError(err);
      } catch {
        // normalizeError re-throws AbortError; catch it here and bail
        return;
      }
      if (isMountedRef.current) {
        setState((prev) => ({ ...prev, isLoading: false, error: normalized }));
        onErrorRef.current?.(normalized);
      }
    }
  }, [asyncFn]);

  useEffect(() => {
    if (options.immediate) execute();
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [execute]);

  const cancel = useCallback(() => abortControllerRef.current?.abort(), []);

  return { ...state, execute, cancel };
}

Architecture note #

The two guards — AbortController and isMountedRef — solve different problems and must coexist. AbortController signals the HTTP client to abandon the connection, which saves bandwidth. But if your HTTP client ignores the signal (older SDKs, WebSocket wrappers, some GraphQL clients), the promise still resolves; isMountedRef catches that and prevents the orphaned state update. Checking controller.signal.aborted inside the success branch handles the edge case where the signal fires between the await resolving and the synchronous code that follows.


Async error flow: from rejection to fallback UI #

Async error flow: rejection to fallback UI A flowchart showing four stages: Async operation fires a promise rejection, useAsyncCatch intercepts and normalizes it, optional boundary escalation for critical errors, and final fallback UI rendering with optional retry. Async operation fetch / axios / WebSocket throws useAsyncCatch normalizeError() AbortController guard isMountedRef guard setState(error) Component error state → inline retry / toast UI severity=critical ErrorBoundaryContext setCriticalError() Boundary fallback full-page error UI non-critical path critical escalation

Edge cases and gotchas #

Failure mode Symptom Mitigation
Component unmounts mid-request setState called on detached tree; React warning in dev isMountedRef guard inside catch and success branch
Rapid re-execution (typeahead, scroll) Stale promises resolve out of order, overwriting newer data Abort previous controller at top of execute before creating a new one
Third-party SDK ignores AbortSignal Promise resolves after unmount despite abort isMountedRef.current check remains the final safety net
Web Worker rejections unhandledrejection fires on main thread with no component context Route errors via postMessage with a typed envelope back to the hook’s execute
window.unhandledrejection listener Fires for all promise rejections — strips component context Use only as last-resort telemetry sink; never drive UI state from it
SSR / Next.js server components AbortController and navigator do not exist in the Node runtime Guard with typeof window !== 'undefined' or restrict hook use to Client Components
HMR double-execution in Vite Cleanup runs then immediately re-runs useEffect, firing execute twice The abort-on-re-execute pattern handles this; verify with console.count during dev

Advanced variant: bridging to synchronous error boundaries #

Hooks manage async state; React Error Boundary Implementation handles synchronous render failures via componentDidCatch. To route critical async errors into the boundary fallback, lift error state into a Context that the boundary reads.

import {
  createContext,
  useContext,
  useState,
  useCallback,
  type ReactNode,
} from 'react';
import { useAsyncCatch, type AsyncErrorPayload } from './useAsyncCatch';

// ─── Context ───────────────────────────────────────────────────────────────
interface ErrorBoundaryContextValue {
  criticalError: AsyncErrorPayload | null;
  setCriticalError: (err: AsyncErrorPayload | null) => void;
  clearCriticalError: () => void;
}

const ErrorBoundaryContext = createContext<ErrorBoundaryContextValue>({
  criticalError: null,
  setCriticalError: () => {},
  clearCriticalError: () => {},
});

export const ErrorBoundaryProvider = ({ children }: { children: ReactNode }) => {
  const [criticalError, setCriticalError] = useState<AsyncErrorPayload | null>(null);
  const clearCriticalError = useCallback(() => setCriticalError(null), []);
  return (
    <ErrorBoundaryContext.Provider value={{ criticalError, setCriticalError, clearCriticalError }}>
      {children}
    </ErrorBoundaryContext.Provider>
  );
};

export const useErrorBoundaryContext = () => useContext(ErrorBoundaryContext);

// ─── Wrapper that auto-escalates critical errors ──────────────────────────
export function useBoundaryMappedAsync<T>(
  asyncFn: (signal: AbortSignal) => Promise<T>
) {
  const { setCriticalError } = useErrorBoundaryContext();

  return useAsyncCatch(asyncFn, {
    onError: (err) => {
      // Only hand critical errors to the boundary; warnings render inline
      if (err.severity === 'critical') {
        setCriticalError(err);
      }
    },
  });
}

With useBoundaryMappedAsync, a service-layer 503 escalates to a full-page error fallback while a 404 stays as an inline “not found” message — without any conditional logic in the consuming component.

Severity filtering prevents a nested boundary from absorbing an error that should bubble. Compare this with the error propagation strategies that govern how boundaries decide whether to re-throw or handle locally.


Cross-framework variants: Vue 3 and Svelte #

To maintain consistent telemetry and state reset cleanup across a heterogeneous stack, the React hook pattern translates directly into Vue’s Composition API and Svelte stores. The same normalizeError utility works unchanged.

// Vue 3 Composition API
import { ref, onUnmounted } from 'vue';
import { normalizeError, type AsyncErrorPayload } from './normalizeError';

export function useVueAsyncCatch<T>(asyncFn: (signal: AbortSignal) => Promise<T>) {
  const data = ref<T | null>(null);
  const isLoading = ref(false);
  const error = ref<AsyncErrorPayload | null>(null);
  let controller: AbortController | null = null;

  const execute = async () => {
    controller?.abort();
    controller = new AbortController();
    isLoading.value = true;
    error.value = null;

    try {
      const result = await asyncFn(controller.signal);
      if (!controller.signal.aborted) data.value = result;
    } catch (err) {
      if (err instanceof DOMException && err.name === 'AbortError') return;
      if (!controller.signal.aborted) {
        error.value = normalizeError(err);
      }
    } finally {
      isLoading.value = false;
    }
  };

  onUnmounted(() => controller?.abort());
  return { data, isLoading, error, execute };
}
// Svelte 4 — writable store equivalent
import { writable } from 'svelte/store';
import { normalizeError } from './normalizeError';

export function createSvelteAsyncCatch<T>(asyncFn: (signal: AbortSignal) => Promise<T>) {
  const state = writable<{ data: T | null; isLoading: boolean; error: unknown }>({
    data: null,
    isLoading: false,
    error: null,
  });
  let controller: AbortController | null = null;

  const execute = async () => {
    controller?.abort();
    controller = new AbortController();
    state.update((s) => ({ ...s, isLoading: true, error: null }));

    try {
      const result = await asyncFn(controller.signal);
      if (!controller.signal.aborted) {
        state.set({ data: result, isLoading: false, error: null });
      }
    } catch (err) {
      if (err instanceof DOMException && err.name === 'AbortError') return;
      if (!controller.signal.aborted) {
        state.update((s) => ({ ...s, isLoading: false, error: normalizeError(err) }));
      }
    }
  };

  const cancel = () => controller?.abort();
  return { state, execute, cancel };
}

The Vue & Svelte global error handler patterns complement these store-level hooks by catching errors that escape individual composables or stores — the two layers work in tandem, not as substitutes for each other.


Persistence: retry queue and session state preservation #

When an async operation fails after the user has changed form state or triggered a write, discarding the payload entirely forces them to repeat work. A retry queue with exponential backoff preserves the payload and re-attempts delivery, falling back to IndexedDB for crash resilience when the in-memory queue is at risk of loss.

interface RetryTask {
  id: string;
  payload: unknown;
  attempt: number;
  maxAttempts: number;
  execute: () => Promise<void>;
  nextRetryAt: number;
}

const retryQueue = new Map<string, RetryTask>();

export const scheduleRetry = (task: Omit<RetryTask, 'nextRetryAt'>) => {
  const backoff = Math.min(1000 * 2 ** task.attempt, 30_000); // cap at 30 s
  retryQueue.set(task.id, { ...task, nextRetryAt: Date.now() + backoff });
};

export const drainRetryQueue = async () => {
  const now = Date.now();
  for (const [id, task] of retryQueue) {
    if (now < task.nextRetryAt) continue;
    try {
      await task.execute();
      retryQueue.delete(id);
    } catch {
      if (task.attempt >= task.maxAttempts) {
        retryQueue.delete(id);
        await persistToIndexedDB(`failed_task_${id}`, task.payload);
      } else {
        scheduleRetry({ ...task, attempt: task.attempt + 1 });
      }
    }
  }
};

/** Best-effort IndexedDB persistence with quota guard */
export const persistToIndexedDB = async (key: string, value: unknown): Promise<void> => {
  try {
    const serialized = JSON.stringify(value);
    if (serialized.length > 5 * 1_024 * 1_024) {
      console.warn(`[crash-recovery] Payload too large to persist: ${key}`);
      return;
    }
    // Wire to your IDB wrapper here (idb-keyval, Dexie, etc.)
  } catch (err) {
    console.warn('[crash-recovery] Persistence failed:', err);
  }
};

Never use synchronous localStorage writes inside onError — it blocks the main thread during error recovery, which is exactly when you can least afford it. Use the Beacon API or a deferred IDB write instead.

For preserving partial form state through crashes, the Draft Auto-Save Recovery Workflows pattern integrates naturally on top of this retry queue.


Telemetry: non-blocking observability #

import { useEffect } from 'react';
import type { AsyncErrorPayload } from './useAsyncCatch';

export function useErrorTelemetry(error: AsyncErrorPayload | null) {
  useEffect(() => {
    if (!error) return;

    const payload = {
      ...error,
      fingerprint: fingerprintError(error),
      userAgent: navigator.userAgent,
      networkStatus: navigator.onLine ? 'online' : 'offline',
      sessionId: sessionStorage.getItem('session_id') ?? 'anonymous',
    };

    // sendBeacon is fire-and-forget: survives page unload, never blocks render
    const blob = new Blob([JSON.stringify(payload)], { type: 'application/json' });
    if (!navigator.sendBeacon('/api/telemetry/errors', blob)) {
      // sendBeacon returns false if the queue is full — fall back to a queued fetch
      void fetch('/api/telemetry/errors', { method: 'POST', body: blob, keepalive: true });
    }
  }, [error]);
}

/** Deterministic fingerprint for server-side deduplication */
function fingerprintError(err: AsyncErrorPayload): string {
  const raw = `${err.code}:${err.message.split('\n')[0]}:${err.context?.endpoint ?? ''}`;
  let h = 0;
  for (let i = 0; i < raw.length; i++) {
    h = Math.imul(31, h) + raw.charCodeAt(i) | 0;
  }
  return Math.abs(h).toString(36);
}

Fingerprinting on the client prevents your telemetry endpoint from being flooded when a single bad deployment causes the same error to fire at hundreds of events per second.


Graceful degradation and fallback UI #

Map AsyncErrorPayload.severity and code to distinct UI states rather than rendering a single generic error message. Transient failures (4xx from rate-limiting, network blips) warrant an inline retry button; critical service failures should suppress the retry and surface a support path.

import type { AsyncErrorPayload } from './useAsyncCatch';

interface AsyncFallbackProps {
  error: AsyncErrorPayload | null;
  onRetry: () => void;
  isLoading: boolean;
}

export const AsyncFallback = ({ error, onRetry, isLoading }: AsyncFallbackProps) => {
  if (isLoading) {
    return <div role="status" aria-live="polite" aria-busy="true">Loading…</div>;
  }
  if (!error) return null;

  const isTransient =
    error.code.startsWith('HTTP_4') || error.code === 'NETWORK_TIMEOUT';

  return (
    <div role="alert" aria-live="assertive">
      <p>
        {error.severity === 'critical'
          ? 'Service is temporarily unavailable.'
          : 'Could not complete the request.'}
      </p>
      {isTransient && (
        <button type="button" onClick={onRetry}>
          Try again
        </button>
      )}
    </div>
  );
};

Debounce the transition into the error state by at least 300 ms for network-dependent operations — showing an error flash on a 200 ms blip degrades perceived quality without providing actionable information. This pattern dovetails with fallback UI rendering patterns that handle the layout implications of switching between content and error states.


Testing and CI/CD validation #

Fault injection during CI is the only reliable way to assert that the hook’s state transitions are correct — production network conditions cannot be controlled.

import { renderHook, act } from '@testing-library/react';
import { useAsyncCatch } from './useAsyncCatch';

describe('useAsyncCatch', () => {
  it('sets error state on rejection and does not throw', async () => {
    const failFn = jest.fn().mockRejectedValue(new Error('Service unavailable'));
    const { result } = renderHook(() => useAsyncCatch(failFn));

    await act(async () => {
      await result.current.execute();
    });

    expect(result.current.error).not.toBeNull();
    expect(result.current.error?.code).toBe('UNKNOWN_ERROR');
    expect(result.current.isLoading).toBe(false);
    expect(result.current.data).toBeNull();
  });

  it('does not update state after unmount', async () => {
    let resolveLateFn!: (v: string) => void;
    const lateFn = () =>
      new Promise<string>((res) => { resolveLateFn = res; });

    const { result, unmount } = renderHook(() =>
      useAsyncCatch((_signal) => lateFn())
    );

    act(() => { void result.current.execute(); });
    unmount();
    // Resolve after unmount — must not trigger a state update or React warning
    await act(async () => { resolveLateFn('late data'); });

    // State remains at initial — no setState on detached tree
    expect(result.current.data).toBeNull();
  });

  it('cancels previous request when execute is called again', async () => {
    let callCount = 0;
    const slowFn = jest.fn((_signal: AbortSignal) =>
      new Promise<number>((res) => setTimeout(() => res(++callCount), 100))
    );

    const { result } = renderHook(() => useAsyncCatch(slowFn));

    act(() => { void result.current.execute(); });
    // Immediately fire a second execute — first should be aborted
    await act(async () => { await result.current.execute(); });

    // Only the second request's result should be in state
    expect(result.current.data).toBe(1); // only one resolved (the second)
    expect(result.current.error).toBeNull();
  });
});

In Playwright end-to-end tests, use page.route() to intercept network requests and force 503 responses. Assert that the [role="alert"] region appears, its text matches the severity, and that the retry button triggers exactly one new network request.


Frequently asked questions #

How do custom hooks intercept errors that bypass traditional try/catch blocks? By wrapping async operations in a centralized executor that normalizes promise rejections into synchronous state before any other code runs. The hook’s execute function is the single call site for every async operation in a component, so there is no path by which a rejection can escape without being caught.

Can async error hooks coexist with framework-level error boundaries? Yes. Hooks manage component-level async state; boundaries handle synchronous render failures. The useBoundaryMappedAsync pattern shown above bridges the two: onError escalates critical payloads into Context, and the boundary reads that Context during its render phase.

What prevents memory leaks when a component unmounts mid-request? AbortController cancels the HTTP connection (saving bandwidth), and isMountedRef prevents setState from firing if the promise resolves after unmount. Both guards must coexist because not every async operation respects AbortSignal.

How should QA teams simulate async failures for regression testing? Use jest.fn().mockRejectedValue(...) for unit-level state-machine tests and page.route() or MSW request handlers for integration and E2E tests. Run the full hook lifecycle — execute, wait for error state, verify no state updates post-unmount — in each CI run.