JavaScript & TypeScript Runtime Deep Dive

Target audience: candidates interviewing for frontend, full-stack, or Node.js backend roles where the interviewer probes “what does the event loop actually do?”, “explain this”, “why is typeof null === 'object'”, or “how does TypeScript narrow this union?”

Scope: V8 (Chrome / Node) primarily, with mentions of SpiderMonkey (Firefox) and JSC (Safari) where they diverge. TypeScript 5.x.

JS sits in an awkward place: senior interviewers know the language is full of warts and they will use them. Memorizing trivia is necessary but not sufficient. The leverage comes from understanding the engine model and the type system’s structural reasoning.


1. V8 Internals — Ignition + TurboFan

V8 (and similar engines) compile JS through a pipeline:

  1. Parser → AST.
  2. Ignition — bytecode interpreter. Fast startup.
  3. TurboFan — optimizing JIT. Profiles hot code, generates speculative machine code.
  4. Deoptimization — when speculation breaks (a function suddenly receives a different type), TurboFan bails back to Ignition.
function add(a, b) { return a + b; }
// Called 10000x with (number, number) → TurboFan compiles it to fast int add.
add("foo", "bar");   // Type changed → deopt, recompile or bail out.

Hidden classes (shapes / maps)

V8 tracks the structural “shape” of objects internally — what fields exist in what order. Each property add changes the hidden class. Two objects with the same hidden class share a fast property layout.

function Point(x, y) { this.x = x; this.y = y; }
const a = new Point(1, 2);
const b = new Point(3, 4);
// a and b share a hidden class → fast.

a.z = 5;            // a's hidden class diverges from b's → slower.

Inline caches (ICs)

Property access (obj.x) is monitored. If the hidden class is consistent, the IC fast-paths to a direct memory offset. Polymorphic ICs (multiple shapes) are slower; megamorphic (>4) drops to a hash lookup.

Interview takeaway: initialize all properties in the constructor in the same order. Don’t add/delete properties dynamically in hot code.


2. Event Loop — Tasks, Microtasks, RAF

The browser/Node runs one JS thread. Concurrency comes from yielding back to the event loop.

┌─────────────────────────┐
│      Call Stack         │
└────────┬────────────────┘
         │ runs to completion
┌────────▼────────────────┐
│   Microtask Queue       │ ← Promises, queueMicrotask, MutationObserver
└────────┬────────────────┘
         │ drained fully
┌────────▼────────────────┐
│      Task Queue         │ ← setTimeout, setInterval, I/O, UI events
└─────────────────────────┘

Order of execution:

  1. Run the current synchronous code to completion.
  2. Drain the entire microtask queue.
  3. Pick one task from the task queue.
  4. Repeat from step 2.
console.log('a');
setTimeout(() => console.log('b'), 0);
Promise.resolve().then(() => console.log('c'));
console.log('d');
// a, d, c, b

Microtasks starve macrotasks if they keep enqueueing themselves:

function loop() { Promise.resolve().then(loop); }
loop();           // freezes the event loop — UI never repaints

requestAnimationFrame

Browser-only. Fires before the next paint, ~60fps. Use for animation; cheaper than setInterval(_, 16) because it’s coalesced with paint.

Node specifics

Node uses libuv. Its event loop has phases (timers, pending callbacks, poll, check, close). process.nextTick runs before microtasks (a Node-only queue with even higher priority than Promise microtasks).

process.nextTick(() => console.log('next'));
Promise.resolve().then(() => console.log('promise'));
// next, promise

3. async / await

async functions return a Promise. await desugars to .then.

async function f() {
    const a = await fetch1();
    const b = await fetch2(a);
    return b;
}
// equivalent to:
function f() {
    return fetch1().then(a => fetch2(a));
}

await x where x is not a thenable wraps it in Promise.resolve(x).

Sequential vs parallel

// Sequential (slow if independent):
const a = await op1();
const b = await op2();

// Parallel (correct when independent):
const [a, b] = await Promise.all([op1(), op2()]);

Default to Promise.all when the operations are independent — common interview ask.

Errors

try/catch works on await. An unhandled rejection in async context surfaces as unhandledrejection (browser) / process.on('unhandledRejection') (Node).

async function f() {
    try {
        await mayReject();
    } catch (e) {
        // handle
    }
}

4. Promise Gotchas

  • A promise is not “the running operation” — it represents a value that will exist later. The work is already started before the Promise is constructed (in most APIs).

  • Errors thrown inside .then callbacks become rejections of the chain.

  • Forgetting return inside .then breaks chaining:

    p.then(x => {
        doSomething(x);     // forgot return — next .then sees undefined
    }).then(use);
    
  • Promise.all short-circuits on first rejection. Use Promise.allSettled to wait for all and inspect.

  • Unhandled rejection is now noisy in Node and the browser. Always attach a .catch or await inside try/catch.

  • Promise is not cancelable. AbortController + AbortSignal pattern handles cancellation explicitly.

const ctrl = new AbortController();
fetch(url, { signal: ctrl.signal });
// later:
ctrl.abort();

5. Memory Model and GC

V8’s heap has generational GC:

  • Young generation (Scavenger / semi-space copying): minor GC, very fast (~ms). Most objects die young.
  • Old generation (Mark-sweep / mark-compact): major GC. Concurrent marking, parallel sweeping, incremental compaction.

Memory leaks in JS

  • Unintentional globals — assigning to a name without let/const (in non-strict mode) creates a global, never collected.
  • Closures — capturing large objects in long-lived callbacks.
  • Event listeners — not removed when DOM nodes are detached.
  • TimerssetInterval callbacks retain captured state forever.
  • Detached DOM — references to removed DOM nodes from JS keep them alive.
  • Map / Set — keys held strongly. Use WeakMap / WeakSet for “annotations on objects.”

Profiling

Chrome DevTools → Memory → heap snapshot, allocation timeline. Look for retained sizes and detached DOM trees.


6. Object Model — Prototypes

Every object has an internal [[Prototype]] (accessed via Object.getPrototypeOf or __proto__). Property lookup walks the prototype chain.

const a = { x: 1 };
const b = Object.create(a);
b.y = 2;
b.x;                    // 1 — looked up on a
Object.getPrototypeOf(b) === a;   // true

Foo.prototype is the object that becomes __proto__ of instances created with new Foo().

function Foo() {}
const f = new Foo();
f.__proto__ === Foo.prototype;       // true
Foo.prototype.__proto__ === Object.prototype;

class

class is sugar over prototypes. The methods live on Foo.prototype.

class Foo {
    greet() { return 'hi'; }
}
typeof Foo.prototype.greet;          // 'function'

Object.create(null)

A “dictionary object” with no prototype — no inherited properties from Object.prototype. Useful as a hash map.


7. this Binding

JS binds this at call site, not at definition. Five rules in priority order:

  1. new: this = new instance.
  2. call / apply / bind: explicit binding wins.
  3. Method call (obj.f()): this = obj.
  4. Plain call (f()): this = undefined in strict mode, global object otherwise.
  5. Arrow functions: no own this — inherit from surrounding lexical scope.
const obj = { x: 1, f() { return this.x; } };
const g = obj.f;
obj.f();          // 1
g();              // undefined (strict) — `this` is global / undefined

class C {
    val = 42;
    arrow = () => this.val;        // bound to instance
    method()    { return this.val; }
}
const c = new C();
const a = c.arrow;
const m = c.method;
a();              // 42 — arrow captured `this`
m();              // TypeError — method lost `this`

8. Closures, var / let / const, Scope

A closure is a function plus the lexical environment it was created in.

function counter() {
    let n = 0;
    return () => ++n;
}
const c = counter();
c(); c(); c();        // 1, 2, 3

var (function-scoped, hoisted)

console.log(x);    // undefined (hoisted, not initialized)
var x = 1;

let / const (block-scoped, TDZ)

console.log(y);    // ReferenceError — TDZ
let y = 1;

The “Temporal Dead Zone” is the period between block entry and the let/const declaration. Accessing the binding in TDZ throws.

Loop var capture

for (var i = 0; i < 3; i++) setTimeout(() => console.log(i), 0);
// 3 3 3 — single `i`, all callbacks share it

for (let i = 0; i < 3; i++) setTimeout(() => console.log(i), 0);
// 0 1 2 — fresh `i` per iteration

This is the classic JS interview question. Use let.


9. Equality

  • === strict — same type and value, with NaN !== NaN and +0 === -0.
  • == loose — type coercion. Don’t use it except for x == null (matches both null and undefined).
  • Object.is(a, b) — like === but Object.is(NaN, NaN) === true and Object.is(+0, -0) === false.
NaN === NaN;             // false
Object.is(NaN, NaN);     // true
1 == '1';                // true (coercion)
[] == false;             // true (!)
[] == ![];               // true (!)

The == rules are an interview trap. Memorize only the null/undefined exception; use === everywhere else.


10. Top Gotchas

typeof null === 'object'

A historical bug, never fixed for compatibility. Test for null with === null.

parseInt radix

parseInt('010');         // 10 in modern engines (used to be 8)
parseInt('010', 10);     // always 10 — pass the radix.

Always pass 10. ESLint enforces this.

Floating-point

0.1 + 0.2;           // 0.30000000000000004
0.1 + 0.2 === 0.3;   // false

Use Number.EPSILON tolerance, or bigint for exact integer arithmetic.

Array coercion

[] + [];             // ''
[] + {};             // '[object Object]'
{} + [];             // 0  (in some contexts — `{}` parsed as block)

Don’t + non-numbers. Use template literals or explicit String(x).

== and falsy

0 == '';             // true
0 == '0';            // true
'' == '0';           // false

This is why === exists.

for...in vs for...of

  • for (const k in obj) iterates enumerable string-keyed properties (includes inherited!).
  • for (const v of iterable) iterates iterable’s values.
const arr = [1, 2, 3];
for (const i in arr)  console.log(i);   // '0' '1' '2' — strings, indexes
for (const v of arr)  console.log(v);   // 1 2 3

Don’t use for...in on arrays.

delete on array

delete arr[i] leaves a hole (sparse array), doesn’t shorten. Use splice.


11. Map vs Object

ObjectMap
KeysStrings & symbolsAnything
IterationObject.keys/entries, no order guarantee for non-intInsertion order
SizeObject.keys(o).length (O(N))m.size (O(1))
Inheritance pollutionYes (__proto__, toString…)No
JSONYesNo (need conversion)

Use Map when:

  • Keys are dynamic strings (esp. user input).
  • You need any-typed keys.
  • Insertion order matters.
  • You add/remove keys frequently.

Use Object when:

  • Keys are known compile-time / config-shaped.
  • You’ll JSON-serialize.
  • You’re using TypeScript’s structural types.

12. Set, WeakMap, WeakSet

  • Set — collection of unique values; insertion order.
  • WeakMap — keys are objects, weakly held. If the key is GC’d, the entry disappears. Not iterable. Use for “annotations on objects.”
  • WeakSet — set of weakly-held objects. Use for “have I seen this object?” without preventing GC.
const tags = new WeakMap();
function tag(node, value) { tags.set(node, value); }
// when `node` is GC'd, the tag is gone too.

Practical use: caches keyed on DOM nodes, private state on objects, libraries that observe but don’t own.

WeakRef (newer)

new WeakRef(obj) lets you hold a weak reference and dereference it (.deref()) later, getting the object or undefined if collected. Niche — you probably don’t need it.


13. TypeScript — Structural Typing & Generics

TypeScript types are structural. If two types have the same shape, they’re compatible.

interface Named { name: string }
const u: Named = { name: 'a', age: 30 };   // OK — extra props allowed in this position
function greet(p: Named) { return p.name; }
greet({ name: 'a', extra: 1 } as any);

Generics

function identity<T>(x: T): T { return x; }
identity<number>(42);
identity('hi');                  // T inferred as string

Constraints

function len<T extends { length: number }>(x: T): number { return x.length; }

Conditional types

type IsString<T> = T extends string ? true : false;
type A = IsString<'hi'>;          // true
type B = IsString<42>;            // false

Mapped types

type Partial<T> = { [K in keyof T]?: T[K] };
type Readonly<T> = { readonly [K in keyof T]: T[K] };

Utility types

Partial<T>, Required<T>, Pick<T, K>, Omit<T, K>, Record<K, V>, ReturnType<F>, Parameters<F>, Awaited<T>. Memorize the names; they come up.


14. TS Narrowing

The control-flow analyzer narrows union types based on runtime checks.

function f(x: string | number) {
    if (typeof x === 'string') {
        x.toUpperCase();           // narrowed to string
    } else {
        x.toFixed(2);              // narrowed to number
    }
}

Narrowing operators

  • typeof"string", "number", "boolean", "undefined", "object", "function", "symbol", "bigint".

  • instanceof → for class instances.

  • in operator: if ('foo' in obj).

  • Equality: if (x === null), if (x === undefined).

  • Discriminated unions:

    type Result = { ok: true; value: string } | { ok: false; error: Error };
    function f(r: Result) {
        if (r.ok) r.value;       // OK
        else r.error;            // OK
    }
    
  • User-defined type guards:

    function isString(x: unknown): x is string {
        return typeof x === 'string';
    }
    
  • Assertion functions:

    function assertNumber(x: unknown): asserts x is number {
        if (typeof x !== 'number') throw new Error('not a number');
    }
    

Exhaustiveness with never

type Shape = { kind: 'circle' } | { kind: 'square' };
function area(s: Shape): number {
    switch (s.kind) {
        case 'circle': return ...;
        case 'square': return ...;
        default: const _: never = s; throw new Error('unreachable');
    }
}

The never assignment fails to compile if a new variant is added — catches missing cases.


15. Performance Tips

  • Stable hidden classes — set all properties in the constructor in the same order. Don’t add later.
  • Avoid delete on hot objects — it transitions to dictionary mode.
  • Monomorphic functions — call them with the same shapes. Polymorphic = slower.
  • Typed arrays for numeric work — Float64Array, Int32Array. Pre-allocated, contiguous, no boxing.
  • Avoid arguments in hot code; use ...rest. arguments defeats some optimizations.
  • for over forEach in hot loops — slightly faster, no callback overhead. Less true with modern engines but still measurable on tight loops.
  • Pre-compile regexes — declare at module scope, not inside functions.
  • Avoid leaking with try/catch in hot functions on old V8 (pre-2017). Modern V8 handles it; not a real concern anymore.
  • Profile before optimizing. Chrome DevTools Performance tab; Node --prof and clinic.js.
  • Reduce object churn — V8 likes long-lived monomorphic objects.
// Bad — creates new shapes per call:
function pt() { return { x: 1, y: 2 }; }

// Better — a class V8 can specialize:
class Pt { constructor(x, y) { this.x = x; this.y = y; } }

16. Node vs Browser

NodeBrowser
Globalsprocess, Buffer, __dirname, globalwindow, document, navigator
ModulesCommonJS (require) + ESMESM + bundlers
I/Olibuv: fs, net, dns, child_processfetch, Web APIs
DOMNone (use jsdom if needed)Yes
Threadsworker_threads, clusterWorker, SharedArrayBuffer

libuv thread pool

Node uses a thread pool (default 4) for fs, dns.lookup, crypto.pbkdf2, etc. — anything that can’t be epoll-ed.

UV_THREADPOOL_SIZE=16 node app.js

Network I/O is not on the thread pool; it’s on the event loop using async syscalls.

worker_threads vs cluster

  • worker_threads — separate JS thread, separate event loop, can share memory via SharedArrayBuffer. Use for CPU-bound work.
  • cluster — multiple processes, no shared memory, IPC via channels. Use for scaling HTTP servers across cores.

process vs globalThis

globalThis (ES2020) is the universal global object — works in browser, Node, workers.


17. What To Memorize Cold

  • V8 pipeline: Ignition (bytecode) → TurboFan (optimizing JIT). Hidden classes + ICs drive speed. Property order matters.
  • Event loop: sync runs to completion → drain microtasks → one task → repeat. Microtasks include Promises, queueMicrotask.
  • Node nextTick > Promise microtask > timers/IO.
  • async/await desugars to Promises. Use Promise.all for independent work.
  • Promises: not cancelable, errors → rejection, must catch. AbortController for cancellation.
  • GC: generational. Leaks: globals, closures, listeners, timers, detached DOM. WeakMap/WeakSet for object-keyed metadata.
  • Prototype chain: __proto__ link, prototype property on functions/classes. class is sugar.
  • this rules: new > call/apply/bind > method > default. Arrow inherits lexical.
  • var hoisted/function-scoped, let/const block-scoped + TDZ. Loop var capture: use let.
  • Equality: === always; Object.is for NaN/±0; == only for x == null.
  • Top traps: typeof null is "object", parseInt radix, FP math, for...in vs for...of, delete on array.
  • Map vs Object: Map for dynamic keys, any types, ordered, fast size. WeakMap for object-keyed weak metadata.
  • TS structural typing. Utility types: Partial/Required/Pick/Omit/Record/ReturnType. Conditional + mapped types.
  • TS narrowing: typeof, instanceof, in, discriminated unions, type guards, assertion functions, never for exhaustiveness.
  • Perf: stable hidden classes, monomorphic call sites, typed arrays for numeric, pre-compile regex, profile.
  • Node: libuv thread pool for fs/dns/crypto. worker_threads (CPU) vs cluster (HTTP scale).

JS is forgiving until it isn’t. The interviewer will test the spots where it isn’t. Fluency on the event loop, this, equality, and TypeScript narrowing usually decides senior-level outcomes.