A React dashboard that feels perfectly responsive with 200 rows may become almost unusable at 20,000. Memoization is often the first proposed fix, but React.memo cannot help much when the browser is laying out thousands of DOM nodes or a large data transformation is blocking the main thread.
Those problems may produce the same visible symptom—a slow interface—but they have different causes. Rendering too much calls for virtualization. Expensive JavaScript may belong in a Web Worker. Oversized or repeatedly downloaded datasets point toward pagination, caching, or IndexedDB. Media introduces another set of costs.
Getting the diagnosis right matters because users experience far more than the initial page load. Google estimates that roughly 90% of a user's time on a page is spent after it has loaded, when responsiveness depends on how quickly the application handles input, updates data, and paints the next frame.
Find the bottleneck before choosing the optimization
Most performance problems in data-heavy React applications fall into four categories.
Rendering cost grows with the number and complexity of DOM nodes React creates, reconciles, and updates. A table containing 10,000 visible rows is expensive even if every component is neatly organized.
Computation cost comes from JavaScript occupying the main thread. Sorting large arrays, parsing CSV files, filtering records, aggregating chart data, and transforming API responses can all delay keyboard input, scrolling, and rendering.
Network cost usually appears as an oversized response or a chain of dependent requests. A 4 MB JSON payload is one kind of problem; twelve sequential API calls are another.
Media cost becomes significant when an application loads large images, videos, or 3D assets before they are needed—or serves them at a higher resolution than the interface can display.
Memoization has a narrower role than it is often given. React.memo and useCallback can prevent avoidable component updates, while useMemo can prevent the same calculation from being repeated when its dependencies have not changed. None of them makes an unavoidable 400 ms transformation cheap, reduces a large API response, or removes thousands of DOM nodes.
Start with the React Profiler
Record the interaction that feels slow: changing a filter, sorting a table, opening a tab, or selecting a chart range.
The React DevTools Profiler shows which components rendered, how long the commit took, and what triggered the update. A wide flame graph containing hundreds of component renders usually points to a rendering problem. Depending on what the trace shows, the answer may be virtualization, more stable props, narrower state subscriptions, or selective memoization.
The same profiling model applies in React 18 and 19, so upgrading React does not change the basic diagnostic workflow.
Check what is blocking the main thread
Record the same interaction in the Chrome DevTools Performance panel. Look for long tasks—work that occupies the main thread for 50 ms or more.
A long JavaScript block during a calculation, without a corresponding burst of React rendering, suggests a computation bottleneck. Virtualizing a list will not fix it. The calculation may need to be reduced, scheduled differently, or moved to a Web Worker.
Chrome's Long Animation Frames API, available since Chrome 123, adds more detail by attributing slow frames to the scripts responsible for them. That makes it useful when investigating poor Interaction to Next Paint results.
Read the request waterfall
The Network panel answers two different questions:
- How much data is being transferred?
- Are requests happening in parallel or waiting unnecessarily for one another?
A single 3 MB response calls for pagination, compression, or a smaller field selection. A diagonal sequence of independent requests suggests a waterfall that could be flattened. Client-side caching may improve repeat navigation, but it does not make the first oversized response smaller.
The central distinction is simple: are there too many elements to render, or is one piece of JavaScript preventing the browser from rendering at all? The React Profiler is best suited to the first question; the Performance panel helps answer the second.
Match the symptom to the fix
The techniques in this article are complementary, but they are not interchangeable.
- If the page janks while scrolling through a long table or list, the likely cause is rendering overload created by thousands of DOM nodes. Start with list or table virtualization using react-window, TanStack Virtual, or React Virtuoso. Keep in mind that off-screen content will not be present in the DOM, so accessibility, find-in-page, and SEO may require additional work.
- If the interface freezes during sorting, filtering, parsing, or aggregation, heavy computation is probably blocking the main thread. Try reducing the amount of work or moving the calculation to a Web Worker. Workers add serialization overhead, cannot access the DOM, and require additional coordination code.
- If a large dataset is repeatedly downloaded or kept in memory, consider persisting suitable data in IndexedDB with Dexie.js, idb, or RxDB. This reduces repeated loading and memory pressure, but the application must handle browser eviction, synchronization, schema changes, and stale data.
- If the same requests run repeatedly or form a network waterfall, use TanStack Query or SWR together with data prefetching and parallel requests. The trade-off is that caching introduces questions around invalidation, staleness, and memory consumption.
- If images, videos, or 3D assets delay interaction, start with compression, right-sizing, lazy loading, and intent-based preloading. Placeholders require additional UX work, while excessive prefetching can waste bandwidth.
A complex application may need several of these changes. A large 3D product viewer, for instance, might use a Worker for calculations, IndexedDB for model data, virtualization for property lists, and preloading for media. The important part is that each technique addresses a measured cost.
Virtualization, pagination, or both?
Virtualization and pagination both limit how much work the browser performs, but they change the product in different ways.
Virtualization maintains the experience of one continuous dataset. It renders only the rows inside or near the viewport and reuses DOM nodes as the user scrolls. A table may contain 50,000 records while keeping only a few dozen row elements mounted.
That makes virtualization a natural fit for authenticated dashboards, chat histories, log viewers, data grids, and infinite feeds. The compromise is that off-screen items do not exist in the DOM. Browser find-in-page cannot discover them, crawlers cannot index them, and some assistive-technology workflows require deliberate focus and navigation handling.
Pagination places a fixed number of items on each page and provides controls for moving between pages. It is usually easier to make crawlable, linkable, and accessible. Public search results, product categories, article indexes, and other SEO-relevant listings generally benefit from this model.
A hybrid approach is often the practical answer. The server returns a bounded page of records, and the client virtualizes that page if it is still large. Payload sizes remain controlled, while the browser avoids mounting more rows than the user can see.
Remember that grids can require two forms of virtualization. Row virtualization helps when there are thousands of records. Column virtualization matters when a financial or analytical grid contains dozens of columns. A library that handles simple lists well may not be equally capable with two-dimensional grids, sticky columns, variable row heights, and keyboard navigation.
- Virtualization lets the user scroll through one continuous list while keeping only the visible rows in the DOM. It works well for dashboards, feeds, logs, chat histories, and authenticated applications. The downside is that off-screen content may not be indexed by search engines, while accessibility and browser find-in-page require additional work.
- Pagination divides results into separate pages and keeps the number of DOM elements under control. Pages can be indexed, linked, and shared individually. It is usually the better choice for public listings, product directories, article indexes, and search results.
- Server pagination combined with client-side virtualization provides a middle ground. The server returns a limited page of data, while the client renders only the visible part of that page. This works well for large authenticated datasets and complex data grids, although it requires managing both pagination and virtualization.
For libraries, react-window remains a focused option for straightforward lists and is actively maintained. Its older sibling, react-virtualized, is rarely the first choice for a new implementation.
TanStack Virtual is headless, which suits teams that want complete control over markup and styling or already use the TanStack ecosystem. React Virtuoso provides more behavior out of the box and is particularly useful for variable-height content.
The library decision should follow the interface requirements. A basic fixed-height list does not need the same machinery as an editable grid with pinned columns, dynamic heights, grouped rows, and accessible keyboard navigation.
Move expensive calculations to Web Workers
When a trace shows a long calculation without a corresponding rendering spike, moving that work off the main thread can restore responsiveness. A Web Worker can sort, parse, aggregate, or transform data while the browser continues to process input and paint frames.
Workers have firm boundaries. They cannot access the DOM, window, or localStorage. They can use fetch, WebSockets, and IndexedDB, which means a worker can retrieve, transform, and persist a large dataset without routing every step through the UI thread.
Messages between the main thread and a worker normally use the structured-clone algorithm. Large values must be copied, and that serialization cost can erase part of the expected performance gain.
Transferable objects avoid that copy for resources such as ArrayBuffer. Ownership moves from one thread to the other, leaving the original buffer unusable on the sending side. For binary files, large typed arrays, and some data-processing pipelines, this can be considerably cheaper than cloning.
SharedArrayBuffer allows genuine shared memory, but it introduces stricter deployment requirements. The page must be cross-origin isolated using COOP and COEP headers, which may conflict with embedded third-party content. Most applications can use transferable objects without taking on that constraint.
The native postMessage interface is manageable for one or two worker operations but becomes tedious as the API grows. Comlink, a small library from Google Chrome Labs, wraps worker communication in a proxy so asynchronous worker functions can be called more like ordinary methods.
A worker pool can help when the application runs many independent jobs. Instead of creating a worker for every task, the application maintains a small number of workers and feeds them work from a queue. The pool size still needs restraint: saturating every available core may speed up the calculation while competing with the browser for resources.
Workers do not reduce layout or painting cost. A page that mounts 10,000 rows will remain expensive after its data transformation moves off-thread. Workers address computation; virtualization addresses rendering.
Use IndexedDB when the browser needs a real local data store
React state is not persistent storage, and localStorage is poorly suited to large datasets. It stores strings, operates synchronously, and commonly offers only a few megabytes per origin. Reading or parsing a large value can block the main thread before the application renders anything useful.
IndexedDB is asynchronous and transactional, with a practical capacity far beyond localStorage, depending on the browser, device, and available disk space. It can store objects, typed arrays, Blob values, and files without manually converting everything to JSON.
That makes it useful for offline-first and local-first products, large working sets that must survive a reload, and expensive data that can be reconstructed but should not be fetched on every visit.
The native IndexedDB API is verbose and event-driven, so most React applications use a wrapper:
- Dexie.js provides a promise-based database and query layer.
- idb stays close to the native API while replacing the event-based interface with promises.
- RxDB adds reactive queries and replication for applications that need live synchronization.
Choosing IndexedDB changes more than storage. Once a working copy lives in the browser, the application needs rules for refreshing it, resolving conflicts, migrating schemas, and distinguishing fresh data from stale data. A local-first architecture is a data-model decision, not a larger version of a cache.
Eviction also has to be expected. Browsers may remove storage under pressure. Safari applies a particularly strict tracking-prevention policy under which script-writable storage, including IndexedDB, can be deleted after seven days without user interaction with the site.
Unless the product explicitly guarantees durable browser storage, treat IndexedDB as a local copy that can be rebuilt. The server—or another controlled persistence layer—should remain the source of truth.
Cache repeat work and remove network waterfalls
After rendering and computation have been addressed, repeated network work often becomes the next visible delay.
TanStack Query and SWR solve several problems that otherwise end up scattered through components: request deduplication, cached results, background refreshes, retry behavior, and stale-data handling. They can return an existing result immediately during repeat navigation instead of showing another loading state for data the user has already seen.
TanStack Query's defaults deserve attention. It considers fetched data stale immediately by default, while unused cache entries are garbage-collected after five minutes. Those settings are safe but can produce unnecessary refetches when the underlying data changes infrequently. staleTime should reflect how quickly each resource can become outdated, not one global guess for the entire application.
Both libraries support the stale-while-revalidate pattern: return cached data first, verify it in the background, and update the interface if the response has changed. It improves perceived performance because repeat visits do not begin with an empty screen.
Prefetching can remove the remaining wait from predictable navigation. Route data can be requested when the user hovers over a link, focuses a control, or reaches a point where the next action is likely. Prefetch too aggressively, however, and the application downloads data the user never requests.
Request structure matters just as much as caching. Independent calls should begin together through Promise.all, parallel queries, or a backend endpoint designed to aggregate them. A five-request waterfall cannot be corrected by memoizing the component that waits for it.
Media benefits from the same discipline. A gallery, 3D viewer, or image-heavy customer interface can preload the next likely asset, retain previously fetched media, and lazy-load everything outside the active view. Service-worker caching through Workbox can extend the approach to unreliable connections and offline use.
Measure whether the change helped
A performance change is only useful if it improves the interaction users actually perform.
Interaction to Next Paint (INP) replaced First Input Delay as a Core Web Vital in March 2024. Unlike FID, which evaluated only the first interaction, INP considers interactions across the visit. A result of 200 ms or less at the 75th percentile is considered good.
That makes INP relevant to data-heavy React products, where roughly 90% of a user's time may occur after the initial load. A dashboard can achieve an excellent Largest Contentful Paint score and still feel slow every time someone filters a table.
Core Web Vitals will not capture every product-specific operation. “Time to render 10,000 rows,” “time to display the first interactive chart,” and “duration of a CSV import” require custom instrumentation.
The Performance API provides the necessary primitives:
performance.mark("filter-start");
// Run the operation.
performance.mark("filter-end");
performance.measure("filter-duration", "filter-start", "filter-end");
Benchmark before and after under the same conditions: identical dataset, device profile, browser version, and network throttling. Comparing a developer laptop with a production user's older machine produces an impressive number, but not a useful one.
Lab and field measurements answer different questions. Lighthouse and local traces provide controlled, repeatable feedback while engineers iterate. Real-user monitoring, CrUX, and the web-vitals library show what happens across the hardware and network conditions users actually have. Lab data helps isolate the change; field data confirms that the improvement survived production.
How these techniques combine in production
The strongest implementations rarely depend on one optimization.
Vitus, a construction-intelligence platform that works with several large 3D models, combined Web Workers, list virtualization, and IndexedDB. Model calculations moved off the main thread. React Virtuoso handled long property and data lists because pagination did not fit the product requirements. Dexie.js stored large model data locally instead of keeping the entire working set in React state.
A separate change—from the previous model format to SVF2 with parallel loading—cut model size roughly 5× and load time about 2.5×. That result came from addressing several layers: computation, rendering, storage, and network transfer.
Datasport, a sports-event platform with chart- and table-heavy dashboards, needed a different combination. Large tables were virtualized because pagination was not suitable, GET requests were cached, and component memoization reduced unnecessary rendering. React Context replaced Redux in areas where a smaller state layer was sufficient. Web Workers were not part of the solution because main-thread computation was not the defining bottleneck.
The Sales Platform, a media-heavy fashion-tech interface, paired virtualization with a dedicated media layer. Virtualized rendering reduced the number of mounted elements by about 70%, while preloading and caching reduced wait times by 50%. SWR managed data caching, and Workbox supported service-worker caching and offline behavior.
The same constraints appear in analytics products. GazeHealth, a mental-health analytics platform, renders a custom D3 Sankey diagram alongside roughly fifteen data visualizations over large datasets. GoodShape, an employee-wellness platform, combines complex charts with AG Grid tables and optimized large-data processing.
The projects use related tools, but not identical stacks. That is the useful lesson: performance work begins with the bottleneck, not with a preferred library.
When specialized performance engineering makes sense
One slow table or blocking filter is usually a bounded problem. An experienced engineer can profile the interaction, identify the expensive work, and implement a focused fix without redesigning the application.
The decision changes when performance problems cross architectural boundaries. Worker APIs, client-side databases, synchronization rules, data-fetching layers, and two-dimensional virtualization all create decisions that other parts of the product will depend on. Those changes need ongoing ownership.
Capacity matters as well. A team may understand the problem but lack time to investigate it without delaying product work. Engineers who have already implemented virtualized grids, worker pipelines, and IndexedDB-backed data layers can often recognize the performance signature faster than a team encountering it for the first time.
External help is most useful when the scope is explicit. A short engagement can address a measured bottleneck. A dedicated team is more appropriate when performance work affects the application's data architecture and will continue across multiple releases.
Top comments (0)