Why doesn't RAM go down after I collect the garbage?
🌱 seedlingplanted tended #bun#lessons#performance
Written for Anyone running a Bun or JavaScript server that keeps a lot of data in RAM, measuring memory and watching the number refuse to fall.
I used to think that cutting RAM meant finding what was holding it. This week I learned the biggest eater can be holding nothing at all.
I took the rerun numbers on the afternoon of 3 Nov on Bun 1.4.4, on a Mac, with the actual 277,138 lines of real data. The numbers from the real server come from the fix log, and I couldn't measure those again. AI helped with both the fix and the rerun.
Tiếng Việt: Vì sao dọn rác xong mà RAM vẫn chẳng xuống?.
A server sitting idle, holding 2 GB
There's a legal lookup site I work on whose server settled at about 2 GB after start and reached 2.9 GB under load. The target was under 1 GB.
The first thing anyone tries is listing what holds the memory. We loaded one holder at a time in a clean process and read the number. Search terms 637 MB, the document list 242 MB, the link graph about 250 MB. All of these could be cut, and were: 637 fell to 175.
But add them all up and you still don't reach the real figure. A big piece was missing and belonged to no holder.
The biggest culprit holds nothing
The missing piece was a heading index: 277 thousand lines of legal headings, cut into word pairs, recording which pair sits on which line. The index itself is tiny, but building it grew the process by 880 to 1,188 MB, and that growth stayed.
My reflex was to clean up after the build: Bun.gc(true), then Bun.shrink(). The log says that gave back 7 MB. The collector had done its work, only that much data was still alive, and the operating system still saw a process just as large.
The fix runs against the reflex: don't try to get it back after the peak, don't let it reach the peak. Collect every 20 thousand lines, right in the middle of the loop. In exchange the build went from 1.5 seconds to about 2.3, in the background, with room between batches for a waiting request.
My rerun missed the peak
A commit message is a claim, so I meant to run it again to be sure. First I wrote a loop that made 1 GB of garbage and read the number. Result: 22 MB, nothing to see. Every way of collecting looked the same.
Second, I used the real data and the real word-cutting function, still in a clean process. The gap was only about 20 MB, a long way from 880.
I nearly wrote that the log was wrong. Then I noticed something else: the real server builds the index while already holding a pile of live data, and my process was empty. So I loaded 3.28 million live objects first, like the server, and built again. I ran every row below at least twice:
| live data already held | collecting | RAM at the end | peak | build time |
|---|---|---|---|---|
| none | never | ~155 MB | ~205 MB | 1.2 s |
| none | during the build | ~135 MB | ~180 MB | 1.3 s |
| ~660 MB | never | ~1,140 MB | ~1,980 MB | 1.2 s |
| ~660 MB | after, gc + shrink | ~1,110 MB | ~1,985 MB | 1.2 s |
| ~660 MB | during the build | 699 MB | 786 MB | 2.1 s |
| ~660 MB | only cutting, no index kept | 666 MB | ~1,970 MB | 1 s |
The size doesn't match the real server, since my 3.28 million objects are stand-ins. The shape does: the fuller the house, the higher the garbage piles before anyone collects it. A clean process will never show this, and a clean process was exactly how we were hunting the culprit.
The last row corrects a line in the log
The fix log says "memory the allocator has grown to does not come back." The last row shows that isn't the whole story. Same peak of nearly 2 GB, but when the loop only cuts and keeps nothing, RAM falls back to 666 MB, close to where it started.
So pure garbage comes back fine; what sticks is garbage mixed with survivors. The index keeps arrays that grow a little at a time, spread evenly through the garbage. My guess is that each live scrap pins the memory page around it, so the operating system can't take it back. That's only a guess, I haven't looked inside the allocator to prove it.
The other traps on the way
Estimates before the cut were off by up to 15 times. The next two cuts were estimated at about 100 MB and 50 MB. Measured, they saved 50 MB and 3 MB. Part of it is that a typed array lives outside the heap: the heap fell while RAM took 16 MB back.
Reading the number after one minute is lying to yourself. The real server held 750 to 780 MB for the first few minutes, then stepped up to 1.28 GB with no requests at all. JavaScript's own counters didn't move across that step. Since then we only read after at least five minutes.
Saving RAM isn't worth it if the answers change. A fixed set of 62 questions compares each result before and after. None changed, and the summed time even fell from 2,082 to 1,313 ms. The real server went from 2,249 MB to 1,013 MB, though those two readings sit at different times after a restart (3.7 hours against 5 minutes), so I won't call that final yet.
Try it yourself
Paste this into a file, then run bun file.ts none and bun file.ts during:
const during = process.argv[2] === "during";
const mb = () => Math.round(process.memoryUsage().rss / 1048576);
// what a server already holds
const live = Array.from({ length: 3_000_000 }, (_, i) => ({ id: i, name: `row ${i}` }));
const words = "trách nhiệm của cơ quan quản lý nhà nước về đất đai và nhà ở thời hạn bảo hiểm hợp đồng lao động".split(" ");
const index = new Map<string, number[]>();
Bun.gc(true);
const before = mb();
for (let h = 0; h < 300_000; h++) {
if (during && h % 20_000 === 0) Bun.gc(true);
const text = Array.from({ length: 12 }, (_, i) => words[(h * (i + 7)) % words.length]).join(" ");
const cut = text.normalize("NFD").replace(/\p{M}/gu, "").split(" ");
for (const pair of new Set(cut.slice(1).map((w, i) => `${cut[i]} ${w}`))) {
const list = index.get(pair);
if (list) list.push(h);
else index.set(pair, [h]);
}
}
Bun.gc(true);
Bun.shrink();
await Bun.sleep(1000);
console.log(during ? "during" : "after", { before, after: mb(), live: live.length, pairs: index.size });
On my machine, collecting at the end adds about 40 MB of RAM, and collecting during the build adds only 3 to 7 MB. The snippet is small, so the gap is small too. The more live data and the longer the lines, the wider it gets.
The price grows as well: every Bun.gc(true) has to walk all that live data. With a 1.7 GB heap, the build on my stand-in data went from 0.7 seconds to 3.7. So do this for background work only, never while answering a request.
What I still don't know
- I haven't proved the guess about pinned memory pages. The last row of the table is only indirect evidence.
- Nobody has explained the idle step yet. Processes running together stepped together, which might point at macOS memory pressure, but that's also just a guess.
- I measured on a Mac. The Linux server only has before and after figures, not a full table like the one above.
- The 12-hour number hasn't been read. Memory may creep back up slowly, and everything here may only be the story of the first five minutes.
Have you met a RAM number that wouldn't fall, and the reason turned out to be somewhere you weren't looking? Write me a letter below.
Write papa dragon a letter
If this note stirred something, write to me here. A letter reaches only me and never shows up on the page by itself. If one is lovely and you allow it, I may quote it under the note, without your name.
Works from a terminal too: curl -d 'msg=…' olragon.com/garden/high-water-en/letter