Type feedback & feedback vectors
Learn how JavaScript engines collect runtime type feedback in vectors and use warm-up evidence to specialize hot code safely.
- 01Explain how the engine collects notesSay how an engine writes down the values each operation really sees while the code runs, because JavaScript code has no fixed types.
- 02Read slots and their statesConnect a slot number in the bytecode, like
[0], to a slot in a FeedbackVector, and name states likeSignedSmall,Number, andAny. - 03Warm up code honestlyExplain why the first runs are slow, why one new kind of value widens a slot, and how to warm up a speed test so its timings are fair.
The engine learns from the values your code really uses
A JavaScript engine is the program inside a browser or Node that runs your code. In JavaScript, a variable has no fixed type. The same a + b can add two numbers on one call and join two strings on the next.
So the engine can't know from the code alone what a + b will get. Instead, it watches. Each time the code runs, the engine writes a short note: "this + got two small whole numbers." Later, it uses those notes to make faster code for the values it has seen.
Type feedback is the set of notes an engine keeps about the values one operation has really seen. An operation here is one place in the code that does one thing, like one +, one user.name, or one function call. Each operation gets its own notes.
This lesson comes after tiered compilation and before inline caches. Here you learn where the notes are kept and how they change. Later lessons show how the engine uses the notes to make fast code, and what it does when a guess turns out wrong.
Picture a cafe where each drink station keeps a notebook of what people order there. Every morning, the espresso station only gets double shots. So the barista gets that station ready for double shots before anyone asks. Then one customer orders a smoothie at the same station. The quick setup no longer fits, so the barista goes back to the slower, general way.
- In real life: The barista watches real customers
- In JavaScript: The engine watches real calls
- In real life: Each drink station has its own notebook
- In JavaScript: Each operation has its own feedback slot
- In real life: Espresso is always ordered here, so it is made ready early
- In JavaScript: The same kind of value every time lets the engine make a fast path
- In real life: One smoothie order changes the plan
- In JavaScript: A new kind of value widens the notes and can undo the fast path
These lessons help if you want to look back: data types, numbers in depth, type coercion, bytecode and interpreters, and measuring performance.
Collecting types while the code runs
THE INTERPRETER WATCHESAt first, the notes are empty. The engine starts every function in its interpreter: the part that runs bytecode, a simple list of steps made from your code. While it runs each step, it writes down what that step saw.
In V8, the engine in Chrome and Node, the interpreter is called Ignition. V8's faster compilers, Maglev and TurboFan, read the notes later. But first, the notes have to be filled in with real values.
The code below is a small model of this, written in plain JavaScript. It keeps notes for one +. The names of the states, like SignedSmall, are V8's real names:
SignedSmall: small whole numbers, like1and2.Number: any numbers, including decimals like1.5.String: only strings.Any: a mix, so anything can come.
const slot = createSlot("add slot #0"); function createSlot(id) { return { id, state: "None", count: 0 };} function classify(left, right) { if (Number.isInteger(left) && Number.isInteger(right)) return "SignedSmall"; if (typeof left === "number" && typeof right === "number") return "Number"; if (typeof left === "string" && typeof right === "string") return "String"; return "Any";} function merge(previous, observed) { if (previous === "None" || previous === observed) return observed; if ((previous === "SignedSmall" && observed === "Number") || (previous === "Number" && observed === "SignedSmall")) return "Number"; return "Any";} function specialize(state) { return { SignedSmall: "int add fast path", Number: "number add fast path", String: "string concatenate fast path", Any: "generic add path", }[state] ?? "collecting in the interpreter";} function callAdd(slot, left, right) { const observed = classify(left, right); slot.state = merge(slot.state, observed); slot.count += 1; const result = left + right; return { result, summary: slot.state + " -> " + specialize(slot.state), };} function callOptimizedAdd(plan, left, right) { return left + right;} console.log(callAdd(slot, 1, 2).summary);console.log(callAdd(slot, 1.5, 2.25).summary);console.log(callAdd(slot, "1", "2").summary);Line by line:
- Line 1 makes one empty slot. A slot is the place where the notes for one operation are kept.
- Lines 7 to 12,
classify, look at the two values of one call and name their kind. - Lines 14 to 18,
merge, mix the new kind with the old notes. The notes only get wider, never narrower. - Lines 20 to 27,
specialize, name the fast plan that would fit the current state. - Lines 29 to 38,
callAdd, do one addition and update the slot. - Lines 44 to 46 call the same slot three times and print the state after each call.
The first line printed is SignedSmall -> int add fast path. The decimals 1.5 and 2.25 widen the state to Number. Then two strings arrive. A + with strings joins them instead of adding, so this slot becomes Any.
Step through a toy slot as it takes notes from real calls. The state goes from None, to SignedSmall, to Number.
script
callAdd(slot, 1, 2);callAdd(slot, 3, 4);callAdd(slot, 1.5, 2.25);Each slot belongs to one place in the code. A different + in the same function has its own slot. That slot could stay String or SignedSmall forever. The engine makes fast code for each place, not for the + sign everywhere.
Feedback vectors and slots
INSIDE V8In V8, each function keeps all its slots together in one list. That list is called a FeedbackVector. There are three parts to know:
- The
JSFunctionis the function object your code holds. - The
FeedbackCellis a small holder that the function points to. - The
FeedbackVectoris the full list of slots. The cell points to it once it exists.
V8 does not make the full vector right away. It waits until the function has run enough times. Many functions run only once or never, so this saves memory. V8 calls this lazy feedback allocation: "lazy" means "only when it is needed", and "allocation" means setting memory aside.
V8's public notes on how functions are built describe JSFunction, FeedbackCell, and FeedbackVector. The V8 Lite blog post explains why V8 makes feedback vectors only after a function has run a while: it saves memory for functions that never get busy.
You can see the slot number in the bytecode. Node prints the bytecode of a function when you pass it the flag --print-bytecode:
$ node --print-bytecode --print-bytecode-filter=add add.js[generated bytecode for function: add]Parameter count 3 19 S> ... @ 0 : 0b 04 Ldar a1 28 E> ... @ 2 : 3b 03 00 Add a0, [0] 32 S> ... @ 5 : ae ReturnLook at line 5, Add a0, [0]. The [0] is not an array. It is the number of the feedback slot for this add step. Once the function has run enough, slot 0 of its vector holds the notes for this +.
[0] is not an array. It is the number of a slot in the feedback vector of add. After calls with small whole numbers, slot 0 says SignedSmall.The states of one add slot, from narrow to wide:
NoneSignedSmallNumberNumberOrOddballAny
NumberOrOddball means numbers mixed with true, false, null, or undefined. V8 calls those four values "oddballs". Strings and BigInts have their own states on the way to Any: String, StringOrStringWrapper, BigInt64, and BigInt.
Notes for calls, properties, operators, loops, and literals
KINDS OF SLOTSA slot is not only for +. V8 has slots for many operations: calls, reading and writing properties, math and comparisons, for...in loops, instanceof, typeof, copying objects, and literals like {} and []. You don't need to learn the list. It shows that the notes say much more than "the type of a variable".
Read each row of the table as: "for this operation, the slot writes down this, and the engine uses it like this."
| Which operation | What the slot writes down | How the engine uses it |
|---|---|---|
Math and comparisons, like a + b or a < b | The kind of values it got, like SignedSmall, Number, NumberOrOddball, String, BigInt64, BigInt, or Any. | It picks fast math for numbers, and knows whether + adds numbers or joins strings. |
Reading or writing a property, like user.name | The shapes of the objects it saw, and how to reach the property in each shape. | It feeds inline caches. The next lesson, Inline caches, explains them. |
A function call, like fn(value) | Which function ran, and roughly how often. | If the same function always runs, the engine may copy its body into the call. |
for...in loops and instanceof | The object shapes the loop walked through, or the class that was checked. | Repeated loops and checks run faster while the shapes stay the same. |
Literals, like {} and [] | Where the object or array was made, and a ready-made model of it. | The next object from the same line can copy that model instead of starting from nothing. |
function readName(user) { return user.name;} console.log(readName({ name: "Ada", role: "admin" }));console.log(readName({ name: "Grace", role: "engineer" }));Line 2 reads user.name. Lines 5 and 6 call it with two objects that have the same properties in the same order, so it prints Ada, then Grace. The slot for line 2 writes down the object's shape: V8's record of which properties an object has and where each one is stored. V8 calls a shape a map.
These notes feed the inline caches you'll meet in the next lesson. That lesson also explains the names monomorphic (one shape), polymorphic (a few shapes), and megamorphic (many shapes).
function apply(fn, value) { return fn(value);} function inc(value) { return value + 1;} let calls = 0;for (const value of [1, 2, 3]) { calls += 1; apply(inc, value);} console.log("target: " + inc.name);console.log("calls: " + calls);The call fn(value) on line 2 runs inc every time. Lines 15 and 16 print target: inc and calls: 3, to show in plain JavaScript what the call slot would note: the same function, called again and again.
When a call always runs the same function, the faster compiler may inline it: copy the function's body into the place of the call, so no call is needed. Slots for literals remember where an object or array was made, so the next {} or [] from that line does not start from nothing.
Why the first runs are slow, and what warm-up means
COLD AND HOTA function that has just started is cold. A function that has run many times is hot. A cold function pays for a few things first:
- Turning the code into bytecode.
- Running slowly in the interpreter.
- Making the feedback vector, once it has run enough.
- Filling the slots with notes.
- Waiting until the engine decides it is hot enough for a faster compiler.
So the first runs are the least typical runs. If you time only them, you mostly time this setup work. Running the code many times before you start timing is called warm-up.
function add(a, b) { return a + b;} for (let i = 0; i < 10000; i += 1) { add(i, i + 1);} console.log("warmed with 10000 number calls");console.log(add(1, 2));Lines 5 and 6 call add 10,000 times with numbers. Line 9 prints warmed with 10000 number calls, and line 10 prints 3. The loop does not make add faster by itself. It gives the engine time to collect notes about numbers. If add stays hot, the engine can then make fast code for numbers.
Warm-up only helps if the values match the ones you time. The fast code has a guard: a quick check in front that asks "are these the values I was made for?" If the answer is no, the engine leaves the fast code. That is called a deopt, short for deoptimization.
Step through a toy engine: the notes give a fast plan with a guard. Strings fail the guard, and the answer is still correct: "34".
script
callAdd(slot, 1, 2);const plan = specialize(slot.state);callOptimizedAdd(plan, 3, 4);callOptimizedAdd(plan, "3", "4");Suppose a test warms up with numbers and then times strings. It measured the switch from one kind of value to another. Sometimes you want exactly that. Most of the time it is an accident. Use the habits from Measuring performance:
- Warm up first.
- Use each result, so the engine can't skip the work.
- Take many timings, not one.
- Report the middle value (the median) or how much the timings vary.
Real V8 output from Node 22
V8 12.4So far, the code was a model. The lesson's tests also run real Node 22, which uses V8 12.4, and check its output. Small details like memory addresses, counts, and exact timing change between versions. So the tests only check short, stable pieces of text.
The programs below use %DebugPrint and %OptimizeFunctionOnNextCall. These are V8 test commands, not JavaScript. They only work when you start Node with the flag --allow-natives-syntax, so these blocks don't run here.
function add(a, b) { return a + b;} for (let i = 0; i < 30; i += 1) { add(i, i + 1);} %DebugPrint(add);Captured on Node 22 (V8 12.4)- feedback vector: ... [FeedbackVector]- invocation count: 22- slot #0 BinaryOp BinaryOp:SignedSmall { ... }Lines 5 and 6 of the program call add 30 times with whole numbers. Line 9 prints what V8 knows about add. In the output, line 2 shows that a feedback vector exists. Line 4 shows slot 0, the +, with the state SignedSmall.
Captured on Node 22 (V8 12.4)NEVER: feedback metadata is not available in SFIONCE: No feedback vector, but we have a closure feedback cell arrayMANY: slot #0 BinaryOp BinaryOp:SignedSmallThis output comes from three functions. never was never called, once was called once, and many was called 30 times:
- Line 2:
neverhas no feedback information at all yet. - Line 3:
oncehas a feedback cell, but no vector yet. - Line 4:
manyhas a vector, and slot 0 saysSignedSmall.
function add(a, b) { return a + b;} for (let i = 0; i < 100000; i += 1) { add(i, i + 1);} %OptimizeFunctionOnNextCall(add);add(1, 2);add("x", "y");$ node --allow-natives-syntax --no-concurrent-recompilation --trace-opt --trace-deopt probe.js # Node 22 / V8 12.4 capture[marking ... reason: hot and stable][completed compiling ...][bailout (kind: deopt-eager, reason: not a Smi): begin. deoptimizing ...]The program warms add with numbers on lines 5 and 6. Line 9 asks V8 to make fast code on the next call. Line 10 uses it with numbers, and line 11 sends strings. In the trace, line 2 says V8 picked add because it was hot and stable. Line 4 shows the deopt. The reason is not a Smi: a Smi, short for "small integer", is V8's fast form for small whole numbers, and a string is not one.
Here is what this V8 version prints for one add slot, depending on the values it saw:
- Only small whole numbers:
BinaryOp:SignedSmall. - Whole numbers, then one decimal:
BinaryOp:Number. - Booleans or
null:NumberOrOddball. - Only strings:
String. - Small BigInts:
BigInt64. Larger BigInts:BigInt. - A mix of different meanings:
Any.
Try it: keep one add slot fast, or make it general
PLAYGROUNDEach button sends one call to the same toy slot. Start with whole numbers, then try strings or BigInts. Watch three things:
- The slot's current state.
- The fast plan that state would allow.
- Whether the guard would let the call you picked use that plan.
const slot = createSlot("add slot #0");callAdd(slot, 1, 2); // small whole numbers: SignedSmallcallAdd(slot, 1.5, 2.25); // decimals: wider, NumbercallAdd(slot, "1", "2"); // strings too: AnyNonecollecting in the interpreterwould passNo calls yet. Start with whole numbers, then try strings.
Press a button to send one call to this add slot. The slot starts empty, then gets wider as wider kinds of values come.
add(1, 2)thenadd(3, 4)at the same+.add(1, 2)thenadd(1.5, 2.25).add(1, 2)thenadd(true, false).add(1, 2)thenadd("1", "2").- A new
+that only ever runsadd("a", "b"). add({ valueOf() { return 1; } }, 2)after number calls.
Sort each set of calls by what happens to one slot in the lesson's model.
What this means for your everyday code
CLEAR CODE FIRSTType feedback is not a reason to write strange code. Most code should be easy to read first. The useful rule is small. It applies only to hot paths: the code that runs most often, which you found by measuring. On those paths, keep the kinds of values and the shapes of objects the same from call to call.
- When you make many similar objects, give them the same properties in the same order.
- If a hot
+is meant for numbers, turn text into numbers before the loop, not inside it. - Don't trust a small speed test until its warm-up values match the real values.
- Keep code easy to read, unless measuring shows a place is hot and its values keep changing kind.
Moving between the engine's speed levels is in Tiered compilation. The states of a property or call cache are in Inline caches. Guards and deopts get their own lessons later. This lesson is about the notes that all of them read.
Common misconceptions
"Feedback is the same as TypeScript types."
TypeScript types are checked before the code runs, and then removed. Type feedback is collected by the engine while the JavaScript runs.
"One function has one type."
Notes belong to each operation. Two + signs in one function can have different states.
"Warm-up always makes speed tests fair."
Warm-up only helps when it uses typical values and the timed results are still used.
"A slot in the Any state is a bug."
For code that is meant to take many kinds of values, Any is the right state. Only change code you measured as hot.
"V8 test commands belong in app code."
%DebugPrint and similar commands are for experiments and tests, not for real apps.
Read each row of the table as: "this word means this, and here is the mix-up to avoid."
| Word | What it means | The mix-up to avoid |
|---|---|---|
| Static type | A type that is fixed and checked before the program runs. | JavaScript engines don't get these from your code. They collect notes while it runs. |
| Type feedback | The notes about the values one operation has really seen. | Early on, the notes can be too narrow, because the code hasn't warmed up yet. |
| Inline cache | A quick memory at one property read or call: "last time it was this shape, so look here". | The next lesson covers inline caches. This lesson is about the notes they read. |
| Speculative optimization | Fast code that guesses future values will look like past values. | Later lessons cover it, with deopts. Here you only see the notes it reads. |
Practice exercises
5 EXERCISESA slot is in the SignedSmall state. Then the same + gets two decimals. What is the new state?
SignedSmall -> ?
// after one call with 1.5 and 2.25The state becomes Number, because a decimal number came to the same add slot.
A + slot has only seen numbers. Then it gets two strings. What state does the toy slot move to?
add(1, 2);
add("1", "2");It becomes Any. Fast code made from this slot now has to handle any values.
What does [0] mean in this line of bytecode?
Add a0, [0][0] is the number of the feedback slot for this add step.
Why should a small speed test run the code many times with typical values before it starts timing?
Warm-up lets the interpreter collect type feedback. It also gives the engine time to switch to faster code before you start timing.
On a hot path you have measured, what should stay the same from call to call?
// Fill in the rule:
// On measured hot paths, keep ____ reasonably consistent, but readability first.On measured hot paths, keep the kinds of values (types) and the shapes of objects the same. Easy-to-read code still comes first.
Quiz: check your understanding
8 QUESTIONSFor each question: find the operation, list the values it saw, and decide whether its fast plan can stay narrow.
Question 1 of 8What is type feedback?
Choose an answer to see the explanation.
Question 2 of 8What does this toy feedback snippet print last?
Read the code, then predictlet state = "None"; function merge(previous, observed) { if (previous === "None" || previous === observed) return observed; if (previous === "SignedSmall" && observed === "Number") return "Number"; return "Any"; } state = merge(state, "SignedSmall"); state = merge(state, "Number"); console.log(state);Choose an answer to see the explanation.
Question 3 of 8In V8 bytecode such as
Add a0, [0], what is[0]?Choose an answer to see the explanation.
Question 4 of 8Which calls move the same
+slot toAnyin the lesson's model?Choose an answer to see the explanation.
Question 5 of 8What does this warm-up example print first?
Read the code, then predictfunction add(a, b) { return a + b; } for (let i = 0; i < 3; i += 1) add(i, i + 1); console.log("warmed with number calls"); console.log(add(1, 2));Choose an answer to see the explanation.
Question 6 of 8Why might the engine copy a function's body into a busy call (inline it)?
Choose an answer to see the explanation.
Question 7 of 8Which speed-test habit follows from type feedback?
Choose an answer to see the explanation.
Question 8 of 8What should everyday code do with this knowledge?
Choose an answer to see the explanation.
Key takeaways
- The code alone doesn't say which kinds of values will come, so the engine watches and writes notes.
- In V8, a step in the bytecode names its slot, like
[0]. The slots live in aFeedbackVector, made only once a function has run enough. - An add slot can say
SignedSmall,Number,String,BigInt64,Any, and more. It only gets wider. - Property and call slots feed inline caches and inlining. Literal slots remember where objects and arrays are made.
- First runs are slow: the engine interprets, makes the vector, fills the slots, and waits before it makes faster code.
Remember the one-liner.
A feedback vector holds the engine's notes on what each step of a busy function really saw.
Up next: Inline caches, where property and call slots become monomorphic, polymorphic, or megamorphic.