raps fetch calls, caches results, and exposes a read() method that suspends until resolution.
// resource.ts
type Resource<T> = {
read: () => T;
status: 'pending' | 'success' | 'error';
error?: Error;
};
export function createResource<T>(
key: string,
fetchFn: () => Promise<T>,
ttlMs: number = 300_000
): Resource<T> {
const cache = new Map<string, { data: T; timestamp: number }>();
const resource: Resource<T> = {
status: 'pending',
read() {
const cached = cache.get(key);
const isStale = cached && Date.now() - cached.timestamp > ttlMs;
if (cached && !isStale) {
resource.status = 'success';
return cached.data;
}
if (resource.status === 'pending') {
throw fetchFn().then(
(data) => {
cache.set(key, { data, timestamp: Date.now() });
resource.status = 'success';
},
(error) => {
resource.status = 'error';
resource.error = error;
throw error;
}
);
}
if (resource.status === 'error') {
throw resource.error;
}
return cache.get(key)!.data;
},
};
return resource;
}
Step 2: Compose Suspense Boundaries with Colocated Fallbacks
Place <Suspense> as close to the data dependency as possible. Nested boundaries enable progressive rendering without blocking the entire tree.
// UserProfile.tsx
import { Suspense } from 'react';
import { createResource } from './resource';
const userResource = createResource('user:123', () =>
fetch('/api/user/123').then((res) => res.json())
);
function UserProfile() {
const user = userResource.read();
return <h1>{user.name}</h1>;
}
export function UserPage() {
return (
<Suspense fallback={<ProfileSkeleton />}>
<UserProfile />
</Suspense>
);
}
Step 3: Integrate Error Boundaries
Suspense does not catch errors. React requires ErrorBoundary to handle rejected promises and prevent UI crashes.
// ErrorBoundary.tsx
import { Component, ErrorInfo, ReactNode } from 'react';
type Props = { fallback: ReactNode; children: ReactNode };
type State = { hasError: boolean };
export class ErrorBoundary extends Component<Props, State> {
state: State = { hasError: false };
static getDerivedStateFromError(): State {
return { hasError: true };
}
componentDidCatch(error: Error, info: ErrorInfo) {
console.error('[ErrorBoundary]', error, info);
}
render() {
if (this.state.hasError) return this.props.fallback;
return this.props.children;
}
}
// App.tsx
import { ErrorBoundary } from './ErrorBoundary';
export function App() {
return (
<ErrorBoundary fallback={<GlobalErrorFallback />}>
<Suspense fallback={<AppLoader />}>
<UserPage />
</Suspense>
</ErrorBoundary>
);
}
Step 4: Preload for Concurrent Safety
Suspense performance depends on early promise initiation. Preload resources outside the render cycle to avoid waterfall delays.
// preload.ts
export function preloadResource<T>(resource: ReturnType<typeof createResource<T>>) {
// Triggers promise without blocking render
try { resource.read(); } catch {}
}
Call preloadResource(userResource) in route handlers, hover events, or useEffect with requestIdleCallback to warm the cache before mount.
Architecture Decisions & Rationale
- Cache-First Resource Layer: Suspense requires idempotent reads. A cache prevents duplicate fetches on concurrent renders and enables deterministic fallback composition.
- Colocated Boundaries: Nested
<Suspense> components allow independent loading states. Blocking the entire tree with a root-level boundary negates concurrent rendering benefits.
- Explicit Error Boundaries: Suspense pauses rendering; it does not handle exceptions. Error boundaries restore UI stability and enable retry logic.
- Preload Outside Render: Starting promises during navigation or interaction ensures data arrives before component mount, reducing fallback visibility and improving LCP.
Pitfall Guide
-
Treating Suspense as a Loading Spinner Wrapper
Suspense is a rendering control primitive, not a visual component. Wrapping it around static UI without data dependencies creates unnecessary suspense cycles and degrades concurrent scheduling.
-
Omitting Error Boundaries
Unhandled promise rejections inside Suspense crash the component tree. Every <Suspense> must sit within an <ErrorBoundary> or framework equivalent.
-
Waterfall Suspense Chains
Nesting components that each call .read() sequentially creates network waterfalls. Preload dependencies in parallel and colocate boundaries to enable concurrent resolution.
-
Hydration Mismatches in SSR
Streaming SSR with Suspense requires deterministic fallback content. If server and client fallbacks differ, React throws hydration warnings. Use identical skeleton structures and avoid client-only randomness.
-
Mixing async/await with Suspense
async functions return promises that resolve outside Reactâs scheduler. Suspense only intercepts thrown promises during render. Mixing paradigms breaks concurrent interruption and causes stale closures.
-
Over-Fetching Without Cache Invalidation
Cached resources persist until TTL expiry. Failing to invalidate on mutations leads to stale UI. Implement explicit cache clearing or versioned keys (user:123:v2) after mutations.
-
Ignoring Concurrent Rendering Implications
Suspense components may render multiple times during interruption. Avoid side effects in render, memoize expensive computations, and use useTransition for non-urgent updates to prevent fallback flicker.
Best Practices from Production:
- Preload aggressively on navigation, hover, or viewport entry.
- Colocate suspense boundaries at the data dependency level, not the page level.
- Use deterministic fallback UI that matches server-rendered skeletons.
- Version cache keys to enforce invalidation after mutations.
- Test concurrent edge cases: fast network + slow component, interrupted renders, retry loops.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| SPA with client-only data | Cache-first createResource + nested Suspense | Eliminates loading flag boilerplate, enables concurrent updates | Low (refactor existing fetch hooks) |
| Streaming SSR / Next.js 14+ | useSuspenseQuery or framework-native Suspense | Native chunking, automatic hydration alignment | Medium (framework migration) |
| Legacy codebase migration | Gradual boundary insertion + error boundary scaffolding | Reduces risk, allows incremental Suspense adoption | High initially, low long-term |
| Mobile / Offline-first | Cache-first + SWR fallback + optimistic updates | Suspense alone doesnât handle offline; requires cache strategy | Medium (requires service worker integration) |
| Real-time dashboards | Suspense + WebSocket sync + versioned keys | Prevents stale renders, maintains concurrent safety | Low (add versioning to existing streams) |
Configuration Template
Copy-ready resource factory with TTL, preload helper, and boundary composition:
// suspense-config.ts
import { Suspense, ErrorBoundary } from 'react';
import type { ReactNode } from 'react';
export function createSuspenseScope({
children,
fallback,
errorFallback,
}: {
children: ReactNode;
fallback: ReactNode;
errorFallback: ReactNode;
}) {
return (
<ErrorBoundary fallback={errorFallback}>
<Suspense fallback={fallback}>{children}</Suspense>
</ErrorBoundary>
);
}
// resource-factory.ts
export { createResource } from './resource';
export { preloadResource } from './preload';
Usage:
import { createSuspenseScope } from './suspense-config';
export function DataSection() {
return createSuspenseScope({
fallback: <SectionSkeleton />,
errorFallback: <SectionError />,
children: <DataComponent />,
});
}
Quick Start Guide
- Install React 18+ and verify concurrent features are enabled (
"react": "^18.0.0").
- Create a resource factory using the
createResource template. Export it for reuse.
- Wrap data-dependent components with
<Suspense fallback={...}> and place an <ErrorBoundary> at the nearest layout level.
- Preload on navigation by calling
preloadResource(resource) in your routerâs beforeEnter or useEffect with requestIdleCallback.
- Run a concurrent test: throttle network to 3G, trigger route changes, and verify fallbacks compose without UI flicker or hydration warnings.
Suspense is not a loading pattern. It is a rendering coordination primitive. When implemented with cache-first resources, colocated boundaries, and explicit error handling, it eliminates async state fragmentation and unlocks streaming architectures. The shift requires architectural discipline, but the return is predictable, concurrent-safe UI composition at scale.