cf.completefrontendCode editorOpen lab
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
    Identify the one undefined valueExplain why V8 represents every observable undefined as a tagged pointer to one read-only Oddball object.
  • 02
    Read the bytecode pathsRecognize LdaUndefined, LdaTheHole, TDZ checks, missing-argument fills, and shadowed undefined bindings in V8 bytecode.
  • 03
    Separate holes from missing valuesDistinguish array holes, missing properties, missing arguments, undeclared names, and the global undefined property in real code.

One oddball, many sources

JavaScript has exactly one observable undefined value. In V8 12.4, every undefined your program can receive is a tagged pointer to one prebuilt read-only heap object: the undefined oddball. Returning it, storing it in a register, or comparing with it does not allocate a new value.

Definition

undefined inside V8 is the read-only Oddball object whose cached conversions are ToNumber → NaN, ToString → "undefined", and typeof → "undefined". Bytecode usually reaches it with LdaUndefined or through an already-filled slot.

Three ordinary sources, 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));

Lines 1 and 2 both make the same value, so line 5 prints true. The function on line 3 falls off the end, so line 6 prints undefined. Line 7 confirms that the value identity is the same. If you need the beginner language tour first, read undefined; if the phrase “tagged pointer” is still new, review tagged values.

Real-life analogyOne shared blank note

Imagine one shared blank note. When someone has nothing to add, they point to that note instead of making a new blank one.

In real life: One blank note is kept
In JavaScript: One read-only undefined oddball
In real life: Everyone can point to that note
In JavaScript: Every register or property can hold a pointer to it
In real life: Pointing to it makes no new note
In JavaScript: Producing undefined does not allocate

Where the analogy stops: A note is visible and physical. The oddball is an engine object behind tagged values, and other engines can represent undefined differently.

The local probes in this lesson run on Node 22.23.x with V8 12.4.254.21 and pointer compression off. Chrome uses a newer V8 build and commonly enables pointer compression and the V8 sandbox, so addresses and object sizes differ. The stable ideas here are the type names, bytecode mnemonics, and source-level invariants.

The undefined oddball

V8 calls null, undefined, true, and false “oddballs.” The primary source is oddball.h: it lists cached to_number_raw, to_string, to_number, type_of, and kind fields, with kUndefined = 4. Heap setup creates a separate undefined_map, marks that map undetectable, and initializes the oddball with NaN, "undefined", and "undefined".

Node-only %DebugPrint(undefined)JavaScript
%DebugPrint(undefined);

The test asserts the stable output: [Oddball] in ReadOnlySpace: #undefined, a map with type: ODDBALL_TYPE, stable_map, undetectable, non-extensible, and prototype: <null>. The same map concept appears for null inside the engine, which is why both values are “undetectable” internally.

V8 source lines 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);

setup-heap-internal.cc allocates the maps and values during startup. The debug output proves this Node build places the value in ReadOnlySpace; V8's read-only serializer emits read-only pages and the read-only roots table into the snapshot image.

JSC and SpiderMonkey contrastText
// 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;

JavaScriptCore's JSCJSValue.h documents Undefined: 0x0a and ValueUndefined = OtherTag | UndefinedTag. SpiderMonkey's Value.h gives JSVAL_TYPE_UNDEFINED = 0x03 and a JSVAL_TAG_UNDEFINED tag. Same JavaScript value; different engine layout.

The value and its neighbors across engines
ThingRepresentationVerified detail
V8 undefinedRead-only Oddball heap objectODDBALL_TYPE, kind kUndefined = 4, cached NaN, "undefined", and typeof string.
V8 the_hole_valueRead-only Hole objectInternal marker for array holes and TDZ-style sentinels; JavaScript never receives it directly.
JavaScriptCoreImmediate JSValueValueUndefined = OtherTag | UndefinedTag, documented as invalid pointer value 0x0a.
SpiderMonkeyTagged JS::ValueJSVAL_TYPE_UNDEFINED = 0x03; PUNBOX64 combines it with JSVAL_TAG_MAX_DOUBLE.

Where bytecode gets undefined

Ignition bytecode has a direct instruction for the common case: LdaUndefined. The child-process tests prove that return;, falling off the end of a function, return void 0, and return undefined all compile to LdaUndefined followed by Return when undefined resolves to the global value.

Stable mnemonic examplesText
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

The interesting exception is a shadowed binding. function f() { let undefined = 5; return undefined; } returns 5 because the local name is just a local variable. The bytecode uses LdaSmi, Star0, then later Ldar r0; it does not load the oddball.

TDZ checks use a different sentinel. For const r = y; let y = 1;, Node prints LdaTheHole, Star1, and ThrowReferenceErrorIfHole. The hole exists so the engine can distinguish “not initialized yet” from the real undefined value.

Registers and missing arguments

Before the first bytecode runs, the interpreter entry trampoline creates the function's register file. In V8 12.4, both x64 and arm64 source say: “push undefined as the initial value for all register file entries.” The accumulator is already loaded with the undefined root when dispatch starts.

Interpreter entry trampoline excerptsText
// 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.

Missing arguments are filled before the callee reads them. V8's arguments adaptor frame article explains that since V8 8.9 the old adaptor frame is gone; under-application assigns the remaining parameters undefined. The current x64 InvokePrologue still shows the concrete fill loop.

InvokePrologue fills missing formalsText
// 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);
Missing arguments become undefined frame slots
Step 0 of 6Ready
Your turn: follow the blue line

Replay a call with fewer arguments than formal parameters. The values are real; the stack-frame drawing is a teaching model.

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 makeBadge(name, role, city) {  const stackSlots = [name, role, city];  return stackSlots.map(String).join(" | ");} 
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.

The replay is a labelled teaching model of the call frame, but the values are recorded from the real function: name is "Ada", while role and city are the actual undefined values supplied for the missing formals.

The hole is not undefined

V8 12.4 has a separate Hole type, not an Oddball, for internal markers. The source initializes the_hole_value and several distinct holes: property_cell_hole_value, hash_table_hole_value, promise_hole_value, uninitialized_value, arguments_marker, optimized_out, stale_register, and more. JavaScript code never receives the marker directly.

V8's Hole source, not an OddballText
// 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());
Hole or undefined?
  • let x; x
  • void 0
  • second formal parameter when the call passed one argument
  • ({}).missing after the prototype walk fails
  • the middle slot in [1, , 3]
  • an array element after delete array[1]
  • a let binding before initialization
  • reading doesNotExist
  • [1, , 3][1] after Array.prototype[1] = "surprise"
Try it yourself
0 of 9 correct

Sort each case by what exists before ordinary JavaScript observes a result.

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

An empty chair is ready to use. A reserved card is different: it asks for a rule before the chair is treated normally. Holes work like that card.

In real life: An empty chair anyone can use
In JavaScript: The observable undefined value
In real life: A reserved card on a chair
In JavaScript: The internal hole marker
In real life: The card is handled before sitting
In JavaScript: The engine turns a hole into lookup behavior

Where the analogy stops: People can see a reserved card. JavaScript cannot see the hole marker; only engine internals and diagnostics can name it.

Holey arrays and the no-elements protector

Sparse array syntax creates holes. In this Node/V8 build, [1, , 3] debug-prints as HOLEY_SMI_ELEMENTS with <the_hole_value>, while [1, undefined, 3] is PACKED_ELEMENTS because index 1 is a real element. The broader elements-kind story is in Elements kinds.

Node-only %DebugPrint for sparse versus explicit undefinedJavaScript
%DebugPrint([1, , 3]);%DebugPrint([1, undefined, 3]);
Holes skip own-element workPop 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)));

The first line of output is false true. Both reads on line 5 print undefined undefined, but the paths differ: the sparse read has no own element. Object.keys and forEach skip the hole and visit the explicit undefined element.

A hole consults the prototype chainPop 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];  }}

This runnable example cleans up after itself. While Array.prototype[1] exists, the hole at index 1 can read the inherited "surprise". V8 protects fast paths with a NoElements protector so they can skip prototype-element walks until someone adds indexed elements to Array.prototype or Object.prototype.

Node-only NoElements protector probeJavaScript
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];}

The native available in Node 22 is %NoElementsProtector(). It prints true, then false after the prototype write. The source name is verified in protectors.h, where NoElements maps to NoElementsProtector.

A hole reads through the prototype chain
Step 0 of 8Ready
Your turn: follow the blue line

Replay a sparse-array read. JavaScript never sees the hole marker directly; it sees the result of the property lookup.

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 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];}
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.

Missing properties

A named property load first checks the receiver's own shape, then walks prototypes until it either finds a property or reaches null. If nothing is found, the JavaScript result is undefined. This is different from an undeclared identifier, which throws.

Own property, inherited property, missing propertyPop 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);
V8 LoadNonExistent handler excerptsText
// 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);
Keep the IC claim modest

The V8 source names LoadHandler::LoadNonExistent, LoadFullChain, and a prototype-chain validity cell. That supports a modest claim: inline caches can cache a “nonexistent” result guarded by the receiver map and prototype-chain validity, then return the undefined constant. For broader IC mechanics, go to Inline caches and prototype internals.

The global undefined property

Modern ECMAScript makes globalThis.undefined non-writable, non-enumerable, and non-configurable. The descriptor is specified for the global value property in ECMA-262. The local test proves the descriptor and the assignment rules.

Descriptor and assignment behaviorPop 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);  }})();

The first log prints false false false. The sloppy assignment is ignored and prints undefined. The strict assignment throws and the catch block prints TypeError. This explains why void 0 is mostly a legacy/minifier idiom today. Terser documents an unsafe_undefined compression option for substituting void 0 in specific legacy-compatible situations, but V8's bytecode shows no speed win for unshadowed code.

Playground: pick a snippet and read the bytecode

Choose one of more than ten snippets. The left pane shows the source, and the right pane shows only the stable bytecode mnemonics captured from real Node output. Tests rerun every option with --print-bytecode --print-bytecode-filter.

Playground: pick a snippet and read V8 bytecode
Selected sourceJavaScript
function retVoid() { return; }retVoid();
Captured mnemonicsretVoid
Mnemonic listingText
00  LdaUndefined01  Return

A bare return loads the undefined oddball and returns it.

Try it yourself

A bare return loads the undefined oddball and returns it.

Listings show the stable mnemonics captured from Node 22 / V8 12.4 with --print-bytecode. Addresses, offsets, and feedback slots are intentionally omitted.

Practical use

Most application code should not chase oddballs. Use this engine knowledge to avoid confusing holes with present values, to read profiles more accurately, and to keep code clear when undefined has a real meaning.

Practical rules that survive engine details
HabitUse it whenEngine reason
Prefer packed arraysUse [value, undefined, value] when a present undefined element is intended.A holey elements kind carries extra checks and prototype-observation rules.
Avoid casual new Array(n) in hot pathsFill deliberately or use Array.from when every index should exist.Pre-sized arrays start with holes; measure before rewriting clear code.
Do not redefine undefinedA local let undefined = ... is legal but confusing.It disables the special global LdaUndefined shortcut for that binding.
Use void 0 for legacy minifier patterns, not speedModern V8 emits the same LdaUndefined for unshadowed undefined and void 0.Choose readability unless a build tool deliberately wants void 0.
  • Prefer packed arrays when every index should exist; use explicit undefined only when a present element matters.
  • Do not pre-size with new Array(n) in a hot path unless measurement says the holey shape is acceptable.
  • Do not create local bindings named undefined; it is legal, but it fights readers and the special bytecode path.
  • Use void 0 for deliberate compatibility or generated code, not because modern V8 makes it faster.

Common misconceptions

  • “Every empty-looking slot is undefined.” No. Array holes and TDZ sentinels are internal holes until a rule turns them into a result or a throw.
  • “Reading a hole simply returns undefined.” Not directly. The prototype chain is consulted first, so inherited indexed properties can be observed.
  • “void 0 is faster than undefined.” Not for the unshadowed cases proved here; both compile to LdaUndefined.
  • “Missing property and undeclared variable are the same.” A missing property returns undefined; an unresolved identifier throws ReferenceError.
  • “Node debug addresses are facts to memorize.” Addresses and sizes vary by build. Type names and bytecode mnemonics are the stable evidence.
undefined, holes, missing properties, and undeclared names
CaseWhat it isWhat JS observesEngine note
undefinedThe actual language valuetypeof is "undefined"; strict equality can test itV8 Oddball in ReadOnlySpace.
array holeA missing element slotReads as undefined only after prototype lookupV8 Hole, not an Oddball.
missing propertyNo own or inherited property foundProperty load returns undefinedIC can cache a LoadNonExistent handler.
missing argumentNo value was supplied for a formal parameterParameter is undefined inside the functionInvoke prologue fills missing slots with the undefined root.
undeclared nameNo lexical or global binding existsReading throws ReferenceErrorNot a value and not the undefined oddball.

Practice exercises

6 EXERCISES
Exercise 1 · Warm-upName the undefined bytecode

Which bytecode mnemonic loads the real undefined value for this function?

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 hole

    What does the first log, 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

      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 global descriptor

        Is the global undefined property writable?

        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

          What does the sparse read print after the prototype write?

          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 array shape

            Which word describes arrays with every index present?

            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 V8's observable undefined value in this lesson?

                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 8Which bytecode pair did Node 22 print for return undefined and return void 0?

                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 8What does this TDZ bytecode prove?

                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 8What does the missing-argument program 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 8What happens when a sloppy function assigns to global undefined?

                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 production habit follows from holes and elements kinds?

                Choose an answer to see the explanation.

              Key takeaways

              • Observable undefined is one read-only V8 Oddball; producing it does not allocate.
              • LdaUndefined covers common sources such as return;, void 0, and the unshadowed global identifier.
              • The hole is a separate internal marker used for sparse arrays and TDZ-style checks; JavaScript cannot observe it directly.
              • Missing properties and missing arguments produce undefined through different engine paths.
              • Prefer packed arrays and clear code; measure hot paths before changing shape for engine reasons.

              One-line definition: undefined is V8's read-only undefined Oddball, while holes and missing lookups are engine paths that may eventually produce that value.

              Up next: null inside the engine.

              CompleteFrontend Clear concepts. Working examples.