Handling Large CSV Files in Browsers

May 28, 20265 min read

The Browser Memory Problem

A 100 MB CSV file expands to roughly 300-500 MB in JavaScript memory after parsing — each string, number, and object adds overhead. Loading the entire file into memory at once can crash the browser tab or freeze the UI for seconds.

The solution: process the file incrementally, never holding the entire dataset in memory at once.

Strategy 1: Stream-Based Parsing

Instead of reading the whole file into a string and splitting on newlines, read it in chunks and parse incrementally.

async function parseCSV(file, onRow) {
  const stream = file.stream();
  const reader = stream.getReader();
  const decoder = new TextDecoder();
  let buffer = '';

  while (true) {
    const { done, value } = await reader.read();
    if (done) break;

    buffer += decoder.decode(value, { stream: true });
    const lines = buffer.split('\n');

    // Keep the last incomplete line in the buffer
    buffer = lines.pop();

    for (const line of lines) {
      const row = parseRow(line);
      if (row) onRow(row);
    }
  }

  // Process any remaining data
  if (buffer) onRow(parseRow(buffer));
}

This approach holds at most one chunk of text plus one incomplete line in memory — constant space regardless of file size.

Strategy 2: Web Workers for CPU-Heavy Parsing

Parsing is CPU-intensive. Running it on the main thread blocks the UI — buttons stop responding, animations freeze, and the browser may show a "page unresponsive" dialog.

Move parsing to a Web Worker:

// main.js
const worker = new Worker('csv-worker.js');

worker.postMessage({ file });
worker.onmessage = (e) => {
  if (e.data.type === 'row') {
    appendRowToTable(e.data.row);
  } else if (e.data.type === 'done') {
    console.log(`Processed ${e.data.count} rows`);
  }
};

// csv-worker.js
self.onmessage = async (e) => {
  const file = e.data.file;
  let count = 0;

  await parseCSV(file, (row) => {
    self.postMessage({ type: 'row', row });
    count++;
  });

  self.postMessage({ type: 'done', count });
};

The main thread stays responsive. The worker handles parsing in the background.

Strategy 3: Virtual Scrolling for Display

Even if you parse efficiently, rendering 100,000 rows in the DOM crashes the browser. Each DOM node consumes memory and triggers layout calculations.

Virtual scrolling renders only the visible rows:

function renderVisibleRows(container, allRows, scrollTop, rowHeight) {
  const visibleCount = Math.ceil(container.clientHeight / rowHeight);
  const startIndex = Math.floor(scrollTop / rowHeight);
  const endIndex = Math.min(startIndex + visibleCount, allRows.length);

  // Render only rows in the viewport
  const fragment = document.createDocumentFragment();
  for (let i = startIndex; i < endIndex; i++) {
    const row = createRowElement(allRows[i]);
    row.style.transform = `translateY(${i * rowHeight}px)`;
    fragment.appendChild(row);
  }

  container.innerHTML = '';
  container.appendChild(fragment);
  container.style.height = `${allRows.length * rowHeight}px`;
}

Libraries like tanstack/virtual or react-virtualized handle this pattern for you.

Strategy 4: Chunked File Reading

For files over 50 MB, avoid FileReader.readAsText() — it loads the entire file into a string. Use File.slice() to read in controlled chunks:

async function readFileInChunks(file, chunkSize = 5 * 1024 * 1024) {
  let offset = 0;
  while (offset < file.size) {
    const chunk = file.slice(offset, offset + chunkSize);
    const text = await chunk.text();
    processChunk(text);
    offset += chunkSize;
  }
}

This lets you update a progress bar and gives the browser time to garbage-collect between chunks.

Combining All Strategies

A production-grade CSV viewer combines all three:

  1. Read the file in chunks using File.slice() or file.stream()
  2. Parse rows in a Web Worker to keep the UI responsive
  3. Render rows using virtual scrolling to limit DOM nodes
  4. Aggregate summary statistics incrementally instead of storing all rows
let stats = { total: 0, sumByColumn: {} };

await parseCSV(file, (row) => {
  stats.total++;
  Object.entries(row).forEach(([col, val]) => {
    if (typeof val === 'number') {
      stats.sumByColumn[col] = (stats.sumByColumn[col] || 0) + val;
    }
  });
});

This computes totals, averages, and counts without ever storing the full dataset.

Performance Benchmarks

File SizeNaive (readAll)Streaming + WorkerVirtual Scroll Rows
1 MB~200ms~180ms20
50 MB3-5s, UI freezes~800ms20
200 MBTab crash~3s20
1 GBTab crash~15s20

The DOM node count stays constant at ~20 regardless of file size with virtual scrolling.

Key Takeaways

  • Large CSV files can crash the browser if loaded entirely into memory
  • Use streaming APIs (file.stream()) to parse incrementally — constant memory usage
  • Move CPU-heavy parsing to Web Workers to keep the UI responsive
  • Virtual scrolling limits DOM nodes to the visible viewport regardless of dataset size
  • Compute aggregations incrementally rather than storing all rows in an array
  • Combine all strategies for production-grade CSV handling in the browser

Try It Yourself

Convert CSV files of any size to JSON using our CSV to JSON Converter. Paste or upload your data and get structured JSON output instantly.