12 Minutes
How JavaScript Garbage Collection Works
Fix Bugs Faster! Log Collection Made Easy
If you’ve ever written JavaScript, you’ve never had to write code that says “delete this object, I’m done with it.” That’s because there’s a process called garbage collection running in the background to save you the hassle.
Most of the time this process works smoothly and invisibly. But it can also cause an app to suddenly start stuttering for no obvious reason if you don’t manage it effectively. This post will give you the tools to do just that.
We’ll show you how to:
- Improve app performance by understanding what garbage collection actually does.
- Understand how the V8 engine for Google Chrome decides what to keep and remove.
- Use WeakMap, WeakSet, and WeakRef without accidentally keeping objects alive.
- Read a memory graph in Chrome DevTools.
- Recognize where garbage collection turns into memory leaks.
First, what is JavaScript garbage collection?
Garbage collection, or GC, is the process that identifies data your code can no longer use and frees the memory it was taking up. Whereas languages such as C and C++ can reque you to explicitly release allocated memory, using mechanisms like free() or delete, JavaScript engines handle memory reclamation automatically in the background.
This is crucial because a forgotten free() call is one of the most common sources of bugs in older languages like C. JavaScript avoids that entire class of bugs by handling it for you.
Note that garbage collection runs on its own internal schedule. You can’t force it to run and you can’t turn it off in normal code.
The garbage collector: a feature, not a function
The garbage collector is the actual piece of software that performs memory cleanup — specifically, the memory that’s no longer reachable or needed. It’s built directly into the JavaScript engine rather than a function you import or invoke.
- Chrome, Node.js, and Microsoft Edge all run on Google’s V8 engine, which has a garbage collector built in.
- Firefox uses a different engine, SpiderMonkey, and Safari uses JavaScriptCore. Each has its own garbage collector implementation.
In practice, this means you can’t call the garbage collector directly from regular application code.
For testing and debugging purposes only, Chrome exposes a manual trigger: launching with --js-flags="--expose-gc" makes a function called global.gc() callable. This exists strictly for profiling and is never something to ship in production.
How the garbage collector decides what to free
The garbage collector decides what to remove based on reachability. In other words, whether an object can still be accessed from your running code, directly or through a chain of references.
- If it can be reached, it stays.
- If nothing leads back to it anymore, it’s safe to remove.
- Age doesn’t matter. Reachability is the only parameter.
This check starts from the root set: objects and references that are known to be live, such as active execution contexts and global objects.From there, the collector follows every reference outward, marking what it finds. Whatever’s left unmarked gets removed.
Consider this sample code.
let user = { name: "Bugfender" };
// Reachable, "user" points to it.
user = null;
// Unreachable now. Eligible for collection.
The object was initially reachable because the root-level variable user pointed to it. When user was set to null, that reference disappeared.
If no other references to the object exist, the object is no longer reachable from the roots and becomes eligible for garbage collection.
The early scanning technique: Reference counting
Reference counting is an older garbage-collection technique that allows us to estimate the reachability of an object rather than checking it directly, using built-in integer counters.
- Every object keeps a count of how many things point to it.
- The moment that count hits zero, it’s freed immediately.
The problem is circular references: two objects pointing at each other never hit zero, even if nothing else in the program can reach either one. The count says “still referenced,” but nothing outside the pair can actually reach them, so the estimate is wrong.

This gap has pushed modern engines toward mark-and-sweep techniques, which use direct checks rather than inference.
The modern technique: Mark-and-sweep
Mark-and-sweep checks reachability directly by walking outward from the root set instead of counting references. Every collection cycle runs the same sequence:
- Mark from the roots: start from the root set already covered above.
- Follow every reference: visit every object reachable from those starting points, marking each one along the way.
- Stop when nothing new is found: when the cycle is complete, every genuinely reachable object has now been visited exactly once.
- Sweep the unmarked: anything left unmarked gets removed from memory.
- Reclaim the space: the freed memory is now available again and can accept new objects.
This approach forms the foundation of garbage collection in modern JavaScript engines.
Reference counting vs mark-and-sweep
Most modern engines have migrated to mark-and-sweep, and although reference counting is fairy memory-efficient, a quick comparison of the two approaches shows us why the migration happened.
| Reference counting | Mark-and-sweep |
|---|---|
| Frees an object the instant its count hits zero | Waits for a collection cycle to run |
| Still fails on circular references, as covered above | Handles them correctly, since it checks reachability directly instead of counting |
| Adds overhead to every single assignment, since a counter updates constantly | Only adds overhead when a collection cycle runs |
| Rarely used alone in modern engines | Used, in some form, by V8 and every other major JavaScript engine |
The circular reference gap is the deciding factor: it’s why modern engines build on mark-and-sweep instead. V8 layers substantial optimizations on top of this basic process, which we’ll cover next.
Generational garbage collection in V8
The Generational Hypothesis in JavaScript is the idea that most objects die young: they’re created and discarded almost immediately, and only a small fraction stick around for the long haul.
V8 demonstrates this principle by separating newly created objects from long-lived ones, allowing each group to be collected differently.
- Young generation (Scavenger): Most newly allocated objects start in the young generation. V8’s Scavenger frequently collects this relatively small area, copying surviving objects and eventually promoting longer-lived objects to the old generation.
- Old generation (Major GC): Objects that survive several rounds of scavenge get promoted into this space, because repeated survival is a strong signal of relevance and durability. Major GC uses marking, sweeping and, where necessary, compaction, with much of this work performed incrementally, concurrently, or in parallel.
Short-lived objects get cheap, frequent collection. Long-lived objects get rare, thorough collection. That split is what keeps the average overhead of garbage collection low.
For the full technical breakdown, see the official V8 blog.
Working with WeakMap, WeakSet, and WeakRef
JavaScript gives us weak-reference tools for cases where we want to associate data with objects without keeping those objects alive by accident.
A normal variable, Map, or Set creates a strong reference. As long as that reference exists, the object cannot be treated as garbage. Weak references let the garbage collector remove an object when nothing else strongly references it.
| Structure | What it helps us do |
|---|---|
| WeakMap | Attach metadata to an object without controlling its lifecycle |
| WeakSet | Track objects without preventing them from being garbage-collected |
| WeakRef | Hold an optional reference to an object that may disappear later |
Use them when cleanup should follow the original object automatically. Avoid them when we need iteration, counting, or predictable storage. In those cases, a normal Map or Set is clearer.
How to use weak references in JavaScript
WeakMap is useful when we want to attach extra data to an object without extending its lifetime. Common cases include DOM element metadata, request context, class instance state, or debugging data.
const metadata = new WeakMap();
let button = document.querySelector("#submit");
metadata.set(button, {
clicked: true,
source: "checkout"
});
button = null;
// If the DOM element is removed and nothing else references it,
// the WeakMap entry can disappear with it.
WeakSet works when we only need to know whether an object has been seen, processed, or marked. This fits tree traversal, graph traversal, validation passes, or tracking temporary objects.
const processedNodes = new WeakSet();
let node = { id: "checkout-error" };
processedNodes.add(node);
if (processedNodes.has(node)) {
console.log("Already processed");
}
node = null;
// The WeakSet does not keep the object alive.
WeakRef is the least commonly used of the three. It’s primarily designed for memory-sensitive caches or other scenarios where it’s acceptable for an object to disappear at any time once nothing else references it.
let report = { message: "Null pointer exception" };
const cachedReport = new WeakRef(report);
report = null;
const value = cachedReport.deref();
if (value) {
console.log(value.message);
} else {
console.log("Report was already collected");
}
Based on our own long (and sometimes bitter) experience, we’d advise you to use WeakMap or WeakSet when the object should control the lifecycle. Use WeakRef only when it’s acceptable for the value to disappear between checks.
How garbage collection affects app performance
Every garbage collection cycle uses CPU time, which affects overall JavaScript performance. Most cycles are small, but memory-heavy apps can make the collector pause work long enough for users to notice.
A GC pause is a period during which garbage-collection work temporarily interrupts JavaScript execution. Modern engines perform much of this work concurrently or incrementally, but some phases can still require pauses, which leads to Jank: stuttering scroll, delayed taps, dropped animation frames or a frozen UI.
- Short pauses, where JavaScript execution briefly slows while memory is checked.
- Higher CPU usage, especially when the app allocates many short-lived objects.
- Growing memory pressure, where the heap expands and collection cycles become more expensive.
- Battery drain on mobile devices, when repeated allocation and cleanup keeps the CPU active.
- Slower production sessions, where issues appear only after long usage, large datasets, or device-specific conditions.
Common garbage collection problems
The garbage collection process can only free memory that is no longer reachable. When memory keeps growing, the problem is usually not the collector itself. Instead, our code is keeping objects alive longer than intended.
| Problem | Why it happens |
|---|---|
| Unremoved event listeners | Event listeners continue referencing DOM elements or component state when they’re no longer needed. |
| Closures retaining objects | Functions capture variables that stay in memory longer than expected. |
| Global variables | Objects remain reachable for the lifetime of the page or Node.js process. |
| Unbounded caches | Cached data grows indefinitely because old entries are never removed. |
| Detached DOM nodes | Elements removed from the page are still referenced somewhere in JavaScript. |
| High allocation rates | Frequently creating temporary objects increases GC activity and CPU overhead. |
A healthy heap rises and falls as garbage collection runs. If the baseline continues increasing after multiple collection cycles, the application is likely retaining references or suffering from a JavaScript memory leak.
How to improve garbage collection performance
We can’t control when garbage collection runs, but we can reduce its performance impact by writing memory-efficient code and avoiding unnecessary allocations.
To optimize resources, it’s important to focus on the patterns that create memory pressure during real use:
- Avoid unnecessary object creation inside render loops, animation frames, and frequent event handlers.
- Remove event listeners when DOM elements, components, or subscriptions are no longer needed.
- Limit unbounded caches so old values do not stay reachable forever.
- Reuse objects carefully when processing large datasets or repeated operations.
- Watch heap growth in Chrome DevTools to check whether memory returns to baseline after collection.
- Test long sessions on real devices, because GC slowdowns often appear after sustained use, not during a quick local check.
For browser debugging, our guide to debugging site performance with Chrome is a useful next step. For mobile-specific performance patterns, you’ll find plenty of tips in our Android app performance guide.
How to observe garbage collection in Chrome DevTools
Chrome DevTools lets us observe garbage collection by recording heap usage over time. The Performance panel makes it easy to identify healthy collection cycles and spot potential memory leaks.
- Open the Performance panel: in DevTools, select the Performance tab.
- Enable the Memory checkbox: This adds a live heap graph running alongside the recording.
- Record a session: interact with your app normally while the recording is active.
- Read the resulting pattern: A sawtooth shape, with memory rising steadily then dropping sharply, is a normal, healthy scavenge cycle. A line that keeps climbing and never drops back down is your signal to dig deeper.
For Node.js processes, you can also raise the amount of heap memory available before the engine feels pressure to collect: node --max-old-space-size=4096 index.js increases the old generation’s limit. This is useful for testing whether an application genuinely needs more memory or is retaining objects unexpectedly because of a memory leak.
How Bugfender helps track memory performance
DevTools works well while you’re testing in your own, tightly controlled local environment. But GC-related slowdowns often only appear after a real user has spent a long stretch of time in the app, the kind of session that’s genuinely hard to reproduce yourself.
Bugfender captures logs and user actions directly from real devices, so we can see what led up to a slowdown without needing to reproduce it locally ourselves. It works across web, iOS, and Android.
If you want to see for yourself, try Bugfender for free. Our users tell us they love the visibility over production logs and the ability to investigate memory-related slowdowns on real user devices. But please: make your own mind up.
Frequently Asked Questions
Can you force garbage collection in JavaScript?
Not in normal application code. Chrome exposes a manual trigger for testing only, launching with --js-flags="--expose-gc" makes global.gc() callable, but this is strictly a debugging tool. In production, garbage collection always runs automatically and can’t be triggered on demand from regular code.
Does garbage collection run on a fixed schedule?
No. The engine decides when to collect based on memory pressure, not a timer. Minor GC triggers when the young generation fills up, and major GC runs when the old generation needs reclaiming. There’s no fixed interval you can predict or rely on.
Why does the heap grow even without a leak?
Normal allocation grows the heap temporarily until the next collection cycle sweeps it back down. This shows up as a sawtooth pattern in DevTools, climbing then dropping. A leak is different: the heap climbs and never returns to baseline after a collection.
How is garbage collection different in Node.js?
Node.js uses the same V8 engine as Chrome, so the same generational mark-and-sweep process applies. The practical difference is uptime: a browser tab closes and releases everything, while a Node.js server process runs indefinitely, so any leaked reference accumulates for far longer before it becomes visible.
Expect The Unexpected!
Debug Faster With Bugfender