Skip to main content

Memory Management and Garbage Collection

Deep dive into V8 memory management, garbage collection, memory leaks, and optimization strategies for production Node.js services.


Table of Contents

  1. V8 Memory Architecture
  2. Garbage Collection Fundamentals
  3. GC Algorithms in V8
  4. Memory Leak Patterns
  5. Heap Snapshots and Analysis
  6. Buffer Management
  7. Memory Monitoring
  8. Memory Limits
  9. Optimization Strategies
  10. Production Best Practices

V8 Memory Architecture

Heap Structure

V8 manages memory in a segmented heap. The heap consists of multiple spaces, each serving different purposes and using different garbage collection strategies.

New Space (Young Generation) stores newly allocated objects. Most objects die young—they're allocated, used briefly, and discarded. New space is small, typically 1-8MB.

Old Space (Old Generation) stores objects that survive multiple garbage collections. These are long-lived objects. Old space is much larger, consuming most of the heap.

Large Object Space stores objects exceeding size thresholds. These objects get special treatment because moving large objects is expensive.

Code Space stores compiled JavaScript code and JIT-compiled machine code. Code objects are immutable after compilation.

Map Space stores hidden classes (maps in V8 terminology) that describe object shapes. Maps are critical for V8's optimization but require separate management.

Generational Hypothesis

V8's architecture is based on the generational hypothesis: most objects die young. By focusing garbage collection on new space, V8 efficiently reclaims memory with minimal work.

New space collections (Scavenge) are frequent but fast. Old space collections (Mark-Sweep-Compact) are infrequent but slow. This trade-off optimizes for the common case.

Pointer Model

V8 uses tagged pointers. The low bits of pointers indicate whether they point to objects or contain small integers directly (SMI - small integer). This enables efficient integer operations without heap allocation.

Tagged pointers require alignment—objects must be aligned to boundaries. This wastes some memory but enables fast type checking and integer operations.

Memory Segments

The heap consists of semi-spaces and pages. New space has two semi-spaces used for copying collection. Old space consists of memory pages allocated from the OS.

Pages are typically 256KB. When V8 needs more memory, it requests pages from the OS. When pages are unused, they can be returned to the OS.

Off-Heap Memory

Not all memory is in the V8 heap. Buffers, external resources, and native objects exist outside the heap. V8's heap size limits don't account for this memory.

ArrayBuffers store data outside the heap. The ArrayBuffer object is on the heap, but the backing store is off-heap. Large buffer allocations can exhaust memory without triggering GC.

Memory Limits

By default, V8 limits heap size to approximately 1.4-1.7GB on 64-bit systems and 700-800MB on 32-bit systems. These limits prevent excessive GC overhead.

You can increase limits with --max-old-space-size flag. However, larger heaps mean longer GC pauses. There's a trade-off between memory capacity and GC latency.


Garbage Collection Fundamentals

Automatic Memory Management

JavaScript provides automatic memory management. Developers allocate objects but don't explicitly free them. The garbage collector reclaims memory from objects that are no longer reachable.

This eliminates entire categories of bugs—use-after-free, double-free, and memory corruption. But it introduces GC pauses and makes memory usage less predictable.

Reachability

An object is reachable if there's a reference path from a root. Roots include:

  • Global objects
  • Active stack frames
  • Registers
  • Handle scopes (for native code)

If an object isn't reachable from any root, it's garbage and can be collected. Circular references don't prevent collection—as long as the cycle isn't reachable from a root.

GC Pauses

Garbage collection pauses application execution. During a collection, JavaScript execution stops while the GC scans memory and reclaims objects.

Pauses impact latency. A 50ms GC pause adds 50ms to every request being processed. For latency-sensitive applications, GC pauses are critical.

V8 uses multiple strategies to minimize pauses—incremental marking, concurrent marking, and parallel collection.

Write Barriers

To support incremental and concurrent GC, V8 uses write barriers. When you assign a pointer, V8 records the assignment to track changes during concurrent collection.

Write barriers have small performance costs. Every pointer write executes additional instructions. But they enable concurrent GC, which dramatically reduces pause times.


GC Algorithms in V8

Scavenge (Minor GC)

Scavenge collects new space using a copying collector. New space is divided into two semi-spaces: From-space and To-space.

During collection:

  1. Scan roots and identify live objects in From-space
  2. Copy live objects to To-space
  3. Update pointers to copied objects
  4. Swap From-space and To-space roles

Objects that survive multiple Scavenges are promoted to old space. This moves long-lived objects out of the fast path.

Scavenge is fast because:

  • Only live objects are touched (dead objects are ignored)
  • New space is small
  • Copying compacts memory automatically

Scavenge pause time is proportional to the number of live objects in new space, not the size of new space itself.

Mark-Sweep (Major GC)

Mark-Sweep collects old space. It's more complex because old space is large and moving objects is expensive.

Mark phase:

  1. Mark all objects reachable from roots
  2. Traverse object graph, marking reachable objects
  3. Use tricolor marking (white, gray, black) for incremental marking

Sweep phase:

  1. Scan old space
  2. Free unmarked objects
  3. Add free memory to free lists

Mark-Sweep doesn't move objects, so it causes fragmentation. Periodically, V8 runs compaction to defragment memory.

Mark-Compact

Mark-Compact is like Mark-Sweep but includes a compaction phase. After marking, it moves live objects to the beginning of memory, eliminating fragmentation.

Compaction is expensive—it requires updating all pointers to moved objects. V8 only compacts when fragmentation is severe.

Incremental Marking

Incremental marking spreads the marking work across multiple small steps interleaved with JavaScript execution. Each step does a small amount of marking work and yields back to the application.

This reduces pause times dramatically. Instead of one long pause, you get many tiny pauses that are less noticeable.

Incremental marking requires write barriers to track pointer changes during marking. Without write barriers, the collector might miss objects that become reachable during marking.

Concurrent Marking

Concurrent marking runs on background threads while JavaScript executes. Most marking work happens concurrently without pausing JavaScript.

A final pause is still needed to handle objects modified during concurrent marking, but it's much shorter than full marking.

Concurrent marking significantly reduces GC impact on latency-sensitive applications.

Parallel Scavenge and Sweep

Parallel collection uses multiple threads for copying (Scavenge) or sweeping (Mark-Sweep). Work is divided among threads, reducing collection time.

Parallelism is limited by synchronization overhead and the number of available CPU cores. Scalability is good up to 4-8 threads.

Idle-Time GC

V8 can perform GC work during idle time when the event loop has no work. This hides GC cost by doing it when the application would be waiting anyway.

Idle-time GC is triggered when the event loop is idle and V8 estimates GC work is needed. It performs incremental marking or sweeping in chunks during idle periods.


Memory Leak Patterns

Global Variables

Accidental global variables prevent GC. In non-strict mode, assigning to undeclared variables creates globals.

Globals are roots, so objects they reference are always reachable. Accumulating globals causes memory growth.

Always use strict mode and declare variables with let, const, or var. Use linters to detect undeclared variables.

Detached DOM Nodes

In applications that manipulate DOM-like structures, detached nodes are a common leak. If you remove a node from a tree but retain a reference, the entire subtree remains in memory.

Always null out references to removed nodes. Use weak references (WeakMap, WeakSet) when appropriate.

Closures Capturing Context

Closures capture their outer scope. If a long-lived closure captures variables, those variables can't be collected even if only the closure still references them.

Be mindful of closure scope. Extract only needed data before creating closures. Don't capture large objects when you only need a small piece.

Event Listeners

Adding event listeners without removing them leaks the listener function and its closure scope. Every listener holds references to captured variables.

Always remove event listeners when they're no longer needed. Use once() for listeners that should fire only once. Track listeners and clean up during object destruction.

Timers and Intervals

setInterval() keeps callback and its closure scope in memory until cleared. If you lose the timer ID, you can't clear it—permanent leak.

Always clear timers and intervals when done. Store timer IDs and clear them during cleanup. Use setTimeout() recursively instead of setInterval() if appropriate.

Promises Without Rejection Handlers

Promises that never settle keep their executor and callbacks in memory indefinitely. If a promise waits for an event that never occurs, it leaks.

Always ensure promises settle. Implement timeouts for operations that might hang. Test code paths that reject promises.

Caching Without Limits

Unbounded caches grow indefinitely. If you cache every unique input, memory consumption is unbounded.

Implement cache size limits using LRU eviction. Use weak caches (WeakMap) for objects you don't need to keep alive. Monitor cache size and evict entries when limits are exceeded.

Buffer Accumulation

Accumulating buffers in arrays or queues without consuming them leaks memory. If production rate exceeds consumption rate, queues grow unbounded.

Implement backpressure to match production to consumption rate. Set queue size limits and reject new items when full. Monitor queue depths.


Heap Snapshots and Analysis

Taking Snapshots

Heap snapshots capture the entire heap state at a point in time. They show all objects, their sizes, and references between them.

Take snapshots periodically in development to track memory usage patterns. In production, take snapshots on-demand when investigating suspected leaks.

Snapshots are large—hundreds of megabytes to gigabytes for production heaps. Store them externally and transfer for offline analysis.

Chrome DevTools

Chrome DevTools can analyze heap snapshots. Connect DevTools to Node.js via inspector protocol. Take snapshots and analyze in the browser.

The snapshot viewer shows:

  • Object counts by constructor
  • Retained size (memory held by object and its references)
  • Shallow size (memory of object itself)
  • Retainer chains (why objects aren't collected)

Comparison View

Compare two snapshots to see what changed between them. This reveals leaks—objects that persist when they should have been collected.

Take a baseline snapshot, perform operations, take another snapshot. Objects in the second but not the first are potential leaks.

Compare constructor counts and retained sizes. Growing counts indicate leaks of that object type.

Retainer View

Retainer view shows what keeps an object alive. It displays the reference chain from roots to the object.

Use retainer view to understand why suspected leaks aren't collected. Trace the chain to find the root reference and determine if it should exist.

Distance from Root

Objects closer to roots are more fundamental to application state. Objects far from roots are usually transient.

If transient objects appear far from roots and persist, investigate the reference chain. There's likely an unintended reference keeping them alive.

Allocation Timeline

Allocation timeline shows when objects are allocated over time. Repeated allocation patterns indicate potential leaks or inefficiencies.

Record allocations while performing operations. Patterns of continuous allocation without collection indicate leaks or excessive object creation.


Buffer Management

Buffer Basics

Buffers are fixed-size chunks of memory allocated outside the V8 heap. They're used for binary data and I/O operations.

Unlike JavaScript objects, buffers aren't subject to GC pauses. But they also aren't automatically collected—their backing store remains until the Buffer object is collected.

Buffer Pooling

Node.js pools small buffer allocations (under 8KB by default). This reduces allocation overhead and improves performance.

Pooled buffers share backing stores. When you allocate a small buffer, Node.js slices a chunk from the pool. This is efficient but means multiple Buffer objects may reference the same backing store.

Large Buffers

Buffers larger than the pool size get individual allocations. These are expensive to allocate and deallocate.

Reuse large buffers when possible. Maintain pools of large buffers for repeated operations. Reset and reuse buffers instead of allocating new ones.

Buffer Limits

Buffer size is limited by V8's maximum ArrayBuffer size, typically around 2GB on 64-bit systems. Attempting to allocate larger buffers throws errors.

For truly large data, use multiple buffers or stream processing. Don't attempt to load gigabytes into a single buffer.

Memory Accounting

Buffer memory doesn't count against V8 heap limits. You can allocate more buffer memory than heap size.

However, total process memory (heap + buffers + native allocations) is limited by system RAM. Excessive buffer allocation causes out-of-memory errors.

Monitor both heap and total process memory. Buffer-heavy applications may see low heap usage but high total memory.

Buffer Leaks

Buffer objects are on the heap, but backing stores are off-heap. If Buffer objects leak, backing stores leak too.

Track buffer allocations in production. Monitor external memory (V8 provides APIs for this). Alert on growing external memory usage.


Memory Monitoring

Process Memory Metrics

Monitor multiple memory metrics:

  • RSS (Resident Set Size): Total memory in RAM
  • Heap used: Active objects in V8 heap
  • Heap total: Total V8 heap capacity
  • External: Off-heap memory (buffers, native objects)

RSS includes heap, external, stack, and code memory. It's the real memory consumption from OS perspective.

Heap Statistics

Use process.memoryUsage() to get heap statistics. It returns:

  • heapTotal: Total heap capacity
  • heapUsed: Memory currently in use
  • external: External memory
  • arrayBuffers: ArrayBuffer backing stores

Call memoryUsage() periodically and track trends. Growing heapUsed without bound indicates leaks.

GC Statistics

V8 provides GC statistics via performance hooks. Track:

  • GC pause duration
  • GC frequency
  • GC type (Scavenge vs Mark-Sweep)
  • Reclaimed memory per GC

Increasing GC frequency with stable heap size is normal under load. Increasing pause duration indicates fragmentation or large heaps.

Memory Alerts

Set alerts for:

  • Heap usage exceeding 80% of capacity
  • RSS growth over time
  • GC pause duration exceeding thresholds
  • External memory growth

Alert thresholds depend on application characteristics. Test under load to determine normal ranges.

Memory Profiling

Use profiling tools to analyze memory usage:

  • v8-profiler for heap snapshots
  • clinic.js for holistic profiling
  • Native profilers (perf, dtrace) for C++ addons

Profile in production-like environments. Memory patterns differ between development and production due to load and data volume.


Memory Limits

Default Limits

V8's default heap limits are based on expected workload patterns. They balance memory capacity with GC overhead.

For 64-bit systems, old space limit is approximately 1.4GB. New space is much smaller, typically 16MB or less.

These limits are suitable for typical applications. Applications with large working sets need higher limits.

Configuring Limits

Use --max-old-space-size to set old space limit in megabytes. For example, --max-old-space-size=4096 sets a 4GB limit.

Also consider --max-new-space-size for new space. However, larger new space increases Scavenge pause time. Default is usually optimal.

Trade-offs

Larger heaps allow more objects but increase GC pause time. Mark-Sweep-Compact pause time is roughly proportional to heap size.

If pause times are acceptable, increase heap size. If pauses cause latency issues, reduce heap size or reduce memory usage.

Container Environments

In containers, set heap limits based on container memory limits. Leave headroom for off-heap memory, stack, code, and system overhead.

If container has 2GB memory, set --max-old-space-size=1536 (1.5GB). This leaves 500MB for other memory.

Failing to account for total memory causes OOM kills in container environments.

Memory Pressure

Node.js can respond to OS memory pressure signals. When the OS indicates low memory, Node.js triggers GC more aggressively.

This helps prevent OOM errors but increases GC overhead. Applications should monitor and respond to memory pressure by reducing memory usage.


Optimization Strategies

Object Pooling

Reuse objects instead of allocating new ones. Maintain pools of objects and reset them for reuse.

Pooling reduces allocation rate and GC pressure. It's particularly effective for high-frequency allocations.

However, pooling adds complexity. Only pool objects where allocation cost is measurable and significant.

Reduce Object Creation

Every object allocation has cost. Minimize allocations in hot paths.

Strategies:

  • Reuse variables instead of creating new ones
  • Use primitives instead of objects when possible
  • Avoid creating intermediate objects in transformations
  • Flatten object hierarchies

Avoid Large Objects

Large objects fragment old space and are expensive to collect and compact. Break large objects into smaller pieces when possible.

Use arrays of small objects instead of single large objects. This improves GC efficiency and reduces compaction overhead.

Weak References

Use WeakMap and WeakSet for caches and metadata. Weak references don't prevent GC of referenced objects.

When the key object is collected, the weak map entry is automatically removed. This enables caches that don't prevent collection of cached objects.

Streaming Data

Process data in streams instead of loading entire datasets into memory. Streams maintain bounded memory usage regardless of data size.

Use transform streams to process data in chunks. Implement backpressure to match production to consumption rate.

Native Addons

For memory-intensive operations, consider native addons. C++ addons have direct memory control and can use more efficient data structures.

However, addons add complexity and portability issues. Only use when JavaScript performance is insufficient.


Production Best Practices

Right-Size Heaps

Configure heap size based on actual workload. Test under production load to determine required heap capacity.

Leave headroom for traffic spikes. If normal usage is 1GB, configure 1.5GB heap to handle spikes.

Monitor Continuously

Track memory metrics continuously in production:

  • Heap usage trends
  • GC frequency and duration
  • External memory
  • Process RSS

Use monitoring systems to alert on anomalies. Investigate before issues become critical.

Memory Leak Testing

Test for leaks in staging environments. Run load tests for extended periods and monitor memory growth.

Take heap snapshots before and after load tests. Compare to identify growing object types.

Graceful Degradation

When memory is low, reduce functionality to maintain critical operations:

  • Disable caching
  • Reduce retained data
  • Shed load to reduce memory pressure
  • Trigger manual GC (last resort)

Process Restarts

Implement periodic process restarts to clear accumulated memory. This mitigates slow leaks that are hard to fix.

Use rolling restarts to maintain availability. Restart workers sequentially so some workers are always available.

Memory Budgets

Allocate memory budgets to different subsystems. Enforce budgets through monitoring and alerting.

This prevents one subsystem from consuming all memory and impacting others.

Documentation

Document expected memory usage patterns. Record:

  • Normal memory ranges
  • Peak memory during traffic spikes
  • Memory per request/connection
  • Growth rate under load

This baseline enables detecting deviations and identifying leaks early.