Why one Codex task made a Mac use 24 GB of RAM
We traced a 24 GB physical-footprint spike to a browser rollout reader retaining an almost 10 GB task log in 64 KiB chunks, then trying to concatenate it into one string.
This was not a normal large-context cost. In one long-running Codex desktop task, the Browser runtime reread a 9.96 GB rollout JSONL from the beginning, retained the unread portion as 64 KiB buffers, and attempted to join it into a single string. The conversion could not fit Node's string limit, so progress was never committed and polling could start the same read again.
What we measured
Activity Monitor showed a huge process, but the normal JavaScript heap was only about 47 MB. The mass sat in external/native memory. A heap census found 136,918 live Buffer objects; 136,897 of them were exactly 65,536 bytes. Those same-sized buffers accounted for 8,971,681,792 bytes at the sample point — 90.08% of the rollout file then being read.
The process tree tied the allocation to the active Codex Browser runtime rather than an unrelated Node process. Allocation samples led from the rollout tracker into fs/promises.read and Buffer.allocUnsafe(65536).
The failure chain
The reader walked from its stored offset to end-of-file, appended every 64 KiB chunk to an array, then called Buffer.concat(...).toString("utf8"). On the measured Node 24.14.0 runtime, MAX_STRING_LENGTH was 536,870,888 bytes — far below the 9.96 GB input.
Because the full conversion did not complete, the reader did not advance its durable file offset or observe the turn-completion record. A 250 ms poller and file watcher could therefore schedule another read from offset zero. Serial queuing prevented simultaneous scans, but it did not make the repeated full-file work bounded.
- Unbounded input: the active task log had grown to almost 10 GB.
- Unbounded retention: every chunk stayed reachable until one final concat.
- No incremental checkpoint: the offset advanced only after the whole parse succeeded.
- Incomplete teardown: closing browser tabs did not dispose the rollout tracker or Node kernel.
Why the number stayed high
Live memory and Activity Monitor's process number are not identical. At one sample, the process still had gigabytes of live large allocations; later, much of the resident set was allocator-reclaimable empty space. That explains why the displayed footprint could remain alarming even after a read phase changed state.
The reliable diagnosis used four views together: PID ancestry, physical footprint over time, V8 external memory, and allocation-object sizes. VSZ or one Activity Monitor screenshot alone would not have isolated the cause.
The bounded fix
A safe reader should parse complete lines incrementally, cap each batch and pending fragment, and commit the byte offset as records are accepted. It should start a new browser attachment at the current end of an old multi-gigabyte rollout unless historical replay is explicitly required.
Teardown must also be a runtime contract: tab finalization, tracker disposal, in-flight read cancellation, and child-process exit need separate verification. For long tasks, the immediate operational guard is simpler — hand the work to a fresh task before starting Browser or Computer Use.
Sources and evidence
- Local incident record
Measured on Codex macOS 26.803.41515 / Browser plugin 26.803.41515; no rollout contents or credentials were read or published.
- Node.js Buffer constants
The runtime limit used to distinguish a large allocation from an impossible full-string conversion.
CleanDeck