completefrontend
Open lab My progress
THE JAVASCRIPT FIELD GUIDE

undefined inside the engine

Trace how V8 stores undefined as a read-only oddball, emits LdaUndefined bytecode, separates holes, and handles missing values.

By the end, you can
  • 01
    Explain the one undefined valueSay why every undefined in V8 is a pointer to the same read-only oddball, made once when V8 starts.
  • 02
    Read the bytecodeRecognize LdaUndefined, LdaTheHole, the TDZ check, how missing arguments get filled, and a local variable named undefined.
  • 03
    Tell gaps from missing valuesTell apart array gaps, missing properties, missing arguments, names that were never declared, and the global undefined property.

One undefined, many ways to get it

You know undefined from everyday JavaScript. This lesson shows how a JavaScript engine stores it. An engine is the program that runs your code. We use V8, the engine inside Chrome and Node.js. Every fact here was checked in Node 22, which uses V8 version 12.4.

Inside V8, there is exactly one undefined. V8 makes it once, when it starts. V8 calls a few special built-in values oddballs, and undefined is one of them. A variable that holds undefined only stores a pointer to it. A pointer is a memory address: it says where a value is. So getting undefined never makes a new value.

The main idea

In V8, undefined is one oddball object that lives in read-only space: a part of memory that nothing is allowed to change. The object keeps a few ready-made answers:

  • As a number, it is NaN.
  • As a string, it is "undefined".
  • typeof gives "undefined".

V8's small instructions usually fetch it with one instruction, LdaUndefined, or find it already waiting in a slot. You'll meet both later in this lesson.

Three ways to get undefined give one valuePop out in the code editor (opens in a new tab)JavaScript
const fromIdentifier = undefined;const fromVoid = void 0;function noReturn() {} console.log(fromIdentifier === fromVoid);console.log(noReturn());console.log(Object.is(fromIdentifier, undefined));

Line 1 stores undefined by name. Line 2 uses the void operator: void runs the expression after it and always gives undefined. Line 3 makes a function with no return.

  • Line 5 prints true: the first two ways give the same value.
  • Line 6 prints undefined: a function that reaches its end without return gives undefined.
  • Line 7 prints true. Object.is checks whether two values are exactly the same value.
Three cards on the left: undefined, void 0, and noReturn(). An arrow from each leads to the same single undefined card on the right, which V8 made once.you writeundefinedvoid 0noReturn()you getundefinedmade once, shared
Three different pieces of code, one result. Each gives a pointer to the same undefined, which V8 made once when it started.

If you want the everyday rules for undefined first, read undefined. This lesson also uses the idea of a tagged pointer: a stored pointer with a small mark that says "this is a pointer, not a small number". The Tagged values lesson explains it.

Real-life analogyOne shared blank note

A team shares one blank note on a board. When someone has nothing to write, they point to that note. They don't make a new blank note each time.

In real life: There is one blank note on the board
In JavaScript: There is one undefined in V8
In real life: Anyone can point to that note
In JavaScript: Any variable, property, or slot can hold a pointer to it
In real life: Pointing to it makes no new note
In JavaScript: Getting undefined never makes a new value

One more detail about the test machine. Node 22 here runs V8 12.4.254.21 without pointer compression, a trick that stores pointers in fewer bytes. Chrome uses newer V8 versions, usually with pointer compression and the sandbox, a fence that limits which memory V8 can touch. So memory addresses and object sizes differ. What stays the same: the names of V8's types, the names of its instructions, and the rules in its source code.

Where V8 keeps undefined

V8 is written in the C++ language, and its source code is public. So we can read how it builds undefined. The file oddball.h describes oddballs. Its comment says that four values are oddballs: null, undefined, true, and false.

Each oddball stores a few ready-made answers, so V8 never has to work them out again:

  • to_number_raw and to_number: the value as a number. For undefined, that is NaN.
  • to_string: the value as a string, "undefined".
  • type_of: what typeof gives, also "undefined".
  • kind: a small number that says which oddball this is. For undefined, it is kUndefined = 4.

V8 also gives every object a map: V8's record of a value's type and shape. undefined gets its own map, undefined_map. That map is marked undetectable. This mark means "treat this value like undefined in loose checks such as x == null". The map of null has the same mark.

Ask Node how it stores undefined (Node only)JavaScript
%DebugPrint(undefined);

%DebugPrint is a special V8 helper. It prints what V8 stores for a value. It only works when you start Node with node --allow-natives-syntax, so this block can't run on this page. The lesson's test runs it and checks the parts that are the same on every run:

  • [Oddball] in ReadOnlySpace: #undefined: it is the oddball, kept in read-only space.
  • type: ODDBALL_TYPE: the map says its type is oddball.
  • stable_map, undetectable, and non-extensible: the map never changes, has the undetectable mark, and can't get new properties.
  • prototype: <null>: it has no prototype to look things up in.

You'll see the same kind of map again in null inside the engine. That is why both null and undefined are "undetectable" inside V8.

Lines from V8's source code that this lesson relies onText
// V8 12.4: src/objects/oddball.h// The Oddball describes objects null, undefined, true, and false.// [to_number_raw]: Cached raw to_number computed at startup.// [to_string]: Cached to_string computed at startup.// [to_number]: Cached to_number computed at startup.// [typeof]: Cached type_of computed at startup.static constexpr uint8_t kUndefined = 4; // V8 12.4: src/heap/setup-heap-internal.ccInitializePartialMap(undefined_map, meta_map, ODDBALL_TYPE, sizeof(Undefined));roots.undefined_map()->set_is_undetectable(true);Oddball::Initialize(isolate(), factory->undefined_value(), "undefined",                    factory->nan_value(), "undefined", Oddball::kUndefined);

These lines come from two V8 files. In plain words, the highlighted lines say:

  1. Line 2: the oddball type covers null, undefined, true, and false.
  2. Line 7: the kind number for undefined is 4.
  3. Line 10: make a map called undefined_map, with the type ODDBALL_TYPE.
  4. Line 11: mark that map as undetectable.
  5. Lines 12 and 13: fill in the ready-made answers: the string "undefined", the number NaN, the typeof text "undefined", and the kind.

The second file, setup-heap-internal.cc, builds these maps and values when V8 starts. The debug output above shows that this Node build keeps undefined in read-only space. V8's read-only serializer saves that read-only memory into the startup snapshot: a saved copy of memory that V8 loads every time it starts.

Other engines store undefined differently. The next block shows two of them. JavaScriptCore is Safari's engine, and SpiderMonkey is Firefox's engine.

How Safari's and Firefox's engines store undefinedText
// JavaScriptCore runtime/JSCJSValue.h// False: 0x06// True:  0x07// Undefined: 0x0a// Null: 0x02static constexpr int32_t OtherTag       = 0x2;static constexpr int32_t UndefinedTag   = 0x8;static constexpr int32_t ValueUndefined = OtherTag | UndefinedTag; // SpiderMonkey js/public/Value.henum JSValueType : uint8_t {  JSVAL_TYPE_UNDEFINED = 0x03,  JSVAL_TYPE_NULL = 0x04,};JSVAL_TAG_UNDEFINED = JSVAL_TAG_MAX_DOUBLE | JSVAL_TYPE_UNDEFINED;

Numbers that start with 0x are written in hexadecimal (base 16). For example, 0x0a is 10.

  • In JavaScriptCore's JSCJSValue.h, the comment on line 4 lists Undefined: 0x0a. Line 8 builds it as ValueUndefined = OtherTag | UndefinedTag. This is not a pointer. The stored value itself is the number 0x0a.
  • In SpiderMonkey's Value.h, line 12 gives undefined the type number JSVAL_TYPE_UNDEFINED = 0x03. Line 15 turns that into a mark called JSVAL_TAG_UNDEFINED.

So the JavaScript value is the same in every engine. Only the way it is stored in memory is different. The table below has one row per value or engine. Read each row as: "this is how it is stored, and this is the proof."

How undefined and the hole are stored, in V8 and two other engines
Which value or engineHow it is storedWhat the source or test shows
V8's undefinedOne oddball object in read-only memory. Every undefined is a pointer to it.Its type is ODDBALL_TYPE and its kind is kUndefined = 4. It keeps ready-made answers: NaN, "undefined", and the typeof text.
V8's hole, the_hole_valueA separate Hole object in read-only memory.V8 uses it to mark array gaps and the TDZ. Your code never gets it.
JavaScriptCore (Safari)Not a pointer. The stored value is a fixed number.ValueUndefined = OtherTag | UndefinedTag, which the comment lists as 0x0a.
SpiderMonkey (Firefox)A stored value with a type mark.JSVAL_TYPE_UNDEFINED = 0x03. On 64-bit machines it is combined into the mark JSVAL_TAG_UNDEFINED.

Where bytecode gets undefined

Before V8 runs a function, it turns it into bytecode: a list of small, simple instructions. V8's interpreter, called Ignition, runs these instructions one at a time. Each instruction has a short name. For example, LdaUndefined means "load undefined".

The instructions in this lesson use two kinds of slots:

  • The accumulator: one special slot that holds the value the interpreter is working on right now.
  • Registers: numbered slots for local variables, written r0, r1, and so on.

Names that start with Lda load a value into the accumulator. Return gives back whatever is in the accumulator. The lesson's test asks Node to print the bytecode of four small functions. All four print LdaUndefined, then Return:

  • a bare return;
  • a function that reaches its end without return
  • return void 0
  • return undefined, when undefined means the usual global one
Bytecode for six small functionsText
return;                 LdaUndefined, Returnfall off the end       LdaUndefined, Returnreturn void 0          LdaUndefined, Returnreturn undefined       LdaUndefined, Returnlet x; return x        LdaUndefined, Star0, Returnx === undefined        Ldar, TestUndefined, Return

Each row shows a piece of code on the left and its instructions on the right. "Fall off the end" means the function reaches its last line without a return.

  1. Lines 1 to 4: load undefined into the accumulator, then return it.
  2. Line 5: load undefined, then Star0 stores it in register r0, which holds x. Then return it.
  3. Line 6: Ldar loads x into the accumulator. TestUndefined checks whether it is undefined and leaves true or false. Then return that answer.

There is one exception. You are allowed to make a local variable called undefined. Then the name means your variable, not the real undefined. We say the local variable shadows the global one. So function f() { let undefined = 5; return undefined; } returns 5. Its bytecode uses LdaSmi (load a small whole number), Star0, and later Ldar r0. It never loads the real undefined.

A let or const variable can't be read before its line runs. Reading it too early throws a ReferenceError. The time before the line runs is called the temporal dead zone, or TDZ. V8 marks a variable in its TDZ with a different value: the hole. For const r = y; let y = 1;, Node prints these instructions:

  • LdaTheHole: load the hole.
  • Star1: store it in register r1, the slot for y.
  • ThrowReferenceErrorIfHole: when the code reads y, throw a ReferenceError if the slot still holds the hole.

The hole lets V8 tell "not set up yet" apart from the real undefined. You'll learn more about the hole below.

Registers and missing arguments

When a function is called, V8 gives that call a stack frame: a block of memory for that one call. The registers live in the stack frame. Before the first instruction runs, V8 runs a short piece of setup code called the interpreter entry trampoline. One of its jobs is to put undefined into every register.

V8's source has this setup code for each kind of processor. The block shows two: x64 (most desktop and laptop computers) and arm64 (phones and newer Macs). Both say the same thing.

V8 fills every register with undefined (from V8's source)Text
// V8 12.4: src/builtins/x64/builtins-x64.cc// If ok, push undefined as the initial value for all register file entries.__ LoadRoot(kInterpreterAccumulatorRegister, RootIndex::kUndefinedValue);__ Push(kInterpreterAccumulatorRegister);// The accumulator is already loaded with undefined. // V8 12.4: src/builtins/arm64/builtins-arm64.cc// If ok, push undefined as the initial value for all register file entries.__ LoadRoot(kInterpreterAccumulatorRegister, RootIndex::kUndefinedValue);__ PushMultipleTimes(kInterpreterAccumulatorRegister, x11);// The accumulator is already loaded with undefined.

In plain words, the highlighted lines say:

  1. Lines 2 and 8: the comment says "push undefined as the starting value for every register".
  2. Lines 3 and 9: LoadRoot fetches undefined. A root is a value the engine always keeps ready, such as undefined.
  3. Lines 4 and 10: push that value into the register slots.
  4. Lines 5 and 11: the accumulator now holds undefined too.

So every register starts as undefined. A register never starts with leftover data from earlier code.

Now the missing arguments. A function's parameters are the names in its brackets, like name, role, city. What happens if you pass fewer values than that? The missing parameters become undefined. V8's arguments adaptor frame article explains how. Before V8 version 8.9, V8 built an extra stack frame to handle this. Since 8.9, that extra frame is gone. The call setup fills the missing parameters with undefined itself. In the x64 source, that call setup code is named InvokePrologue.

V8 fills missing parameters with undefined (from V8's source)Text
// V8 12.4: src/codegen/x64/macro-assembler-x64.cc// Underapplication. Move the arguments already in the stack, including the// receiver and the return address.// Fill remaining expected arguments with undefined values.LoadRoot(kScratchRegister, RootIndex::kUndefinedValue);movq(Operand(r8, expected_parameter_count, times_system_pointer_size, 0),     kScratchRegister);

In plain words, the highlighted lines say:

  1. Line 4: the comment says "fill the remaining expected arguments with undefined".
  2. Line 5: fetch undefined into a spare register.
  3. Lines 6 and 7: write it into the slot of the next missing parameter. V8 repeats this for each missing one.
The call makeBadge with Ada. Three boxes: name holds Ada, which you passed. role and city each hold undefined, which V8 filled in.makeBadge("Ada")"Ada"nameyou passed itundefinedrolefilled in by V8undefinedcityfilled in by V8
The call passes one value, but makeBadge has three parameters. Before the function starts, V8 puts undefined into the two that got nothing.
A call with one value: the rest become undefined
Step 0 of 6Ready
Your turn: follow the blue line

A call that passes fewer values than the function has parameters. The values are real. The frame drawing is a simple model, not real engine memory.

Running in
  1. script
Next: line 6
function makeBadge(name, role, city) {  const stackSlots = [name, role, city];  return stackSlots.map(String).join(" | ");} 
CallStoreChangeResultBlue line: runs next. 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.

Press Step to walk through the replay. It calls makeBadge("Ada") with one value. Then name is "Ada", and role and city are undefined. These values come from really running the function. The frame drawing beside the code is a simple model, not real engine memory.

The hole is not undefined

You met the hole in the TDZ example. The hole is a marker: a special value V8 uses only inside itself, to mean "nothing is here yet" or "nothing is here at all". Your code never gets the hole itself. When your code reads a slot that holds the hole, V8 follows a rule first. The rule either gives a normal result or throws an error.

In V8 12.4, the hole is not an oddball. It has its own type, Hole. V8 also makes several different holes, one for each job. Here are the ones in the source below:

  • the_hole_value: the main hole, used for gaps in arrays and for the TDZ.
  • property_cell_hole_value, hash_table_hole_value, promise_hole_value, uninitialized_value, arguments_marker, optimized_out, and stale_register: more holes, each used by one part of V8.
V8's hole has its own type, not Oddball (from V8's source)Text
// V8 12.4: src/objects/hole.hclass Hole : public HeapObject {  // Holes store NaN at the HeapNumber value offset for optimized code.  DECL_FIELD_OFFSET_TQ(RawNumericValue, HeapObject::kHeaderSize, "float64")}; // V8 12.4: src/heap/setup-heap-internal.ccHole::Initialize(isolate(), factory->the_hole_value(), factory->hole_nan_value());set_property_cell_hole_value(*factory->NewHole());set_hash_table_hole_value(*factory->NewHole());set_promise_hole_value(*factory->NewHole());set_uninitialized_value(*factory->NewHole());set_arguments_marker(*factory->NewHole());set_optimized_out(*factory->NewHole());set_stale_register(*factory->NewHole());

In plain words, the highlighted lines say:

  1. Line 2: Hole is its own kind of object. It is not an Oddball.
  2. Line 8: set up the main hole, the_hole_value.
  3. Lines 9 to 15: make one more separate hole for each of the other jobs.
Hole or undefined?
  • let x; x
  • void 0
  • the second parameter, when the call passed only one value
  • ({}).missing, after nothing in the prototype chain has it
  • the middle slot in [1, , 3]
  • an array item after delete array[1]
  • a let variable before its line runs
  • reading doesNotExist
  • [1, , 3][1] after Array.prototype[1] = "surprise"
Try it yourself
0 of 9 correct

Sort each case. Does your code get the real undefined? Does V8 keep a hole there? Or is there no undefined value at all?

Choose a category for every card. You can change an answer at any time; Reset clears them all.
Real-life analogyAn empty chair and a reserved card

At a dinner, an empty chair is ready to use. A chair with a "reserved" card is different. Before anyone sits there, the staff check the card and decide what to do.

In real life: An empty chair anyone can sit on
In JavaScript: The real undefined value
In real life: A "reserved" card on a chair
In JavaScript: The hole, V8's own marker
In real life: Staff check the card before anyone sits
In JavaScript: V8 follows a rule before your code gets a result

Arrays with gaps

You can write an array with a gap: [1, , 3]. The two commas with nothing between them leave index 1 empty. An array with gaps is called a sparse array. V8 keeps the hole in the empty slot.

V8 also labels every array with an elements kind: what sort of items it holds, and whether it may have gaps. Two labels matter here:

  • HOLEY_SMI_ELEMENTS: small whole numbers, and there may be gaps. (A Smi is a small whole number.)
  • PACKED_ELEMENTS: any values, and no gaps. Packed means every index has an item.

The Elements kinds lesson covers all the labels.

Ask Node how it stores two arrays (Node only)JavaScript
%DebugPrint([1, , 3]);%DebugPrint([1, undefined, 3]);

In Node 22, line 1 prints HOLEY_SMI_ELEMENTS, and the empty slot shows as <the_hole_value>. Line 2 prints PACKED_ELEMENTS. In [1, undefined, 3], index 1 is a real item that holds undefined, so there is no gap.

A gap and an undefined item behave differentlyPop out in the code editor (opens in a new tab)JavaScript
const sparse = [1, , 3];const explicit = [1, undefined, 3]; console.log(1 in sparse, 1 in explicit);console.log(sparse[1], explicit[1]);console.log(Object.keys(sparse).join(","));console.log(Object.keys(explicit).join(","));sparse.forEach((value, index) => console.log("sparse", index, String(value)));explicit.forEach((value, index) => console.log("explicit", index, String(value)));

Line 1 makes an array with a gap. Line 2 makes an array that holds undefined at index 1. Then:

  • Line 4 prints false true. The in operator asks "does this array have an item at this index?" sparse has none at index 1; explicit has one.
  • Line 5 prints undefined undefined. A plain read can't tell the two apart.
  • Lines 6 and 7 print 0,2 and 0,1,2. Object.keys lists the indexes that have items, so it skips the gap.
  • Lines 8 and 9: forEach also skips the gap. It prints two lines for sparse and three for explicit.
Left: the array 1, gap, 3. Index 1 is empty, so 1 in sparse is false. Right: the array 1, undefined, 3. Index 1 holds undefined, so 1 in explicit is true.[1, , 3]0121empty31 in sparse: false[1, undefined, 3]0121undefined31 in explicit: true
Both arrays read undefined at index 1. But sparse has nothing there, a gap, while explicit really holds undefined.

Why does reading a gap give undefined? Because a gap means "no item here", and JavaScript then looks somewhere else. Every array has a prototype: a shared object it falls back on, Array.prototype. When an array has no item at an index, JavaScript looks for that index in Array.prototype, then in Object.prototype. This line of fallback objects is called the prototype chain. Only if nothing is found there does the read give undefined.

A gap looks in Array.prototypePop out in the code editor (opens in a new tab)JavaScript
const sparse = [1, , 3];const hadOwn = Object.prototype.hasOwnProperty.call(Array.prototype, 1);const oldValue = Array.prototype[1]; try {  Array.prototype[1] = "surprise";  console.log(sparse[1]);} finally {  if (hadOwn) {    Array.prototype[1] = oldValue;  } else {    delete Array.prototype[1];  }}

Line 1 makes the array with a gap. Lines 2 and 3 remember whether Array.prototype already had an item 1, and what it was. Normally it has none. Line 6 puts "surprise" at Array.prototype[1]. Line 7 reads sparse[1] and prints surprise: the gap found the item in the prototype.

A finally block always runs after try. Here, lines 9 to 13 put Array.prototype back the way it was, so later code on this page isn't affected.

Reading sparse[1]. The sparse cart holds items 0 and 2 but no item 1. An arrow labelled no item 1 leads to the Array.prototype cart, which holds item 1, surprise.sparse[1]0: 12: 3sparseno item 1Array.prototype1: "surprise"prototypeitem 1 is found here
sparse has no item 1, so JavaScript looks in Array.prototype next. The example put "surprise" there, so sparse[1] gives "surprise".

Looking through the prototype chain for every gap would be slow. So V8 keeps a protector: a yes-or-no flag that says "nobody has put numbered items on Array.prototype or Object.prototype". While the flag is true, fast code can skip the prototype lookup. The flag is called the NoElements protector. Once someone adds a numbered item there, V8 sets the flag to false and never sets it back.

Ask Node about the NoElements protector (Node only)JavaScript
console.log(%NoElementsProtector());const hadOwn = Object.prototype.hasOwnProperty.call(Array.prototype, 1);const oldValue = Array.prototype[1];try {  Array.prototype[1] = "prototype element";  console.log(%NoElementsProtector());} finally {  if (hadOwn) Array.prototype[1] = oldValue;  else delete Array.prototype[1];}

%NoElementsProtector() is another special helper that needs node --allow-natives-syntax. Line 1 prints true. Line 5 adds an item at Array.prototype[1]. Line 6 prints false. Lines 8 and 9 put the prototype back. The name comes from V8's protectors.h, where NoElements becomes NoElementsProtector.

Reading a gap looks in Array.prototype
Step 0 of 8Ready
Your turn: follow the blue line

Reading a gap in an array. Your code never sees the hole itself. It sees the result of looking up index 1.

Running in
  1. script
Next: line 1
const explicit = [1, undefined, 3];console.log(1 in sparse);console.log(1 in explicit);console.log(sparse[1]);const hadOwn = Object.prototype.hasOwnProperty.call(Array.prototype, 1);const oldValue = Array.prototype[1];try {  Array.prototype[1] = "surprise";  console.log(sparse[1]);} finally {  if (hadOwn) Array.prototype[1] = oldValue;  else delete Array.prototype[1];}
CallStoreChangeResultBlue line: runs next. 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.

Press Step to walk through the replay. It reads sparse[1] twice: once before the prototype gets an item 1, and once after. The first read gives undefined. The second gives "surprise".

Missing properties

Reading a property such as receiver.missing works in steps:

  1. V8 looks in the object itself. A property stored there is called an own property.
  2. If it isn't there, V8 looks in the object's prototype, then in that prototype's prototype, and so on.
  3. The chain ends at null. If no object had the property, the read gives undefined.

A missing variable name is different. Reading a name that was never declared throws a ReferenceError.

Own, inherited, and missing propertiesPop out in the code editor (opens in a new tab)JavaScript
const parent = { inherited: "from prototype" };const receiver = Object.create(parent);receiver.present = 1; console.log(receiver.present);console.log(receiver.inherited);console.log(receiver.missing);console.log("missing" in receiver);

Line 1 makes a parent object with one property. Line 2 uses Object.create(parent) to make a new object whose prototype is parent. Line 3 gives the new object its own property, present.

  • Line 5 prints 1, found in the object itself.
  • Line 6 prints from prototype, found in parent. A property found this way is inherited.
  • Line 7 prints undefined. No object in the chain has missing.
  • Line 8 prints false. The in operator checks the whole chain and finds nothing.

V8 makes repeated property reads fast with an inline cache: a small memory at each property read in your code. It remembers what happened last time, so the next read can skip the search. What it saves is called a handler: a short recipe for the read. One handler is named LoadNonExistent. It means "this property doesn't exist, so give undefined".

How V8 remembers a missing property (from V8's source)Text
// V8 12.4: src/ic/ic.ccHandle<Smi> smi_handler = LoadHandler::LoadNonExistent(isolate());handler = MaybeObjectHandle(LoadHandler::LoadFullChain(    isolate(), lookup_start_object_map(),    MaybeObjectHandle(isolate()->factory()->null_value()), smi_handler)); // V8 12.4: src/ic/accessor-assembler.cc// This is a handler for a load of a non-existent value.exit_point->Return(UndefinedConstant()); // V8 12.4: src/ic/handler-configuration.ccHandle<Object> validity_cell = Map::GetOrCreatePrototypeChainValidityCell(    lookup_start_object_map, isolate);handler->set_validity_cell(*validity_cell);

In plain words, the highlighted lines say:

  1. Lines 2 and 3: make a LoadNonExistent handler that covers the whole prototype chain.
  2. Lines 8 and 9: this handler is for a missing property, and it gives back undefined.
  3. Lines 12 and 14: attach a validity cell, a flag that stays valid only while the prototype chain doesn't change.
What the source shows, and what it doesn't

The source names LoadHandler::LoadNonExistent, LoadFullChain, and a validity cell for the prototype chain. That supports one small claim: V8 can remember "this property is missing" and give undefined quickly. It does so only while the object's map is the same and the prototype chain hasn't changed. For the full story, see Inline caches and prototype internals.

The global undefined can't be changed

The name undefined is also a property of the global object, globalThis. Every property has a few settings. You can read them with Object.getOwnPropertyDescriptor. The result is called a descriptor. Three of its settings matter here:

  • writable: can you give the property a new value?
  • enumerable: does it show up when you list an object's properties?
  • configurable: can you delete it or change its settings?

For globalThis.undefined, all three are false. The language rules, ECMA-262, say so, and the lesson's test checks it.

Try to change the global undefinedPop out in the code editor (opens in a new tab)JavaScript
const descriptor = Object.getOwnPropertyDescriptor(globalThis, "undefined");console.log(descriptor.writable, descriptor.enumerable, descriptor.configurable); (function sloppyAssignment() {  undefined = "changed";  console.log(undefined);})(); (function strictAssignment() {  "use strict";  try {    undefined = "changed";  } catch (error) {    console.log(error.name);  }})();
  • Lines 1 and 2 read the descriptor and print false false false.
  • Lines 5 and 6 run in normal, non-strict code. The assignment on line 5 is quietly ignored, so line 6 prints undefined.
  • Line 10 turns on strict mode. Now the assignment on line 12 throws, and line 14 prints TypeError.

You may see void 0 in older or generated code. In very old JavaScript, the global undefined could be changed, so careful code wrote void 0 instead. A minifier is a tool that makes code smaller, and some still write void 0 because it is shorter. Terser, a popular minifier, has an option for this called unsafe_undefined. But it isn't faster: the bytecode above is the same for void 0 and the usual undefined.

Playground: pick some code and read its bytecode

Pick one of the twelve pieces of code. The left side shows the JavaScript. The right side shows the names of its bytecode instructions, copied from real Node output. The lesson's test runs each piece again with Node's --print-bytecode --print-bytecode-filter options and checks that the names still match.

Playground: pick some code and read its bytecode
The code you pickedJavaScript
function retVoid() { return; }retVoid();
Instruction namesretVoid
Bytecode instructionsText
00  LdaUndefined01  Return

A bare `return;` loads `undefined` with `LdaUndefined`, then `Return` gives it back.

Try it yourself

A bare return; loads undefined with LdaUndefined, then Return gives it back.

The instruction names are copied from real Node 22 output (V8 12.4, run with --print-bytecode). Addresses and other numbers that change from run to run are left out.

Practical use

Most everyday code doesn't need to think about oddballs. This lesson helps in three ways: you won't mix up a gap with a real undefined, you'll read performance tools more easily, and you'll write clearer code when undefined means something.

The table below turns the lesson into habits. Read each row as: "do this, like this, because of this."

Habits that come from how V8 stores undefined
HabitHow to do itWhy it helps
Keep arrays packedWrite [value, undefined, value] when you want a real empty item, not a gap.Arrays with gaps need extra checks, and a gap can show items from the prototype.
Don't use new Array(n) without thinking in code that runs very oftenFill the array on purpose, or use Array.from, when every index should have an item.new Array(n) starts full of gaps. Measure before you rewrite clear code.
Don't make a variable named undefinedA local let undefined = ... is allowed, but it confuses readers.V8 can no longer use its quick LdaUndefined instruction for that name.
Use void 0 only for old code or minifiers, not for speedModern V8 makes the same LdaUndefined for undefined and void 0.Write the clearer one, unless a build tool chooses void 0 for you.
  • When every index should have an item, keep the array packed. Use a real undefined item only when an empty item is the point.
  • In code that runs very often, don't make arrays with new Array(n), which starts full of gaps, unless measuring shows it is fine.
  • Don't make local variables named undefined. It is allowed, but it confuses readers and V8's quick LdaUndefined path.
  • Use void 0 only for old browsers or generated code. Modern V8 doesn't make it faster.

Common mix-ups

  • "Every empty-looking slot is undefined." No. Array gaps and TDZ variables hold the hole. V8 follows a rule before your code gets a result or an error.
  • "Reading a gap simply gives undefined." Not straight away. JavaScript looks in the prototype chain first, so an item there can show up.
  • "void 0 is faster than undefined." No. In the cases tested here, both become the same LdaUndefined instruction.
  • "A missing property and an undeclared variable are the same." No. A missing property gives undefined. An undeclared name throws a ReferenceError.
  • "The addresses in Node's debug output are facts to memorize." No. Addresses and sizes change between builds. Type names and instruction names stay the same.

The table below compares five things that are easy to mix up. Read each row as: "when I have this, what is it, what does my code see, and what does V8 do?"

undefined, gaps, missing properties, missing arguments, and undeclared names
When you haveWhat it isWhat your code seesWhat V8 does
undefinedThe real JavaScript value.typeof gives "undefined", and === undefined is true.Points to the one oddball in read-only memory.
An array gapAn index with no item.A read gives undefined, but only after looking in the prototype chain.Keeps the hole there, not the oddball.
A missing propertyNo object in the chain has this property.The read gives undefined.Can remember the miss with a LoadNonExistent handler.
A missing argumentThe call passed fewer values than there are parameters.The parameter is undefined inside the function.Fills the empty parameter slots with undefined before the function starts.
A name that was never declaredNo variable with that name exists.Reading it throws a ReferenceError.There is no value at all, so no undefined either.

Practice exercises

6 EXERCISES

Each exercise is a short program. The first ones ask for one word or one output. The last one asks you to pick a kind of array. Try a guess first, then use the hints if you get stuck.

Exercise 1 · Warm-upName the instruction that loads undefined

This function returns undefined. Which bytecode instruction loads the real undefined for it?

Starter codePop out in the code editor (opens in a new tab)JavaScript
function f() { return undefined; }
f();

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

    Exercise 2 · Warm-upSpot the gap

    What does the first line of output, 1 in sparse, print?

    Starter codePop out in the code editor (opens in a new tab)JavaScript
    const sparse = [1, , 3];
    const explicit = [1, undefined, 3];
    console.log(1 in sparse);
    console.log(1 in explicit);

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

      Exercise 3 · PracticePredict a missing parameter

      The call passes one value to a function with two parameters. What does the program print?

      Starter codePop out in the code editor (opens in a new tab)JavaScript
      function second(a, b) {
        return b;
      }
      console.log(second("first"));

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

        Exercise 4 · PracticeRead the settings of the global undefined

        Can you change the global undefined property? Run the code and write what it prints.

        Starter codePop out in the code editor (opens in a new tab)JavaScript
        const descriptor = Object.getOwnPropertyDescriptor(globalThis, "undefined");
        console.log(descriptor.writable);

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

          Exercise 5 · PracticeExplain the prototype surprise

          After the code puts an item into Array.prototype, what does sparse[1] print?

          Starter codePop out in the code editor (opens in a new tab)JavaScript
          const sparse = [1, , 3];
          const hadOwn = Object.prototype.hasOwnProperty.call(Array.prototype, 1);
          const oldValue = Array.prototype[1];
          
          try {
            Array.prototype[1] = "surprise";
            console.log(sparse[1]);
          } finally {
            if (hadOwn) {
              Array.prototype[1] = oldValue;
            } else {
              delete Array.prototype[1];
            }
          }

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

            Exercise 6 · ChallengeChoose the kind of array

            Which word does V8 use for an array that has an item at every index?

            Starter codePop out in the code editor (opens in a new tab)JavaScript
            const packed = Array.from({ length: 3 }, (_, index) => index + 1);
            const holey = new Array(3);
            console.log(packed.length, 1 in packed);
            console.log(holey.length, 1 in holey);

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

              Quiz: check your understanding

              8 QUESTIONS
              undefined internals quiz · 8 questionsScore: first tries count
              1. Question 1 of 8What is undefined inside V8?

                Choose an answer to see the explanation.

              2. Question 2 of 8What does this program print?

                Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
                const a = undefined;
                const b = void 0;
                console.log(a === b);

                Choose an answer to see the explanation.

              3. Question 3 of 8Node 22 printed the bytecode for return undefined and return void 0. Which two instructions did both give?

                Choose an answer to see the explanation.

              4. Question 4 of 8What do these checks print?

                Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
                const sparse = [1, , 3];
                const explicit = [1, undefined, 3];
                console.log(1 in sparse);
                console.log(1 in explicit);

                Choose an answer to see the explanation.

              5. Question 5 of 8This function reads y before its let line. What does its bytecode show?

                Read the code, then predictJavaScript
                function tdz() {
                  const r = y;
                  let y = 1;
                  return r;
                }

                Choose an answer to see the explanation.

              6. Question 6 of 8The function has two parameters, but the call passes one value. What does it print?

                Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
                function second(a, b) {
                  return b;
                }
                console.log(second("first"));

                Choose an answer to see the explanation.

              7. Question 7 of 8This non-strict function tries to change the global undefined. What does it print?

                Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
                (function sloppyAssignment() {
                  undefined = "changed";
                  console.log(undefined);
                })();

                Choose an answer to see the explanation.

              8. Question 8 of 8Which habit follows from what you learned about gaps?

                Choose an answer to see the explanation.

              Key takeaways

              • V8 has exactly one undefined: a read-only oddball made at startup. Getting undefined never makes a new value.
              • The LdaUndefined instruction loads it for return;, void 0, and the usual global undefined.
              • The hole is a different marker, used for array gaps and the TDZ. Your code never gets the hole itself.
              • Missing properties and missing arguments both give undefined, but V8 gets there in different ways.
              • Keep arrays packed and code clear. Measure before you change code for the engine's sake.

              One-line definition: undefined is V8's one read-only oddball. Holes and missing lookups are paths inside V8 that may end in that value.

              Up next: null inside the engine.

              CompleteFrontend Clear concepts. Working examples.