React 19 Performance Secrets, Concurrent Rendering, Suspense & Real Tips

I've been shipping React apps for over a decade, leading teams and wrestling with slow UIs more times than I care to admit. React 19 (and the concurrent features that came before it) changed how I approach performance: instead of micro-optimizing every render, I try to design for responsiveness and progressive loading. Below are the practical lessons I use on projects today, things I learned the hard way and keep teaching in PersiaJS meetups and on NoghteVorood.
Why this matters
- Users care about responsiveness more than raw render speed. A UI that stays snappy feels fast.
- Concurrent rendering lets React interrupt and prioritize work, but you have to give it hints.
- Suspense is the composition primitive for progressive loading; use it for code, data, and images.
Concurrent rendering: treat interactivity as the first-class citizen
Concurrent rendering means React can pause a large render, show something interactive, then finish the rest. But it won’t do the right thing automatically unless you mark which updates are low and which are urgent.
Concrete rules I follow:
- Mark non-urgent updates (lists, search results, charts) with startTransition/useTransition.
- Keep input handlers synchronous if they must be instant (typing, dragging).
- Use useDeferredValue for debouncing UI that follows fast-changing inputs.
- Profile, don’t assume what’s slow.
Here’s a TypeScript example showing startTransition in a search UI. I use types everywhere;
it's how I code. Here's a TypeScript example demonstrating startTransition and useDeferredValue:
import React, { useState, useTransition, useDeferredValue } from "react";
type Item = { id: string; text: string };
function filterItems(items: Item[], q: string): Item[] {
const lower = q.toLowerCase();
return items.filter((it) => it.text.toLowerCase().includes(lower));
}
export function SearchList({ items }: { items: Item[] }) {
const [query, setQuery] = useState<string>("");
const [isPending, startTransition] = useTransition();
// Prefer deferring heavy renders
const deferredQuery: string = useDeferredValue(query, { timeoutMs: 2000 });
function onChange(e: React.ChangeEvent<HTMLInputElement>) {
const v = e.target.value;
// Keep typing input snappy
setQuery(v);
// If we were updating a global state or heavy list, we'd wrap in startTransition:
startTransition(() => {
// e.g., setFilteredItems(filterItems(items, v)) // expensive update
});
}
const results = filterItems(items, deferredQuery);
return (
<div>
<input value={query} onChange={onChange} placeholder="Search…" />
{isPending ? <div>Updating results…</div> : null}
<ul>
{results.map((it) => (
<li key={it.id}>{it.text}</li>
))}
</ul>
</div>
);
}What I used to get wrong: I wrapped every state update in useTransition thinking it made everything faster. It doesn’t. You should reserve transitions for updates that can be interrupted (rendering large lists, non-critical UI). Overusing them hides real problems.
Suspense: not just for code-splitting
Suspense lets you compose loading states: code, data, images. My rule of thumb: load the shell and critical interactions first, then let Suspense fill in the rest. That gives users something usable fast and keeps the perceived performance high. Two practical approaches:
- Use Suspense for code-splitting (React.lazy).
- Build a small "resource" wrapper when you want Suspense-style data fetching without pulling in a whole library.
type Status = "pending" | "success" | "error";
interface Resource<T> {
read(): T;
}
function wrapPromise<T>(promise: Promise<T>): Resource<T> {
let status: Status = "pending";
let result: T;
let error: unknown;
const suspender = promise.then(
(r) => {
status = "success";
result = r;
},
(e) => {
status = "error";
error = e;
}
);
return {
read() {
if (status === "pending") {
throw suspender;
} else if (status === "error") {
throw error;
} else {
return result;
}
},
};
}
// Usage in a component
function fetchUser(userId: string): Resource<{ id: string; name: string }> {
return wrapPromise(
fetch(`/api/users/${userId}`).then((r) => r.json())
);
}Then in your component:
import React, { Suspense } from "react";
const resource = fetchUser("alice");
function UserDetails() {
const user = resource.read(); // suspends if not ready
return <div>{user.name}</div>;
}
export function Page() {
return (
<Suspense fallback={<div>Loading user…</div>}>
<UserDetails />
</Suspense>
);
}Note: In production apps I use a proper data layer (React Query, Relay, or a custom cache) that integrates with Suspense and handles caching, revalidation, and errors. But the pattern above is useful to understand what Suspense does under the hood.
Real tips & patterns I actually use on projects
- Profile first. The React Profiler will tell you what actually renders. Use it. I once spent two sprints optimizing memoization that the profiler showed didn’t matter.
- Virtualize large lists. I use react-window or similar; for lists > 100 items it’s almost always worth it.
- Split contexts. Massive context objects cause wide re-renders. Either split them, or use a selector pattern or useSyncExternalStore for subscriptions.
- Example: prefer multiple small contexts (ThemeContext, AuthContext) rather than a single giant AppContext.
- Hydration strategy with SSR: stream critical HTML and hydrate high-priority interactive regions first. If you render a big dashboard, hydrate the toolbar and inputs first.
- Use the Profiler and web-vitals (CLS, LCP, FID/INP). Real user metrics are the only source of truth. I track INP now because it reflects interaction latency better than FID.
- Offload heavy CPU work to Web Workers. I moved PDF parsing and complex chart generation into workers and cut frame drops in half.
- Use requestIdleCallback or setTimeout for non-urgent work. StartTransitions don’t replace scheduling background work.
- Images: serve appropriately sized images with srcset and lazy-loading. Progressive image placeholders (LQIP or blur) + Suspense-style placeholders feel fast.
- Code-splitting: chunk by route and by major UI areas (e.g., admin panel separate bundle). I once shipped a 1.2MB initial bundle; pushing big admin-only code behind lazy() reduced TTI a lot.
Selective hydration & streaming SSR
I’ve been on projects where the HTML renders quickly, but React hydration kills the main thread. Solution: stream HTML and hydrate only what's interactive first. Options:
- Render static components as plain HTML until user interaction.
- Use progressive hydration libraries or custom logic to attach listeners lazily.
- For forms and small widgets, hydrate eagerly; hydrate charts and complex widgets in the background.
Example of where this saved me: a reporting app with many charts where users first interact with filters. Hydrating filters first and deferring charts cut time-to-interactive on critical UI by ~40%.
Context & external store subscriptions
If you maintain an external store (e.g., global state outside React), use useSyncExternalStore to subscribe efficiently.
import { useSyncExternalStore } from "react";
type State = { user?: { id: string } };
const store = {
state: {} as State,
listeners: new Set<() => void>(),
getState() {
return this.state;
},
setState(next: State) {
this.state = next;
this.listeners.forEach((l) => l());
},
subscribe(listener: () => void) {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
},
};
export function useExternalUser() {
return useSyncExternalStore(
(listener) => store.subscribe(listener),
() => store.getState().user,
() => store.getState().user // server snapshot
);
}
This avoids re-rendering the whole tree and lets you select exactly what you need.
Mistakes I made and what I learned
- I used to optimize without data. I’d guess where the hot path was. Use the Profiler and real user metrics.
- I thought memoization is a silver bullet. It’s not. It solves a few cases. Measure the overhead vs benefit.
- I assumed Suspense would magically fix data loading. Without a solid caching strategy, you get wasted network requests and stale UI. Use a cache with proper staleness policies.
- I delayed accessibility thinking it would hurt perf. It actually helps: keyboard-first users are often power users — and fixing it later is costly.
Closing: small changes, big wins
React's concurrent model gives you a different surface to reason about performance: prioritize user interactions, compose loading experiences with Suspense, and measure. In one project I shifted from micro-optimizations (memoing every callback) to focusing on transitions and selective hydration — the app felt snappier to users, and we spent less time on brittle micro-optimizations.
If you want, I can:
- review a small part of your app and point out quick wins,
- share an audit checklist I use,
- or walk through converting a component to use Suspense + a caching layer.
I run gazar.dev and the Monday by Gazar newsletter where I write about these patterns often — happy to dig in on any of the examples above.
