
How to Handle Large CSV Files in JavaScript Without Freezing the Browser
I generated a CSV with one million rows, eleven columns, and a file size of 108 MB, enabled Chrome's long-task observer, and ran the file through Updog Importer from the file picker to the grid.
Parsing took 2.7 seconds. The full import took 12 seconds. The browser continued painting during both, although the main thread still hit one 389 ms task when the parsed rows arrived from the worker. Every measurement in this article comes from that run in Chrome 151 on 4 September 2026.
Handling a large CSV in JavaScript stalls a page at several boundaries, and fixing one leaves the others in place. Parsing occupies a thread for seconds. The parsed data takes several times the file size in memory. Moving a million rows into application state blocks the main thread again. Rendering every row turns the problem into layout and paint. Filtering scans the full dataset on every keystroke.
Each boundary has its own mechanism, and a Web Worker covers the first of them.
Parsing runs in a worker and comes back whole
Updog Importer sends the File to a Web Worker. The main thread posts the file with postMessage, and the browser clones the File as a reference to the same bytes, so nothing is copied into a second JavaScript buffer first.
Inside the Web Worker, the whole file is read into memory, decoded into one string, and parsed by PapaParse in one call.
export const parseCsvFile = async ({ file }: ParseInput): Promise<ParseResult> => { const { text, delimiter } = await decodeFileText(file);
const results = Papa.parse<string[]>(text, { header: false, skipEmptyLines: false, delimiter, });
const rows = results.data;
for (const row of rows) { for (let i = 0; i < row.length; i++) row[i] = cleanCell(row[i]); }
return { kind: "rows", rows, delimiter, errors };};Decoding happens before parsing. The decoder sees the complete byte sequence, so a multibyte character cannot be split between parsing chunks, and delimiter detection runs on decoded text. Each cell then passes through one normalizer, which removes zero-width characters, replaces non-breaking spaces, and trims the value.
The worker returns the parsed rows in one message.
On the million-row file, reading the bytes took 65 ms and UTF-8 decoding took 46 ms when measured separately in a worker. The full worker round trip, from posting the file to receiving the parsed rows on the main thread, took 2,704 ms.
The main thread remained available while the worker parsed. The expensive moment came when the result arrived. Structured cloning had to materialize one million row arrays on the receiving side, producing a 389 ms main-thread task.
| Stage | Measured |
|---|---|
| File posted to rows on the main thread | 2,704 ms |
| Reading the bytes in the worker | 65 ms |
| Decoding UTF-8 in the worker | 46 ms |
| Longest main-thread task during parsing | 389 ms |
| Main-thread heap after parsing | 351 MB |
The Web Worker removed parsing from the main thread. It did not reduce the size of the parsed result or change the fact that all one million rows arrived there together.
At that boundary the cost is memory, and the usual answer to memory is streaming.
Streaming fits a one-pass job
The standard advice for a large CSV is to stream it. Read the file in chunks, process each chunk as it arrives, and discard it when the work is done.
That works for one-pass jobs. Summing a column, checking a header, or sending rows to a server in batches can finish with one chunk before the next arrives. Memory stays close to the size of the active chunk.
An editable dataset has a different requirement. A person may sort it, filter it, correct a cell in the middle, and undo that correction. An in-memory editor needs the rows to remain available after parsing.
Put a streaming parser in front of that editor and the code eventually does this.
const rows = [];
parser.on("chunk", (chunk) => { rows.push(...chunk);});
parser.on("end", () => { renderGrid(rows);});Parsing is incremental, and storage accumulates. Every chunk stays in rows, so the final dataset occupies the same memory the grid would have needed after a whole-file parse.
Updog Importer therefore parses the CSV in one pass inside the worker. Column matching, validation, sorting, filtering, editing, and undo all operate on a complete in-memory dataset, so the more important problem is reducing what that dataset costs once it exists.
The shape of a row decides the memory
A million rows with eleven columns is eleven million cells, and the row objects around those cells have a cost of their own.
V8 normally gives objects with the same property layout a shared hidden class and stores their values in fixed slots. Add properties one at a time past a threshold, and an object can move into dictionary mode, where it carries a property table of its own. V8's write-up on fast properties describes the two representations. Measured on Chrome 151, objects built one key at a time switched at the twentieth key.
The difference is large. We measured one million rows with twenty properties.
| How the row was built | Memory | Column read | Column write |
|---|---|---|---|
| 20 keys, added one at a time | 412 MB | 40 ms | 88 ms |
| 20 keys, shape created up front | 92 MB | 4 ms | 10 ms |
A CSV importer naturally tends towards the first shape. It reads a header, creates an empty row, and assigns one cell after another.
Updog Importer creates the complete shape first. createRowShape builds one template containing every column key, then each imported row starts as a clone of that template.
const templateOf = (keys, booleanKeys) => { const body = keys .map((key) => JSON.stringify(key) + ":" + (booleanKeys.has(key) ? "false" : '""') ) .join(",");
return JSON.parse("{" + body + "}");};
const template = templateOf(keys, booleanKeys);
const make = () => { return { ...template };};Every row therefore starts with the same set of properties before any imported value is written. Updating an existing key does not change that shape.
That also gives the editor a consistent row contract. A column absent from the file still exists on every row as an empty value. Without that, later comparison code has to distinguish between a missing property and an empty cell, which can make untouched rows look changed.
The store keeps those rows in a plain array indexed by sequential id. Deleting a row leaves undefined in its slot, so the array keeps one element kind in V8 and no row after it moves.
The importer also releases the raw representation while it builds the final one. Rows arrive from the worker as arrays of strings, while the editor stores row objects. Holding both complete representations for the whole import would keep two copies of the dataset on the heap at the same time.
Instead, the import converts 5,000 rows at a time. Once one chunk has entered the store, its raw rows are cleared before the next chunk begins.
await onChunk(rows, { chunkIndex, chunkCount, isLastChunk });
for (let i = processed; i < end; i++) { rawRows[dataIdx[i]] = undefined;}processed = end;
if (processed < dataIdx.length) await yieldToBrowser();The heap measurements show the result.
| Main-thread heap | Measured |
|---|---|
| After parsing | 351 MB |
| Peak during import | 833 MB |
| After garbage collection | 580 MB |
The peak stays below the raw grid plus the complete editor store because part of the raw representation is released while the store is still being built.
At this size, memory depends on how the values are represented and on how long two representations stay alive together, as much as on how many values there are.
The main thread yields between chunks
Landing a million rows is main-thread work by design. The store, validator, and grid all work over the same row objects, so those objects have to be created where the application reads them.
The importer keeps that work responsive by yielding between chunks.
export const yieldToBrowser = (): Promise<void> => { if (typeof MessageChannel === "undefined") { return new Promise((resolve) => setTimeout(resolve, 0)); } return new Promise((resolve) => { const { port1, port2 } = new MessageChannel(); port1.onmessage = () => { port1.close(); resolve(); }; port2.postMessage(null); });};A microtask is not enough. Promise continuations run before the event loop reaches its next rendering opportunity, so repeatedly awaiting resolved promises can keep the main thread occupied without giving the browser a chance to paint.
A timer can create that break, but chained timers eventually hit the HTML timer clamp. After more than five nested timers, a timeout below 4 ms is raised to at least 4 ms.
MessageChannel schedules another task without that timer clamp. When one import chunk finishes, control returns to the event loop before the next one starts. That gives the browser an opportunity to paint and to process input, where a single continuous task would keep both waiting until the import ended.
| Import of one million rows | Measured |
|---|---|
| Click on Import to grid on screen | 12,261 ms |
| Tasks longer than 50 ms | 71 |
| Longest task | 122 ms |
| Main-thread heap at peak | 833 MB |
| Main-thread heap after garbage collection | 580 MB |
The import still takes twelve seconds, and some chunks still produce long tasks. The difference is that those tasks are separated, with the event loop running between them. In this run, 71 tasks crossed the 50 ms long-task threshold and the longest lasted 122 ms.
Validation uses the same pattern. Revalidating a column after a bulk change runs through the same chunked processor, with a yield between chunks.
Large bulk operations do the same. An operation touching at least 50,000 cells shows progress and processes rows in batches of 25,000. Splitting a column across a million rows therefore advances through bounded pieces, and the main thread is free between them.
Yielding does not make the work cheaper. It changes when the browser gets control back.
Rendering scales with the viewport
Every row in the store is now an object. A table built from DOM elements would also need an element for every visible cell, and each element brings layout and paint work with it.
DOM virtualization limits that cost by keeping only the visible rows mounted. A canvas grid goes further and removes the cell elements altogether. In both designs, the work required to draw a frame depends mostly on the viewport, and the total row count stops dominating the cost of a frame.
Updog Importer draws the grid on a canvas. When something changes, it redraws the visible viewport. When nothing changes, it does not draw another frame.
The rendering engine, the caches that keep full viewport redraws cheap, and the scroll mapping that lets the grid move beyond the browser's maximum element height are covered in the canvas grid post.
That solves the rendering boundary. It does not help with the work that happens before the first frame appears. Parsing, memory, and store construction still need their own limits, which is why rendering is only one part of the large-file path.
Filtering still walks every row
Viewport-sized rendering does not help with operations that inspect the dataset itself. A search term or error filter still has to test every row. On a million-row dataset, each change to the filter can mean another million-row scan.
Run that work on the main thread and the grid can render quickly while typing still freezes the page.
Updog Importer keeps a second representation of the dataset in a worker. Each row becomes a flat string for searchable cell text, with bitmasks for error and edit state. Filtering and sorting run against that worker-side data, and only the matching row ids return to the main thread.
An edit does not resend the dataset. Changing one cell sends a small message with the updated text for that row, so the worker keeps its copy in sync.
On one million rows with fifteen columns, a filter round trip took between 30 and 250 milliseconds in the session measured for the filter panel post, which also covers the ordering of the checks, the worker representation, and the protection against stale results.
Rendering scales with the viewport. Filtering and sorting still scale with the dataset, so they need a separate execution path.
The browser sets the ceiling
Everything above still runs inside one browser tab, and browser memory sets the ceiling for any client-side importer.
In this run, one million rows with eleven columns settled at 580 MB on the main thread. The filtering worker held its own mirror of the dataset on top of that. One million rows is the scale this design makes room for by controlling representation, temporary copies, rendering, and dataset-wide work.
The worker boundary also isolates parsing from the rest of the page. If parsing cannot complete because the worker runs out of resources, the importer can terminate that path and report the failure without turning the parsing loop into main-thread work.
Beyond the memory available to a browser tab, the architecture has to change. The same applies to scheduled imports that run with nobody at the keyboard. Those jobs belong on a server. The client-side and server-side post covers that boundary.
The million-row run shows why no single optimization was enough. A worker kept parsing off the main thread. A shared row shape kept the store compact. Chunked writes returned control to the browser during import. Canvas limited rendering to the viewport. A second worker handled filters and sorts over the full dataset.
Each mechanism removed a different way for the page to stall. Together they took a 108 MB CSV from the file picker to an editable grid of one million rows without turning the twelve-second import into one twelve-second main-thread task.