JavaScript Interview Traps: A Survival Guide
I have reviewed hundreds of technical interviews as a senior engineer, and I keep seeing the same traps trip up candidates who otherwise know their material. These are not obscure edge cases. They are things that appear in codebases and in interview questions every single week. This lesson is a structured reference: each trap, followed by the explanation, followed by the fix.
Trap 1: Event Loop Ordering
The event loop is almost always on the interview rubric for senior positions. The question is usually: "what does this code print, and in what order?"
The rule:
- Run all synchronous code in the call stack.
- Drain the entire microtask queue (Promises,
queueMicrotask). - Take one macrotask (setTimeout, setInterval, I/O callbacks).
- Drain the entire microtask queue again.
- Repeat.
console.log("1"); // Synchronous
setTimeout(() => console.log("2"), 0); // Macrotask
Promise.resolve().then(() => console.log("3")); // Microtask
queueMicrotask(() => console.log("4")); // Microtask
console.log("5"); // Synchronous
// Output: 1, 5, 3, 4, 2
Explanation:
- "1" and "5" run synchronously first.
- "3" and "4" run before "2" because Promise callbacks and
queueMicrotaskgo into the microtask queue, which is drained before any macrotask runs. setTimeout(fn, 0)does not mean "run immediately." It means "enqueue as a macrotask after at least 0ms." The microtask queue always runs first.
Advanced version: microtasks spawning microtasks keep the microtask queue going before any macrotask can run.
Promise.resolve()
.then(() => {
console.log("A");
// This queues another microtask before setTimeout can run
return Promise.resolve();
})
.then(() => console.log("B"));
setTimeout(() => console.log("C"), 0);
// Output: A, B, C
Trap 2: Closure in Loop with var
Covered in the closures lesson, but it appears so frequently in interviews that it belongs here too.
Problem:
const fns: Array<() => void> = [];
for (var i = 0; i < 3; i++) {
fns.push(() => console.log(i));
}
fns.forEach((f) => f()); // 3, 3, 3
Explanation: var is function-scoped. There is one i shared by all closures. After the loop, i is 3.
Fix:
// Fix 1: use let (block-scoped, new binding per iteration)
const fnsFixed: Array<() => void> = [];
for (let i = 0; i < 3; i++) {
fnsFixed.push(() => console.log(i));
}
fnsFixed.forEach((f) => f()); // 0, 1, 2
// Fix 2: capture with an IIFE (legacy code)
const fnsIIFE: Array<() => void> = [];
for (var j = 0; j < 3; j++) {
((captured) => fnsIIFE.push(() => console.log(captured)))(j);
}
fnsIIFE.forEach((f) => f()); // 0, 1, 2
Trap 3: Mutating Object Arguments
Primitives (numbers, strings, booleans) are passed by value. Objects are passed by reference. This causes silent bugs when a function modifies an object that the caller did not expect to change.
Problem:
function addRole(user: { name: string; roles: string[] }, role: string): void {
user.roles.push(role); // Mutates the caller's object
}
const alice = { name: "Alice", roles: ["viewer"] };
addRole(alice, "editor");
console.log(alice.roles); // ['viewer', 'editor']: original is mutated!
Explanation: user in the function is the same object reference as alice in the caller. Pushing to user.roles pushes to alice.roles.
Fix: Return a new object instead of mutating, or explicitly clone if mutation is intended.
// Immutable approach
function addRoleImmutable(
user: { name: string; roles: string[] },
role: string
): { name: string; roles: string[] } {
return { ...user, roles: [...user.roles, role] }; // New object, new array
}
const alice = { name: "Alice", roles: ["viewer"] };
const updated = addRoleImmutable(alice, "editor");
console.log(alice.roles); // ['viewer']: untouched
console.log(updated.roles); // ['viewer', 'editor']
The spread only does a shallow clone. If user had nested objects, those would still be shared references. For deep cloning, use structuredClone() (available in Node.js 17+ and modern browsers).
Trap 4: NaN Comparisons
NaN is the only value in JavaScript that is not equal to itself. This means NaN === NaN is false. It also means typeof NaN === 'number' is true, which surprises people every time.
Problem:
const result = parseInt("not a number");
console.log(result); // NaN
console.log(result === NaN); // false: NaN !== NaN always
console.log(result == NaN); // false: same issue with ==
console.log(typeof result === "number"); // true: NaN is of type 'number'
// The old way (do not use)
console.log(isNaN("hello")); // true: but coerces the argument first!
console.log(isNaN(undefined)); // true: undefined coerces to NaN
Fix: Use Number.isNaN, which does not coerce its argument.
console.log(Number.isNaN(NaN)); // true
console.log(Number.isNaN(undefined)); // false: no coercion
console.log(Number.isNaN("hello")); // false: not a number type
console.log(Number.isNaN(parseInt("not a number"))); // true
// Safe number validation pattern
function parseSafeInt(input: string): number | null {
const parsed = parseInt(input, 10);
return Number.isNaN(parsed) ? null : parsed;
}
console.log(parseSafeInt("42")); // 42
console.log(parseSafeInt("abc")); // null
Always use Number.isNaN, not global isNaN. And always pass a radix (10) to parseInt to avoid octal parsing on strings starting with "0".
Trap 5: Floating Point Precision
Problem:
console.log(0.1 + 0.2); // 0.30000000000000004
console.log(0.1 + 0.2 === 0.3); // false
Explanation: JavaScript uses IEEE 754 double-precision floating point. 0.1 and 0.2 cannot be represented exactly in binary. The small errors accumulate.
Fix: For comparison, use an epsilon (acceptable margin of error). For financial calculations, use integers (store cents, not dollars) or a library like decimal.js.
const EPSILON = Number.EPSILON; // 2.220446049250313e-16
function nearlyEqual(a: number, b: number, epsilon = EPSILON): boolean {
return Math.abs(a - b) < epsilon;
}
console.log(nearlyEqual(0.1 + 0.2, 0.3)); // true
// For display, round to significant digits
console.log((0.1 + 0.2).toFixed(2)); // "0.30"
console.log(parseFloat((0.1 + 0.2).toFixed(10))); // 0.3
// For money: work in integers
const priceInCents = 10 + 20; // 30 cents, exact
console.log(priceInCents / 100); // 0.3, display only
Trap 6: == vs === and Type Coercion
=== (strict equality) checks both value and type. == (loose equality) applies type coercion rules that are often surprising.
The coercion rules for == are complex:
console.log(0 == false); // true: false coerces to 0
console.log("" == false); // true: both coerce to 0
console.log(null == undefined); // true: special case in the spec
console.log(null == 0); // false: null only equals undefined
console.log([] == false); // true: [] → "" → 0, false → 0
console.log([] == ![]); // true: one of the most confusing results in JS
// Arithmetic coercion
console.log("5" + 3); // "53": + triggers string concatenation
console.log("5" - 3); // 2: - triggers numeric conversion
console.log("5" * "3"); // 15: both coerce to numbers
console.log(true + true); // 2: booleans coerce to 0/1
Fix: Always use ===. The only intentional use of == in modern code is x == null, which checks for both null and undefined in one expression.
function processValue(x: string | number | null | undefined): void {
if (x == null) {
// catches both null and undefined
console.log("no value");
return;
}
// TypeScript now knows x is string | number
console.log(x);
}
In TypeScript, the type system prevents most accidental coercions. But understanding == is still tested in interviews because it reveals how well you know the runtime.
Trap 7: Temporal Dead Zone in Practice
The TDZ was covered in the closures lesson, but its practical manifestation in real code deserves its own entry. The most common case is circular imports or out-of-order initialisation.
Problem:
// This works with var but not with let/const
function demonstrateTDZ(): void {
// Using before declaration
try {
const upper = name.toUpperCase(); // ReferenceError
console.log(upper);
} catch (e) {
console.log("TDZ error:", (e as Error).message);
}
const name = "alice";
}
demonstrateTDZ();
// A subtler case: class declarations also have TDZ
try {
const obj = new MyClass(); // ReferenceError: class not yet initialised
} catch (e) {
console.log("Class TDZ:", (e as Error).message);
}
class MyClass {
value = 42;
}
class declarations (unlike function declarations) are not hoisted with their definition. You cannot use a class before its declaration line, even in the same scope.
Fix: Always declare before use. Enable "no-use-before-define" in your ESLint config to catch this statically.
Trap 8: Object Key Gotchas
Problem 1: All object keys are strings (or Symbols). Numeric keys are coerced to strings.
const obj: Record<string, string> = {};
obj[1] = "one";
obj[1.5] = "one point five";
console.log(Object.keys(obj)); // ['1', '1.5']: numbers become strings
Problem 2: If you need non-string keys, use Map.
const map = new Map<object, string>();
const key1 = { id: 1 };
const key2 = { id: 1 }; // Different reference
map.set(key1, "first");
map.set(key2, "second");
console.log(map.get(key1)); // "first"
console.log(map.get(key2)); // "second": different key reference
console.log(map.size); // 2
Problem 3: Object spread and Object.assign only copy own enumerable properties. They do not copy prototype methods.
class Point {
constructor(public x: number, public y: number) {}
toString(): string {
return `(${this.x}, ${this.y})`;
}
}
const p = new Point(1, 2);
const copy = { ...p };
console.log(copy.x); // 1: own property, copied
// copy.toString(): TypeError: copy.toString is not a function
// toString is on Point.prototype, not on p itself
Quick Reference
| Trap | Wrong Pattern | Right Pattern |
|---|---|---|
| Async ordering | Assume setTimeout(fn,0) is immediate | Microtasks run first; macrotasks last |
| Loop closures | var in closures | Use let |
| Mutation | Modify argument objects | Return new objects |
| NaN check | x === NaN or isNaN(x) | Number.isNaN(x) |
| Float comparison | 0.1 + 0.2 === 0.3 | Use epsilon or integer arithmetic |
| Equality | == | === (except x == null) |
| TDZ | Use let/const/class before declaration | Declare first |
| Object keys | Use objects as Map keys | Use Map |
Key insight: These traps exist because JavaScript was designed with coercion and flexibility as first principles, and TypeScript layers type safety on top but cannot change the runtime. Knowing these traps is what separates engineers who understand the runtime from those who only understand the types.