What optimizing compilers do
Learn how inlining, scalar replacement, folding, hoisting, bounds checks, and representation choices make hot JavaScript faster.
- 01Name the classic passesSay what inlining, scalar replacement, constant folding, dead-code elimination, moving work out of loops, and removing bounds checks each do to hot code.
- 02Keep what code does apart from how it runsRead the before and after pictures as ideas, not as changes to your file or as real V8 output.
- 03Check V8 facts with careCheck facts in Node 22 and V8 12.4, such as optimization traces, flags, and which numbers are Smis, without trusting speed numbers.
Same meaning, less work
A JavaScript engine first runs your code with an interpreter, which starts quickly but runs slowly. When a function runs very often, the engine calls it hot. It then hands the hot function to an optimizing compiler: the part of the engine that builds much faster machine code for it.
The compiler has one firm rule. The fast code must act exactly like your code. It must print the same things, throw the same errors, change the same values, and return the same answers. It only does fewer steps inside the engine.
The compiler first turns your function into its own list of small steps, called the IR (short for intermediate representation). An optimization pass is one rewrite of that IR that does less work but never changes what your program does. Each pass has one job. In this lesson, the passes:
- copy a small function into the code that calls it;
- skip making an object that no other code can see;
- work out fixed math ahead of time;
- move repeated work out of a loop;
- drop a safety check that can never fail;
- pick a cheaper way to store a number.
Each pass relies on guards: quick checks in the fast code that make sure the compiler's guesses still hold.
Each pass in this lesson has a before snippet and an after snippet. Both are plain JavaScript, so you can run them and see that the output stays the same. The after snippet is only a picture of the idea. It is not what a real compiler prints, such as TurboFan or Maglev in V8 (the engine in Chrome and Node). Real engines never change your file; they change only their own IR.
The table lists the six passes. Read each row from left to right: the pass, what it changes, and why that helps.
| Pass | What it changes | Why it helps hot code |
|---|---|---|
| Inlining | Copies a small function's body into the code that calls it. | It saves the jump into the function and back, and lets other passes see the body. |
| Scalar replacement | Keeps an object that never leaves its function as plain values, one per property. | The engine can skip making the object at all. |
| Constant folding | Works out fixed math, such as 60 * 60, once while compiling. | There is less math left to do while the code runs. |
| Dead-code elimination | Removes code that can never run, or whose result nobody uses. | The fast code gets shorter. |
| Loop-invariant code motion | Moves work that gives the same answer in every round to before the loop. | The same math is not repeated in every round. |
| Bounds-check elimination | Removes the check that an array index is inside the array, when it can never fail. | Busy loops skip a check on every read. |
The Optimizing compilers lesson shows how a whole compiler is built. The Speculation & deoptimization lesson covers guards, and how the engine goes back to slower code when a guess turns out wrong. This lesson stays on the rewrites themselves.
Function inlining: copy a small helper into the loop
A helper is a small function that other code calls. Each call costs a little work: the engine must jump into the function and then come back. Inlining means the compiler copies the helper's body into the code that calls it, so there is no call any more. The compiler only does this when the call always goes to the same small function. Your program still acts as if the 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, which adds 7 to a price. Line 8 calls fee once for each item. The output is 81, because 17 + 27 + 37 is 81. If line 8 runs very often, and it always calls the same small fee, the compiler may inline 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 holds item + 7, the body of fee, right inside the loop. Line 9 prints the same 81. With the body in plain view, the compiler can run more passes on it, such as working out fixed math or picking a cheaper way to store the numbers.
fee. After, the helper's one line is copied into the loop, so there is no call. Both versions print 81.Step through the helper calls that a compiler may inline. The replay follows real JavaScript calls. The inlined code above is only a picture of the change inside the engine.
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 happens only inside the engine. For your program, the function still exists: error messages and the debugger can still show it. The engine also skips inlining when a function is large, or when one call can reach different functions. Think of inlining as a hidden copy kept safe by guards, not as a change to your file.
Escape analysis and scalar replacement
Making an object costs work. The engine must find memory for it, and later clean that memory up. Some objects are used only inside one function and then forgotten. Two passes work together on that case:
- Escape analysis checks whether an object escapes, which means whether any code outside the function could ever get hold of it. An object escapes if it is returned, saved in an outside variable, or passed to another function.
- Scalar replacement runs when the object does not escape. It keeps each property as a separate plain value, called a scalar, and skips making the object.
function routeFee(x, y) { const point = { x, y }; return point.x * 2 + point.y * 3;} console.log(routeFee(4, 5));Line 2 makes a point object with x and y. Line 3 reads point.x and point.y and returns a number. The point is never returned, saved outside, passed to other code, or compared with another object. So it does not escape. Line 6 prints 23, because 4 * 2 + 5 * 3 is 23.
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. Lines 2 and 3 hold point_x and point_y, one plain value for each property. Your code still makes an object. The fast code can skip it, because no other code could ever tell the difference.
routeFee. No other code ever sees it, so fast code can keep just its two numbers and skip making the object. Both versions print 23.Watch a short-lived object that never leaves its function. Fast code can keep two plain values instead, but your program still acts as if the object was made.
script
function routeFee(x, y) { const point = { x, y }; return point.x * 2 + point.y * 3;} You are adding up a total. You could write a few numbers on a scrap note first. But if nobody ever asks for the note, you can skip it and write the numbers straight in your notebook.
- In real life: You write numbers on a scrap note
- In JavaScript: The code makes
{ 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 numbers straight in the notebook
- In JavaScript: The properties become two plain values
- In real life: If someone asks for the note, you must write it
- In JavaScript: If the object escapes, it must really be made
Constant folding and dead-code elimination
Two more passes often work as a pair:
- Constant folding works out math whose answer never changes, once, while compiling. A constant is a value that never changes.
- Dead-code elimination (DCE for short) removes dead code: code that can never run, or whose result nobody ever uses.
Folding can show that an if test is always false. Then DCE can remove that whole branch.
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 sets DEBUG to false, so the if on line 6 never runs. 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));Line 1 now holds the ready answer, 3600. The if branch is gone, and DEBUG is gone with it, because nothing reads it any more. Line 8 prints the same 3630.
60 * 60 is always 3600, so the compiler works it out once. DEBUG is always false, so the debug branch can never run and is removed. The program still prints 3630.You see 2 + 3 many times on a page. You work it out once and remember 5, instead of adding again each time. A compiler can do the same with fixed math before the hot code runs.
- In real life: You know
2 + 3is always 5 - In JavaScript:
60 * 60is the same on every run - In real life: You work out the answer once
- In JavaScript: Constant folding works out the fixed math
- In real life: You reuse the answer each time
- In JavaScript: The fast code keeps the ready answer,
3600
Loop-invariant code motion: move repeated work out
A loop runs the same lines many times. Some work inside a loop gives the same answer in every round. That work is called loop-invariant; invariant means "does not change". Loop-invariant code motion (LICM for short) moves that work before the loop, so it runs only once. Moving code up like this is also called hoisting. The compiler only does it when the move can't change what the program does.
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));Math.sqrt works out a square root: Math.sqrt(9) is 3. Line 4 works out Math.sqrt(rate) in every round. item changes each round, but rate does not. With items [9, 16] and a rate of 9, line 9 prints 75, because 9 * 3 + 16 * 3 is 75.
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 works out the square root once and saves it in scale. Line 5 uses scale in each round, and line 10 prints the same 75.
rate is 9 in every round, so Math.sqrt(rate) is always 3. The after picture works it out once, before the loop. Both versions print 75.For this array of numbers, the move is safe. A real compiler must also check a few things first:
Math.sqrtis still the built-in function, not one your code put in its place.- Turning
rateinto a number can't run any of your own code. ratereally stays the same during the loop.
If any of these stops being true, the compiler must not move the work.
A compiler can't freely move code that might:
- run your own JavaScript;
- throw an error at a different moment;
- read a value that changes;
- touch a proxy (an object that runs your code when you use it) or a getter (a function that runs when a property is read).
"Invariant" always means the compiler has proved the value can't change, under JavaScript's rules and its current guards.
Bounds-check elimination: prove the index is safe
When you read values[i], the engine first checks that i is inside the array: not below 0, and below values.length. This is a bounds check; the bounds are the first and last valid positions. Bounds-check elimination removes this check when the compiler can prove that i is always inside the array.
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]));You never write line 4 in real JavaScript. It only shows the check that the engine does for you. The loop on line 3 already makes sure that i starts at 0 and stops before values.length. So in this loop, the check on line 4 can never fail.
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, because 3 + 4 + 5 is 12. The fast code still keeps guards. If the array or its length changes in a way the proof didn't expect, the engine keeps the checks, or goes back to slower code. Going back like that is called deoptimization.
Representation selection: Smis, doubles, and words
JavaScript has one number type for whole numbers and fractions alike. Inside, the engine can store a number in different ways. Each way is called a representation. When guards prove it is safe, the compiler picks the cheapest one:
- A Smi (small integer) is a whole number small enough to fit right inside the value itself, with no extra memory. An integer is a whole number.
- A double is a 64-bit number that can hold fractions, like
1.5. - A word32 is a 32-bit whole number. The computer's chip can work with these very fast.
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. The | is the bitwise OR operator. Like every bitwise operator, it first turns its numbers into 32-bit whole numbers, which drops any fraction. OR with 0 then leaves that whole number as it is. So line 9 prints 42.
Line 6 keeps the fraction: line 10 prints 2, because 1.5 + 0.5 is 2. The compiler can also pick a word32 by itself, with no | 0 in your code, when its guards prove the result stays the same.
Each row below is one way the engine can store a number. None of them is a JavaScript type: your code only ever sees numbers.
| Representation | What it is | When it helps |
|---|---|---|
| Smi | A small whole number kept right inside the value, with no extra memory. | Fast whole-number math and array positions, while the numbers stay small. |
| Double | A 64-bit number that can hold fractions. | Needed for numbers like 1.5 and for many Math results. |
| Word32 | A 32-bit whole number, like the result of x | 0. | Fast when the compiler proves the numbers stay 32-bit whole numbers. Writing | 0 yourself also changes your results outside that range. |
The next code only runs in Node, with a special setting. %IsSmi is a V8 helper that answers "is this value stored as a Smi?". Helpers that start with % only work when Node starts with --allow-natives-syntax, so this block has no Run button. The ** operator means "to the power of".
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^30 - 1 is Smi", %IsSmi(2 ** 30 - 1));console.log("2^30 is Smi", %IsSmi(2 ** 30));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 lesson's tests run this code in Node 22, which uses V8 12.4. Line 3 prints whether the build uses pointer compression, a memory-saving setting that makes Smis smaller. So lines 4 to 7 check both edges, 2 ** 30 and 2 ** 31, instead of guessing one size. In every build, 1.5 is not a Smi, and 3.9 | 0 prints 3.
The Tagged values, Smis & heap numbers lesson shows how values are stored in memory. Here you only need one idea: the compiler can pick a cheaper way to store a number when its guards prove the result stays the same.
Experiment: step through a tiny optimizer
Now put three passes together. The next replay uses a short list of steps written for this lesson, a bit like a tiny IR. It is not V8's real IR. It runs three passes in order: constant folding, then dead-code elimination, then loop-invariant code motion. Watch the list change while the program's answer stays 3675.
Run three passes on a tiny list of steps: work out fixed math, remove dead code, and move repeated work out of the loop.
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));Next, try the free version. Turn one pass off at a time and watch what changes:
- Constant folding changes
60 * 60into3600. - Dead-code elimination removes the
if (DEBUG)step, so the list gets one step shorter. - Loop-invariant code motion moves the square root before the loop.
- The program output stays
3675every time.
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;
Passes on: constant folding, dead-code elimination, loop-invariant code motion. The list has 7 steps, and the program still gives 3675.
- The body of a small
fee(item)helper is copied into a busy loop. - An
if (DEBUG)branch disappears once the compiler knowsDEBUGis alwaysfalse. Math.sqrt(rate)moves before a loop becauseratenever changes inside it.- A short-lived
{ x, y }that never leaves its function becomes two plain values. - Adding two numbers that are always small whole numbers uses a Smi or 32-bit add instead of the general number add.
- A loop with
i < array.lengthlets fast code skip the check on each array read.
Put each card under the kind of pass it describes. If you pick the wrong one, the card explains why.
Real Node and V8 evidence without timing claims
Speed numbers change from run to run and from computer to computer, so this lesson never claims a speedup. Instead, the lesson's tests start separate Node programs and check facts that stay the same:
- the Node version starts with
v22.and the V8 version starts with12.4.; - the optimization flags exist (a flag is a setting you give Node when you start it, like
--trace-opt); - a function really gets optimized;
- the trace output mentions TurboFan, V8's optimizing compiler, and inlining;
- turning some passes off does not change the function's result.
Before each run, the tests also clear the test runner's own NODE_TEST_* settings, so each Node program starts clean.
The next code also needs --allow-natives-syntax, so it has no Run button. Line 13 tells V8 to get ready to optimize hotTotal. Line 14 runs it 20 times to warm it up. Line 15 asks V8 to optimize it on the next call. Line 16 prints the result, 45. Line 17 prints a status number. V8 packs several yes-or-no facts about the function into that number, such as whether it is optimized.
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 look for --trace-opt, --trace-turbo-inlining, --turbo-inlining, --turbo-escape, --trace-turbo-escape, and --allow-natives-syntax in the list that node --v8-options prints. They also run the same program with --no-turbo-inlining (inlining off) and --no-turbo-escape (escape analysis off). The output stays 45 every time.
It proves facts about this Node build: Node starts with v22., V8 starts with 12.4., TurboFan can optimize the code, the inlining trace has a line with Inlining in it, and turning passes off keeps the same result. It does not claim the code got faster.
Use this knowledge safely
Don't write code aimed at one pass. These passes explain why simple, steady hot code is easier for engines to speed up. But clear code and real measurements come first.
- Write clear functions first. Don't copy helper bodies into loops by hand.
- In hot code you have measured, pass the same kinds of values, and objects with the same properties, when that fits your data.
- Keep short-lived objects when they make code easier to read. Change them only if a profile shows that making objects is slow.
- Warm up a benchmark with realistic values first, and use its results so the engine can't skip the work.
- Use V8 flags and
%helpers only in local checks and tests, never in app code. - Measure before and after any change you make for speed.
Suppose a profile (a report from a tool like Chrome DevTools that shows where the time goes) points at a slow loop. This lesson gives you ideas to test. Maybe one call reaches different functions. Maybe a short-lived object escapes. Maybe the loop repeats the same work. Maybe the numbers are mixed, so the compiler can't pick a cheap way to store them. Test each idea with a measurement. Don't change code on a hunch.
Common misconceptions
- "The after snippets are what V8 prints." They are pictures of rewrites a compiler might make. They are not real compiler output.
- "Inlining always makes code faster." It makes the fast code bigger, the engine limits how much it inlines, and it can lead to more deoptimizations.
- "If the compiler skips making an object, the object never existed." Your program still acts exactly as if the object was made.
- "Bounds-check elimination makes JavaScript arrays unsafe." It only removes extra checks that can never fail, and only where the compiler has proved that.
- "Smi is a JavaScript type." It is one way the engine stores a number. Your program still sees an ordinary number.
Each row in the table is an idea that is easy to mix up with another. Read what it means, then what it is not.
| Idea | What it means | What it is not |
|---|---|---|
| Your code | The JavaScript you wrote, and the rules it must follow. | The compiler's IR, which can look like a different program inside the engine. |
| Optimization | A rewrite inside the engine that keeps the same results, based on the values seen and what the compiler can prove. | A promise that every run gets faster. |
| Deoptimization | Going back to slower code when a guess turns out wrong. | A bug, or a change in your results. |
| Representation | How the engine stores a number inside, once it has proved that is safe. | A JavaScript type your code can see or rely on. |
Practice exercises
What number does this print?
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 call to fee inside the engine, but the 7 is still added to 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. Fast code could keep the point as two plain values, but the math is the same.
Look back at the folding example. The compiler knows DEBUG is always false. Which branch can dead-code elimination remove?
The if (DEBUG) branch. Constant folding shows that DEBUG is always false, so the branch can never run, and dead-code elimination removes it.
Which expression can loop-invariant code motion move before the loop?
for (const item of items) {
total += item * Math.sqrt(rate);
}Math.sqrt(rate). It gives the same answer in every round, so loop-invariant code motion can work it out once, before the loop.
What does this print?
console.log(3.9 | 0);It prints 3. | 0 turns 3.9 into a 32-bit whole number, which drops the .9.
What should you do before you change code because of an idea about the compiler?
// A product list feels slow when filtering 5,000 items.
// Before you change any helpers by hand, your next step is: ____Measure first. Use a profile to find the slow part, then try one small change and compare the numbers before and after.
Quiz: check your understanding
For each question, first work out what the JavaScript prints. Then name the rewrite that keeps that output.
Question 1 of 8What is the main job of an optimization 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 passes can skip making a short-lived
{ x, y }object that never leaves its function?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 8Why can
Math.sqrt(rate)move before 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 sentence about bounds-check elimination is true?
Choose an answer to see the explanation.
Question 8 of 8What did the Node checks prove, without using any timings?
Choose an answer to see the explanation.
Key takeaways
- An optimizing compiler rewrites its own IR, never your file, and the fast code must act exactly like your code.
- Inlining copies a small helper into the code that calls it, which lets other passes see more.
- Escape analysis finds objects that never leave a function; scalar replacement then keeps their properties as plain values.
- Constant folding, dead-code elimination, loop-invariant code motion, and bounds-check elimination remove or move work only when the compiler can prove it is safe.
- The compiler can store a number as a Smi, a double, or a word32; your code still only sees ordinary numbers.
Remember the one-liner.
An optimization is a proven shortcut inside the engine: your code means the same thing, and hot code does less work.
Up next: On-stack replacement. It shows how a long loop can switch to fast code while it is still running, without waiting for the next call.