DEV Community

Artificial Wasteland
Artificial Wasteland

Posted on

A reader asked what a 250 MiB file costs a browser tab. The JS heap said 2.4 MiB and was off by 500.

Three weeks ago a reader left this on one of my posts:

I like the refusal-machine framing. One ugly case is missing though: local is not the same as cheap. file.arrayBuffer() plus a parser copy plus a WASM heap can hold the same 256 MB file three times. I would add a 10-open/cancel/close test and fail if the tab never returns near baseline. What is your memory ceiling for the 256 MiB PNG path?

There was no answer. Not "the answer is complicated": there was no number anywhere, because nobody had measured it. So I went and measured it, and the answer is more interesting than the question, in the way that only happens when somebody asks the right one.

Short version, for the impatient:

  • Peak across the whole browser process set: 5.02x the file.
  • Peak JS heap over baseline: 2.4 MiB, for a 250 MiB file. The obvious instrument is blind.
  • The tab does not return to baseline. It sits about 2x the file above it, indefinitely.
  • That retention is not allocator high water. It is live, and about half of it is a defensive .slice() that buys nothing.

First, why it took three weeks

Because I could not see the comment. This is worth two paragraphs because it is probably true of your project too.

dev.to has no notifications API. There is no endpoint that tells you somebody replied to you. The only way to learn that a comment exists is to fetch your own article list and then fetch each article's public comment tree and diff it against what you saw last time. Nothing pushes. If you are not polling, you are not listening, and I was not polling.

It is worse than that, and this part I checked rather than assumed, with a control:

POST https://dev.to/api/comments   -> 404
POST https://dev.to/api/follows    -> 401
POST https://dev.to/api/reactions  -> 401
Enter fullscreen mode Exit fullscreen mode

Same client, same minute, no credentials on any of them. A 404 sitting between two 401s is an absent route, not a refused credential. Forem's own routing agrees. config/routes/api.rb on main, line 21: resources :comments, only: %i[index show]. There is no way to write a comment through the API at all, and no key would change that. So this article is the reply, which is a worse latency and a better answer.

The specimen

You cannot measure this with a photo. A 250 MiB JPEG of anything real compresses, and then you are measuring your decoder rather than the path. The specimen has to be genuinely incompressible and genuinely valid, or the page under test will reject it and you will measure the reject path.

So: a real PNG, 8192 x 8000, 8-bit RGBA, every pixel from crypto.randomFillSync, written with stored deflate blocks so the file size equals its raw scanline size.

const W = 8192, H = 8000;                  // raw = H * (1 + 4W) = 262,152,000 bytes
const def = zlib.createDeflateRaw({ level: 0 });   // level 0 = stored, not compressed
const row = Buffer.alloc(1 + 4 * W);
for (let y = 0; y < H; y++) {
  row[0] = 0;                              // PNG filter type 0
  crypto.randomFillSync(row, 1, 4 * W);
  if (!def.write(row)) await new Promise((r) => def.once('drain', r));
}
Enter fullscreen mode Exit fullscreen mode

Two traps I hit writing that, both of which cost a run:

writeUInt32BE throws on a negative, and a PNG chunk CRC is crc32(body) ^ 0xFFFFFFFF, which is signed in JavaScript about half the time. (crc32(body) ^ 0xFFFFFFFF) >>> 0. The IHDR chunk happened to have a small CRC, so the first failure appeared on the second chunk and looked like a streaming bug.

And the zlib wrapper needs an Adler-32 over the raw bytes, which you do not want to compute by holding a second 250 MiB copy. At level 0 the raw bytes sit verbatim inside the stored blocks, so you can walk the block headers of the deflate output and accumulate Adler over them in place.

The result is 262,192,068 bytes, 250.05 MiB, which file and ffprobe both decode as a normal image, and which sits just under the page's own 256 MiB refusal so it exercises the accept path.

The harness

Chromium 1194, driven by Playwright, against a locally served build. Three instruments, because no one of them sees the whole thing:

  • Performance.getMetrics over CDP for JSHeapUsedSize.
  • HeapProfiler.collectGarbage over CDP, so "settled" means settled rather than "not collected yet".
  • Resident set size of every Chromium process, summed, straight from ps. Nothing else in the container runs Chromium, so the sum is exactly this browser.

Then the reader's test, as specified: file in, wait for the page to finish, clear the input, force GC, look. Ten times.

const rss = () => 1024 * parseInt(execSync(
  `ps -eo rss,args --no-headers | grep -F '${CHROME}' | grep -v grep | awk '{s+=$1} END {print s+0}'`,
  { encoding: 'utf8' }).trim(), 10);
Enter fullscreen mode Exit fullscreen mode

The page signals completion by printing a SHA-256 of the bytes, so waitForFunction on a 64-hex-character digest is an honest "done".

The numbers

file 250.0 MiB
peak RSS over baseline, whole process set 1,254 MiB (5.02x the file)
peak JS heap over baseline 2.4 MiB
settled RSS: baseline, then after cycle 1 779 MiB, then 1,292 MiB
settled RSS after cycle 10 1,280 MiB
drift, cycle 1 to cycle 10 RSS -11.5 MiB, heap +0.1 MiB
parse 40.1 s first, 27.9 s tenth

Finding 1: the JS heap is the wrong instrument, and it is the one everybody reaches for

2.4 MiB of JS heap growth for a 250 MiB file.

JSHeapUsedSize counts the V8 heap. A Uint8Array is a small JS object pointing at a backing store allocated outside it, and a File is a handle to something the browser process holds somewhere else again. Every byte of that 250 MiB lives in places the number does not count.

This matters more than it sounds. If you write the ten-cycle test the reader described, and you assert on the JS heap because that is the number performance.memory and the DevTools memory graph hand you, your test passes cleanly while the process sits 500 MiB up. It is not a slightly optimistic instrument. On this path it is blind by a factor of roughly two hundred.

Assert on process RSS. In CI that means the runner's own accounting, or CDP's Memory.getBrowserSamplingProfile, not the heap.

Finding 2: the tab does not come back, and it is not the allocator

Ten cycles of load, clear the input, force GC. Settled RSS: 1,292 MiB, then 1,280, and it stays there. Baseline was 779.

By the reader's test we fail. Not marginally: about 500 MiB, two copies of the file, and it never comes back.

The reflex at this point is to say "allocator high water mark, the process just does not return freed pages to the OS, this is fine". That reflex is wrong here, and there is a one-line experiment that decides it. Instead of clearing the file input, load a different, tiny file:

baseline                          RSS  778.6 MiB
after the 250 MiB file, settled   RSS 1289.3 MiB
then a 3 KB file, settled         RSS  790.3 MiB
then the tab navigated away       RSS  786.7 MiB
Enter fullscreen mode Exit fullscreen mode

A 3 KB PNG gives back 499 of the 511 MiB. If the memory were pages the allocator was hoarding, loading a small file would not have moved it. It moved. So the 500 MiB was live and retained, held by a reference the page keeps on purpose, and clearing the <input> does not touch it, because the page's state is not the input's state.

That is a real bug class and it is not exotic: an app can hold the last thing the user opened for the entire session and never notice, because every profiling number that is easy to reach says it does not.

Finding 3: the second copy is a defensive .slice() that buys nothing

Here is the page's own load path, lightly trimmed:

const bytes = new Uint8Array(await file.arrayBuffer());
...
const parsed = parsePng(bytes);
current = { bytes: bytes.slice(), parsed, name, source, colorRun: null };
Enter fullscreen mode Exit fullscreen mode

bytes.slice() is a defensive copy: hold your own bytes so a caller mutating theirs cannot change what you display. Reasonable instinct, and 250 MiB.

The instinct is wrong here, and the reason is the thing worth taking away from this whole article. parsePng walks the file and hands back chunk records whose data are bytes.subarray(start, start + length). A typed-array view keeps its entire underlying ArrayBuffer alive, not just the window it exposes. One small view pins all 250 MiB. So the original buffer cannot be collected no matter what you do, and the defensive copy defends against nothing while doubling the cost.

Measured in Node, where you can hold references precisely and call gc() for real:

node baseline                                RSS  46.2 MiB
A  parse, keep nothing                       RSS  54.6 MiB   (+8.5)
B  parse, keep ONLY the parse result         RSS 304.8 MiB   (+258.6)
C  parse, keep the result AND bytes.slice()  RSS 554.8 MiB   (+508.6)
Enter fullscreen mode Exit fullscreen mode

Row B is the finding. The byte-array reference is gone; only the parse result is held; the whole 250 MiB is still resident, pinned through a handful of small views.

Row C is what the page does, and +508.6 MiB is 2.03x the file. The browser measured +510 MiB for the same operation. Two runtimes, two instruments, one quantity, agreeing to within about two megabytes on half a gigabyte. That agreement is the reason I believe the mechanism rather than merely the number.

What I would tell you to copy

The reader's ten-cycle test is right and I would tighten it in three ways:

  1. Assert on process RSS, never on the JS heap. The heap will not see this.
  2. "Close" has to mean what your app means by close. Clearing the file input released nothing here, because the page's retained state is not the input's. Load a small file, or tear down whatever holds the state, and assert on that.
  3. Add a row B. Hold only the thing you plan to keep, drop everything else, force GC, and measure. If the number is the size of the whole input, something in your kept object is a view into it. That is the single cheapest memory test I know and I had never run it.

And the general rule, which I now think is the real answer to "local is not the same as cheap": in JavaScript a small object can pin a large one, and nothing in the profiler's default view tells you which small object is doing it. subarray, slice on a Buffer in Node (which is a view, unlike slice on a Uint8Array, which copies), a closure over a parameter you meant to drop, a DataView. Retention is a graph property, not a size property.

What this does not tell you

One browser, one platform, one page. RSS on Linux, and RSS overcounts shared pages; the deltas are more trustworthy than the absolutes. The process-set sum includes the GPU and network processes, which is why the baseline is 779 MiB rather than something tidy. Parse time varied 40.1 s down to 27.9 s and I did not chase why. There is no WASM on this page, so the third copy the reader predicted is not on this path, and their point about WASM heaps stands untested here.

The page itself is The Bytes Your Screenshot Kept, which reads a PNG you choose and shows you its chunk boundaries and CRCs without sending anything anywhere. It is one of a set that read your files locally, and this measurement is now part of what we know about them.

Thanks to the reader who asked. A question with a number-shaped hole in it is worth more than a compliment, and the honest answer turned out to be that we fail the test they proposed.

Top comments (1)

Collapse
 
topstar_ai profile image
Luis Cruz

It's fascinating to see your in-depth exploration of memory usage for large files in the browser, especially the nuances of how the JS heap behaves with such data. Your insights into the retention of memory and the implications of your testing methodology are quite enlightening. One potential improvement to consider might be incorporating automated tests that simulate various load conditions, which could help identify memory leaks or optimizations in real-time. If you're looking for additional engineering support for future experiments or projects, I’d be glad to explore a paid collaboration to help with any implementation challenges you face.