I recently found a memory leak in one of my internal web tools. The feature looked simple from the outside. It was a stock opname page where the user scans an item with the camera, submits it, then goes back to the result page. After that they continue the next item. Nothing unusual.
The problem started when the team reported that the page became slower after a few cycles. At first it felt like normal browser lag, but after repeating the flow a few times the memory usage kept climbing and never went down. I could reproduce it by doing the same steps again and again.
Open the camera page, scan, submit, go back to results. Repeat.
It turned out the combination of camera mounting and React Query invalidations was the root cause.
The repeated mount and unmount problem
Every time the user entered the camera page, the app mounted a camera component. When they finished scanning, the page unmounted the camera and went back to the result page. This repeated many times.
I assumed the camera component would clean up everything when unmounted. It did not. The stream from the browser camera stayed open for a short time, event listeners were not fully cleaned up, and references stayed in memory. It was not a huge leak on its own, but it accumulated on every cycle.
After 10 or 15 cycles, the browser became noticeably slower. After 20 cycles, the tab felt heavy and memory usage went up permanently.
The React Query side effect
On top of that, I used React Query to refresh the stock data after every submission. After the user scanned one item and submitted it, I invalidated the query so the result page would refetch the full list.
That list is not small. One request can return hundreds of items for a single stock opname session, with each item marked as scanned or not scanned. So every time one item was processed, the frontend pulled the entire list again.
In practice, this meant:
- Scan one item
- Submit
- React Query refetches a list with hundreds of rows
- Repeat this over and over during the opname
React Query did its job. It cached every response and kept old data in memory until its cache time expired. The problem was the size and frequency of the refetches. Each refetch returned a large list, and because the default React Query cache time is 5 minutes, none of the previous responses were cleared immediately.
During stock opname, the user triggered this refetch over and over, so the browser ended up holding several large responses at the same time before garbage collection finally kicked in.
The navigation pattern made it worse
The design for this flow was a back and forth pattern. List page → camera page → list page → camera page.
I thought it made the UX easier by keeping things simple. But this design is the perfect setup for memory leaks. Anything that is not cleaned up perfectly will multiply with each cycle, because the user repeats it many times in one session. In this case, both the camera instance and the React Query cache inflated the memory.
If the user only visited once or twice, nobody would notice. But stock opname is a repetitive task. Users do it hundreds of times.
The final fix
There were a few changes that finally solved it.
The camera component stopped being recreated from zero for every scan. Instead, a persistent camera instance lived inside a stable parent component. Mount once, reuse it.
React Query caching was limited with a proper stale time, and invalidation only happened when new data was actually needed. No more triggering unnecessary refetches.
The camera stream was cleaned up manually. Tracks were closed, listeners removed, and references cleared before unmounting.
The back-and-forth pattern was retired. Instead, the camera and result view live in the same component, switching between views without fully destroying everything.
After these changes, the memory footprint stayed stable even after a hundred cycles. The page felt normal again.
What I learned
This bug was a good reminder that browser memory leaks do not always come from complicated logic. Sometimes they come from simple design choices.
A camera component looks harmless until you mount and unmount it twenty times in a row. React Query looks simple until invalidations stack up over time. A navigation pattern looks clean until you repeat it a hundred times.
Stock opname is repetitive by nature. Any leak, even a small one, becomes visible fast.
This was one of those cases. The fix was not about writing clever code. It was about noticing how the page was being used, how components were being mounted, and how often things were being recreated.