Code Space, Bytecode Flushing and Function Memory

Heap snapshots show (compiled code) at 60 MB, code_space in Node’s heap statistics keeps creeping up, and the bundle is “only” 3 MB of minified JavaScript. This guide from Understanding the V8 Heap Layout and Memory Segments, in JavaScript Memory Fundamentals & Runtime Mechanics, explains how V8 turns source into bytecode and machine code, where each lives, how unused bytecode is flushed, and which coding patterns make code memory grow without bound.

Symptom Root Cause Immediate Action Measurable Impact
(compiled code) is tens of MB Large bundles compiled into bytecode plus optimised code for hot functions Code-split and remove dead code Lower code memory and parse time
code_space grows steadily in a long-running process Functions created with new Function/eval per request or template Cache compiled functions by source; avoid dynamic compilation Code space plateaus
Memory drops after idle periods, rises on reuse Bytecode flushing discards unused bytecode, recompiles on next call Normal behaviour; do not disable flushing without cause Lower idle footprint
Many copies of the same function logic Each module instance or iframe compiles its own copy Share code via one module instance or a worker One compiled copy instead of many
Deoptimisation loops increase code memory Functions repeatedly optimised and discarded Stabilise types in hot functions Fewer optimised code objects churned

Root Cause: Code Is Data in the Heap, and It Has a Lifecycle

When V8 loads a script it does not compile everything. It pre-parses most functions just enough to find syntax errors and scope boundaries, and compiles a function to bytecode (for the Ignition interpreter) only when it is first called — lazy compilation. Bytecode arrays live on the V8 heap. Functions that run often are then compiled by faster tiers: the Sparkplug baseline compiler produces simple machine code directly from bytecode, Maglev produces mid-tier optimised code, and TurboFan produces highly optimised code for the hottest functions. Machine code lives in the code space (and in code_large_object_space for big code objects). Each tier’s output is kept only while it is useful, and optimised code is discarded on deoptimisation.

That pipeline explains why code memory is larger than the bundle. Bytecode for a function is often several times the size of its minified source, each function also has metadata (SharedFunctionInfo, feedback vectors that record types seen at each operation), and hot functions can have baseline and optimised code on top. A 3 MB bundle whose functions are all eventually called can easily produce tens of megabytes of (compiled code), as seen in the snapshot groups described in heap snapshot system entries explained.

To stop code memory growing with every function ever called, V8 performs bytecode flushing: bytecode of functions that have not executed for a while (tracked as an age that increases across major garbage collections) is discarded, returning the function to its lazily compiled state. If the function is called again, it is recompiled. This keeps long-running pages and servers from accumulating bytecode for code paths used once at startup.

What flushing cannot fix is new code. Every call to new Function(...) or indirect eval with a distinct source string creates a new script, new SharedFunctionInfos and new bytecode. A template engine that compiles templates per request, or a rules engine that builds predicates from strings on the fly, grows code memory with the number of distinct sources, and if the compiled functions are cached forever — the pattern in module-level caches and global singleton leaks — nothing is ever released.

The lifecycle of a function's code A function starts preparsed with no bytecode. On first call it is compiled to bytecode on the heap. When warm it gets Sparkplug baseline code, and when hot, Maglev or TurboFan optimised code in code space. Deoptimisation discards optimised code and returns to bytecode. If the function is not called across several major GCs, its bytecode is flushed and it returns to the preparsed state, to be recompiled on its next call. Preparsed no bytecode yet Bytecode Ignition, on heap Baseline code Sparkplug Optimised code Maglev / TurboFan call warm hot deoptimise → back to bytecode unused across several major GCs → bytecode flushed new Function / eval: a brand-new script each time

Step-by-Step Fix

  1. Quantify code memory. In Node, read code_space and code_large_object_space from v8.getHeapSpaceStatistics(); in the browser, check (compiled code) in a heap snapshot’s Summary. Verification: you know code memory as a share of the heap.
  2. Check whether it grows over time. Sample code space every few minutes under steady load. Verification: a plateau is healthy; a steady climb indicates new code being created.
  3. Find dynamic compilation. Search the codebase and dependencies for new Function, eval(, vm.runInContext, vm.Script and template compilation calls. Verification: you have a list of places that compile code at runtime.
  4. Cache compiled functions by source — with a bound. Compile each distinct template or expression once, store the result in a bounded LRU keyed by source, and reuse it. Verification: code space stops growing with request volume.
  5. Reduce shipped code for browser apps. Use route-based code splitting, remove unused dependencies, and avoid loading the same library in several iframes or bundles. Verification: (compiled code) after a typical session drops, and the Coverage panel (DevTools → More tools → Coverage) shows less unused JavaScript.
  6. Leave flushing on. Do not pass --no-flush-bytecode unless profiling proves recompilation costs outweigh memory savings. Verification: idle footprint falls after inactivity, as expected.
Code space with and without template caching A service that calls new Function for every request's template sees code space climb from 8 to about 190 megabytes over two hours. After caching compiled templates by source in a 500-entry LRU, code space rises to about 14 megabytes during warm-up and stays flat. 200 MB 0 minutes under load (0 → 120) new Function per request compiled templates cached (LRU 500)

Command and Code Reference

Use case: track code memory in a Node.js service.

// code-space.js — log code memory alongside total heap
const v8 = require('node:v8');
setInterval(() => {
  const s = Object.fromEntries(v8.getHeapSpaceStatistics().map((x) => [x.space_name, x.space_used_size]));
  const codeMB = ((s.code_space || 0) + (s.code_large_object_space || 0)) / 1048576;
  const heapMB = v8.getHeapStatistics().used_heap_size / 1048576;
  console.log(`code ${codeMB.toFixed(1)} MB of heap ${heapMB.toFixed(1)} MB`);
}, 60_000).unref();

Use case: compile dynamic templates once per distinct source. A bounded cache prevents both repeated compilation and unbounded retention of compiled code.

// template-cache.js
const MAX = 500;
const compiled = new Map(); // source → render function

function compileTemplate(source) {
  // eslint-disable-next-line no-new-func
  return new Function('data', `with (data) { return \`${source}\`; }`);
}

export function render(source, data) {
  let fn = compiled.get(source);
  if (!fn) {
    fn = compileTemplate(source);             // new script: only on a cache miss
    compiled.set(source, fn);
    if (compiled.size > MAX) compiled.delete(compiled.keys().next().value); // evict oldest
  } else {
    compiled.delete(source);                  // refresh LRU position
    compiled.set(source, fn);
  }
  return fn(data);
}

Verification and Regression Prevention

Code memory is under control when it plateaus under steady load, recovers after idle periods thanks to flushing, and does not scale with request volume or the number of distinct user inputs. In browser apps, confirm with the Coverage panel and a heap snapshot after a typical session that (compiled code) reflects the features actually used rather than the entire bundle.

For prevention, lint against new Function and eval outside a small set of reviewed modules, and require any runtime compilation to go through a bounded cache. Track code space as a separate metric in production dashboards; a slow climb there is a distinctive signal that total-heap graphs hide among data allocations. For templating on servers specifically, prefer engines that precompile templates at build time.

Code memory under steady load and idle Under steady load, code created per distinct user input, for example with new Function or eval, keeps growing code memory with request volume. Stable code plateaus under load and recovers after idle periods as bytecode flushing discards bytecode for functions not recently run. code space steady load → idle new Function per input stable code with flushing

Edge Cases and Gotchas

Closures do not duplicate code

Creating many closures from the same function expression creates many closure objects and contexts, but they share one SharedFunctionInfo and one copy of bytecode. Code memory grows with distinct source, not with the number of closures.

Iframes and realms compile separately

Each realm — an iframe, a vm context, a worker — has its own heap objects for the same source, and loads its own copy of shared libraries. Loading the same large library into many iframes multiplies code memory; host shared functionality in the parent or a single worker where possible.

Source positions and debugging

With DevTools open, V8 may keep extra information for debugging, such as source positions and non-flushed bytecode for breakpoints. Measure code memory with DevTools closed or in headless runs for accurate numbers.

The with statement in templates

The template example uses with for brevity; with makes the compiled function unoptimisable and is disallowed in strict mode. Real template engines generate explicit property access, which also keeps generated code faster.

Frequently Asked Questions

What is (compiled code) in a heap snapshot?

It groups V8’s code-related objects: bytecode arrays, baseline and optimised machine code, and associated metadata. It reflects how much JavaScript has been compiled and how much of it is hot, not how much data your program holds.

Does V8 ever free bytecode?

Yes. Bytecode flushing discards the bytecode of functions that have not run for several major garbage collections, returning them to a lazily compiled state. If they are called again, V8 recompiles them. Flushing is on by default.

Is new Function always a memory problem?

Not by itself. Compiling a handful of functions at startup is fine. It becomes a problem when distinct sources are compiled continuously — per request, per user expression — and especially when the resulting functions are cached without a bound.

How much code memory is normal?

It scales with the amount of JavaScript actually executed. Large single-page apps commonly have tens of megabytes of compiled code after a long session; small services have a few megabytes. The trend matters more than the level: it should plateau.