Gazar Avatar

GAZAR

I am an engineer, entrepreneur, and storyteller.

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

Share:

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.

reactwebperffrontend

Comments

Please log in to post a comment.

No comments yet.

Be the first to comment!