ForgeFrontend - Prepare, Practice, Crack
Secure checkout
Lifetime access
Instant PDF download
Free updates forever

Prepare · Practice · Crack

Tricky JavaScript Interview Questions

Short answer

Trick questions in JavaScript interviews sit on five models: IEEE-754 floating point, type coercion, the event loop, closures and scope, and this-binding. Name the model and the answer follows without memorising anything, which matters, because the interviewer's next question is always why.

The questionThe answerThe model behind it
0.1 + 0.2 === 0.3false - the sum is 0.30000000000000004IEEE-754 doubles
NaN === NaNfalseNaN is the only self-unequal value
typeof null"object"A 1995 bug kept for compatibility
typeof NaN"number"NaN is a numeric value, not a type
var in a loop with setTimeout3 3 3, not 0 1 2One binding shared by every closure
.then() vs setTimeout(fn, 0)then always runs firstMicrotask queue drains before macrotasks
[] + {} vs {} + []"[object Object]" vs 0Statement-versus-expression parsing
[1, 10, 9].sort()[1, 10, 9]Default sort compares strings
The classic traps, and the model each one is really testing.

How to answer a trick question without memorising it

Trick questions have a shape. Almost every one of them sits on top of one of five models, IEEE-754 floating point, type coercion, the event loop, closures and scope, or this-binding, and once you can name the model, the answer falls out of it instead of out of recall. That matters more than it sounds, because interviewers very rarely stop at the answer. They ask why, and a memorised answer has nowhere to go from there.

So the move in the room is always the same three sentences: give the answer, name the model, then give the practical consequence. "False. Both operands are 64-bit doubles and 0.1 has no exact binary representation, so in real code I'd compare within an epsilon, or store money in paise as integers." That is the whole answer, it takes ten seconds, and it demonstrates considerably more than the question asked for.

Why is 0.1 + 0.2 not equal to 0.3?

Every number in JavaScript is a 64-bit IEEE-754 double, including the ones that look like integers. Binary fractions can only represent sums of powers of two, and 0.1 is not one, it is a repeating fraction in binary, exactly the way one third is in decimal. It gets rounded on the way in, the rounding errors add up, and the result is a hair above 0.3.

Floating point, and the two ways to work around itjavascript
1console.log(0.1 + 0.2);
2// → 0.30000000000000004
3
4console.log(0.1 + 0.2 === 0.3);
5// → false
6
7console.log((0.1).toFixed(20));
8// → 0.10000000000000000555   the stored value was never 0.1
9
10// Fix 1: compare within a tolerance
11const near = (a, b) => Math.abs(a - b) < Number.EPSILON;
12console.log(near(0.1 + 0.2, 0.3));
13// → true
14
15// Fix 2: work in the smallest unit as integers, the money answer
16const total = 1999 + 4999;     // paise, not rupees
17console.log(total / 100);
18// → 69.98
19
20console.log(0.1 + 0.7);
21// → 0.7999999999999999        and 0.1 + 0.3 is exact, which is the giveaway
22// → the error depends on the specific values, not on addition being broken

The follow-up almost every interviewer asks is what you would do about it in a real application, and the answer they want is about money. Never store currency as a float, store paise, or cents, as integers and divide only at the point of display. Say that and the question is finished; stop at "binary can't represent 0.1" and you have answered a computer-science question rather than an engineering one.

Why is NaN not equal to itself?

NaN means the result of a numeric operation that has no numeric answer, and two operations that both failed have not necessarily failed in the same way, so the IEEE-754 spec defines every comparison involving NaN as false, including equality with itself. It is the only value in the language with that property, which makes it a neat identity test and a genuine nuisance in array methods.

Detecting NaN, and the two functions that are not the samejavascript
1console.log(NaN === NaN);        // → false
2console.log(typeof NaN);        // → number      it is a numeric value
3console.log(0 / 0, "a" * 2);    // → NaN NaN
4
5// isNaN coerces first - this is the trap
6console.log(isNaN("hello"));            // → true    "hello" became NaN
7console.log(Number.isNaN("hello"));     // → false   it is a string, not NaN
8
9// Object.is knows the difference, and also separates the two zeros
10console.log(Object.is(NaN, NaN));       // → true
11console.log(Object.is(0, -0));          // → false
12console.log(0 === -0);                  // → true
13
14// indexOf uses ===, so it can never find NaN; includes uses SameValueZero
15const xs = [1, NaN, 3];
16console.log(xs.indexOf(NaN));           // → -1
17console.log(xs.includes(NaN));          // → true

The `indexOf` versus `includes` pair is the part worth volunteering, because it is the only place the distinction bites in day-to-day code. It also gives you the vocabulary the interviewer is listening for: `includes` uses SameValueZero, which treats NaN as equal to NaN but still treats +0 and -0 as the same, while `Object.is` separates those two as well.

Why does typeof null return "object"?

Because of a bug in the very first JavaScript implementation that can no longer be fixed. Values were tagged with a small type tag in their low bits, objects used the tag 000, and the null pointer was all zeros, so it read as an object. Correcting it in ES4 was proposed and rejected because it would silently break code across the web.

What typeof can and cannot tell youjavascript
1console.log(typeof null);           // → object      the historic bug
2console.log(typeof undefined);      // → undefined
3console.log(typeof function () {}); // → function
4console.log(typeof []);             // → object      no help at all
5console.log(typeof NaN);            // → number
6
7// The only reliable null check
8const value = null;
9console.log(value === null);        // → true
10
11// Arrays and everything else
12console.log(Array.isArray([]));                     // → true
13console.log(Object.prototype.toString.call([]));    // → [object Array]
14console.log(Object.prototype.toString.call(null));  // → [object Null]
15console.log(Object.prototype.toString.call(new Date()));
16// → [object Date]
17
18// typeof on an undeclared name does NOT throw, the one place it is useful
19console.log(typeof neverDeclared);  // → undefined   no ReferenceError

The genuinely useful half of this answer is that `typeof` is safe on an undeclared identifier where a bare reference would throw a ReferenceError, which is why old feature-detection code is written that way. Mentioning that turns a piece of trivia into something you have clearly used.

What do closures in a loop actually print?

This is the most-asked closure question there is, and the answer is 3 3 3 rather than 0 1 2 when the loop variable is declared with `var`. There is one binding of `i` for the entire function, every deferred callback closes over that same binding, and by the time any of them run the loop has finished and left `i` at 3.

var, let, and the pre-ES6 fixjavascript
1for (var i = 0; i < 3; i++) {
2  setTimeout(() => console.log(i), 0);
3}
4// → 3
5// → 3
6// → 3        one binding, read after the loop finished
7
8for (let j = 0; j < 3; j++) {
9  setTimeout(() => console.log(j), 0);
10}
11// → 0
12// → 1
13// → 2        let creates a FRESH binding per iteration
14
15// The pre-ES6 fix: make a new scope by hand
16for (var k = 0; k < 3; k++) {
17  ((captured) => setTimeout(() => console.log(captured), 0))(k);
18}
19// → 0
20// → 1
21// → 2
22
23// The same trap without any timer at all
24const fns = [];
25for (var n = 0; n < 3; n++) fns.push(() => n);
26console.log(fns.map((f) => f()));
27// → [ 3, 3, 3 ]

The last example is the follow-up to have ready, because it removes `setTimeout` entirely and proves the problem was never about timers. If you can say "this has nothing to do with asynchrony, it is about how many bindings exist", you have answered the question and the follow-up in one move. The per-iteration binding that `let` creates is specified behaviour, not an optimisation, and the loop's update expression copies the value forward into each new binding.

This is 1 of 80+ questions in the JavaScript Interview Kit

Get every question with detailed answers, follow-ups and real code, plus coding challenges and a last-minute revision sheet. One-time payment, instant access.

⚡ Get the JavaScript Interview Kit → ₹299

Why does a promise always beat setTimeout(0)?

Because they queue in different places. Promise callbacks are microtasks and timer callbacks are macrotasks, and the event loop drains the entire microtask queue after the current synchronous work finishes but before it takes a single macrotask. A zero-millisecond timeout is not immediate; it is "after everything already waiting, and after every microtask those produce".

The ordering question, in the form it is usually askedjavascript
1console.log("1 sync");
2
3setTimeout(() => console.log("2 macrotask"), 0);
4
5Promise.resolve().then(() => console.log("3 microtask"));
6
7queueMicrotask(() => console.log("4 microtask"));
8
9(async () => {
10  console.log("5 sync - an async fn runs sync until its first await");
11  await null;
12  console.log("6 microtask - everything after await is queued");
13})();
14
15console.log("7 sync");
16
17// → 1 sync
18// → 5 sync - an async fn runs sync until its first await
19// → 7 sync
20// → 3 microtask
21// → 4 microtask
22// → 6 microtask
23// → 2 macrotask

The line that catches people is number five. An async function is not deferred when you call it, its body runs synchronously right up to the first `await`, and only the continuation after the await becomes a microtask. Getting that one right is what separates a candidate who has learnt "promises before timeouts" from one who understands where the boundary actually is.

Can microtasks starve the macrotask queue?

Yes, and it is a real way to freeze a browser tab. A microtask that queues another microtask will loop forever without ever yielding, because the loop must empty the entire microtask queue before it can take the next macrotask or render a frame. A recursive setTimeout does not have that problem, each call goes to the back of the macrotask queue, so painting and input handling still get their turn.

Why do [] + {} and {} + [] give different answers?

This one is not about coercion at all, which is why it catches people who understand coercion perfectly well. It is about parsing. At the start of a statement, a leading brace is read as a block, not an object literal, so `{} + []` is an empty block followed by unary plus applied to an empty array, which is 0.

Parsing first, coercion secondjavascript
1console.log([] + {});
2// → [object Object]     "" + "[object Object]"
3
4console.log({} + []);
5// → [object Object]     inside console.log it is an EXPRESSION, so this is normal
6
7// But typed at the start of a statement, the brace is a BLOCK:
8// {} + []
9// → 0                   empty block, then +[] which is Number("") === 0
10
11console.log(+[]);        // → 0     [] → "" → 0
12console.log(+{});        // → NaN   {} → "[object Object]" → NaN
13console.log([] + []);    // → ""    both stringify to empty
14
15console.log([1, 2] + [3]);
16// → 1,23                join(",") then concatenate, not element-wise
17
18console.log(1 + "2", 1 - "2", "3" * "4");
19// → 12 -1 12            + prefers string concat, other operators force numbers

The right way to close this answer is to say that you would never write any of it, and that the rule worth carrying is narrower and genuinely useful: `+` is the only arithmetic operator that prefers string concatenation when either side is a string, and every other operator coerces to number. That single sentence explains `1 + "2"` giving "12" while `1 - "2"` gives -1, which is the version of this question that shows up in real bugs.

Why does [1, 10, 9].sort() come out in the wrong order?

Because the default sort converts every element to a string and compares those, so "10" sorts before "9" for the same reason "apple" sorts before "banana". It surprises people badly because the array in the example is already almost sorted, so the bug looks intermittent.

Default sort, the comparator fix, and mutationjavascript
1console.log([1, 10, 9, 2].sort());
2// → [ 1, 10, 2, 9 ]        string comparison
3
4console.log([1, 10, 9, 2].sort((a, b) => a - b));
5// → [ 1, 2, 9, 10 ]        numeric comparator
6
7// sort MUTATES and also returns the same array, a classic source of bugs
8const original = [3, 1, 2];
9const sorted = original.sort((a, b) => a - b);
10console.log(original, sorted === original);
11// → [ 1, 2, 3 ] true
12
13// The non-mutating versions
14console.log([...original].sort((a, b) => b - a));  // → [ 3, 2, 1 ]
15console.log([3, 1, 2].toSorted((a, b) => a - b));  // → [ 1, 2, 3 ]  ES2023
16
17console.log(["b", "a", "Z"].sort());
18// → [ 'Z', 'a', 'b' ]      uppercase sorts first; use localeCompare for text

Two things earn extra credit here. The first is naming the mutation, `sort` sorts in place and returns the same array reference, which is why sorting a prop in React can mutate state and produce a component that will not re-render. The second is `toSorted`, along with `toReversed`, `toSpliced` and `with`, the ES2023 non-mutating pairs that exist precisely because of that class of bug.

Why is `this` undefined inside my callback?

Because in a normal function `this` is decided by how the function is called, not where it was written. Pull a method off its object and pass it as a callback and the receiver is gone, in a module or in strict mode, `this` becomes undefined rather than the global object, which is why the error is usually a TypeError about reading a property of undefined.

Losing the receiver, and the three ways to keep itjavascript
1const counter = {
2  count: 0,
3  incrementRegular() { this.count++; },
4  incrementArrow: () => { /* this is NOT counter here */ },
5};
6
7const detached = counter.incrementRegular;
8// detached();
9// → TypeError: Cannot read properties of undefined (reading 'count')
10
11// 1. bind
12const bound = counter.incrementRegular.bind(counter);
13bound();
14console.log(counter.count);        // → 1
15
16// 2. call it as a method inside a wrapper
17[1, 2].forEach(() => counter.incrementRegular());
18console.log(counter.count);        // → 3
19
20// 3. forEach and map take a thisArg as their second argument
21[1].forEach(counter.incrementRegular, counter);
22console.log(counter.count);        // → 4
23
24// An arrow method captures this from the MODULE, not the object
25console.log(typeof counter.incrementArrow);
26// → function      it runs, it just never touches counter

The half of this question people forget is the reverse trap: an arrow function as an object method is almost always wrong, because it captures `this` from where it was defined rather than from the call. Arrows are the right choice inside a method, a callback in a `map` or a `setTimeout` that needs the enclosing `this`, and the wrong choice as the method itself. Being able to state both directions is what makes the answer complete.

What is the temporal dead zone?

The window between the top of a block and the line where a `let` or `const` is declared. The binding is hoisted - the engine knows it exists, but it is uninitialised, and touching it throws a ReferenceError. This is the precise reason "let and const are not hoisted" is a wrong answer that sounds right.

var, let and function declarations hoist differentlyjavascript
1console.log(hoistedVar);   // → undefined      declared, not yet assigned
2var hoistedVar = 1;
3
4// console.log(inTdz);
5// → ReferenceError: Cannot access 'inTdz' before initialization
6let inTdz = 2;
7
8console.log(fn());         // → works          function declarations hoist whole
9function fn() { return "works"; }
10
11// The shadowing version, which is the interview form
12let shadowed = "outer";
13{
14  // console.log(shadowed);
15  // → ReferenceError - the INNER binding already shadows it here
16  let shadowed = "inner";
17  console.log(shadowed);   // → inner
18}
19
20// const is about the binding, not the value
21const config = { debug: false };
22config.debug = true;       // fine, the object is mutable
23console.log(config.debug); // → true
24// config = {};            // → TypeError: Assignment to constant variable

The shadowing example is the version that appears in interviews, because it looks like it should read the outer variable and instead throws. And the `const` distinction at the end is worth adding unprompted: `const` freezes the binding, not the value, so a const object is fully mutable and only reassignment is blocked. `Object.freeze` is the shallow answer to that follow-up.

Why do object keys collapse to [object Object]?

Because a plain object can only have string or symbol keys. Use an object as a key and it is stringified first, and almost every object stringifies to the same thing, so two different objects overwrite each other in a cache that looked correct in testing with primitive keys.

The stringified-key bug, and why Map existsjavascript
1const cache = {};
2const a = { id: 1 };
3const b = { id: 2 };
4
5cache[a] = "first";
6cache[b] = "second";
7
8console.log(Object.keys(cache));
9// → [ '[object Object]' ]      both keys collapsed into one
10console.log(cache[a]);
11// → second                     a's value was overwritten by b
12
13// Map keys are compared by reference and keep their type
14const map = new Map([[a, "first"], [b, "second"]]);
15console.log(map.get(a), map.size);
16// → first 2
17
18// Numeric object keys are strings too
19const obj = { 1: "one" };
20console.log(Object.keys(obj), obj["1"] === obj[1]);
21// → [ '1' ] true

This is the practical one on the list, it turns up in real memoisation code, which is why it pairs naturally with the memoize question in a coding round. The full answer names the fix and its cost: `Map` compares keys by reference and holds them strongly, so a long-lived Map of DOM nodes or component instances is a memory leak. `WeakMap` is the version that lets the garbage collector reclaim the key.

Frequently asked questions

Why is 0.1 + 0.2 not equal to 0.3 in JavaScript?
All JavaScript numbers are 64-bit IEEE-754 doubles. 0.1 and 0.2 have no exact binary representation, so each is rounded on the way in and the sum comes out as 0.30000000000000004. Compare within Number.EPSILON, or avoid the problem entirely by storing money as integer paise and dividing only for display.
Why is NaN not equal to itself?
IEEE-754 defines every comparison involving NaN as false, including NaN === NaN, because two failed operations have not necessarily failed the same way. It is the only value in the language with that property. Test for it with Number.isNaN, which does not coerce, or Object.is.
What is the difference between isNaN and Number.isNaN?
isNaN coerces its argument to a number first, so isNaN("hello") is true even though a string is not NaN. Number.isNaN returns true only for the actual NaN value. Use Number.isNaN unless you specifically want the coercing behaviour, which you almost never do.
Why does typeof null return "object"?
A bug in the first JavaScript implementation: values carried a type tag in their low bits, objects used 000, and the null pointer was all zeros, so it read as an object. Fixing it was proposed and rejected because it would break existing sites. The only reliable check is value === null.
Why does a var loop with setTimeout print 3 3 3?
var creates one binding for the whole function, so all three callbacks close over the same variable and read it after the loop has finished, when it holds 3. let creates a fresh binding per iteration, giving 0 1 2. The timer is irrelevant - pushing arrow functions into an array and calling them later shows the same result.
Why does a promise callback run before setTimeout(fn, 0)?
They queue in different places. Promise callbacks are microtasks and timers are macrotasks, and the event loop empties the entire microtask queue after the current synchronous work before taking a single macrotask. So a .then always runs before a zero-millisecond timeout, no matter which was scheduled first.
Does an async function run asynchronously from the first line?
No. The body runs synchronously up to the first await; only the continuation after the await is queued as a microtask. This is the line that catches people in output-ordering questions, a console.log before the first await prints in the synchronous phase, alongside the code that called it.
Why does [1, 10, 9].sort() return the wrong order?
The default sort converts elements to strings and compares them, so "10" comes before "9". Pass a comparator - sort((a, b) => a - b), for numbers. Also remember that sort mutates the array in place and returns the same reference; use toSorted or spread into a copy if the original must not change.
Why is this undefined inside my callback?
Because this in a normal function is determined by how it is called, not where it was written. Detaching a method from its object loses the receiver, and in strict mode or a module this becomes undefined rather than the global object. Fix it with bind, an arrow wrapper, or the thisArg parameter that forEach and map accept.
What is the temporal dead zone in JavaScript?
The region between the start of a block and the let or const declaration inside it. The binding is hoisted but uninitialised, so accessing it throws a ReferenceError. This is why saying "let and const are not hoisted" is wrong, they are hoisted, they are just not reachable until the declaration executes.
Are tricky JavaScript questions actually asked in interviews?
Frequently, especially in the first screening round, because they take fifteen seconds to ask and are hard to bluff. They are rarely the deciding factor on their own, but a confident explanation early sets the tone for the rest of the interview, and a blank on three of them in a row reads as surface-level knowledge.
How should I prepare for these without memorising individual answers?
Learn the five models - floating point, coercion, the event loop, scope and closures, this-binding, and then predict outputs before running them. When you get one wrong, work back to which model you misread rather than noting the answer. Memorised answers collapse on the first "why"; a model handles questions you have never seen.

This is 1 of 80+ questions in the JavaScript Interview Kit

Get every question with detailed answers, follow-ups and real code, plus coding challenges and a last-minute revision sheet. One-time payment, instant access.

⚡ Get the JavaScript Interview Kit → ₹299

Full kit

JavaScript Interview Kit · ₹299

Get it →