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:
- Streaming: Process the CSV incrementally, row by row or in chunks, without loading the entire file into memory
- 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-windowPackage 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:
| Library | Popularity | Bundle Size | Best For |
|---|---|---|---|
| react-window | Very popular | ~6kb gzipped | Most use cases |
| react-virtualized | Popular | ~20-30kb larger | Complex 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.stepcallback: 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:
stepprocesses one row at a time - better for memory-constrained environmentschunkprocesses 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
- Process incrementally - Never store the entire file as a string
- Use batches - 1,000-5,000 rows per batch works well for most use cases
- Clean up references - Set large arrays to null when done
- Monitor memory - Use Chrome DevTools Memory panel during development
- 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
Related posts
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 .