I Spent an Entire Night Chasing 380 MB of Ghost Data
I Spent an Entire Night Chasing 380 MB of Ghost Data Tags: #react #debugging #javascript #webdev Okay so. It's 2:47 AM. I'm on my third espresso. The cursor is moving through what I can only describe as wet cement.
I Spent an Entire Night Chasing 380 MB of Ghost Data
Tags: #react #debugging #javascript #webdev
Okay so. It's 2:47 AM. I'm on my third espresso. The cursor is moving through what I can only describe as wet cement. And I just realized the app is leaking memory. Like, aggressively leaking it. 380 MB per cycle. And I have to fix it before standup in nine hours.
Here's the thing that made this one special: nothing was broken. No red text in the console. No React warnings. No crash. The app just... got slower. Gradually. The way a room fills with smoke before you notice it. You'd use it for 20 minutes and suddenly every click felt like you were clicking through a thick liquid.
Users weren't filing bug reports. They were just closing the tab. Opening a new one. Feeling briefly better. And then it started again.
That's the worst kind of bug. The kind that doesn't announce itself.
So I did what any reasonable person does at 1 AM: I opened DevTools and started taking heap snapshots.
First one: 45 MB. Normal. Healthy. A resting heartbeat.
Then I opened and closed the dashboard ten times. Just mashing it. Stress test.
Second snapshot: 425 MB.
I stared at that number for a solid minute.
380 MB of new memory. That never got freed. And the pattern was monotonic — it only went up. Every single cycle added another chunk that just... stayed. Like someone was leaving the lights on in every room they walked out of.
I pulled up the comparison view and filtered for detached DOM nodes. And there they were. Hundreds of them. DOM elements from a component that had unmounted ages ago, still sitting there, still holding references, still existing in a way they shouldn't.
Ghosts.
Now, here's where it gets embarrassing.
I found three leaks. All in the same component. All fixable with cleanup functions I knew I should have written. All the kind of thing that takes four seconds to fix once you see it.
But you don't see it until 2 AM when you're comparing heap snapshots and your eyes are starting to glaze over.
The first one was a WebSocket. Opened on mount. Never closed. Every time someone navigated away, a new connection got created. The old one? Still there. Still breathing. Still holding data. Ten cycles meant ten live connections to a server that thought it was talking to one person.
The fix? return () => ws.close(). One line. I felt like an idiot.
The second one was event listeners. resize and scroll. Added on mount, never removed. After ten cycles, the window had twenty resize handlers and twenty scroll handlers. Every scroll event fired twenty functions. Every resize fired twenty more. It was like having twenty alarm clocks go off at 6 AM and somehow that's worse than one.
The third one is the one that actually kept me up. The subtle one.
A fetch call. Totally normal. Except the component could unmount before the promise resolved. And when it did resolve — 34 MB of chart data — it had nowhere to go. The component was gone. But the closure wasn't. It was still holding that data. Still referencing it. Keeping it alive in an empty room.
I had even created an AbortController. It was sitting right there in the code. Unused. Like a fire extinguisher bolted to the wall of a building that's already on fire. I just... never wired it up.
I don't know what I was thinking. Probably nothing. Probably I wrote it in a hurry, got distracted, and moved on. And it sat there for weeks doing nothing.
So I fixed all three. Wrote the cleanup functions. Added the abort logic. Added a mounted flag because I'm not a saint.
Ran the test again. Ten cycles. Snapshot.
46 MB.
From 425 to 46. The ghosts were gone. The dashboard could finally die when it was supposed to.
I closed DevTools. Stared at the ceiling. Drank the rest of the espresso. It was cold and I drank it anyway.
What I actually learned from this (beyond "write your cleanup functions, you absolute disaster"):
The bug wasn't hard. The finding was hard. The code was obvious in hindsight. But at 2 AM, when you're comparing two snapshots that look 90% identical, when the retained objects are spread across three different categories, when you're tired enough that your brain keeps auto-correcting "this shouldn't be here" to "this is probably fine" — that's when it gets hard.
So now I have a rule. A stupid simple rule I tape to my brain:
If your effect sets something up, it tears it down. No exceptions. No "I'll do it later." No "it's probably fine."
If you add a listener, you remove it. If you open a connection, you close it. If you start a fetch, you can cancel it. If you subscribe to anything, you unsubscribe.
That's it. That's the whole lesson. I just needed to write it down so I remember it the next time it's 2 AM and I'm drinking cold espresso and my cursor is moving through wet cement.
If you've ever spent an unreasonable amount of time on a bug that turned out to be a missing cleanup function, I need you to know: I see you. I respect you. I am you.
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.