Blog
January 11, 2026

Handling Large CSV Files in React (100K+ Rows)

12 mins read

Handling Large CSV Files in React (100K+ Rows)

Loading a 100K row CSV file in React will crash your browser tab. The browser attempts to parse the entire file into memory, then render 100,000 DOM nodes, all on the main thread. The result is often memory exhaustion and an unresponsive UI.

This tutorial shows you how to handle large CSV files in React by combining two patterns: streaming (parse incrementally without loading the entire file into memory) and virtualization (render only visible rows). By the end, you'll have a working component that handles 100K+ rows without breaking a sweat.

Prerequisites

  • Node.js 18+
  • React 18+
  • Basic TypeScript knowledge
  • A large CSV file for testing (100K+ rows)

What you'll build

A CSV upload component that:

  • Accepts large CSV files via drag-and-drop or file selection
  • Parses files using streaming to avoid memory issues
  • Shows a progress indicator during parsing
  • Renders 100K rows smoothly using virtualization
  • Handles errors gracefully

Why Large CSVs Crash Browsers

Three factors combine to crash browsers when handling large CSV files:

Memory Limits

When you load a CSV file using FileReader.readAsText(), the entire file is loaded into memory as a string. A 100MB CSV becomes a 100MB string. Then parsing it into JavaScript objects roughly doubles that memory usage. Browser tabs typically have 1-4GB memory limits, and Chrome will kill tabs that exceed their allocation.

DOM Rendering Limitations

Even if parsing succeeds, rendering 100K rows means creating 100K DOM nodes. Each row might have 10 cells, resulting in 1 million DOM elements. The DOM was not designed for this scale. Browsers become unresponsive with 10K+ elements.

Main Thread Blocking

JavaScript is single-threaded. Parsing a large file blocks the main thread, freezing the UI. Users see an unresponsive page and think the app has crashed.

The Solution: Streaming + Virtualization

The fix requires solving both problems:

  1. Streaming: Process the CSV incrementally, row by row or in chunks, without loading the entire file into memory
  2. Virtualization: Render only the rows visible in the viewport (typically 20-50 rows), regardless of the total dataset size

Step 1: Project Setup

Install the required dependencies:

npm install papaparse react-window
npm install --save-dev @types/papaparse @types/react-window

Package versions used in this tutorial:

  • PapaParse: 5.5.3 (widely used, millions of weekly downloads)
  • react-window: 2.2.4 (popular library with millions of weekly downloads)

Why react-window?

You might wonder whether to use react-window or react-virtualized. Both were created by Brian Vaughn, but react-window is the newer, lighter option:

LibraryPopularityBundle SizeBest For
react-windowVery popular~6kb gzippedMost use cases
react-virtualizedPopular~20-30kb largerComplex requirements

react-window is more popular and significantly smaller. Use it unless you need specific features only available in react-virtualized (like AutoSizer for complex layouts).

Step 2: Configure PapaParse for Streaming

The key to handling large files is PapaParse's streaming API. Instead of parsing the entire file at once, you process it incrementally.

import Papa from 'papaparse';

type ParsedRow = Record<string, string>;

interface StreamingParserConfig {
  file: File;
  onRow: (row: ParsedRow) => void;
  onProgress: (percent: number) => void;
  onComplete: () => void;
  onError: (error: Error) => void;
}

function parseCSVWithStreaming({
  file,
  onRow,
  onProgress,
  onComplete,
  onError,
}: StreamingParserConfig): void {
  let bytesProcessed = 0;
  const totalBytes = file.size;

  Papa.parse(file, {
    header: true,
    worker: true, // Parse in a Web Worker (non-blocking)
    skipEmptyLines: true,
    step: (results, parser) => {
      // Called for each row
      if (results.errors.length > 0) {
        console.warn('Row parse error:', results.errors);
        return;
      }

      // Track progress
      bytesProcessed += JSON.stringify(results.data).length;
      onProgress(Math.min((bytesProcessed / totalBytes) * 100, 99));

      onRow(results.data as ParsedRow);
    },
    complete: () => {
      onProgress(100);
      onComplete();
    },
    error: (error) => {
      onError(new Error(error.message));
    },
  });
}

Key Configuration Options

  • worker: true: Parses in a Web Worker, keeping the main thread responsive. Essential for files over 1MB.
  • step callback: Called for each row as it's parsed. Use this for row-by-row processing.
  • header: true: Treats the first row as column headers, returning objects instead of arrays.

Step 3: Implement Chunk Processing (Faster Alternative)

For better performance with very large files, use chunk processing instead of row-by-row:

interface ChunkParserConfig {
  file: File;
  onChunk: (rows: ParsedRow[]) => void;
  onProgress: (percent: number) => void;
  onComplete: () => void;
  onError: (error: Error) => void;
  chunkSize?: number;
}

function parseCSVWithChunks({
  file,
  onChunk,
  onProgress,
  onComplete,
  onError,
  chunkSize = 10485760, // 10MB chunks
}: ChunkParserConfig): void {
  let bytesProcessed = 0;
  const totalBytes = file.size;

  Papa.parse(file, {
    header: true,
    worker: true,
    skipEmptyLines: true,
    chunkSize,
    chunk: (results, parser) => {
      // Called for each chunk of rows
      if (results.errors.length > 0) {
        console.warn('Chunk parse errors:', results.errors);
      }

      bytesProcessed += chunkSize;
      onProgress(Math.min((bytesProcessed / totalBytes) * 100, 99));

      onChunk(results.data as ParsedRow[]);
    },
    complete: () => {
      onProgress(100);
      onComplete();
    },
    error: (error) => {
      onError(new Error(error.message));
    },
  });
}

Chunk vs Step:

  • step processes one row at a time - better for memory-constrained environments
  • chunk processes batches - faster overall for large files
  • Optimal chunk size is 5-10MB for most use cases

Step 4: Build a Progress Indicator

Users need feedback during long operations. Here's a progress component:

interface ProgressBarProps {
  percent: number;
  status: 'idle' | 'parsing' | 'complete' | 'error';
}

function ProgressBar({ percent, status }: ProgressBarProps) {
  if (status === 'idle') return null;

  return (
    <div className="progress-container">
      <div className="progress-bar">
        <div
          className="progress-fill"
          style={{ width: `${percent}%` }}
        />
      </div>
      <span className="progress-text">
        {status === 'parsing' && `Parsing... ${percent.toFixed(0)}%`}
        {status === 'complete' && 'Complete'}
        {status === 'error' && 'Error occurred'}
      </span>
    </div>
  );
}

Add the CSS:

.progress-container {
  margin: 16px 0;
}

.progress-bar {
  height: 8px;
  background: #e0e0e0;
  border-radius: 4px;
  overflow: hidden;
}

.progress-fill {
  height: 100%;
  background: #3b82f6;
  transition: width 0.2s ease;
}

.progress-text {
  display: block;
  margin-top: 8px;
  font-size: 14px;
  color: #666;
}

Step 5: Render 100K Rows with react-window

Virtualization renders only visible rows. With a viewport showing 20 rows, only 20 DOM elements exist regardless of dataset size.

import { FixedSizeList as List } from 'react-window';

interface VirtualizedTableProps {
  data: ParsedRow[];
  columns: string[];
  height?: number;
  rowHeight?: number;
}

function VirtualizedTable({
  data,
  columns,
  height = 600,
  rowHeight = 35
}: VirtualizedTableProps) {
  const Row = ({ index, style }: { index: number; style: React.CSSProperties }) => {
    const row = data[index];

    return (
      <div style={style} className="table-row">
        {columns.map((col) => (
          <div key={col} className="table-cell">
            {row[col] ?? ''}
          </div>
        ))}
      </div>
    );
  };

  return (
    <div className="virtualized-table">
      {/* Header */}
      <div className="table-header">
        {columns.map((col) => (
          <div key={col} className="table-cell header-cell">
            {col}
          </div>
        ))}
      </div>

      {/* Virtualized body */}
      <List
        height={height}
        itemCount={data.length}
        itemSize={rowHeight}
        width="100%"
      >
        {Row}
      </List>
    </div>
  );
}

Add the table CSS:

.virtualized-table {
  border: 1px solid #e0e0e0;
  border-radius: 8px;
  overflow: hidden;
}

.table-header {
  display: flex;
  background: #f5f5f5;
  border-bottom: 2px solid #e0e0e0;
  font-weight: 600;
}

.table-row {
  display: flex;
  border-bottom: 1px solid #f0f0f0;
}

.table-row:hover {
  background: #fafafa;
}

.table-cell {
  flex: 1;
  padding: 8px 12px;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.header-cell {
  padding: 12px;
}

Complete Example

Here's the full working component that ties everything together:

import { useState, useCallback, useRef } from 'react';
import Papa from 'papaparse';
import { FixedSizeList as List } from 'react-window';

type ParsedRow = Record<string, string>;

interface CSVUploaderState {
  data: ParsedRow[];
  columns: string[];
  status: 'idle' | 'parsing' | 'complete' | 'error';
  progress: number;
  error: string | null;
  rowCount: number;
}

export function LargeCSVUploader() {
  const [state, setState] = useState<CSVUploaderState>({
    data: [],
    columns: [],
    status: 'idle',
    progress: 0,
    error: null,
    rowCount: 0,
  });

  const dataRef = useRef<ParsedRow[]>([]);
  const columnsRef = useRef<string[]>([]);

  const handleFile = useCallback((file: File) => {
    // Reset state
    dataRef.current = [];
    columnsRef.current = [];
    setState({
      data: [],
      columns: [],
      status: 'parsing',
      progress: 0,
      error: null,
      rowCount: 0,
    });

    let bytesProcessed = 0;
    const totalBytes = file.size;
    let isFirstChunk = true;

    Papa.parse(file, {
      header: true,
      worker: true,
      skipEmptyLines: true,
      chunkSize: 10485760, // 10MB chunks
      chunk: (results) => {
        const rows = results.data as ParsedRow[];

        // Capture column names from first chunk
        if (isFirstChunk && rows.length > 0) {
          columnsRef.current = Object.keys(rows[0]);
          isFirstChunk = false;
        }

        // Accumulate data
        dataRef.current.push(...rows);
        bytesProcessed += 10485760;

        setState(prev => ({
          ...prev,
          progress: Math.min((bytesProcessed / totalBytes) * 100, 99),
          rowCount: dataRef.current.length,
        }));
      },
      complete: () => {
        setState({
          data: dataRef.current,
          columns: columnsRef.current,
          status: 'complete',
          progress: 100,
          error: null,
          rowCount: dataRef.current.length,
        });
      },
      error: (error) => {
        setState(prev => ({
          ...prev,
          status: 'error',
          error: error.message,
        }));
      },
    });
  }, []);

  const handleDrop = useCallback((e: React.DragEvent) => {
    e.preventDefault();
    const file = e.dataTransfer.files[0];
    if (file && file.name.endsWith('.csv')) {
      handleFile(file);
    }
  }, [handleFile]);

  const handleFileInput = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
    const file = e.target.files?.[0];
    if (file) {
      handleFile(file);
    }
  }, [handleFile]);

  const Row = ({ index, style }: { index: number; style: React.CSSProperties }) => {
    const row = state.data[index];
    return (
      <div style={{ ...style, display: 'flex', borderBottom: '1px solid #f0f0f0' }}>
        {state.columns.map((col) => (
          <div
            key={col}
            style={{
              flex: 1,
              padding: '8px 12px',
              overflow: 'hidden',
              textOverflow: 'ellipsis',
              whiteSpace: 'nowrap',
            }}
          >
            {row[col] ?? ''}
          </div>
        ))}
      </div>
    );
  };

  return (
    <div style={{ padding: 24 }}>
      {/* Drop zone */}
      <div
        onDrop={handleDrop}
        onDragOver={(e) => e.preventDefault()}
        style={{
          border: '2px dashed #ccc',
          borderRadius: 8,
          padding: 40,
          textAlign: 'center',
          marginBottom: 24,
          background: state.status === 'parsing' ? '#f0f7ff' : '#fafafa',
        }}
      >
        <input
          type="file"
          accept=".csv"
          onChange={handleFileInput}
          style={{ display: 'none' }}
          id="csv-input"
        />
        <label htmlFor="csv-input" style={{ cursor: 'pointer' }}>
          <p style={{ margin: 0, fontSize: 16 }}>
            Drop a CSV file here, or click to select
          </p>
          <p style={{ margin: '8px 0 0', color: '#666', fontSize: 14 }}>
            Supports files with 100K+ rows
          </p>
        </label>
      </div>

      {/* Progress bar */}
      {state.status === 'parsing' && (
        <div style={{ marginBottom: 24 }}>
          <div style={{ height: 8, background: '#e0e0e0', borderRadius: 4, overflow: 'hidden' }}>
            <div
              style={{
                height: '100%',
                width: `${state.progress}%`,
                background: '#3b82f6',
                transition: 'width 0.2s',
              }}
            />
          </div>
          <p style={{ marginTop: 8, color: '#666', fontSize: 14 }}>
            Parsing... {state.progress.toFixed(0)}% ({state.rowCount.toLocaleString()} rows)
          </p>
        </div>
      )}

      {/* Error message */}
      {state.status === 'error' && (
        <div style={{ padding: 16, background: '#fee', borderRadius: 8, marginBottom: 24 }}>
          <p style={{ margin: 0, color: '#c00' }}>Error: {state.error}</p>
        </div>
      )}

      {/* Results */}
      {state.status === 'complete' && state.data.length > 0 && (
        <div>
          <p style={{ marginBottom: 16, color: '#666' }}>
            Loaded {state.rowCount.toLocaleString()} rows with {state.columns.length} columns
          </p>

          {/* Header */}
          <div style={{ display: 'flex', background: '#f5f5f5', borderBottom: '2px solid #e0e0e0', fontWeight: 600 }}>
            {state.columns.map((col) => (
              <div key={col} style={{ flex: 1, padding: '12px' }}>
                {col}
              </div>
            ))}
          </div>

          {/* Virtualized rows */}
          <List
            height={600}
            itemCount={state.data.length}
            itemSize={35}
            width="100%"
          >
            {Row}
          </List>
        </div>
      )}
    </div>
  );
}

Common Pitfalls

Loading the Entire File into Memory

Problem: Using FileReader.readAsText() then parsing the result loads everything into memory, crashing browsers with 300K+ rows.

Solution: Use PapaParse's streaming with step or chunk callbacks. The file is processed incrementally without ever loading completely into memory.

Rendering All Rows to the DOM

Problem: The DOM becomes unresponsive with 10K+ elements. Users cannot scroll or interact with the page.

Solution: Use virtualization (react-window or react-virtualized) to render only visible rows. With a 600px container and 35px row height, you render approximately 17 rows instead of 100K.

Safari Crashes with Large CSVs

Problem: Some developers have reported that Safari may crash with very large CSVs in some configurations, even when other browsers handle them fine.

Solution: Process in smaller chunks, use Web Workers, and test specifically in Safari. Consider implementing pagination as a fallback for extremely large files.

Blocking the Main Thread

Problem: Parsing on the main thread freezes the UI. Users think the app has crashed.

Solution: Always use worker: true in PapaParse config for files over 1MB. This offloads parsing to a Web Worker, keeping the main thread responsive.

Memory Not Released After Parsing

Problem: Memory usage stays high even after processing is complete.

Solution: Process data in chunks, nullify large array references when no longer needed, and avoid storing the entire dataset if you only need aggregations.

Browser Maximum Pixel Limit

Problem: Even with virtualization, some browsers have maximum scroll height limits. Setting very large scroll heights can trigger rendering issues.

Solution: For extremely large datasets (500K+ rows), implement pagination in addition to virtualization.

Memory Management Tips

  1. Process incrementally - Never store the entire file as a string
  2. Use batches - 1,000-5,000 rows per batch works well for most use cases
  3. Clean up references - Set large arrays to null when done
  4. Monitor memory - Use Chrome DevTools Memory panel during development
  5. Consider pagination - For datasets over 500K rows, pagination may provide better UX than infinite scroll

The Easier Way: ImportCSV

Building large file handling from scratch requires 100+ lines of code to handle streaming, progress tracking, error handling, and edge cases like encoding issues and malformed rows. You also need to test across browsers and handle Safari-specific quirks.

ImportCSV handles all of this automatically:

import { CSVImporter } from '@importcsv/react';

function App() {
  return (
    <CSVImporter
      onComplete={(data) => {
        console.log(`Imported ${data.rows.length} rows`);
      }}
    />
  );
}

What you get with ImportCSV:

  • Automatic large file handling - No configuration needed for 100K+ rows
  • Built-in streaming - Handles memory management automatically
  • Progress indicators - Shows users what is happening during long imports
  • Error recovery - Graceful handling of malformed data with row-level error messages
  • Column mapping UI - Users can map CSV columns to your schema visually
  • Data validation - Validate data before it reaches your application
  • Browser compatibility - Tested across Chrome, Firefox, Safari, and Edge

Wrap-up

CSV imports shouldn't slow you down. ImportCSV aims to expand into your workflow — whether you're building data import flows, handling customer uploads, or processing large datasets.

If that sounds like the kind of tooling you want to use, try ImportCSV .