When Your React App Tries to Render 50,000 Rows and Your Browser Says Nope!

3 min read
July 15, 2025
Share:
reactvirtualization

Picture this: It's Friday evening, you're about to wrap up for the weekend, and your product manager casually mentions, "Oh, by the way, users need to see all 50,000 customer records in a single table. Should be easy, right?"

Your browser: crashes Your CPU: screams Your weekend plans: gone

Been there? Yeah, me too. Let me tell you how I learned to make React render massive tables without setting my laptop on fire.

The Problem: More Isn’t Always Better

Here's what happens when you naively try to render 50k table rows:

javascript
// DON'T DO THIS (unless you enjoy pain)
function NaiveTable({ data }) {
  return (
    <table>
      <tbody>
        {data.map(row => (
          <tr key={row.id}>
            <td>{row.name}</td>
            <td>{row.email}</td>
            <td>{row.status}</td>
          </tr>
        ))}
      </tbody>
    </table>
  );
}

// 50,000 DOM nodes later...
// Browser: "I quit."

What happens next?

  • Your app freezes for 10+ seconds
  • Scrolling becomes choppier than a blender with a broken blade
  • Memory usage shoots up like a rocket
  • Users start questioning their life choices (and yours)

Enter Row Virtualization: Your New Best Friend

Row virtualization is like having a smart waiter at a buffet. Instead of bringing you all 50,000 dishes at once (and crushing the table), they only show you what fits on your plate plus a few extras nearby. Genius, right?

The core idea: Only render the rows that are actually visible in the viewport, plus a small buffer.

Building Your Own Virtualized Table (The Hard Way)

Before we jump to libraries, let's understand what's happening under the hood. Here's a basic implementation:

javascript
import React, { useState, useEffect, useRef } from 'react';

function VirtualizedTable({ data, rowHeight = 50 }) {
  const [scrollTop, setScrollTop] = useState(0);
  const [containerHeight, setContainerHeight] = useState(400);
  const containerRef = useRef();

  const totalHeight = data.length * rowHeight;
  const visibleRowCount = Math.ceil(containerHeight / rowHeight);
  const startIndex = Math.floor(scrollTop / rowHeight);
  const endIndex = Math.min(startIndex + visibleRowCount + 1, data.length);

  // Only render visible rows
  const visibleRows = data.slice(startIndex, endIndex);

  const handleScroll = (e) => {
    setScrollTop(e.target.scrollTop);
  };

  return (
    <div
      ref={containerRef}
      style={{ height: containerHeight, overflow: 'auto' }}
      onScroll={handleScroll}
    >
      {/* Spacer to maintain scroll height */}
      <div style={{ height: startIndex * rowHeight }} />

      {/* Visible rows */}
      <table>
        <tbody>
          {visibleRows.map((row, index) => (
            <tr key={row.id} style={{ height: rowHeight }}>
              <td>{row.name}</td>
              <td>{row.email}</td>
              <td>{row.status}</td>
            </tr>
          ))}
        </tbody>
      </table>

      {/* Bottom spacer */}
      <div style={{ height: (data.length - endIndex) * rowHeight }} />
    </div>
  );
}

What's happening here?

  • We calculate which rows should be visible based on scroll position
  • We add spacers above and below to maintain the correct scroll height
  • We only render the visible rows + a small buffer

Result? Smooth scrolling through 50k rows like butter!

The Easy Way: React-Window to the Rescue

Building your own virtualization is fun for learning, but for production apps, let's be smart and use battle-tested libraries. Enter react-window – it's like having a Ferrari engine for your table.

shell
npm install react-window react-window-infinite-loader
javascript
import { FixedSizeList as List } from 'react-window';

function VirtualizedTableWithLibrary({ data }) {
  const Row = ({ index, style }) => (
    <div style={style} className="table-row">
      <span>{data[index].name}</span>
      <span>{data[index].email}</span>
      <span>{data[index].status}</span>
    </div>
  );

  return (
    <List
      height={600}        // Container height
      itemCount={data.length}  // Total number of rows
      itemSize={50}       // Height of each row
      width="100%"
    >
      {Row}
    </List>
  );
}

Boom! 50k rows rendered smoothly with just a few lines of code. It's like magic, but better – it's engineering.

Taking It Up a Notch: Variable Row Heights

Life isn't always uniform. Sometimes your rows have different heights (dynamic content, wrapped text, etc.). Here's how to handle that:

javascript
import { VariableSizeList as List } from 'react-window';

function VariableHeightTable({ data }) {
  // Function to calculate row height
  const getItemSize = (index) => {
    // You can calculate based on content
    const item = data[index];
    const baseHeight = 50;
    const extraHeight = item.description ? 30 : 0;
    return baseHeight + extraHeight;
  };

  const Row = ({ index, style }) => {
    const item = data[index];
    return (
      <div style={style} className="variable-row">
        <h4>{item.name}</h4>
        <p>{item.email}</p>
        {item.description && <p>{item.description}</p>}
      </div>
    );
  };

  return (
    <List
      height={600}
      itemCount={data.length}
      itemSize={getItemSize}  // Dynamic sizing!
      width="100%"
    >
      {Row}
    </List>
  );
}

Real-World Performance Tips

1. Memoization is Your Friend

javascript
const MemoizedRow = React.memo(({ item, style }) => (
  <div style={style}>
    {/* Row content */}
  </div>
));

2. Optimize Your Data Structure

javascript
// Instead of nested objects
const badData = data.map(item => ({
  ...item,
  user: { name: item.userName, email: item.userEmail }
}));

// Flatten for better performance
const goodData = data.map(item => ({
  id: item.id,
  userName: item.userName,
  userEmail: item.userEmail,
  status: item.status
}));

3. Debounce Search/Filter Operations

javascript
import { useMemo, useState } from 'react';
import { debounce } from 'lodash';

function SearchableTable({ data }) {
  const [searchTerm, setSearchTerm] = useState('');

  const debouncedSearch = useMemo(
    () => debounce((term) => setSearchTerm(term), 300),
    []
  );

  const filteredData = useMemo(
    () => data.filter(item =>
      item.name.toLowerCase().includes(searchTerm.toLowerCase())
    ),
    [data, searchTerm]
  );

  return (
    <div>
      <input
        placeholder="Search..."
        onChange={(e) => debouncedSearch(e.target.value)}
      />
      <VirtualizedTable data={filteredData} />
    </div>
  );
}

When NOT to Use Virtualization

Virtualization isn't always the answer. Skip it when:

  • You have less than 1,000 rows (overhead might not be worth it)
  • You need all rows to be searchable by browser (Ctrl+F)
  • You're printing the table
  • Your rows have complex interactions that depend on other rows

Advanced: Infinite Loading with Virtualization

Want to level up? Combine virtualization with infinite loading:

javascript
import InfiniteLoader from 'react-window-infinite-loader';

function InfiniteVirtualTable({ loadMoreData, hasMoreData, data }) {
  const isItemLoaded = (index) => !!data[index];

  const Row = ({ index, style }) => {
    const item = data[index];

    if (!item) {
      return <div style={style}>Loading...</div>;
    }

    return (
      <div style={style}>
        {/* Row content */}
      </div>
    );
  };

  return (
    <InfiniteLoader
      isItemLoaded={isItemLoaded}
      itemCount={hasMoreData ? data.length + 1 : data.length}
      loadMoreItems={loadMoreData}
    >
      {({ onItemsRendered, ref }) => (
        <List
          ref={ref}
          height={600}
          itemCount={hasMoreData ? data.length + 1 : data.length}
          itemSize={50}
          onItemsRendered={onItemsRendered}
        >
          {Row}
        </List>
      )}
    </InfiniteLoader>
  );
}

The Performance Numbers That'll Make You Smile

Before virtualization:

  • Initial render: 8-12 seconds
  • Memory usage: 500MB+
  • Scroll FPS: 15-20
  • User experience: 😑

After virtualization:

  • Initial render: 200-300ms
  • Memory usage: 50-80MB
  • Scroll FPS: 60
  • User experience: 😍

Libraries Worth Checking Out

  1. react-window - Lightweight and fast
  1. react-virtualized - Feature-rich but heavier
  1. @tanstack/react-virtual - Modern, flexible
  1. react-viewport-list - Simple and effective

Debugging Virtualized Tables

javascript
// Add this to see what's happening
function DebugVirtualTable({ data }) {
  const [debugInfo, setDebugInfo] = useState({});

  const onItemsRendered = ({ visibleStartIndex, visibleStopIndex }) => {
    setDebugInfo({
      start: visibleStartIndex,
      end: visibleStopIndex,
      rendered: visibleStopIndex - visibleStartIndex + 1
    });
  };

  return (
    <div>
      <div>Rendering rows {debugInfo.start} to {debugInfo.end} ({debugInfo.rendered} total)</div>
      <List
        onItemsRendered={onItemsRendered}
        // ... other props
      >
        {/* Row component */}
      </List>
    </div>
  );
}

Wrapping Up: From Browser Killer to Smooth Operator

Virtualization transformed my 50k-row table from a browser-crashing nightmare into a smooth, responsive experience. It's like the difference between trying to eat an entire pizza at once versus enjoying it slice by slice.

Key takeaways:

  • Only render what's visible
  • Use libraries like react-window for production
  • Optimize your data structures
  • Profile your performance
  • Remember: premature optimization is evil, but necessary optimization is genius

Pro tip: Start simple, measure performance, then optimize. Don't virtualize everything just because you can – sometimes 500 regular rows work just fine.

Next time someone asks you to render 50k rows, just smile and say, "Challenge accepted." Because now you know the secret: it's not about rendering more rows, it's about rendering smarter.

Got questions about virtualization? Hit me up! I've probably broken it, fixed it, and optimized it at least three times.

P.S. - Yes, I've actually had to explain to a PM why rendering 100k rows at once is a bad idea. The conversation involved drawing diagrams on a whiteboard and a lot of coffee.