What optimizing compilers do
Learn how inlining, scalar replacement, folding, hoisting, bounds checks, and representation choices make hot JavaScript faster.
- 01Recognize classic optimization passesExplain what inlining, scalar replacement, folding, dead-code elimination, loop hoisting, and bounds-check elimination do to optimized IR.
- 02Keep semantics separate from strategyRead before-and-after teaching pictures without mistaking them for source rewrites or guaranteed V8 output.
- 03Use V8 evidence carefullyCheck stable Node 22 and V8 12.4 facts, including optimization traces, flags, and Smi behavior, without trusting timings.
Same meaning, less work
The optimizing compiler sits after the interpreter, type feedback, inline caches, and speculative guards. Its job is not to invent a different JavaScript language. Its job is to prove that a hot piece of code can do fewer internal operations while printing, throwing, mutating, and returning exactly the same observable results.
An optimizing compiler pass is a semantics-preserving rewrite over the engine's internal representation. It may inline a call, remove an allocation, fold a constant, move loop work, remove a redundant check, or choose a cheaper machine representation when guards prove that choice is safe.
The before and after snippets in this lesson are source-level pictures of what can happen to optimized IR. They are deliberately written as JavaScript so you can run them and confirm the output stays the same. They are not real TurboFan, Maglev, Ion, or JavaScriptCore output.
| Pass | What it rewrites | Why it helps hot code |
|---|---|---|
| Inlining | Replace a stable small call with the callee's body in optimized IR. | Avoids call overhead and exposes more constants, types, and fields to later passes. |
| Scalar replacement | Replace a non-escaping object with separate values for its fields. | Can remove an allocation and unlock register-based math. |
| Constant folding | Compute fixed expressions such as 60 * 60 while compiling. | Leaves less arithmetic for runtime. |
| Dead-code elimination | Remove operations whose results cannot be observed. | Shrinks optimized code after guards and folds prove branches unreachable. |
| Loop-invariant code motion | Move work that does not depend on the loop index outside the loop. | Avoids repeating the same calculation every iteration. |
| Bounds-check elimination | Remove array bounds checks after proving the loop index stays in range. | Keeps tight loops from paying redundant safety checks. |
The neighbouring Optimizing compilers lesson covers compiler pipelines, IRs, SSA, and engine architecture. The previous Speculation & deoptimization lesson covers guards and bailouts. Here we stay focused on the transformations themselves.
Function inlining: bring the tiny helper into the hot caller
Inlining means replacing a call in optimized IR with the body of the called function. It is useful only when the compiler has evidence that the call target is stable enough and the body is worth copying. The later code still behaves as if the function call happened.
function fee(price) { return price + 7;} function totalWithHelper(items) { let total = 0; for (const item of items) { total += fee(item); } return total;} console.log(totalWithHelper([10, 20, 30]));Line 1 defines fee. Line 8 calls it for every item. The output is 81: 17 + 27 + 37. If this call site is hot and feedback says it keeps calling the same small helper, the optimizer can consider inlining it.
function totalWithInlinedHelper(items) { let total = 0; for (const item of items) { total += item + 7; } return total;} console.log(totalWithInlinedHelper([10, 20, 30]));Line 4 is the teaching picture: item + 7 is the helper body. In real optimized IR, that exposed expression may let later passes fold constants, choose integer operations, or remove checks around the caller.
Step through the helper call that an optimizing compiler may inline. This replay follows real JavaScript calls, while the inlined code shown nearby is a source-level picture of the compiler's IR.
script
function fee(price) { return price + 7;} function totalWithHelper(items) { for (const item of items) { total += fee(item); } return total;} console.log(totalWithHelper([10, 20, 30]));Inlining does not mean the function disappeared from JavaScript semantics. Stack traces, debugging modes, large functions, changing targets, and deoptimization metadata all affect what an engine chooses. Think “internal copy with guards,” not “source rewrite.”
Escape analysis and scalar replacement
Escape analysis asks whether an object's identity can be observed outside the optimized region. If the object never escapes, scalar replacement can represent its fields as separate local values instead of allocating the object.
function routeFee(x, y) { const point = { x, y }; return point.x * 2 + point.y * 3;} console.log(routeFee(4, 5));Line 2 creates a point. Line 3 reads point.x and point.y, then returns a number. The point is not returned, stored globally, passed to unknown code, or compared by identity.
function routeFeeScalarPicture(x, y) { const point_x = x; const point_y = y; return point_x * 2 + point_y * 3;} console.log(routeFeeScalarPicture(4, 5));The after picture prints the same 23. It shows the idea: the object's fields can live as two scalar values in optimized code. The source still says an object exists; the optimized representation can be cheaper because no outside code can tell.
Watch a temporary object that never escapes. The compiler can replace the allocation with separate scalar values, but the source still behaves as if the object existed.
script
function routeFee(x, y) { const point = { x, y }; return point.x * 2 + point.y * 3;} You might write a few numbers on a temporary note while adding a total. If nobody needs the note itself, only its numbers, write the useful values straight in your notebook.
- In real life: You write numbers on a temporary note
- In JavaScript: The source creates
{ x, y } - In real life: Nobody needs the note itself
- In JavaScript: The object does not escape the function
- In real life: You write the total straight in the notebook
- In JavaScript: Fields become scalar locals
- In real life: If someone needs the note, it must exist
- In JavaScript: If identity escapes, the allocation must remain
Where the analogy stops: People choose how to take notes. A compiler needs proof and guards before it can remove an allocation.
Constant folding and dead-code elimination
Constant folding computes fixed expressions while compiling. Dead-code elimination removes operations whose results cannot be observed. These two often work together: folding can prove that a branch is never taken, and DCE can remove it.
const DEBUG = false;const SECONDS_PER_HOUR = 60 * 60; function expiry(seconds) { const total = seconds + SECONDS_PER_HOUR; if (DEBUG) { console.log("debug", total); } return total;} console.log(expiry(30));Line 2 is always 3600. Line 1 makes the branch on line 6 unreachable in this snippet. The program prints only 3630; it never prints debug.
const SECONDS_PER_HOUR = 3600; function expiryFoldedPicture(seconds) { const total = seconds + SECONDS_PER_HOUR; return total;} console.log(expiryFoldedPicture(30));Do 2 + 3 in your head once instead of every time you see it. A compiler can do the same with a fixed expression before the hot code runs.
- In real life: You know
2 + 3is always 5 - In JavaScript:
60 * 60is the same every run - In real life: You work out the answer once
- In JavaScript: Constant folding computes the fixed expression
- In real life: You reuse the answer each time
- In JavaScript: Optimized code stores the precomputed value
Where the analogy stops: A sum can be easy to see. A compiler folds only when JavaScript semantics prove the value is fixed.
Loop-invariant code motion: move repeated work out
A value is loop-invariant when it does not change from one iteration to the next. If moving it earlier does not change observable behavior, the compiler can compute it once before the loop.
function scaledTotal(items, rate) { let total = 0; for (const item of items) { total += item * Math.sqrt(rate); } return total;} console.log(scaledTotal([9, 16], 9));Line 4 repeats Math.sqrt(rate). The array item changes each iteration, but rate does not. The function prints 75 for [9, 16] and rate 9.
function scaledTotalHoistedPicture(items, rate) { const scale = Math.sqrt(rate); let total = 0; for (const item of items) { total += item * scale; } return total;} console.log(scaledTotalHoistedPicture([9, 16], 9));Line 2 computes the square root once. Line 5 reuses scale. This move is safe here because Math.sqrt with a number argument has no user-defined callback, and rate is not changed by the loop.
A compiler cannot freely move code that may call user JavaScript, throw at a different time, read a changing value, or interact with proxies and accessors. “Invariant” always means “proved invariant under JavaScript semantics and the current guards.”
Bounds-check elimination: prove the index is safe
Array element access needs safety checks inside the engine. Bounds-check elimination removes redundant checks when the optimizer proves the index stays within the array length for the optimized path.
function sumWithModelledBoundsChecks(values) { let total = 0; for (let i = 0; i < values.length; i += 1) { if (i < 0 || i >= values.length) throw new RangeError("out of bounds"); total += values[i]; } return total;} console.log(sumWithModelledBoundsChecks([3, 4, 5]));Real JavaScript does not require you to write line 4. It is a teaching model for the internal safety check. The loop header on line 3 already says i starts at zero and stops before values.length, so the check is redundant for this path.
function sumAfterBoundsCheckElimination(values) { let total = 0; for (let i = 0; i < values.length; i += 1) { total += values[i]; } return total;} console.log(sumAfterBoundsCheckElimination([3, 4, 5]));Both snippets print 12. Optimized code still needs guards: if the array shape changes, the length changes unexpectedly, or the path no longer matches the proof, the engine must keep checks or deoptimize.
Representation selection: Smis, doubles, and words
JavaScript has one number type for both integers and fractions. Engines use internal representations to make proven cases cheaper. A Smi is a small integer stored directly in a tagged value. A double is a 64-bit floating-point number. A word32 operation uses 32-bit integer machine semantics.
function addWord32(x) { return (x + 1) | 0;} function addNumber(x) { return x + 0.5;} console.log(addWord32(41));console.log(addNumber(1.5));Line 2 uses | 0, which is real JavaScript syntax that converts through signed 32-bit integer semantics. Line 6 keeps fractional number behavior. Optimizers can also choose representations internally without source | 0 when guards prove it is safe.
| Representation | Plain meaning | Where it helps |
|---|---|---|
| Smi | A small integer stored directly in a tagged value. | Fast integer arithmetic and indexing when values stay in range. |
| Double | A 64-bit floating-point number representation. | Needed for non-integers such as 1.5 and many Math results. |
| Word32 | A machine-sized integer operation such as the result of x | 0. | Useful when the compiler proves int32 semantics are correct; source | 0 also changes JavaScript semantics outside that range. |
console.log("node", process.version.replace(/\.\d+$/, ".x"));console.log("v8", process.versions.v8.split(".").slice(0, 2).join("."));console.log("pointer compression", process.config.variables.v8_enable_pointer_compression);console.log("2^31 - 1 is Smi", %IsSmi(2 ** 31 - 1));console.log("2^31 is Smi", %IsSmi(2 ** 31));console.log("1.5 is Smi", %IsSmi(1.5));console.log("(3.9 | 0)", 3.9 | 0);The tests run this probe in Node 22 on V8 12.4. Official Node on this machine reports pointer compression off, so 2**31 - 1 is a Smi, 2**31 is not, 1.5 is not, and 3.9 | 0 prints 3. Chrome builds commonly use pointer compression, so their Smi range can differ.
The neighbouring Tagged values, Smis & heap numbers lesson goes deeper on how values live in memory. Here you only need the optimization idea: a compiler can pick a cheaper representation when feedback and guards prove that representation preserves behavior.
Experiment: step through a tiny optimizer
Now combine three passes. The next replay uses a tiny three-address-style IR written for this lesson. It is not V8 IR, but it applies real optimization ideas to a real computed result: the output stays 3675 while the operation list changes.
Apply three classic optimization passes to a tiny teaching IR: fold constants, remove dead code, and hoist loop-invariant work.
script
"const SECONDS = 60 * 60", "const DEBUG = false", "let total = 0", "loop: const scale = Math.sqrt(rate)", "loop: const price = items[i] * scale", "loop: total = total + price", "if (DEBUG) console.log(total)", "return total + SECONDS",]; const folded = foldConstants(ops);const cleaned = removeDeadBranches(folded);const hoisted = hoistLoopInvariantWork(cleaned);console.log(render(hoisted));Try the free-play version next. Turn one pass off at a time. Constant folding changes an operation, DCE removes one, and LICM changes where the square root happens. The result should stay fixed.
const passes = new Set(["fold", "dce", "licm"]);const result = optimizeTinyIR(passes); console.log(result.operations.join("\n"));console.log("operation count", result.opCount);console.log("program output", result.output);const SECONDS = 3600;const DEBUG = false;let total = 0;const scale = Math.sqrt(rate); // hoisted before the looploop: const price = items[i] * scale;loop: total = total + price;return total + SECONDS;
Enabled passes: fold, dce, licm. The model has 7 operations and still returns 3675.
- A tiny
fee(item)body is copied into a hot loop's optimized IR. - An
if (DEBUG)branch disappears afterDEBUGfolds tofalse. Math.sqrt(rate)moves before a loop becauseratedoes not change inside it.- A temporary
{ x, y }that never escapes becomes two local field values. - A proven small integer add uses a Smi or int32 path instead of a generic number path.
- A loop
i < array.lengthlets optimized code avoid a repeated internal bounds check.
Sort each card by the kind of optimization it describes. A wrong answer still explains the distinction.
Real Node and V8 evidence without timing claims
The tests for this lesson run child Node processes. They strip NODE_TEST_* environment variables, assert version patterns instead of exact patch numbers, and check stable facts: flags exist, optimization status is nonzero, trace output mentions TurboFan and inlining, and disabling selected TurboFan passes does not change the function's result.
function helper(x) { return x + 7;} function hotTotal(n) { let total = 0; for (let i = 0; i < n; i += 1) { total += helper(i); } return total;} %PrepareFunctionForOptimization(hotTotal);for (let round = 0; round < 20; round += 1) hotTotal(20);%OptimizeFunctionOnNextCall(hotTotal);console.log("result", hotTotal(5));console.log("status", %GetOptimizationStatus(hotTotal));The tests check --trace-opt, --trace-turbo-inlining, --turbo-inlining, --turbo-escape, --trace-turbo-escape, and --allow-natives-syntax in node --v8-options. They also run the same program with --no-turbo-inlining and --no-turbo-escape to prove the output stays 45.
It proves stable facts about this release build: Node starts with v22., V8 starts with 12.4., TurboFan can optimize the probe, the inlining trace contains a stable Inlining line, and selected optimization flags preserve the result. It does not claim a speedup.
Use this knowledge safely
Working developers should not write code for a single pass name. These optimizations explain why stable, simple hot paths are easier for engines to optimize, but readability and measurements come first.
- Write clear functions first; do not manually inline everything.
- Keep measured hot paths reasonably type-stable and shape-stable when it fits the data model.
- Let temporary objects stay readable unless profiling points at allocation pressure.
- Warm benchmarks with representative values and keep results used.
- Use V8 native syntax and flags only in local diagnostics or tests.
- Measure before and after any performance-motivated rewrite.
If a profile points to a hot loop, this lesson gives you vocabulary for hypotheses: maybe a call target is unstable, a temporary object escapes, the loop repeats invariant work, or mixed numeric representations keep the path generic. Test the hypothesis; do not optimize by superstition.
Common misconceptions
- “The after snippets are what V8 prints.” They are teaching pictures of possible IR rewrites, not real compiler output.
- “Inlining always makes code faster.” It has code-size, stack-trace, budget, and deoptimization trade-offs.
- “A removed allocation means the object never existed semantically.” JavaScript behavior still matches the source object.
- “Bounds-check elimination removes safety from JavaScript arrays.” It removes redundant internal checks only on proven optimized paths.
- “Smi is a JavaScript type.” It is an engine representation. Your program still sees numbers.
| Idea | Means | Do not confuse it with |
|---|---|---|
| Source code | The JavaScript you wrote and the spec semantics it must follow. | Optimized IR, which can look like a different program internally. |
| Optimization | A semantics-preserving internal rewrite chosen from feedback and proofs. | A guarantee of better timing on every run. |
| Deoptimization | The escape hatch when a speculative assumption fails. | A bug or a visible change in result. |
| Representation | How an engine stores a proven value internally. | A portable JavaScript type you can directly rely on. |
Practice exercises
What number prints?
function fee(price) { return price + 7; }
function total(items) { return items.reduce((sum, item) => sum + fee(item), 0); }
console.log(total([10, 20, 30]));It prints 81. Inlining can remove the helper call internally, but the helper's addition still counts for every item.
What does routeFee(4, 5) print?
function routeFee(x, y) {
const point = { x, y };
return point.x * 2 + point.y * 3;
}
console.log(routeFee(4, 5));It prints 23. Scalar replacement could represent the point as two fields internally, but the arithmetic is unchanged.
After DEBUG is known to be false, which branch can dead-code elimination remove?
The if (DEBUG) branch can be removed in the optimized picture after constant folding proves DEBUG is false.
Which expression can the LICM picture move before the loop?
for (const item of items) {
total += item * Math.sqrt(rate);
}Math.sqrt(rate) is the loop-invariant expression in this example, so the model hoists it before the loop.
What does the snippet print?
console.log(3.9 | 0);It prints 3. | 0 converts 3.9 to a signed 32-bit integer, truncating the fraction.
What should you do before changing code for an optimization theory?
// A product grid feels slow when filtering 5,000 items.
// Before rewriting helpers by hand, your next step is: ____Measure first. Use a profile to find the real hot path, then test a focused change and compare before/after results.
Quiz: check your understanding
Answer by preserving JavaScript output first, then naming the internal rewrite.
Question 1 of 8What is the main job of an optimizing compiler pass?
Choose an answer to see the explanation.
Question 2 of 8What does this inlining example print?
Read the code, then predictfunction fee(price) { return price + 7; } function total(items) { return items.reduce((sum, item) => sum + fee(item), 0); } console.log(total([10, 20, 30]));Choose an answer to see the explanation.
Question 3 of 8Which optimization can remove a temporary
{ x, y }allocation when the object never escapes?Choose an answer to see the explanation.
Question 4 of 8What does this constant-folding example print?
Read the code, then predictconst DEBUG = false; const SECONDS = 60 * 60; function expiry(x) { if (DEBUG) console.log('debug'); return x + SECONDS; } console.log(expiry(30));Choose an answer to see the explanation.
Question 5 of 8What makes
Math.sqrt(rate)a loop-invariant candidate in the lesson's loop?Choose an answer to see the explanation.
Question 6 of 8What does this bitwise representation example print last?
Read the code, then predictconsole.log((41 + 1) | 0); console.log(3.9 | 0);Choose an answer to see the explanation.
Question 7 of 8Which statement about bounds-check elimination is safest?
Choose an answer to see the explanation.
Question 8 of 8What did the Node evidence section prove without using timings?
Choose an answer to see the explanation.
Key takeaways
- Optimizing compilers rewrite internal IR, not your source file, and must preserve observable JavaScript behavior.
- Inlining exposes a stable callee body to the caller, which can unlock more optimizations.
- Escape analysis can prove a temporary object never escapes, letting scalar replacement remove the allocation internally.
- Constant folding, DCE, LICM, and bounds-check elimination remove or move work only when proofs and guards make it safe.
- Representation selection chooses Smis, doubles, or word operations internally; JavaScript still exposes normal values.
Remember the one-liner.
An optimization is a proven internal shortcut: same JavaScript meaning, less work on the hot path.
Up next: On-stack replacement, where a long-running loop can enter optimized code without waiting for the next function call.