Skip to content
FJFiras JdayTech Lead · Software Engineer
AboutServicesProjectsExperienceBlogGet in touch
Back to writing
Home/Blog/Optimizing React Performance in Large-Scale Applications

Frontend Development

Optimizing React Performance in Large-Scale Applications

February 10, 20247 min read
Optimizing React Performance in Large-Scale Applications

Discover practical techniques to improve React application performance, from component optimization to state management strategies for enterprise-scale projects.

Performance never bit me on a marketing page. It bit me on the Parnass fleet-operations dashboard: a live map of a passenger-transport fleet updating in real time over SSE and Supabase Realtime, a MapLibre GL canvas, event feeds, and data tables — all wanting to update at once. That is where React stops being forgiving. On a static page you can render sloppily and nobody notices. On a data-dense dashboard fed by a live position stream, one careless re-render cascade drops your frame rate to a slideshow.

I work in Next.js 15 and React 19 with TanStack Query, shadcn/ui, Tailwind, Framer Motion, and Zustand. Over the years I have settled on a small set of techniques that actually move the needle in large apps. None of them are exotic. The discipline is knowing which one the situation calls for — and, just as often, knowing when to do nothing.

Diagnose before you optimize

The most expensive performance mistake is guessing. Before I touch a single memo, I open the React DevTools Profiler, hit record, and interact with the page. It tells me exactly which components rendered, how often, and why. The "why did this render" flag is the one that pays for itself: nine times out of ten a whole subtree is re-rendering because a parent passes a new object or callback identity on every render.

On the Parnass fleet panel this is the pattern that keeps surfacing — a table re-rendering on every position update, not because the rows changed, but because a filter object or handler was rebuilt inline in the parent and handed down with a fresh identity. Without the profiler you end up "optimizing" the wrong component for an afternoon. Measure, find the actual cascade, then act.

Memoization done right — and when not to

React.memo, useMemo, and useCallback are precision tools, not seasoning. React.memo only helps a child whose props are referentially stable. If a parent hands it a fresh object or arrow function each render, the memo does nothing except add a comparison cost.

const Row = React.memo(function Row(props: RowProps) {
  return <tr>{props.vehicle.plate}</tr>
})

// stabilize the callback so the memo can actually skip
const onSelect = useCallback((id: string) => {
  store.select(id)
}, [])

The trap is over-memoizing. Wrapping a cheap component that renders a label in React.memo, or memoizing a value whose dependencies change every render, makes the code slower and harder to read. My rule: reach for memoization when the profiler shows a specific expensive render happening too often, not preemptively. On React 19 the compiler is starting to automate much of this — but the mental model stays the same, so I still profile first.

Virtualize long and real-time lists

A fleet has more rows than a screen has pixels. Rendering a thousand DOM nodes so the user can see twenty is pure waste, and it gets worse when those rows update live. List virtualization renders only what is visible plus a small overscan. I use TanStack Virtual because it composes cleanly with the rest of the stack.

const rows = useVirtualizer({
  count: vehicles.length,
  getScrollElement: () => parentRef.current,
  estimateSize: () => 44,
  overscan: 8,
})

This single change is often the largest win on a data-dense screen. The DOM stays small, scrolling stays smooth, and real-time updates only touch the handful of rows actually mounted rather than the entire dataset.

Tame high-frequency real-time updates

SSE and Supabase Realtime will happily hand you updates faster than React can usefully render them. Calling setState on every position event is the classic way to melt a dashboard. The fix is to decouple the data stream from the render loop: buffer incoming updates and flush them on a throttle, so many events collapse into one paint.

let buffer: Update[] = []

const source = new EventSource('/api/gps/stream')
source.onmessage = (e) => {
  buffer.push(JSON.parse(e.data) as Update)
}

setInterval(() => {
  if (!buffer.length) return
  store.applyBatch(buffer)   // one Zustand write
  buffer = []
}, 250)

Two details matter here. First, I keep the fast-moving state in Zustand, outside React, so batched writes update subscribers selectively instead of re-rendering a tree from the top. Second, I isolate the expensive renderers. The MapLibre GL map and any charts live in their own components that subscribe only to the exact slice they need, so a table update never forces the map to repaint — and vice versa. Keeping those heavy canvases off the shared render path is what keeps the whole screen smooth.

TanStack Query: cache, dedupe, no waterfalls

Most "slow" dashboards are not slow at rendering — they are slow at fetching, usually because requests run in a waterfall. TanStack Query fixes the common cases almost for free. Identical queryKeys are deduped, so ten components asking for the same fleet metadata trigger one request. Cached data serves instantly while a background refetch keeps it fresh, so navigation feels immediate.

const { data } = useQuery({
  queryKey: ['vehicle', id],
  queryFn: () => fetchVehicle(id),
  staleTime: 30_000,
})

The waterfall to hunt down is the sequential one: a parent fetches, renders, and only then does a child start its own fetch. I lift independent queries so they fire in parallel, and I set staleTime deliberately — live telemetry stays near zero, but reference data that changes rarely gets a generous window so it is not refetched on every mount.

Ship less client JS: Server Components, streaming, Suspense

The cheapest render is the one that never reaches the browser. In the Next.js App Router I keep components on the server by default and only mark a subtree 'use client' when it genuinely needs interactivity — the live map, the filters, the real-time table. Static shells, headers, and data-formatting logic stay server-side and never ship their JavaScript.

Streaming with Suspense then lets the page arrive in pieces. I wrap the heavy, data-dependent panels in Suspense so the layout paints immediately while the slow dashboard widgets stream in behind a skeleton, instead of the whole route blocking on the slowest query.

<Suspense fallback={<PanelSkeleton />}>
  <LiveFleetPanel />
</Suspense>

Split the bundle and measure it

Large apps accrete large bundles. A heavy dependency imported at the top of a route gets paid for on first load whether or not the user opens that view. I lazy-load anything weighty and route-specific — the map library, chart packages, export dialogs — so it downloads on demand rather than up front.

const MapView = dynamic(() => import('./MapView'), {
  ssr: false,
  loading: () => <MapSkeleton />,
})

To know what to split, I actually look. Running the bundle analyzer surfaces the real offenders — usually one oversized dependency pulling in half a megabyte for a feature used on one screen. Guessing at bundle size is as futile as guessing at re-renders; open the report and let the numbers point you.

These conventions compound. The same reusable component patterns and design-system rules that cut our per-feature UI development time by around 40% also give performance a single place to live — fix a list or a memo boundary once, and every screen built on it inherits the win.

Takeaways

  • Profile first. The React DevTools Profiler tells you the real cascade; never optimize on a hunch.
  • Memoize surgically. Stabilize props for a genuinely expensive child — skip it for cheap components.
  • Virtualize long lists so the DOM stays small and real-time updates touch only visible rows.
  • Batch and throttle high-frequency streams, keep fast state in Zustand, and isolate maps and charts.
  • Let TanStack Query dedupe and cache, and lift queries to kill sequential waterfalls.
  • Ship less JS with Server Components, streaming Suspense, code-splitting, and a bundle analyzer.

Filed under

ReactPerformanceJavaScriptWeb Development

Share

Firas Jday

Firas Jday

Tech Lead & Senior Software Engineer

// More from the journal

AI Development

Building RAG Pipelines with LangChain: A Developer's Guide

Blockchain

Smart Contract Security: Lessons from Real-World Projects

© 2026 Firas Jday · Tech Lead & Senior Software Engineer · Tunis, Tunisia

Available for CDI & senior freelance — French & international market · jdayfiras.com

0%