docs/beyond/concepts/garbage-collection.mdx
What happens to objects after you stop using them? When you create a variable, assign it an object, and then reassign it to something else, where does that original object go? Does it just sit there forever, taking up space?
let user = { name: 'Alice', age: 30 }
user = null // What happens to { name: 'Alice', age: 30 }?
The answer is garbage collection. JavaScript automatically finds objects you're no longer using and frees the memory they occupy. You don't have to manually allocate or deallocate memory like in C or C++. As MDN documents, the JavaScript engine handles it for you, running a background process that cleans up unused objects — a design choice made since the language's creation in 1995.
<Info> **What you'll learn in this guide:** - What garbage collection is and why JavaScript needs it - How the engine determines which objects are "garbage" - The mark-and-sweep algorithm used by all modern engines - Why reference counting failed and circular references aren't a problem - How generational garbage collection makes GC faster - Practical tips for writing GC-friendly code - Common memory leak patterns and how to avoid them </Info> <Warning> **Prerequisite:** This guide assumes you understand basic JavaScript objects and references. For a deep dive into how V8 implements garbage collection (generational GC, the Scavenger, Mark-Compact), see the [JavaScript Engines](/concepts/javascript-engines) guide. </Warning>Garbage collection (GC) is an automatic memory management process that identifies and reclaims memory occupied by objects that are no longer reachable by the program. The garbage collector periodically scans the heap, marks objects that are still in use, and frees memory from objects that can no longer be accessed.
Think of garbage collection like a city sanitation service. You put trash on the curb (stop referencing objects), and the garbage truck comes by periodically to collect it. You don't have to drive to the dump yourself. The city handles it automatically. But there's a catch: you can't control exactly when the truck arrives, and if you accidentally leave something valuable on the curb (lose your only reference to an object you still need), it might get collected.
The key concept is reachability. An object is considered "alive" if it can be reached from a root. Roots are starting points that the engine knows are always accessible:
Any object reachable from a root, either directly or through a chain of references, is kept alive. Everything else is garbage.
// 'user' is a root (global variable)
let user = { name: 'Alice' }
// The object { name: 'Alice' } is reachable through 'user'
// So it stays in memory
user = null
// Now nothing references { name: 'Alice' }
// It's unreachable and becomes garbage
The garbage collector follows references like a detective following clues:
┌─────────────────────────────────────────────────────────────────────────┐
│ REACHABILITY FROM ROOTS │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ ROOTS REACHABLE OBJECTS │
│ │
│ ┌────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ global │ │ user │ │ address │ │
│ │ variables │ ────────► │ { name, │ ──► │ { street, │ │
│ └────────────┘ │ address } │ │ city } │ │
│ └──────────────┘ └──────────────┘ │
│ ┌────────────┐ │
│ │ call │ ┌──────────────┐ │
│ │ stack │ ────────► │ local vars │ ✓ All reachable = ALIVE │
│ └────────────┘ └──────────────┘ │
│ │
│ │
│ UNREACHABLE (GARBAGE) │
│ │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ { orphaned } │ │ { no refs } │ ✗ No path from roots │
│ └──────────────┘ └──────────────┘ = GARBAGE │
│ │
└─────────────────────────────────────────────────────────────────────────┘
Let's see this in action with a more complex example:
function createFamily() {
let father = { name: 'John' }
let mother = { name: 'Jane' }
// Create references between objects
father.spouse = mother
mother.spouse = father
return { father, mother }
}
let family = createFamily()
// Both father and mother are reachable through 'family'
// family → father → mother (via spouse)
// family → mother → father (via spouse)
family = null
// Now there's no path from any root to father or mother
// Even though they reference each other, they're both garbage
This last point is crucial: objects that only reference each other but aren't reachable from a root are still garbage. The garbage collector doesn't care about internal references. It only cares about reachability from roots.
All modern JavaScript engines use a mark-and-sweep algorithm (with various optimizations). Here's how it works:
<Steps> <Step title="Start from roots"> The garbage collector identifies all root objects: global variables, the call stack, and closures. </Step> <Step title="Mark reachable objects"> Starting from each root, the collector follows every reference and "marks" each object it finds as alive. It recursively follows references from marked objects, marking everything reachable.```
Root → Object A (mark) → Object B (mark) → Object C (mark)
→ Object D (mark)
```
┌─────────────────────────────────────────────────────────────────────────┐
│ MARK-AND-SWEEP IN ACTION │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ BEFORE GC MARKING PHASE │
│ │
│ root ──► [A] ──► [B] root ──► [A]✓ ──► [B]✓ │
│ │ │ │
│ ▼ ▼ │
│ [C] [D] [C]✓ [D] │
│ │ │ │
│ ▼ ▼ │
│ [E] [E] │
│ │
│ │
│ SWEEP PHASE AFTER GC │
│ │
│ root ──► [A]✓ ──► [B]✓ root ──► [A] ──► [B] │
│ │ │ │
│ ▼ ▼ │
│ [C]✓ [D] ← removed [C] (free memory) │
│ │ │
│ ▼ │
│ [E] ← removed │
│ │
└─────────────────────────────────────────────────────────────────────────┘
Notice in our family example that father and mother reference each other. With mark-and-sweep, this isn't a problem:
let family = { father: { name: 'John' }, mother: { name: 'Jane' } }
family.father.spouse = family.mother
family.mother.spouse = family.father
// Circular reference: father ↔ mother
family = null
// Mark phase: start from roots, can't reach father or mother
// Neither gets marked, both get swept
// Circular reference doesn't matter!
The mark-and-sweep algorithm only cares about reachability from roots. Internal circular references don't keep objects alive.
Before mark-and-sweep became standard, some engines used reference counting. Each object kept track of how many references pointed to it. When the count reached zero, the object was immediately freed.
// Reference counting (conceptual, not real JS)
let obj = { data: 'hello' } // refcount: 1
let ref = obj // refcount: 2
ref = null // refcount: 1
obj = null // refcount: 0 → freed immediately
This seems simpler, but it has a fatal flaw: circular references cause memory leaks.
function createCycle() {
let objA = {}
let objB = {}
objA.ref = objB // objB refcount: 1
objB.ref = objA // objA refcount: 1
// When function returns:
// - objA loses its stack reference: refcount goes to 1 (not 0!)
// - objB loses its stack reference: refcount goes to 1 (not 0!)
// Both objects keep each other alive forever!
}
createCycle()
// With reference counting: MEMORY LEAK
// With mark-and-sweep: Both collected (unreachable from roots)
Old versions of Internet Explorer (IE6/7) used reference counting for DOM objects, which caused notorious memory leaks when JavaScript objects and DOM elements referenced each other. According to web.dev's guide on fixing memory leaks, all modern engines now use mark-and-sweep or variations of it, eliminating circular reference leaks entirely.
Modern engines like V8 don't just use basic mark-and-sweep. They use generational garbage collection based on an important observation: most objects die young. According to the V8 blog, the Orinoco garbage collector processes the young generation in under 1 millisecond for most web applications, making GC pauses nearly invisible to users.
Think about it: temporary variables, intermediate calculation results, short-lived callbacks. They're created, used briefly, and become garbage quickly. Only some objects (app state, cached data) live for a long time.
V8 exploits this by dividing memory into generations:
┌─────────────────────────────────────────────────────────────────────────┐
│ GENERATIONAL HEAP LAYOUT │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ YOUNG GENERATION OLD GENERATION │
│ (Most objects die here) (Long-lived objects) │
│ │
│ ┌───────────────────────┐ ┌───────────────────────┐ │
│ │ New objects land │ │ Objects that │ │
│ │ here first │ ─────► │ survived multiple │ │
│ │ │ survives │ GC cycles │ │
│ │ Collected frequently │ │ │ │
│ │ (Minor GC) │ │ Collected less often │ │
│ │ │ │ (Major GC) │ │
│ └───────────────────────┘ └───────────────────────┘ │
│ │
│ • Fast allocation • Contains app state │
│ • Quick collection • Caches, long-lived data │
│ • Most garbage found here • More thorough collection │
│ │
└─────────────────────────────────────────────────────────────────────────┘
Minor GC (Scavenger): Runs frequently on the young generation. Since most objects die young, this is very fast. Objects that survive get promoted to the old generation.
Major GC (Mark-Compact): Runs less frequently on the entire heap. More thorough but slower. Includes compaction to reduce fragmentation.
This generational approach means:
You might wonder: when exactly does the garbage collector run? The short answer is: you don't know, and you can't control it.
Garbage collection is triggered automatically when:
// You CANNOT force garbage collection in JavaScript
// This doesn't exist in the language:
// gc() // Not a thing
// System.gc() // Not a thing
// The engine decides when to run GC
// You just write code and let it handle memory
Modern engines use sophisticated heuristics:
While you can't control GC, you can write code that works well with it:
The simplest way to make objects eligible for GC is to let their references go out of scope:
function processData() {
const largeArray = new Array(1000000).fill('data')
// Process the array...
const result = largeArray.reduce((sum, item) => sum + item.length, 0)
return result
// largeArray goes out of scope here
// It becomes eligible for GC automatically
}
const result = processData()
// largeArray is already unreachable
If you're done with a large object but the function continues running, explicitly nullify it:
function longRunningTask() {
let hugeData = fetchHugeDataset() // 100MB of data
const summary = processSummary(hugeData)
hugeData = null // Allow GC to reclaim 100MB now
// ... lots more code that doesn't need hugeData ...
return summary
}
Accidental globals stay alive forever:
function oops() {
// Forgot 'let' or 'const' - creates global variable!
leaked = { huge: new Array(1000000) }
}
oops()
// 'leaked' is now a global variable
// It will never be garbage collected!
// Fix: Always use let, const, or var
function fixed() {
const notLeaked = { huge: new Array(1000000) }
}
Closures capture variables from their outer scope. If a closure lives long, so do its captured variables:
function createHandler() {
const hugeData = new Array(1000000).fill('x')
return function handler() {
// This closure captures 'hugeData'
// Even if handler() never uses hugeData directly,
// some engines may keep it alive
console.log('Handler called')
}
}
const handler = createHandler()
// 'hugeData' may be kept alive as long as 'handler' exists
// Even though handler() doesn't use it!
// Better: Don't capture what you don't need
function createBetterHandler() {
const hugeData = new Array(1000000).fill('x')
const summary = hugeData.length // Extract what you need
return function handler() {
console.log('Data size was:', summary)
}
// hugeData goes out of scope, only 'summary' is captured
}
Forgotten event listeners and timers are common sources of memory leaks:
// Memory leak: listener keeps element and handler alive
function setupButton() {
const button = document.getElementById('myButton')
const data = { huge: new Array(1000000) }
button.addEventListener('click', () => {
console.log(data.huge.length)
})
// If you never remove this listener, 'data' stays alive forever
}
// Fix: Remove listeners when done
function setupButtonCorrectly() {
const button = document.getElementById('myButton')
const data = { huge: new Array(1000000) }
function handleClick() {
console.log(data.huge.length)
}
button.addEventListener('click', handleClick)
// Later, when cleaning up:
return function cleanup() {
button.removeEventListener('click', handleClick)
// Now 'data' can be garbage collected
}
}
Same with timers:
// Memory leak: interval runs forever
const data = { huge: new Array(1000000) }
setInterval(() => {
console.log(data.huge.length)
}, 1000)
// This interval keeps 'data' alive forever
// Fix: Clear intervals when done
const data = { huge: new Array(1000000) }
const intervalId = setInterval(() => {
console.log(data.huge.length)
}, 1000)
// Later:
clearInterval(intervalId)
// Now 'data' can be garbage collected
ES2021 introduced two features that let you interact more directly with garbage collection: WeakRef and FinalizationRegistry. These are advanced features for specific use cases.
// WeakRef: Hold a reference that doesn't prevent GC
const weakRef = new WeakRef(someObject)
// Later: object might have been collected
const obj = weakRef.deref()
if (obj) {
// Object still exists
} else {
// Object was garbage collected
}
For most applications, WeakMap and WeakSet are better choices. They allow objects to be garbage collected when no other references exist, without the complexity of WeakRef.
```javascript
const obj = { data: new Array(1000000) }
delete obj.data // Removes the property
// But memory isn't freed until GC runs
// AND only if nothing else references that array
// This is also bad for performance (changes hidden class)
// Better: set to undefined or restructure your code
obj.data = undefined
```
```javascript
// Don't do this
function process() {
let a = getData()
let result = transform(a)
a = null // Unnecessary!
let b = getMoreData()
let final = combine(result, b)
result = null // Unnecessary!
b = null // Unnecessary!
return final
}
// Just let variables go out of scope naturally
function process() {
const a = getData()
const result = transform(a)
const b = getMoreData()
return combine(result, b)
}
```
Only nullify when: (1) you're done with a **large** object, (2) the function continues running for a while, and (3) you've measured that it helps.
```javascript
// Memory leak: cache grows forever
const cache = {}
function getCached(key) {
if (!cache[key]) {
cache[key] = expensiveComputation(key)
}
return cache[key]
}
// Better: Use WeakMap (if keys are objects)
const cache = new WeakMap()
function getCached(obj) {
if (!cache.has(obj)) {
cache.set(obj, expensiveComputation(obj))
}
return cache.get(obj)
}
// Cache entries are automatically removed when keys are GC'd
// Or: Use an LRU cache with a maximum size
```
```javascript
// Memory leak in React component (class-style)
class MyComponent extends React.Component {
componentDidMount() {
this.subscription = eventEmitter.subscribe(data => {
this.setState({ data }) // 'this' keeps component alive
})
}
// Forgot componentWillUnmount!
// Component instance stays in memory forever
// Fix:
componentWillUnmount() {
this.subscription.unsubscribe()
}
}
```
```javascript
// Historical problem (IE6/7):
const div = document.createElement('div')
const obj = {}
div.myObject = obj // DOM → JS reference
obj.myElement = div // JS → DOM reference
// In old IE with reference counting, this leaked
// In modern browsers with mark-and-sweep, this is fine
```
Modern browsers handle this correctly. Both objects become garbage when unreachable from roots.
JavaScript has automatic garbage collection. You don't manually allocate or free memory. The engine handles it.
Reachability determines what's garbage. Objects reachable from roots (globals, stack, closures) are kept alive. Everything else is garbage.
Mark-and-sweep is the standard algorithm. The collector marks reachable objects, then sweeps (frees) everything unmarked.
Circular references aren't a problem. Mark-and-sweep handles them correctly. Objects that only reference each other (but aren't reachable from roots) get collected.
Generational GC makes collection fast. Most objects die young, so engines collect the young generation frequently and cheaply.
You can't control when GC runs. The engine decides based on memory pressure, idle time, and internal heuristics.
Don't over-optimize for GC. Let variables go out of scope naturally. Only nullify large objects early if you've measured a benefit.
Watch for common leak patterns: Forgotten event listeners, uncleaned timers, unbounded caches, and closures capturing large objects.
Use WeakMap/WeakSet for caches. They allow keys to be garbage collected, preventing unbounded growth.
For deep V8 internals, see the JavaScript Engines guide. Scavenger, Mark-Compact, concurrent marking, and other advanced topics are covered there.
</Info>**Answer:**
No, the object will NOT be garbage collected. Even though `user` is set to `null`, `admin` still holds a reference to the object. The object is still reachable (through `admin`), so it stays alive.
```javascript
// To make it eligible for GC:
admin = null // Now no references remain
```
No. Modern JavaScript engines use mark-and-sweep garbage collection, which handles circular references correctly. Objects are collected based on **reachability from roots**, not reference counts.
```javascript
function createCycle() {
let a = {}
let b = {}
a.ref = b
b.ref = a
}
createCycle()
// Both objects are collected after the function returns
// The circular reference doesn't keep them alive
```
Circular references only caused leaks in old browsers (IE6/7) that used reference counting for DOM objects.
You cannot force garbage collection in JavaScript. There is no `gc()` function or equivalent in the language specification.
The garbage collector runs automatically when the engine decides it's needed. You can only influence what becomes *eligible* for collection by removing references to objects.
Some environments (like Node.js with `--expose-gc` flag) expose a `gc()` function for debugging, but this should never be used in production code.
document.getElementById('btn').addEventListener('click', () => {
console.log('clicked!')
})
}
```
**Answer:**
There's a potential memory leak. Even though the click handler doesn't use `largeData`, the closure may capture the entire scope, keeping `largeData` alive as long as the event listener exists.
Additionally, the event listener is never removed, so it (and potentially `largeData`) will stay in memory forever.
**Fixes:**
1. Move `largeData` outside the function if it's needed, or extract only what you need
2. Provide a way to remove the event listener
```javascript
function setupClickHandler() {
const handler = () => console.log('clicked!')
document.getElementById('btn').addEventListener('click', handler)
return () => {
document.getElementById('btn').removeEventListener('click', handler)
}
}
const cleanup = setupClickHandler()
// Later: cleanup() to remove the listener
```
Generational garbage collection is effective because of the **generational hypothesis**: most objects die young.
Temporary variables, intermediate results, and short-lived objects are created and discarded quickly. Only a small percentage of objects (app state, caches) live long.
By dividing memory into generations:
- The young generation is collected frequently and cheaply (most objects there are garbage)
- The old generation is collected less often (objects there are likely to survive)
This approach minimizes the time spent on garbage collection while still reclaiming memory effectively.
Mark-and-sweep marks **reachable objects**, not garbage.
The algorithm:
1. Starts from roots (globals, stack, closures)
2. Follows all references, marking each object it can reach
3. After marking, sweeps through memory and frees all **unmarked** objects
Unmarked objects are garbage because they couldn't be reached from any root.