cf.completefrontendCode editorOpen lab
THE JAVASCRIPT FIELD GUIDE

What optimizing compilers do

Learn how inlining, scalar replacement, folding, hoisting, bounds checks, and representation choices make hot JavaScript faster.

By the end, you can
  • 01
    Recognize classic optimization passesExplain what inlining, scalar replacement, folding, dead-code elimination, loop hoisting, and bounds-check elimination do to optimized IR.
  • 02
    Keep semantics separate from strategyRead before-and-after teaching pictures without mistaking them for source rewrites or guaranteed V8 output.
  • 03
    Use 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.

Definition

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.

Classic optimization passes in this lesson
PassWhat it rewritesWhy it helps hot code
InliningReplace 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 replacementReplace a non-escaping object with separate values for its fields.Can remove an allocation and unlock register-based math.
Constant foldingCompute fixed expressions such as 60 * 60 while compiling.Leaves less arithmetic for runtime.
Dead-code eliminationRemove operations whose results cannot be observed.Shrinks optimized code after guards and folds prove branches unreachable.
Loop-invariant code motionMove work that does not depend on the loop index outside the loop.Avoids repeating the same calculation every iteration.
Bounds-check eliminationRemove 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.

Before picture: a helper called from a hot loopPop out in the code editor (opens in a new tab)JavaScript
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.

After picture: the helper body appears inside the loopPop out in the code editor (opens in a new tab)JavaScript
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 a helper before inlining
Step 0 of 8Ready
Your turn: follow the blue line

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.

Running in
  1. script
Next: line 6
Click the blue line to take the next stepPop out in the code editor (opens in a new tab)JavaScript
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]));
CallStoreChangeResultRun = next line. Ran = already executed.
Recent returnsNothing yet. Start with the blue line.
A guided replay recorded from real JavaScript calls, not an engine debugger. Step follows executed statements; Back reviews a snapshot. Reset starts a fresh run.
What inlining does not prove

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.

Before picture: a point object that never escapesPop out in the code editor (opens in a new tab)JavaScript
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.

After picture: the object becomes two scalar valuesPop out in the code editor (opens in a new tab)JavaScript
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.

Step through a non-escaping point object
Step 0 of 5Ready
Your turn: follow the blue line

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.

Running in
  1. script
Next: line 6
Click the blue line to take the next stepPop out in the code editor (opens in a new tab)JavaScript
function routeFee(x, y) {  const point = { x, y };  return point.x * 2 + point.y * 3;} 
CallStoreChangeResultRun = next line. Ran = already executed.
Recent returnsNothing yet. Start with the blue line.
A guided replay recorded from real JavaScript calls, not an engine debugger. Step follows executed statements; Back reviews a snapshot. Reset starts a fresh run.
Real-life analogyWriting the total straight in a notebook

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.

Before picture: a folded constant and a dead debug branchPop out in the code editor (opens in a new tab)JavaScript
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.

After picture: precomputed constant, branch gonePop out in the code editor (opens in a new tab)JavaScript
const SECONDS_PER_HOUR = 3600; function expiryFoldedPicture(seconds) {  const total = seconds + SECONDS_PER_HOUR;  return total;} console.log(expiryFoldedPicture(30));
Real-life analogyDoing 2 + 3 once

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 + 3 is always 5
In JavaScript: 60 * 60 is 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.

Before picture: repeated invariant workPop out in the code editor (opens in a new tab)JavaScript
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.

After picture: compute the scale oncePop out in the code editor (opens in a new tab)JavaScript
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.

Observable behavior comes first

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.

Before picture: the internal check made visiblePop out in the code editor (opens in a new tab)JavaScript
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.

After picture: the proven check is gonePop out in the code editor (opens in a new tab)JavaScript
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.

Word32 and double-friendly source behaviorPop out in the code editor (opens in a new tab)JavaScript
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.

Representations are engine choices, not extra JavaScript types
RepresentationPlain meaningWhere it helps
SmiA small integer stored directly in a tagged value.Fast integer arithmetic and indexing when values stay in range.
DoubleA 64-bit floating-point number representation.Needed for non-integers such as 1.5 and many Math results.
Word32A 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.
Node-only Smi and word32 probeJavaScript
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);
Verified runtime fact

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.

Step through three optimization passes
Step 0 of 5Ready
Your turn: follow the blue line

Apply three classic optimization passes to a tiny teaching IR: fold constants, remove dead code, and hoist loop-invariant work.

Running in
  1. script
Next: line 1
Click the blue line to take the next stepPop out in the code editor (opens in a new tab)JavaScript
  "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));
CallStoreChangeResultRun = next line. Ran = already executed.
Recent returnsNothing yet. Start with the blue line.
A guided replay recorded from real JavaScript calls, not an engine debugger. Step follows executed statements; Back reviews a snapshot. Reset starts a fresh run.

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.

Playground: toggle optimization passes
Toy optimization-pass driverJavaScript
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);
Optimized IR picture3675
7 operations3675 program output
  1. const SECONDS = 3600;
  2. const DEBUG = false;
  3. let total = 0;
  4. const scale = Math.sqrt(rate); // hoisted before the loop
  5. loop: const price = items[i] * scale;
  6. loop: total = total + price;
  7. return total + SECONDS;
Step 3 of 37 operations
Turn passes on or off

Enabled passes: fold, dce, licm. The model has 7 operations and still returns 3675.

This is a teaching model. It does not inspect your browser's engine; it runs a tiny optimizer written for the lesson and checks that the output stays the same.
Which optimization is this?
  • A tiny fee(item) body is copied into a hot loop's optimized IR.
  • An if (DEBUG) branch disappears after DEBUG folds to false.
  • Math.sqrt(rate) moves before a loop because rate does 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.length lets optimized code avoid a repeated internal bounds check.
Try it yourself
0 of 6 correct

Sort each card by the kind of optimization it describes. A wrong answer still explains the distinction.

Choose a category for every card. You can change an answer at any time; Reset clears them all.

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.

Node-only optimization probeJavaScript
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));
Flags checked in this Node 22 build

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.

What the test proves

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.
Similar ideas that are easy to confuse
IdeaMeansDo not confuse it with
Source codeThe JavaScript you wrote and the spec semantics it must follow.Optimized IR, which can look like a different program internally.
OptimizationA semantics-preserving internal rewrite chosen from feedback and proofs.A guarantee of better timing on every run.
DeoptimizationThe escape hatch when a speculative assumption fails.A bug or a visible change in result.
RepresentationHow an engine stores a proven value internally.A portable JavaScript type you can directly rely on.

Practice exercises

Exercise 1 · Warm-upPredict the inlined helper total

What number prints?

Starter codePop out in the code editor (opens in a new tab)JavaScript
function fee(price) { return price + 7; }
function total(items) { return items.reduce((sum, item) => sum + fee(item), 0); }
console.log(total([10, 20, 30]));

Answer, then press Check. Spacing and letter case don’t matter.

    Exercise 2 · Warm-upCompute the scalar-replacement example

    What does routeFee(4, 5) print?

    Starter codePop out in the code editor (opens in a new tab)JavaScript
    function routeFee(x, y) {
      const point = { x, y };
      return point.x * 2 + point.y * 3;
    }
    console.log(routeFee(4, 5));

    Answer, then press Check. Spacing and letter case don’t matter.

      Exercise 3 · PracticeFind the dead branch

      After DEBUG is known to be false, which branch can dead-code elimination remove?

      Answer, then press Check. Spacing and letter case don’t matter.

        Exercise 4 · PracticeSpot the loop invariant

        Which expression can the LICM picture move before the loop?

        Starter codePop out in the code editor (opens in a new tab)JavaScript
        for (const item of items) {
          total += item * Math.sqrt(rate);
        }

        Answer, then press Check. Spacing and letter case don’t matter.

          Exercise 5 · PracticePredict a word32 conversion

          What does the snippet print?

          Starter codePop out in the code editor (opens in a new tab)JavaScript
          console.log(3.9 | 0);

          Answer, then press Check. Spacing and letter case don’t matter.

            Exercise 6 · ChallengeApply it to a real app

            What should you do before changing code for an optimization theory?

            Starter codePop out in the code editor (opens in a new tab)JavaScript
            // A product grid feels slow when filtering 5,000 items.
            // Before rewriting helpers by hand, your next step is: ____

            Answer, then press Check. Spacing and letter case don’t matter.

              Quiz: check your understanding

              Answer by preserving JavaScript output first, then naming the internal rewrite.

              Compiler optimizations quiz · 8 questionsScore: first tries count
              1. Question 1 of 8What is the main job of an optimizing compiler pass?

                Choose an answer to see the explanation.

              2. Question 2 of 8What does this inlining example print?

                Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
                function 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.

              3. Question 3 of 8Which optimization can remove a temporary { x, y } allocation when the object never escapes?

                Choose an answer to see the explanation.

              4. Question 4 of 8What does this constant-folding example print?

                Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
                const 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.

              5. Question 5 of 8What makes Math.sqrt(rate) a loop-invariant candidate in the lesson's loop?

                Choose an answer to see the explanation.

              6. Question 6 of 8What does this bitwise representation example print last?

                Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
                console.log((41 + 1) | 0);
                console.log(3.9 | 0);

                Choose an answer to see the explanation.

              7. Question 7 of 8Which statement about bounds-check elimination is safest?

                Choose an answer to see the explanation.

              8. 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.

              CompleteFrontend Clear concepts. Working examples.