Toolium

Why a Big File Used to Freeze the Whole Tab

Hemanth Gedda8 min read

The first bug report I took seriously on this site described a freeze. Someone dropped a 50MB CSV export into one of the file tools, the cursor turned into a spinner, the tab stopped painting, and after a few seconds Chrome offered to kill the page. The console stayed clean and nothing crashed, because the code did exactly what I wrote it to do: read the whole file into a string, then parse it row by row on the only thread the page has.

I had tested the tool with a 200KB sample and it felt instant. At 50MB the same code held the thread for several seconds, and while it held the thread the browser could not paint a frame, advance an animation, or deliver a click. The parsing logic was correct. I had put it in the wrong place.

One thread runs everything you see

JavaScript on a page runs on the main thread, and the browser shares that thread with nearly everything else the page does: style recalculation, layout, painting, input dispatch, garbage collection for your objects. The event loop hands the thread one task at a time, and each task runs to completion. There is no preemption. If my parse takes four seconds, the click event queued behind it waits four seconds.

The frame budget makes this concrete. A display refreshing at 60Hz gives the browser about 16.7 milliseconds per frame, and Chrome's tooling flags any task over 50 milliseconds as a long task because past that point people feel the lag in taps and keystrokes. A synchronous parse of a 50MB file blows through that budget thousands of times in a row, and once the thread stays blocked for a few seconds the browser stops waiting and offers the page-unresponsive dialog.

Memory pressure makes the stall worse than the raw byte count suggests. Splitting 50MB of text on newlines produces hundreds of thousands of substrings, and splitting each row on commas multiplies that into millions of short-lived allocations. Depending on the engine and the characters involved, the string itself can occupy 50MB or 100MB, and the garbage collector that sweeps up the parse debris runs on the same thread you are trying to free. Scrolling sometimes survives, since the compositor can scroll without the main thread, but any interaction that needs JavaScript (a hover handler, a virtualized list, a framework re-render) sits in the queue with everything else.

Moving the parse to a worker

Web Workers exist for exactly this case. A worker is a second thread with its own JavaScript engine instance, its own global scope, and its own event loop. You create one by pointing the browser at a script with new Worker('parse.js'), and from then on the two threads communicate by posting messages. The worker computes while the main thread renders, and neither one can stall the other.

The restriction that surprises people first: a worker cannot touch the DOM. Inside a worker, document and window do not exist; the DOM belongs to the main thread alone. That was a deliberate design call, since the DOM was never thread-safe and guarding it with locks would import the deadlocks that native UI toolkits spent decades fighting. A worker keeps fetch, timers, IndexedDB, WebAssembly, and the crypto APIs, which covers computation well, and anything destined for the screen has to travel back as data.

In practice a worker-backed tool becomes a small protocol. The page posts the file and options in, the worker posts {progress: 0.42} back every few thousand rows, and a final message carries the result. The progress bar animates because the main thread has nothing else to do, and cancellation, which no amount of cleverness could offer mid-parse on the main thread, becomes a call to worker.terminate().

Cloning copies, transferring moves

postMessage carries a cost that stays invisible until the payloads grow. By default the browser serializes whatever you pass using the structured clone algorithm, which walks the object graph and builds a full copy on the receiving side. For a config object the copy costs nothing worth measuring. For a 100MB ArrayBuffer it means allocating a second 100MB and copying every byte, so for a moment the tab holds 200MB of the same data. On a mid-range phone that spike can push the tab into the zone where the OS kills it for memory pressure.

Transferable objects avoid the copy. Pass a transfer list as the second argument, worker.postMessage(buffer, [buffer]), and the browser moves ownership of the underlying memory instead of duplicating it. The receiving thread sees the same bytes, the sender keeps a detached buffer whose byteLength reads zero, and the handoff costs about the same whether the buffer holds 100 bytes or 100MB.

The catch is the short list of types that can travel this way: ArrayBuffer, MessagePort, ImageBitmap, OffscreenCanvas, the stream classes, a few others. Strings never transfer, and neither do plain objects or arrays. My CSV flow reads the file as an ArrayBuffer, transfers it into the worker for free, decodes it there with TextDecoder, and sends back either something small (column types, row count, a preview slice) or numbers packed into a typed array so the return trip can transfer too. The naive version of the same flow, reading the file into one giant string and cloning it across, doubles both the memory and the wait.

You never needed the whole file in memory

Workers fix responsiveness and leave the memory question standing. A 50MB buffer is manageable; the 800MB log export someone will eventually feed your tool is a different animal. The File object your drop handler receives is a Blob, and a Blob is a handle, a lazy reference to bytes that mostly still sit on disk. Reading is the step that pulls bytes into RAM, which means you decide how many arrive at once.

Blob.slice() is the manual approach. Calling file.slice(offset, offset + 4194304) creates a smaller Blob without reading anything, and awaiting that slice's arrayBuffer() loads only that 4MB window. Loop over the file this way and peak memory stays near the chunk size plus whatever you accumulate, whether the input is 50MB or 5GB. file.stream() is the newer route, returning a ReadableStream whose reader yields chunks the browser sizes itself, and it composes with TransformStream when the processing fits a pipeline shape.

Chunking introduces one honest problem: boundaries. A slice ends wherever it ends, usually mid-row and occasionally mid-character, since a multi-byte UTF-8 sequence can straddle the cut. new TextDecoder().decode(chunk, {stream: true}) holds incomplete byte sequences until the next chunk arrives, which settles the character problem. The row problem stays yours: carry the text after the last newline into the next chunk, and remember that a quoted CSV field may contain a newline that ends nothing. Streaming parsers earn their keep in these ten lines.

Images flip the intuition, because the decoded bitmap dwarfs the file on disk. A 12MB JPEG from a phone camera might decode to 6000 by 4000 pixels at four bytes each, roughly 96MB of RGBA, before your code draws anything. createImageBitmap() accepts resize options, so the browser can decode and downscale in one internal step without handing you the full-size bitmap, and OffscreenCanvas lets a worker handle drawing and re-encoding while the page keeps rendering. For image tools I reach for those two APIs before I consider chunking anything.

When a worker costs more than it saves

Tutorials skip the overhead. Spawning a worker makes the browser fetch, parse, and compile a separate script, which lands in the tens of milliseconds on a warm cache and worse on a cheap phone over a slow network. Every message crosses a serialization boundary, and functions, DOM nodes, and class instances with methods refuse to cross it at all. The worker shares no closures with page code, so state you took for granted must ship over explicitly or get rebuilt. Debugging splits across threads, and a stack trace ending at onmessage tells you where a message arrived without saying why anyone sent it. For a tool that formats a 2KB JSON snippet, a worker adds latency and failure modes while removing a problem the tool never had.

The main-thread alternative is cooperative yielding, and it holds up better than its reputation. Instead of one four-second loop, process a batch of rows, hand the thread back, continue. The portable pause is await new Promise(resolve => setTimeout(resolve, 0)), which lets the event loop paint and deliver input between batches; browsers clamp nested timeouts to roughly 4 milliseconds, and that clamp is the tax per yield. requestIdleCallback() refines this by passing your callback a deadline so you can fill the idle time left in the current frame and no more, and the newer scheduler.yield() puts your continuation ahead of other queued work instead of at the back of the line. Keep each batch under 30 or 40 milliseconds and the page feels alive, even though the total parse now takes longer than the blocking version did.

My working rule after rebuilding these tools: below 50 milliseconds of work, run it inline and move on. Up to a second or so, yield in batches. Beyond that, or wherever the data grows big enough that clone-versus-transfer matters, take the worker and accept its ceremony.

The heavier file tools on this site now run this way: reads chunked through Blob.slice, parsing and encoding inside workers, buffers transferred rather than cloned. Files dropped on Toolium never leave the machine, which is the reason the site exists and the reason I cannot lean on a server for the heavy lifting. When the browser is the whole backend, the main thread is the only production capacity available, and scheduling 50MB of CSV onto it produced the frozen tab this post started with. I wrote more about the client-side choice on the about page.