Startup: snapshots & code caching
Learn how JavaScript engines use startup snapshots, lazy deserialization, code caching, and background compilation to start pages faster.
- 01Explain the startup billSeparate engine startup, built-in initialization, parsing, bytecode compilation, and user-code execution.
- 02Compare reuse mechanismsDistinguish startup snapshots, lazy deserialization, V8 code cache, Node compile cache, and HTTP caching.
- 03Write cache-friendly codeKeep script URLs and source stable, avoid dynamic compilation for hot paths, and use service workers deliberately.
The startup problem
Loading performance already showed that JavaScript costs download, parse, compile, and execution. This expert lesson starts one layer earlier: before your app code runs, the engine and host must create a usable JavaScript world with built-ins such as Object, Array, Math, RegExp, Promise, and often Intl. Doing that from scratch for every page, worker, isolate, or Node process would waste startup time.
Startup performance is the work an engine and host avoid repeating when a page or process starts: initializing built-ins, creating contexts, parsing source, compiling bytecode, and preparing code that was already seen before.
A restaurant does not set every fork from scratch after each guest sits down. It prepares a safe baseline once, opens extra dishes only when needed, and reuses work that still matches the order. Engines do the same kind of bookkeeping for JavaScript startup.
- In real life: Plates, forks, glasses, and napkins already placed
- In JavaScript: Built-ins and global objects already initialized in a startup snapshot
- In real life: A covered serving dish opened only if someone asks
- In JavaScript: Lazy deserialization of a built-in function when first called
- In real life: A translated recipe kept for the next dinner
- In JavaScript: Code cache storing compiled bytecode for identical script source
- In real life: Kitchen prep happening while groceries arrive
- In JavaScript: Streaming parse and background compilation during script download
Where the analogy stops: A dinner table is visible and fixed. Engine snapshots and code caches are implementation details with version checks, invalidation rules, and memory trade-offs. JavaScript semantics must stay the same whether a cache hits or misses.
| Work | Why it exists |
|---|---|
| Create language built-ins | Objects such as Object, Array, Promise, Math, RegExp, and Intl must exist before user code runs. |
| Create contexts | Each page, worker, or isolate needs a global object and a heap with built-in objects attached. |
| Parse and compile scripts | Source text becomes ASTs, bytecode, metadata, and eventually optimized code for hot paths. |
| Run startup code | Framework bootstraps, route modules, hydration, CLI setup, or server initialization still execute after caching helps. |
This lesson goes deeper than Loading performance did. That lesson introduced HTTP caching and V8 code caching from a product-performance angle. Here you will model the engine rules, prove Node behavior, and connect the topic back to lazy parsing, bytecode and interpreters, Meet the engines, service workers, and runtimes.
Startup snapshots
INITIALIZED HEAPV8's Custom startup snapshots post explains the core trick: initialize a context once, serialize that prepared heap into a snapshot blob, embed or provide that blob to the engine, then deserialize it for new isolates or contexts. The post's 2015 numbers are worth treating as historical evidence rather than current promises: on one desktop, context creation dropped from about 40 ms to less than 2 ms; on an average mobile phone, from about 270 ms to 10 ms.
A V8 snapshot captures V8 heap state. The V8 post warns that interactions with the outside world are off-limits while creating the snapshot: native API callbacks, typed-array backing stores, and values derived from Math.random() or Date.now() can be wrong or frozen if captured carelessly.
Think of the startup snapshot as the engine's baseline pantry. It can contain built-in objects, maps, strings, functions, and embedder-provided initialization code. It does not make your application execute for free; after deserialization, your script still runs and may still parse, compile, allocate, fetch, hydrate, and call APIs.
Step through a teaching model of a V8-style startup snapshot plus lazy deserialization. The code is a model, but every state transition is tied to behavior described in V8's public posts.
script
const lazyBuiltins = ["Math.max", "Intl.NumberFormat", "RegExp.prototype.exec"]; function createContextFromSnapshot(pageNeedsIntl) { const heap = { source: "startup-snapshot", ready: [...eagerBuiltins] }; const lazyQueue = [...lazyBuiltins]; const touchedBuiltin = pageNeedsIntl ? "Intl.NumberFormat" : "Math.max"; const index = lazyQueue.indexOf(touchedBuiltin); if (index !== -1) lazyQueue.splice(index, 1); heap.ready.push(touchedBuiltin); return { heapSource: heap.source, eagerCount: eagerBuiltins.length, remainingLazy: lazyQueue.length, touchedBuiltin, };} const boot = createContextFromSnapshot(false);console.log(boot.heapSource);console.log(boot.touchedBuiltin);console.log(boot.remainingLazy);const eagerBuiltins = ["Object", "Array", "Promise"];const lazyBuiltins = ["Math.max", "Intl.NumberFormat", "RegExp.prototype.exec"]; function createContextFromSnapshot(pageNeedsIntl) { const heap = { source: "startup-snapshot", ready: [...eagerBuiltins] }; const lazyQueue = [...lazyBuiltins]; const touchedBuiltin = pageNeedsIntl ? "Intl.NumberFormat" : "Math.max"; const index = lazyQueue.indexOf(touchedBuiltin); if (index !== -1) lazyQueue.splice(index, 1); heap.ready.push(touchedBuiltin); return { heapSource: heap.source, eagerCount: eagerBuiltins.length, remainingLazy: lazyQueue.length, touchedBuiltin, };} const boot = createContextFromSnapshot(false);console.log(boot.heapSource);console.log(boot.touchedBuiltin);console.log(boot.remainingLazy);The walkthrough is a teaching model, not V8 source. It captures the important relationship: a new context can start from a prepared heap, then touch only the built-ins the page actually uses. The same page behavior must be observable whether the engine rebuilt the heap slowly or deserialized it quickly.
Lazy deserialization
LOAD ON TOUCHSnapshots save startup time, but copying everything eagerly can waste memory. V8's Lazy deserialization post says V8 v6.4 enabled lazy deserialization by default in 2018, reducing V8 heap memory by more than 500 KB per Chrome tab on average. The same post reported that popular sites used only about 30% of built-in functions on average, with some using only 16%.
The mechanism is concrete: built-in code objects live at known positions in a dedicated snapshot area. A lazy placeholder points to the DeserializeLazy built-in. On the first call, V8 finds the relevant code object, deserializes it, and installs it on the function so later calls go directly to normal code. Each built-in function and bytecode handler is deserialized at most once per isolate.
V8's Embedded builtins post, published in August 2018, describes making built-in code isolate-independent and process-shareable. V8 reported a 19% reduction in median V8 heap size per website over the preceding year. That is related to snapshots, but it is a memory-sharing improvement, not a website API.
Lazy deserialization is a good reminder for application code: do not eagerly import and run every optional feature at startup. Engines are already careful about built-ins; your app should be careful about route code, editors, charting libraries, locales, and admin-only tools.
Node.js user-land startup snapshots
NODE 22.23.1Node exposes V8 startup snapshots to user-land. In Node 22.23.1, this lesson verified both the command-line flags and the v8.startupSnapshot API. A tiny real run produced: Node 22.23.1 and StartupPerformance:build-snapshot when launched from a snapshot blob.
node --snapshot-blob snap.blob --build-snapshot entry.jsnode --snapshot-blob snap.blob main.jsglobalThis.lessonSnapshot = { title: "StartupPerformance", compiledAt: "build-snapshot",};console.log("Node " + process.versions.node);console.log( globalThis.lessonSnapshot.title + ":" + globalThis.lessonSnapshot.compiledAt,);Node's V8 docs show a second shape: the build script can register callbacks. Use addSerializeCallback to compact or release serializable state before the blob is written. Use setDeserializeMainFunction when the snapshot itself should provide the process entry point instead of a separate main.js file.
const v8 = require("node:v8"); const state = { words: ["snapshot", "cache"] }; v8.startupSnapshot.addSerializeCallback((data) => { data.words.push("serialized");}, state); v8.startupSnapshot.setDeserializeMainFunction((data) => { console.log(data.words.join(","));}, state);Snapshot builds must be deterministic. Do not bake secrets, per-machine paths, open sockets, random numbers, current dates, or request-specific data into a blob. Treat it like a compiled artifact that may be reused by many launches.
Code caching
BYTECODE REUSECode caching is different from a startup snapshot. After compiling a script, an engine can serialize compilation metadata, then later consume that data with the same source. V8's Code caching for JavaScript developers post says Chrome has two levels for classic and module scripts: a best-effort in-memory isolate cache keyed by source code, and a serialized on-disk cache managed by Chrome and associated with the script URL in the HTTP resource cache.
| Outcome | When | What happens |
|---|---|---|
| Cold run | First matching URL/content load | No bytecode cache. Download or validate bytes, parse, compile, execute, and remember the resource. |
| Warm run | Second matching load | Chrome can reuse the resource and serialize compiled code after compiling again. The isolate memory cache may also help same-process navigations. |
| Hot run | Third matching load | Chrome can pass serialized metadata to V8, which deserializes code data and skips eligible compilation work. |
| Small script | Below 1 KiB of source in V8's 2019 guidance | Chrome skips code cache because overhead can exceed the benefit. |
| Changed URL or bytes | New hash, timestamp, random inline source, or a 200 response with changed content | Treat as new code. Existing compiled metadata cannot safely apply. |
As of September 2026, this lesson still treats those V8 posts as public guidance, not a guaranteed browser contract. The numbers you should quote carefully are from the posts: Chrome 66's improved code caching reported a 20–40% reduction in parse and compilation time on many pages, and the developer post documented a 1 KiB source-size threshold. Heuristics can change, so production work should measure real traces rather than depend on a magic threshold.
import vm from "node:vm"; const source = "function add(a, b) { return a + b; } add(2, 3);";const first = new vm.Script(source, { produceCachedData: true });const cachedData = first.cachedData ?? first.createCachedData(); const accepted = new vm.Script(source, { cachedData });console.log(accepted.cachedDataRejected); const changed = new vm.Script(source + " console.log('changed');", { cachedData });console.log(changed.cachedDataRejected);The Node test for this lesson uses the real node:vm API. It proves that cached data is accepted for the identical source (cachedDataRejected === false) and rejected when extra source is appended (true). Timing is deliberately not asserted; cache correctness is the observable fact.
import { enableCompileCache } from "node:module"; const result = enableCompileCache("./.node-compile-cache");console.log(result.status);console.log(result.directory.endsWith(".node-compile-cache"));Node 22.23.1 also exposes module.enableCompileCache() and the NODE_COMPILE_CACHE=dir environment variable. Node's docs say this persists V8 code cache for CommonJS and ES modules in a directory, may slow the first module-graph load, and can speed subsequent loads when module contents do not change.
Build a browser cache teaching model
STEP + PLAYThe model below encodes the rules just described: stable URL and source, a 1 KiB minimum, a cold/warm/hot sequence, source invalidation, URL invalidation, and a recent-visit window. It is intentionally small enough to audit line by line, then flexible enough to play with.
Step through a browser teaching model for V8 code caching. It encodes the documented cold/warm/hot flow, the 1 KiB source-size threshold, and a recent-visit window used by this lesson's simulator.
script
const largeSource = "console.log('dashboard shell');\n" + "export const route = 'dashboard';\n".repeat(44);const changedSource = largeSource + "export const build = 'v2';\n";const tinySource = "console.log('tiny');"; function decideEngineCacheLoads(loads) { const entries = new Map(); return loads.map((load) => { const sizeBytes = load.content.length; const previous = entries.get(load.url); const changed = previous ? previous.content !== load.content : false; const stale = previous ? load.hour - previous.lastSeenHour > rules.recentVisitHours : false; if (sizeBytes < rules.minimumSourceBytes) { return { outcome: "too-small", usesCachedBytecode: false }; } if (!previous || changed || stale) { entries.set(load.url, { content: load.content, hits: 1, lastSeenHour: load.hour, hasDiskCodeCache: false, }); return { outcome: "cold", usesCachedBytecode: false }; } previous.hits += 1; previous.lastSeenHour = load.hour; if (!previous.hasDiskCodeCache) { previous.hasDiskCodeCache = true; return { outcome: "warm", usesCachedBytecode: false }; } return { outcome: "hot", usesCachedBytecode: true }; });} const loads = [ { url: "/assets/app.8f3a1c.js", content: largeSource, hour: 0 }, { url: "/assets/app.8f3a1c.js", content: largeSource, hour: 1 }, { url: "/assets/app.8f3a1c.js", content: largeSource, hour: 2 }, { url: "/assets/app.8f3a1c.js", content: changedSource, hour: 3 }, { url: "/assets/tiny.js", content: tinySource, hour: 4 }, { url: "/assets/app.91bd22.js", content: largeSource, hour: 5 }, { url: "/assets/app.91bd22.js", content: largeSource, hour: 80 },]; const decisions = decideEngineCacheLoads(loads);console.log(decisions.map((item) => item.outcome).join(" -> "));console.log(decisions.map((item) => item.usesCachedBytecode ? "use cache" : "compile").join(" -> "));const rules = { minimumSourceBytes: 1024, recentVisitHours: 72 };const largeSource = "console.log('dashboard shell');\n" + "export const route = 'dashboard';\n".repeat(44);const changedSource = largeSource + "export const build = 'v2';\n";const tinySource = "console.log('tiny');"; function decideEngineCacheLoads(loads) { const entries = new Map(); return loads.map((load) => { const sizeBytes = load.content.length; const previous = entries.get(load.url); const changed = previous ? previous.content !== load.content : false; const stale = previous ? load.hour - previous.lastSeenHour > rules.recentVisitHours : false; if (sizeBytes < rules.minimumSourceBytes) { return { outcome: "too-small", usesCachedBytecode: false }; } if (!previous || changed || stale) { entries.set(load.url, { content: load.content, hits: 1, lastSeenHour: load.hour, hasDiskCodeCache: false, }); return { outcome: "cold", usesCachedBytecode: false }; } previous.hits += 1; previous.lastSeenHour = load.hour; if (!previous.hasDiskCodeCache) { previous.hasDiskCodeCache = true; return { outcome: "warm", usesCachedBytecode: false }; } return { outcome: "hot", usesCachedBytecode: true }; });} const loads = [ { url: "/assets/app.8f3a1c.js", content: largeSource, hour: 0 }, { url: "/assets/app.8f3a1c.js", content: largeSource, hour: 1 }, { url: "/assets/app.8f3a1c.js", content: largeSource, hour: 2 }, { url: "/assets/app.8f3a1c.js", content: changedSource, hour: 3 }, { url: "/assets/tiny.js", content: tinySource, hour: 4 }, { url: "/assets/app.91bd22.js", content: largeSource, hour: 5 }, { url: "/assets/app.91bd22.js", content: largeSource, hour: 80 },]; const decisions = decideEngineCacheLoads(loads);console.log(decisions.map((item) => item.outcome).join(" -> "));console.log(decisions.map((item) => item.usesCachedBytecode ? "use cache" : "compile").join(" -> "));- Load 1
/assets/app.8f3a1c.js1528 bytes · hour 0 coldfirst time this URL/content pair appears
- Load 2
/assets/app.8f3a1c.js1528 bytes · hour 1 warmsecond matching load compiles again, then writes serialized bytecode for next time
- Load 3
/assets/app.8f3a1c.js1528 bytes · hour 2 hotmatching URL and source have serialized bytecode attached
Yes. The engine can consume serialized code data.
hot: cached bytecode reused. matching URL and source have serialized bytecode attached
Notice the important distinction: a warm run is better than a cold network miss, but it is not the same as a disk code-cache hit. In the V8 developer post, the warm run writes serialized metadata that makes the next matching load hot.
Streaming and background compilation
OVERLAP WORKNot all startup wins come from reusing old work. V8's Background compilation post says Chrome could parse JavaScript on a background thread via StreamedSource since version 41, starting as soon as the first network chunk arrived. Starting with Chrome 66, V8 moved much of bytecode compilation off the main thread too, reducing main-thread compilation time by about 5–20% on typical websites in their measurements.
There are limits. That post says only top-level script code and immediately invoked function expressions were compiled on a background thread at the time; inner functions still compiled lazily when first executed. Chrome 78 later enabled script streaming during preload, so <link rel="preload" as="script"> could begin compilation before the parser reached the later <script> tag.
<link rel="preload" as="script" href="/assets/app.js"><script src="/assets/app.js" defer></script> <script type="module"> const module = await WebAssembly.compileStreaming(fetch("/app.wasm")); console.log(module instanceof WebAssembly.Module);</script>WebAssembly exposes the overlap directly with WebAssembly.compileStreaming(fetch(...)). V8's Chrome 65 post says WebAssembly compilation can begin as module bytes arrive and compile functions on background threads. JavaScript does not expose the same compile-streaming API, but the browser can still stream parse and compile eligible scripts internally.
Writing cache-friendly code
STABLE BYTESThe most useful advice from V8's developer post is deliberately boring: do not change code or URLs unnecessarily. Stable external scripts let HTTP cache, service worker cache, and code cache line up. Random inline source, timestamps in URLs, and per-request generated code make each load look new.
const scripts = [ { url: "/assets/app.8f3a1c.js", sourceBytes: 1488, note: "stable hashed file" }, { url: "/assets/app.js?build=2026-09-26T15:23", sourceBytes: 1488, note: "timestamped URL" }, { url: "inline", sourceBytes: 640, note: "tiny inline bootstrap" },]; for (const script of scripts) { const stableUrl = script.url.includes("8f3a1c"); const largeEnough = script.sourceBytes >= 1024; console.log(script.note + ": " + (stableUrl && largeEnough ? "cache-friendly" : "review"));}| Choice | Cache signal | Why |
|---|---|---|
| Stable hashed files | Good | /assets/app.8f3a1c.js keeps one URL for one content version, then changes URL when content changes. |
| Timestamped script URLs | Bad | /app.js?ts=now creates a new cache key even if the source is identical. |
| Large meaningful chunks | Usually good | Scripts over the code-cache threshold can be reused; do not split into dozens of tiny files just for purity. |
| One huge bundle | Mixed | It may cache well after repeat visits, but first load, memory, invalidation, and unused execution can be worse. |
eval and new Function | Bad for hot code | Dynamically generated strings do not behave like stable external script resources and are hard to cache or audit. |
| Service worker precache | Good when maintained | Chrome can create full code cache for JS resources added during the install event, but cleanup and update policy are your job. |
- Serve
/assets/app.8f3a1c.jswith long-lived immutable caching. Append `?t=Date.now()` to every script request.- Split a route into many 600-byte helper scripts.
- Precache important JS during the service worker install event.
- Merge every route into one 900 KB bundle.
Generate hot handlers with `new Function(template)` on every page load.- Use preload or modulepreload for an important entry script.
Sort each production choice by whether it helps code reuse, defeats it, or depends on the surrounding architecture.
V8's developer post says JavaScript resources added to Cache Storage during a service worker's install event can get a full code cache immediately. If the Cache API is filled later, a normal code cache can be generated one load faster than the typical cold, warm, hot path. That benefit depends on correct service worker lifecycle and cleanup.
Production checklist
Startup performance work is most valuable when you pair engine knowledge with product traces. Use it to ask better questions, not to cargo-cult every internal heuristic.
- Measure first-load and repeat-load traces separately. A code-cache hit cannot fix first visit cost.
- Keep route entry URLs stable with content hashes; avoid timestamps and random query strings.
- Keep source bytes stable between deploys unless behavior really changed.
- Prefer external scripts for non-trivial code; keep inline scripts small and deterministic.
- Do not generate hot code with
evalornew Functionunless you have a measured reason. - Use service worker precaching only with a versioned cleanup plan.
- For Node CLIs or servers, test whether startup snapshots or module compile cache help your real launch path.
| Mechanism | Stores | Where | Helps | Does not help |
|---|---|---|---|---|
| HTTP cache | Response bytes and headers | Browser resource cache, CDN, or service worker | Avoids network transfer when the URL and validators allow it | Does not skip parse, compile, or execution by itself |
| V8 code cache | Serialized parse/compile result, usually bytecode metadata | Chrome isolate memory cache or on-disk metadata attached to the cached script | Avoids some parse/compile work on repeat loads of identical source | Not a first-load fix; invalidated by changed source or unsuitable heuristics |
| Startup snapshot | An initialized V8 heap with built-ins and optional embedder state | V8 binary, Chrome, Node, or another embedder | Avoids rebuilding built-ins and startup libraries for every isolate/context | Cannot capture arbitrary outside resources safely |
| Node compile cache | On-disk V8 code cache for CommonJS and ES modules | A directory chosen by module.enableCompileCache() or NODE_COMPILE_CACHE | Speeds later Node module graph loads when contents do not change | May slow the first load and can make V8 coverage less precise |
Common misconceptions
- “HTTP cache and code cache are the same.” HTTP cache stores response bytes. V8 code cache stores compilation metadata for matching source.
- “A startup snapshot runs my app ahead of time.” It starts from prepared heap state; app code still executes after deserialization.
- “Warm means bytecode was reused.” In Chrome's public V8 model, warm is the second load that compiles again and writes disk code-cache metadata.
- “Tiny files always cache better.” Too many tiny scripts can miss code-cache size heuristics and add request and scheduling overhead.
- “Service workers automatically make scripts fast forever.” They can help, but stale caches, quota eviction, encoding/module mismatches, and lifecycle bugs still matter.
- “Streaming compilation is JIT optimization.” It overlaps parse/bytecode compilation with download; optimized JIT tiers still need runtime feedback.
Practice exercises
5 EXERCISESType the built-in name mentioned by the starter output.
const timeline = ["deserialize initialized heap", "lazy deserialize Math.max", "run page script"];
console.log(timeline[1]);The output is lazy deserialize Math.max, so the built-in name is Math.max.
Type the exact lifecycle string printed by the program.
const outcomes = ["cold", "warm", "hot"];
console.log(outcomes.join(" -> "));The sequence is cold -> warm -> hot: first load compiles, second matching load writes code cache, third matching load can consume it.
vm.Script rejection flagsType the two booleans printed by the Node cached-data demo, separated by a comma.
const accepted = false;
const changedSourceRejected = true;
console.log(String(accepted) + "," + String(changedSourceRejected));The expected pair is false,true: same source accepted, changed source rejected.
Type the label printed for the hashed script.
const url = "/assets/app.8f3a1c.js";
const bytes = 1488;
console.log(url.includes("8f3a1c") && bytes >= 1024 ? "cache-friendly" : "review");The script is labeled cache-friendly because one stable hashed URL maps to a large enough source file.
Which two engine phases does the starter program join?
const phases = ["download", "parse", "compile", "execute"];
console.log(phases.slice(1, 3).join(" + "));The output is parse + compile. Streaming lets the browser overlap parsing and compilation with incoming script bytes where eligible.
Check your understanding
8 QUESTIONSQuestion 1 of 8What problem does a startup snapshot primarily solve?
Choose an answer to see the explanation.
Question 2 of 8What does the startup snapshot walkthrough print?
Read the code, then predictconst timeline = ["deserialize initialized heap", "lazy deserialize Math.max", "run page script"]; console.log(timeline[1]);Choose an answer to see the explanation.
Question 3 of 8In Chrome's public V8 guidance, when does the on-disk code cache help a stable external script?
Choose an answer to see the explanation.
Question 4 of 8What does the cache sequence program print?
Read the code, then predictconst outcomes = ["cold", "warm", "hot"]; console.log(outcomes.join(" -> "));Choose an answer to see the explanation.
Question 5 of 8Which Node 22.23.1 API enables on-disk module compile cache?
Choose an answer to see the explanation.
Question 6 of 8What does the VM cached-data exercise print?
Read the code, then predictconst accepted = false; const changedSourceRejected = true; console.log(String(accepted) + "," + String(changedSourceRejected));Choose an answer to see the explanation.
Question 7 of 8Which statement about streaming and background compilation is accurate?
Choose an answer to see the explanation.
Question 8 of 8Which production habit is most cache-friendly?
Choose an answer to see the explanation.
Takeaways and next
- Startup snapshots avoid rebuilding the initialized JavaScript heap for every context or process.
- Lazy deserialization keeps built-in code as placeholders until the isolate actually touches it.
- Code caching reuses parse/compile results for identical source; it is separate from HTTP caching.
- Chrome's public V8 guidance describes cold, warm, and hot runs, a 1 KiB source threshold, and service worker precache advantages.
- Node 22.23.1 has user-land startup snapshots,
v8.startupSnapshot,vm.Scriptcached data, and module compile cache APIs. - Cache-friendly code keeps URLs and bytes stable, avoids dynamic hot code, and measures first load separately from repeat load.
Remember the one-liner.
Engines start faster by preparing a world once, loading only the built-ins they touch, and reusing compiled code only when the script still matches.
Next, tiered compilation explains what happens after startup: engines watch running code, collect feedback, and recompile hot paths through interpreter, baseline, mid-tier, and optimizing tiers.