Lets Revise Javascript - 1
The JavaScript concepts interviewers use to make people fail: closures, this, prototypes, the event loop, coercion, and hoisting, with tricky output puzzles.
These are the JavaScript concepts that interviewers use to find out whether you actually understand the language or just recognize its syntax. Each section states the concept tightly, then hits you with the tricky code and the edge cases that make people fail. Try to predict every output before reading the answer. The traps are the point: if you can explain why each surprising result happens, you understand the mechanism, and that is what the hard rounds are testing.
Execution Context: The Two Phases
Almost every trap below traces back to one fact: JavaScript does not run your code in a single pass. Every scope (the global scope, and each function call) is processed in two phases, and knowing what happens in each is what makes hoisting, the temporal dead zone, and scope predictable instead of mysterious.
Phase 1, creation (setup, before any line executes). The engine scans the scope and reserves memory for every declaration it finds:
functiondeclarations: fully created, the name is bound to the finished function.varnames: created and initialized toundefined.letandconstnames: created but left uninitialized (no value at all yet).thisand the scope chain are also set up here.
Phase 2, execution (the code actually runs, top to bottom). Now statements execute in order, and this is where assignments happen. The = value part of var x = value runs here, not in phase 1.
Take this code:
console.log(a);
var a = 1;
foo();
function foo() {}
let b = 2;Phase 1 walks the code first and only sets up declarations (no statements run yet):
a -> created, set to undefined (var)
foo -> created, set to <function> (function declaration)
b -> created, uninitialized (let: exists but no value)Phase 2 then runs the statements top to bottom:
console.log(a) -> a exists as undefined -> logs undefined
a = 1 -> a is now 1
foo() -> foo was ready since phase 1 -> runs fine
b = 2 -> b's TDZ ends here, b is now 2The takeaway: a name can be used in phase 2 before its assignment line because phase 1 already created it. What differs is the value it holds at that point: undefined for var, a ready function for a declaration, and an error-on-access uninitialized state for let/const.
Two consequences fall directly out of this and drive the next sections:
- Hoisting is just phase 1 reserving memory before phase 2 runs. Nothing "moves up"; declarations are simply set up first, which is why a
varreads asundefined(created in phase 1, assigned in phase 2) and a function can be called above its line (fully created in phase 1). - The temporal dead zone is the gap in phase 2 between entering the scope and reaching a
let/constline. The binding exists (phase 1 created it) but is still uninitialized, so touching it throws a ReferenceError instead of givingundefined. This is deliberately stricter thanvarto catch use-before-declaration.
The Call Stack
The two phases describe how one context is processed. The call stack describes how contexts relate as functions call each other. It is a stack (last in, first out) of execution contexts: calling a function pushes a new context on top, and returning pops it off. Only the context on top is running at any moment; everything below it is paused, waiting for the thing above to finish.
The mechanism. JavaScript has one call stack, which is why it is single-threaded: it can only do one thing at a time, at the top of the stack. When you call a function, its context (its phase-1 setup, then phase-2 execution) is pushed on. If that function calls another, a new context goes on top and the caller waits underneath. Each return pops the top context and hands control back to the one below, resuming it exactly where it paused. When the stack is finally empty, the synchronous run is done, and only then does the engine pick up asynchronous work (the event loop section builds directly on this).
function third() { console.log("in third"); }
function second() { third(); }
function first() { second(); }
first();
// Stack grows, then unwinds:
// [global]
// [global, first]
// [global, first, second]
// [global, first, second, third] <- third runs, logs, returns
// [global, first, second] <- pops back down
// [global, first]
// [global]Why this matters for the traps. Three things fall out of the single stack:
- Synchronous code runs to completion first. Async callbacks cannot run until the stack is empty, which is the foundation of every event-loop ordering puzzle later. A
setTimeout(fn, 0)cannot interrupt a running function;fnwaits for the stack to clear. - Stack overflow. Each call adds a frame, and the stack has a finite size. Unbounded recursion (a function that calls itself with no base case) keeps pushing frames until it exceeds the limit and throws
RangeError: Maximum call stack size exceeded.
function loop() { return loop(); }
loop(); // RangeError: Maximum call stack size exceeded- Blocking. Because there is one stack on one thread, a long synchronous task (a huge loop, heavy computation) sits at the top of the stack and freezes everything else until it returns, since nothing else can run while the stack is occupied.
Gotcha. "Single-threaded" is about this one call stack, not about the whole runtime. The stack runs your JavaScript one frame at a time, but the environment (timers, network, and in Node the libuv thread pool) does slow work elsewhere and queues callbacks to run on the stack later, once it is empty.
Hoisting and the Temporal Dead Zone
Declarations are set up in memory before any code runs. function declarations hoist fully (name and body). var hoists as undefined. let and const hoist but stay uninitialized in a "temporal dead zone" until their line, and touching them there throws. This is phase 1 (creation) from the previous section in action.
Why the traps work. Because assignments only happen in phase 2, a var name exists as undefined above its assignment line. Because let/const are created uninitialized, accessing them before their line throws rather than yielding undefined. And because function declarations are fully created in phase 1, a same-named var sees an existing binding and does not overwrite it, so the function wins.
console.log(a); // ?
var a = 1;
console.log(b); // ?
let b = 2;First logs undefined (var is hoisted, value not yet assigned). Second throws ReferenceError (b is in the temporal dead zone). People expect both to be undefined; the second one throwing is the trap.
Trickier:
var x = 1;
function test() {
console.log(x); // ?
var x = 2;
}
test();Logs undefined, not 1. The inner var x is hoisted to the top of test, so the local x shadows the outer one and exists (as undefined) before the assignment. People expect 1 because they forget the local declaration hoists above the console.log.
The function-vs-variable priority trap:
console.log(typeof foo); // ?
var foo = "bar";
function foo() {}Logs "function". Function declarations hoist above var declarations, so foo is the function at that point; the = "bar" assignment has not run yet. Most people say "string" or "undefined".
The one that fails almost everyone:
function outer() {
console.log(fn); // ?
console.log(expr); // ?
function fn() {}
var expr = function () {};
}
outer();First logs the function fn (declaration, fully hoisted). Second logs undefined (only the var expr binding hoisted, not the function assigned to it). The asymmetry between a function declaration and a function expression assigned to a var is the trap.
Gotcha. "Hoisting moves code to the top" gives wrong predictions. Only declarations are registered early; assignments stay in place. That is the entire reasonvarreads asundefinedbefore its line instead of its assigned value.
Scope and Lexical Scope
Scope is the set of places a variable is accessible from. JavaScript has three: global scope, function scope, and block scope (any { }, but only for let/const, not var). The word that matters most is lexical: scope is determined by where code is written in the source, not by where or how it is called at runtime. A function's accessible variables are fixed the moment you write it.
The mechanism. When a function is defined, it is permanently associated with the scope it was written inside. At runtime, resolving a variable walks the scope chain outward: the engine looks in the current scope, then the scope that lexically encloses it, then the next, out to global, stopping at the first match. If nothing matches, you get a ReferenceError (or, for an assignment in non-strict mode, an accidental global). The chain is built from the nesting in the source, not the call stack, which is the entire distinction between lexical (what JavaScript uses) and dynamic scope (what it does not). This is why a function called from a completely different place still sees the variables from where it was written, and it is the exact foundation closures are built on.
const x = "global";
function outer() {
const y = "outer";
function inner() {
const z = "inner";
console.log(x, y, z); // ?
}
inner();
}
outer();Logs global outer inner. inner looks up each name along its lexical chain: z locally, y in outer, x in global. Inner scopes see outward; the reverse is not true.
Outer cannot see inner:
function outer() {
function inner() {
const secret = 42;
}
inner();
console.log(secret); // ?
}
outer();Throws ReferenceError: secret is not defined. The chain only goes outward, never inward, so outer has no access to inner's locals. People sometimes assume calling inner() somehow exposes its variables; it does not.
Lexical, not dynamic (the defining test):
const value = "global";
function print() {
console.log(value); // ?
}
function run() {
const value = "local";
print(); // called from inside run()
}
run();Logs global, not local. print was written in global scope, so it resolves value there, regardless of being called from inside run. If JavaScript were dynamically scoped it would print local. This single example is the cleanest proof that JavaScript scope is lexical, and understanding it is what makes closures and this (which, unlike scope, is dynamic) finally make sense as opposites.
The shadowing trap:
let a = 1;
function f() {
console.log(a); // ?
let a = 2;
console.log(a); // ?
}
f();Throws ReferenceError on the first log. Because let a inside f declares a in the function's scope, that local shadows the outer a for the whole function body, and the first access lands in its temporal dead zone. People expect the first log to print the outer 1; instead the inner declaration poisons the earlier line. With var this would print undefined instead of throwing.
Closures
A closure is a function that retains access to variables from the scope where it was defined, even after that scope has returned. The key subtlety that produces most bugs: a closure captures the variable, not a snapshot of its value.
The mechanism. Every function, when created, gets an internal reference to the environment (the set of variable bindings) it was born in. This is fixed at definition time, not call time, which is why JavaScript is lexically scoped. When the outer function returns, its local variables would normally be discarded, but if an inner function still references them, that environment is kept alive on the heap instead of being torn down. So the variables survive precisely because something still points at them. This is also why closures are a memory concern: a closure that references one variable keeps its entire enclosing environment reachable, so capturing a small field of a huge object can pin the whole object in memory until the closure is gone. The reason the loop bug exists is that var creates one binding in the function scope that every iteration and every closure shares, whereas let creates a new binding on each iteration, so each closure captures a different one.
The most-asked interview bug:
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 100);
}
// ?Prints 3, 3, 3. var is function-scoped, so all three callbacks share one i, which is 3 by the time they fire. Change var to let and it prints 0, 1, 2, because let creates a fresh binding per iteration.
The same trap in disguise:
const fns = [];
for (var i = 0; i < 3; i++) {
fns.push(() => i);
}
console.log(fns[0](), fns[1](), fns[2]()); // ?Prints 3 3 3. Same single shared i. People who "fixed" the setTimeout version by memorizing let often still miss this because there is no timer to hint at asynchrony.
Pre-let fix worth knowing (they may ask):
for (var i = 0; i < 3; i++) {
(function (j) {
setTimeout(() => console.log(j), 100);
})(i);
}
// Prints 0, 1, 2 -> the IIFE copies i into a new variable j each iterationShared vs independent closures:
function counter() {
let count = 0;
return () => ++count;
}
const a = counter();
const b = counter();
console.log(a(), a(), b()); // ?Prints 1 2 1. a and b each closed over their own count. The trap is assuming they share state; each call to counter() makes a fresh scope.
The deep one:
function makeFuncs() {
let result = [];
for (let i = 0; i < 3; i++) {
result.push(function () { return i; });
}
return result;
}
const [f0, f1, f2] = makeFuncs();
console.log(f0(), f1(), f2()); // ?Prints 0 1 2 (with let). But if you switch it to var i, it prints 3 3 3. Interviewers flip this one keyword and watch whether you catch it.
Gotcha. Closures hold a live reference, so if the captured variable is mutated later, the closure sees the new value. This is not a snapshot. Every "prints 3, 3, 3" bug is this single fact.
The this Keyword
this is decided by how a function is called, not where it is defined. Arrow functions are the exception: they have no own this and inherit it from the enclosing scope.
The mechanism. When a regular function is invoked, the engine sets this based on the call expression itself, resolved fresh on every call. There are exactly four binding rules, checked in this precedence: new binding (calling with new makes this the new object), explicit binding (call/apply/bind set this to the argument you pass), implicit binding (calling as obj.fn() sets this to obj, the thing left of the last dot), and default binding (a bare fn() gives undefined in strict mode, the global object otherwise). The single most useful rule of thumb: look at what is immediately left of the parenthesis at the call site. obj.method() has obj on the left, so this is obj. A bare method() has nothing meaningful on the left, so this defaults. Arrow functions opt out of this entire system: they capture this from the surrounding lexical scope at definition time and ignore how they are called, which is why bind, call, and apply cannot change an arrow's this, and why an arrow is the fix for a callback that would otherwise lose it.
The classic lost-this:
const obj = {
name: "Ada",
greet() { return this.name; },
};
const g = obj.greet;
console.log(obj.greet()); // ?
console.log(g()); // ?Prints "Ada" then undefined. Called as a method, this is obj. Pulled off and called bare, this is undefined (strict mode), so this.name throws or is undefined. Detaching a method loses its this.
The nested-function trap:
const obj = {
name: "Ada",
greet() {
function inner() { return this.name; }
return inner();
},
};
console.log(obj.greet()); // ?Throws or logs undefined. inner is called as a plain function, so its this is not obj, even though it is defined inside a method. People assume nesting preserves this; it does not. An arrow function would, because it captures lexically.
Arrow vs regular in an object:
const obj = {
name: "Ada",
regular() { return this.name; },
arrow: () => this.name,
};
console.log(obj.regular()); // ?
console.log(obj.arrow()); // ?Prints "Ada" then undefined (the arrow captured this from the surrounding module/global scope, not obj). The trap is thinking an arrow method is "cleaner"; here it breaks the method.
The this in a callback:
const obj = {
vals: [1, 2, 3],
name: "Ada",
show() {
return this.vals.map(function (v) {
return `${this.name}: ${v}`; // ?
});
},
};
console.log(obj.show()); // ?The this.name inside the regular callback is undefined, so it produces "undefined: 1" etc. map's callback is called as a plain function. Fix with an arrow callback (inherits this) or map(fn, this).
Bind, and double bind:
function f() { return this.x; }
const bound = f.bind({ x: 1 });
const reBound = bound.bind({ x: 2 });
console.log(reBound()); // ?Prints 1, not 2. Once bound, a function's this cannot be rebound; the second bind is ignored. Almost everyone says 2.
Prototypes and Inheritance
JavaScript inheritance is prototype-based. Property lookup walks the prototype chain until it finds the property or hits null. class is sugar over this.
The mechanism. Every object has an internal link (accessible as Object.getPrototypeOf(obj), historically __proto__) to another object, its prototype. Reading a property is a search: the engine checks the object's own properties first, and if not found, follows the prototype link to the next object, and the next, until it finds the property or reaches null and returns undefined. Crucially, writing does not walk the chain the same way: assigning obj.x = 1 creates or updates an own property on obj itself, which shadows anything with that name further up the chain rather than modifying the prototype. This asymmetry (reads climb, writes land locally) explains most prototype surprises. Because methods live on the shared prototype and are found by lookup at call time, all instances see the current prototype, so mutating the prototype affects every instance that has not shadowed that property. The two confusing names: prototype is a property that exists on constructor functions, holding the object that instances built with new will link to; __proto__ is the actual link on any object pointing at its prototype. So new Dog().__proto__ === Dog.prototype. A class with extends does nothing new underneath: it wires Child.prototype's link to Parent.prototype, forming the chain, and super calls up it.
Own vs inherited:
const parent = { greeting: "hi" };
const child = Object.create(parent);
child.greeting = "hello";
console.log(child.greeting); // ?
delete child.greeting;
console.log(child.greeting); // ?Prints "hello" then "hi". Setting greeting on child creates an own property that shadows the prototype. Deleting it reveals the inherited one again. The trap is thinking the delete removed the property entirely.
prototype vs __proto__:
function Dog() {}
const d = new Dog();
console.log(d.__proto__ === Dog.prototype); // ?
console.log(Dog.__proto__ === Function.prototype); // ?
console.log(Dog.prototype.__proto__ === Object.prototype); // ?All true. prototype is a property on the constructor; __proto__ is the actual link on any object. Dog is itself a function, so its __proto__ is Function.prototype. This distinction fails most candidates.
Mutating a shared prototype:
function Animal() {}
Animal.prototype.legs = 4;
const a = new Animal();
const b = new Animal();
Animal.prototype.legs = 2;
console.log(a.legs, b.legs); // ?Prints 2 2. Both instances read legs from the shared prototype at lookup time, so changing the prototype affects all instances that have not shadowed it. The trap is expecting instances to have snapshotted the value.
The array-length surprise via prototype chain:
console.log([].__proto__ === Array.prototype); // ?
console.log([].__proto__.__proto__ === Object.prototype); // ?
console.log([].__proto__.__proto__.__proto__); // ?true, true, then null. The chain ends at null, which is why looking up a missing property eventually yields undefined rather than looping forever.
Promises, async/await, and the Event Loop
Synchronous code runs first. Then microtasks (promise callbacks, queueMicrotask) drain completely. Then one macrotask (setTimeout, setInterval) runs, and microtasks drain again after it. await pauses a function and schedules its continuation as a microtask.
The mechanism. JavaScript runs on a single thread with two queues and one rule. The call stack runs all synchronous code to completion first; nothing async interrupts it. Async callbacks wait in one of two queues: the microtask queue (promise .then/.catch/.finally callbacks, await continuations, queueMicrotask) and the macrotask queue (setTimeout, setInterval, I/O, UI events). The event loop's rule is: after the stack empties, drain the entire microtask queue (including any microtasks those microtasks schedule), then take one macrotask, then drain the entire microtask queue again, and repeat. That "microtasks fully drain between each macrotask" rule is why a Promise.then always beats a setTimeout(0) queued at the same moment, and why a timeout scheduled inside a promise callback runs after other already-queued timeouts. For async/await, the mental model is that everything up to and including the awaited expression runs synchronously, then await suspends the function and registers the rest of it as a microtask that resumes when the awaited promise settles. So an async function is synchronous until its first await, and each await after that is a microtask boundary.
The baseline everyone should get:
console.log("1");
setTimeout(() => console.log("2"), 0);
Promise.resolve().then(() => console.log("3"));
console.log("4");
// ?Prints 1, 4, 3, 2. Sync first (1, 4), then the microtask (3), then the macrotask (2), even at 0 ms.
Where it gets nasty:
console.log("A");
setTimeout(() => console.log("B"), 0);
Promise.resolve().then(() => {
console.log("C");
setTimeout(() => console.log("D"), 0);
});
Promise.resolve().then(() => console.log("E"));
console.log("F");
// ?Prints A, F, C, E, B, D. Sync: A, F. Microtasks drain: C (which schedules another timeout D), then E. Now macrotasks in order queued: B, then D. The trap is that D was queued during microtask draining, so it lands after B.
async/await desugaring:
async function fn() {
console.log("1");
await console.log("2");
console.log("3");
}
console.log("0");
fn();
console.log("4");
// ?Prints 0, 1, 2, 4, 3. fn() runs synchronously to the await (1, then 2 because the argument is evaluated), then suspends; control returns and 4 prints; the continuation 3 runs as a microtask afterward. People miss that everything before the await is synchronous and only the part after is deferred.
The killer combination:
async function a() {
console.log("a1");
await b();
console.log("a2");
}
async function b() {
console.log("b1");
}
console.log("start");
a();
Promise.resolve().then(() => console.log("promise"));
console.log("end");
// ?Prints start, a1, b1, end, a2, promise. Trace: start; a() runs to a1, calls b() which logs b1, then a suspends at the await; back to top level, the .then is queued, end logs. Now microtasks: a2 (the await continuation, queued first) then promise. The ordering of a2 before promise is what trips people.
Boss level (trace it before reading):
console.log("1");
setTimeout(() => console.log("2"), 0);
Promise.resolve().then(() => {
console.log("3");
return Promise.resolve(); // returning a promise adds an extra microtask tick
}).then(() => console.log("4"));
Promise.resolve().then(() => console.log("5")).then(() => console.log("6"));
console.log("7");
// ?Prints 1, 7, 3, 5, 6, 4, 2. The trace, tick by tick. Synchronous: 1, 7. Microtask round 1: the two first-level .thens run in queue order, so 3 (which returns a promise), then 5. Returning a promise from a .then means its next .then has to wait for that returned promise to be adopted, which costs an extra microtask tick, so 4 is delayed. Microtask round 2: 6 runs (the chained .then after 5), while the 3 chain is still resolving its returned promise. Microtask round 3: 4 finally runs. Then the sole macrotask: 2. The lesson that fails nearly everyone: return Promise.resolve() inside a .then inserts extra microtask ticks, so 4 loses the race to 5 and 6 even though it was chained earlier. If 3 had returned a plain value instead of a promise, the order would be 1, 7, 3, 5, 4, 6, 2.
The forgotten-return trap:
async function get() {
return 42;
}
console.log(get()); // ?
get().then(v => console.log(v)); // ?First logs a Promise, not 42. An async function always returns a promise; the second line unwraps it to log 42. People expect the first to print 42.
Promise.all vs sequential timing:
async function slow(x) {
return new Promise(r => setTimeout(() => r(x), 100));
}
// Version 1
async function seq() {
const a = await slow(1);
const b = await slow(2);
return [a, b];
}
// Version 2
async function par() {
return Promise.all([slow(1), slow(2)]);
}seq() takes ~200ms (each await blocks the next). par() takes ~100ms (both start together). The trap is not knowing that sequential await serializes independent work.
Type Coercion and Equality
== coerces before comparing; === does not. Coercion rules produce results that look random but are not.
The mechanism. === returns false immediately if the types differ; otherwise it compares directly. == follows a fixed algorithm when the types differ: null and undefined are equal to each other and to nothing else; if one side is a number and the other a string, the string is converted to a number; a boolean is always converted to a number first (true to 1, false to 0); and if one side is an object and the other a primitive, the object is converted to a primitive (via valueOf/toString) and the comparison retries. This is why [] == false is true: false becomes 0, [] becomes "" becomes 0, and 0 == 0. It is also why "" == "0" is false: both are already strings, so no conversion happens and the two different strings simply are not equal. Separately, the + operator is overloaded: if either operand is a string (or becomes one via object-to-primitive), it concatenates; otherwise it adds numerically. -, *, and / have no string meaning, so they always coerce to numbers. Knowing these two rule sets (the == conversion order and the + string preference) turns every "wat" example into a deterministic trace.
The == minefield:
console.log(0 == ""); // ?
console.log(0 == "0"); // ?
console.log("" == "0"); // ?
console.log(null == undefined); // ?
console.log(null == 0); // ?
console.log(NaN == NaN); // ?true, true, false, true, false, false. Note 0 == "" and 0 == "0" are both true but "" == "0" is false (two strings, compared directly). null equals undefined but not 0. NaN equals nothing, including itself.
The array coercion horrors:
console.log([] == ![]); // ?
console.log([] + []); // ?
console.log([] + {}); // ?
console.log([1,2] == "1,2"); // ?true, "", "[object Object]", true. [] == ![] is true because ![] is false, then both sides coerce to 0. Arrays stringify ([] to "", [1,2] to "1,2"). These are the "wat" classics.
Floating point:
console.log(0.1 + 0.2 === 0.3); // ?
console.log(0.1 + 0.2); // ?false, and 0.30000000000000004. IEEE 754 doubles cannot represent 0.1 or 0.2 exactly, so the sum carries rounding error. Compare with a tolerance or use integers for money.
typeof traps:
console.log(typeof null); // ?
console.log(typeof []); // ?
console.log(typeof function(){});// ?
console.log(typeof NaN); // ?"object", "object", "function", "number". typeof null being "object" is a historical bug. Arrays are "object" (use Array.isArray). NaN is a "number" despite meaning "not a number."
The increment coercion:
console.log("5" - 2); // ?
console.log("5" + 2); // ?
console.log("5" * "2");// ?
console.log(true + 1); // ?
console.log([] + 1); // ?3, "52", 10, 2, "1". - and * coerce to numbers; + prefers string concatenation if either operand is a string. true becomes 1. [] becomes "" then concatenates.
Value vs Reference (and Mutation)
Primitives copy by value; objects (and arrays and functions) copy by reference. This one fact drives a family of bugs.
The mechanism. A variable holding a primitive stores the value itself. A variable holding an object stores a reference, a pointer to the object which lives on the heap. Assignment always copies what the variable holds: for a primitive that is the value (so the two become independent), for an object that is the pointer (so both names point at one shared object). Passing an argument is the same assignment: the parameter receives a copy of what you passed. This produces the asymmetry people trip on: inside a function, mutating an object parameter's properties changes the shared object the caller sees, but reassigning the parameter only repoints the local copy and the caller is unaffected. const participates in this at the binding level: it forbids reassigning the variable (repointing it), but if the variable points at an object, the object's contents are still mutable, because mutation does not touch the binding. And any copy you make ({...obj}, Object.assign, slice) copies only one level of references, so nested objects remain shared; a true independent copy needs structuredClone or an equivalent deep clone.
let a = { n: 1 };
let b = a;
b.n = 2;
console.log(a.n); // ?2. b is the same object as a. Mutating through one is visible through the other.
const does not mean immutable:
const arr = [1, 2, 3];
arr.push(4);
console.log(arr); // ?
arr = []; // ?Logs [1, 2, 3, 4], then the reassignment throws TypeError. const freezes the binding, not the contents; you can mutate the object but not repoint the variable. People conflate the two.
The function-argument mutation:
function change(obj, val) {
obj.x = 10; // mutates the shared object
val = 20; // reassigns the local copy only
}
const o = { x: 1 };
let v = 1;
change(o, v);
console.log(o.x, v); // ?10 1. The object's property changed (shared reference); the primitive did not (copied by value). The asymmetry is the trap.
The copy that is not a copy:
const original = { list: [1, 2] };
const copy = { ...original }; // shallow
copy.list.push(3);
console.log(original.list); // ?[1, 2, 3]. Spread is a shallow copy: the top level is new, but list is still the same array reference. Nested mutation leaks back. structuredClone is needed for a deep copy.
Default parameter and shared reference:
function add(item, arr = []) {
arr.push(item);
return arr;
}
console.log(add(1)); // ?
console.log(add(2)); // ?[1] then [2], not [1] then [1,2]. Default parameters are evaluated fresh each call, so this is actually safe, unlike the classic Python mutable-default bug. Interviewers ask this expecting you to wrongly say [1,2].
Scope, var/let/const, and Blocks
var is function-scoped and can be redeclared; let/const are block-scoped and cannot. Blocks matter.
if (true) {
var x = 1;
let y = 2;
}
console.log(x); // ?
console.log(y); // ?1 then ReferenceError. var ignores the block and leaks to the function/global scope; let is confined to the block. The trap is expecting both to leak or both to be confined.
Redeclaration:
var a = 1;
var a = 2; // fine
console.log(a); // ?
let b = 1;
let b = 2; // ?First is 2; the second line throws SyntaxError (cannot redeclare a let in the same scope). People forget var silently allows redeclaration.
The switch fallthrough scope trap:
switch (1) {
case 1:
let z = "a"; // ?
break;
case 2:
let z = "b";
break;
}Throws SyntaxError: the whole switch is one block, so two let z collide. Wrapping each case in { } fixes it. A subtle one that surprises even experienced people.
Miscellaneous Killers
A grab bag of one-line questions that fail people.
console.log([1, 2, 3].map(parseInt)); // ?[1, NaN, NaN]. map passes (value, index), and parseInt takes (string, radix), so you get parseInt("1",0)=1, parseInt("2",1)=NaN, parseInt("3",2)=NaN. The accidental second argument is the trap.
console.log(0.1 + 0.2 == 0.3); // false (floating point)
console.log((0.1 + 0.2).toFixed(1)); // ?"0.3". toFixed rounds and returns a string; the trap is expecting a number.
let arr = [1, 2, 3];
arr.length = 0;
console.log(arr[0]); // ?undefined. Setting length = 0 empties the array. People forget length is writable.
console.log(typeof typeof 1); // ?"string". Inner typeof 1 is "number" (a string), and typeof "number" is "string".
const x = { a: 1 };
const y = { a: 1 };
console.log(x == y); // ?
console.log(x === y); // ?false, false. Object comparison is by reference, not contents. Two identical-looking objects are different references.
console.log(1 < 2 < 3); // ?
console.log(3 > 2 > 1); // ?true, but false. 3 > 2 > 1 is (3 > 2) > 1 which is true > 1 which is 1 > 1 which is false. Chained comparisons do not mean what they read.
console.log([] == false); // ?
console.log([0] == false); // ?
console.log([1] == true); // ?true, true, true. Arrays coerce through string then number; empty and [0] become 0 (falsy side), [1] becomes 1. Deeply confusing and a favorite closer.
The Traps, Summarized
The single sentence behind each family of failures:
- Hoisting: declarations register early, assignments do not, so
varreadsundefinedbefore its line and function declarations outrankvar. - Closures: they capture the variable, not a value snapshot, so shared
varin a loop yields the final value everywhere. this: set by the call site, not the definition; detaching a method or nesting a plain function loses it; arrows capture it lexically and cannot be rebound.- Prototypes: instances read from the shared prototype at lookup time, so prototype changes affect all non-shadowing instances;
prototypeis on constructors,__proto__is on objects. - Event loop: all microtasks drain before the next macrotask, and everything before an
awaitis synchronous. - Coercion:
==converts first, producing0 == ""true and array "wat" results;+prefers strings while-and*force numbers. - Reference: objects share, primitives copy,
constfreezes the binding not the contents, and spread is shallow.
If you can look at any of the snippets above and narrate why the output is what it is, using the mechanism rather than memorized outcomes, you will handle the deep JavaScript round, because that narration is exactly what it is built to extract.
Related
API Integration in JavaScript: Reading Responses, Handling Errors, and Stripe
Read API responses the right way: res.json() vs res.text(), error handling, timeouts, retries, React data fetching, and real Stripe payments and webhooks.
Next.js Caching in 2026: From the Four-Layer Model to use cache
A beginner-to-advanced guide to Next.js caching: the four cache layers, the RSC payload, Cache Components, and when revalidateTag beats updateTag.
Node.js Memory, Garbage Collection & Production Failures
How Node.js memory really works: the V8 heap, garbage collection, memory leaks, OOM crashes, and diagnosing it all in production on EC2 and ECS.