| title | 20 JavaScript Output Questions That Will Break Your Brain π§ |
|---|---|
| description | Guess the output! Hoisting, closures, the + operator, Promises, this, and more β ranked by chaos level. |
Pause the video before scrolling. Guess the output. Comment your answer before checking π
- Section 1: Hoisting & Scope Traps
- Section 2: Closures & The Hidden State
- Section 3: The Infamous
+Operator - Section 4: Promises & The Event Loop
- Section 5:
thisβ The Ultimate Mind Bender - Section 6: Mixed Chaos (Boss Level)
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log("var:", i), 0);
}
for (let j = 0; j < 3; j++) {
setTimeout(() => console.log("let:", j), 0);
}π Click to reveal answer
var: 3
var: 3
var: 3
let: 0
let: 1
let: 2
Why: var is function-scoped, so all three callbacks share the same i, which is 3 by the time the callbacks run. let is block-scoped β every iteration gets a fresh binding, so each callback captures its own j.
console.log(foo());
console.log(bar);
var bar = "bar value";
function foo() {
return "foo value";
}
var foo = "foo as variable";π Click to reveal answer
foo value
undefined
Why: Function declarations are hoisted entirely (with their body), so foo() works even before its line. var bar is hoisted but only the declaration β its assignment hasn't run yet, so it's undefined. Note: var foo = "foo as variable" doesn't overwrite foo before the call because hoisting puts function declarations above var declarations in priority, and the reassignment happens after the console.log calls.
let x = 10;
function test() {
console.log(x);
let x = 20;
}
test();π Click to reveal answer
Uncaught ReferenceError: Cannot access 'x' before initialization
Why: Even though an outer x exists, the inner let x creates a new binding in the function scope that is hoisted to the top of test() β but stays in the Temporal Dead Zone until its declaration line executes. JS sees the local x declared later and refuses to let you peek at the outer one.
Section 2: Closures & The Hidden State
function createCounter() {
let count = 0;
return {
increment: () => ++count,
reset: () => (count = 0),
value: count
};
}
const counter = createCounter();
counter.increment();
counter.increment();
console.log(counter.value);
console.log(counter.increment());π Click to reveal answer
0
3
Why: value: count captures the primitive value of count at creation time (which was 0), not a live reference. Only increment and reset close over the live count variable. So counter.value stays frozen at 0 forever, while increment() keeps mutating the real count in the closure.
function delayedLogs() {
for (var i = 1; i <= 3; i++) {
(function (i) {
setTimeout(function () {
console.log("Value:", i);
}, i * 100);
})(i);
}
}
delayedLogs();π Click to reveal answer
Value: 1
Value: 2
Value: 3
Why: The IIFE (function(i){...})(i) creates a new scope per iteration, capturing the current value of i at call time β fixing the classic var closure bug without needing let. This is the "old-school" pre-ES6 fix every senior dev had memorized.
const funcs = [];
for (var i = 0; i < 3; i++) {
funcs.push(function () {
return i;
});
}
console.log(funcs.map(f => f()));
console.log(funcs[0] === funcs[1]);π Click to reveal answer
[3, 3, 3]
false
Why: All three functions close over the same i (which ends at 3), so calling them all returns 3. But they're still three distinct function objects in memory β different references β so funcs[0] === funcs[1] is false. People often assume "same closure value" means "same function reference," which is wrong.
console.log(1 + "2" + 3);
console.log(1 + 2 + "3");
console.log("1" + 2 + 3 - 1);
console.log(4 + 5 + "px");
console.log("px" + (4 + 5));π Click to reveal answer
"123"
"33"
"122"
"9px"
"px9"
Why: + evaluates left to right. Once a string appears, everything to its right concatenates. But "1" + 2 + 3 - 1 β "1" + 2 = "12", then "12" + 3 = "123", then "123" - 1 switches back to numeric subtraction (since - only does math) β 123 - 1 = 122 β but result is coerced back... actually stays as number 122, displayed without quotes β wait, look closely: it prints "122" because... no β JS prints 122 as a number. The trick: console.log of a number doesn't add quotes, so the real output is 122 (number), not "122" (string) β easy to misread!
console.log([] + []);
console.log([] + {});
console.log({} + []);
console.log([1, 2] + [3, 4]);
console.log([] + null);
console.log([] + undefined);π Click to reveal answer
""
"[object Object]"
"[object Object]"
"1,23,4"
"null"
"undefined"
Why: + on objects/arrays triggers toString() (via ToPrimitive). [].toString() β "". {}.toString() β "[object Object]". [1,2].toString() β "1,2". The 3rd line ({} + []) is also famous because in a statement-starting position, {} can be parsed as an empty block, not an object literal β but inside console.log(...), it's an expression, so it's "[object Object]" here. (Try typing {} + [] directly in a console β you might get 0 due to that parsing quirk!)
console.log(+"3" + +"4");
console.log(+true);
console.log(+"");
console.log(+" 42 ");
console.log(+"4abc");
console.log(+[]);
console.log(+[1, 2]);
console.log(+{});π Click to reveal answer
7
1
0
42
NaN
0
NaN
NaN
Why: Unary + forces numeric conversion. +"3" + +"4" = 3 + 4 = 7 (both converted to numbers FIRST, so no concatenation). +true β 1. +"" β 0 (empty string converts to 0!). Whitespace is trimmed before conversion. "4abc" can't fully parse β NaN. +[] β [].toString() β "" β 0. +[1,2] β "1,2" β NaN. +{} β "[object Object]" β NaN.
console.log("1");
setTimeout(() => console.log("2"), 0);
Promise.resolve().then(() => console.log("3"));
console.log("4");π Click to reveal answer
1
4
3
2
Why: Synchronous code (1, 4) runs first. Then the microtask queue (Promises) is fully drained before the macrotask queue (setTimeout) β even with a 0ms delay. Microtasks always win.
console.log("start");
setTimeout(() => console.log("timeout"), 0);
Promise.resolve()
.then(() => console.log("promise 1"))
.then(() => console.log("promise 2"));
Promise.resolve().then(() => console.log("promise 3"));
console.log("end");π Click to reveal answer
start
end
promise 1
promise 3
promise 2
timeout
Why: All sync code runs first (start, end). Then microtasks run in FIFO order of when they're queued β promise 1 and promise 3 were both queued in this tick, so promise 1 runs, then promise 3 (queued earlier than promise 2, which only gets queued after promise 1 resolves). promise 2 is queued after promise 1 finishes, landing it last among microtasks. timeout runs last as a macrotask.
async function getValue() {
return 10;
}
async function test() {
console.log("A");
const result = getValue();
console.log("B", result);
const awaited = await getValue();
console.log("C", awaited);
}
test();
console.log("D");π Click to reveal answer
A
B Promise { 10 }
D
C 10
Why: async function test() runs synchronously until the first await. So "A" logs immediately. getValue() without await returns a Promise object, not 10 β so "B" logs the Promise wrapper. At await getValue(), execution pauses and yields control back β "D" (sync, outside) logs next. Then the microtask resumes, logging "C 10" last.
const p1 = new Promise((res) => setTimeout(() => res("p1"), 300));
const p2 = new Promise((res) => setTimeout(() => res("p2"), 100));
const p3 = new Promise((_, rej) => setTimeout(() => rej("p3 error"), 200));
Promise.race([p1, p2, p3])
.then(console.log)
.catch(console.log);
Promise.allSettled([p1, p2, p3]).then((results) =>
console.log(results.map((r) => r.status))
);π Click to reveal answer
p2
["fulfilled", "fulfilled", "rejected"]
Why: Promise.race settles with whichever promise finishes first β p2 resolves at 100ms, beating p1 (300ms) and p3 (200ms), so "p2" logs via .then. Promise.allSettled waits for all promises regardless of success/failure and never short-circuits β it logs statuses for all three after the longest one (p1 at 300ms) finishes. The order of these two logs depends on timing β race resolves first since p2 settles before p1.
const obj = {
name: "Alice",
regular: function () {
console.log("Regular:", this.name);
},
arrow: () => {
console.log("Arrow:", this.name);
},
nested: function () {
const inner = () => {
console.log("Nested arrow:", this.name);
};
inner();
}
};
obj.regular();
obj.arrow();
obj.nested();π Click to reveal answer
Regular: Alice
Arrow: undefined
Nested arrow: Alice
Why: regular is called as obj.regular(), so this = obj. The top-level arrow function is defined where this refers to the enclosing scope (module/global, not obj) β arrow functions never get their own this. But inner (inside nested) is an arrow function defined inside a regular function, so it inherits this from nested's call context (obj), giving "Alice".
class Timer {
constructor() {
this.seconds = 0;
}
start() {
setInterval(function () {
this.seconds++;
console.log(this.seconds);
}, 1000);
}
}
const t = new Timer();
t.start();π Click to reveal answer
NaN
NaN
NaN
... (forever, or TypeError in strict mode)
Why: The callback passed to setInterval is a regular function, called by the timer with this = globalThis (or undefined in strict mode). this.seconds++ becomes globalThis.seconds++ β undefined++ β NaN, and it keeps assigning NaN to a new global property every second. In strict mode (e.g., inside a class, which is implicitly strict), this is undefined, so it throws TypeError: Cannot read properties of undefined.
Fix: use an arrow function for the callback, or .bind(this).
class Button {
constructor(label) {
this.label = label;
}
handleClick() {
console.log(`Clicked: ${this.label}`);
}
}
const btn = new Button("Submit");
const handler = btn.handleClick;
btn.handleClick();
handler();π Click to reveal answer
Clicked: Submit
Uncaught TypeError: Cannot read properties of undefined (reading 'label')
Why: btn.handleClick() is called with btn as the receiver, so this = btn. But handler = btn.handleClick extracts the bare function reference β when called as handler(), it's invoked with no receiver, so this is undefined (class bodies are strict mode by default). Accessing this.label on undefined throws.
const arr = [10, 12, 15, 21];
for (let i = 0; i < arr.length; i++) {
setTimeout(() => {
console.log(`Index: ${i}, Element: ${arr[i]}`);
}, arr[i] * 100);
}π Click to reveal answer
Index: 1, Element: 12
Index: 0, Element: 10
Index: 2, Element: 15
Index: 3, Element: 21
Why: Two things happening at once: (1) let gives each iteration its own i, so the closure captures are correct (0,1,2,3 map to correct arr[i]). (2) But the delay is arr[i] * 100, so index 1 (12 β 1200ms) fires before index 0 (10 β 1000ms)... wait β actually 10*100=1000 < 12*100=1200, so index 0 should fire first! Recheck: 1000 < 1200, so Index: 0 fires first, then Index: 1. The real trick is that most people assume execution order = setTimeout order, but here it happens to MATCH index order anyway β the real gotcha is realizing the delays are different per iteration and you must compute them, not assume 0,1,2,3 blindly. (Correct order: 0, 1, 2, 3 β by delay value, which coincidentally matches array order here since arr is sorted ascending.)
class Loader {
constructor() {
this.status = "idle";
}
load() {
this.status = "loading";
return new Promise((resolve) => {
setTimeout(() => {
this.status = "done";
resolve(this.status);
}, 100);
});
}
}
const loader = new Loader();
console.log(loader.status);
loader.load().then((result) => {
console.log("Resolved:", result);
console.log("Current status:", loader.status);
});
console.log(loader.status);π Click to reveal answer
idle
loading
Resolved: done
Current status: done
Why: console.log(loader.status) runs before load() is called β "idle". Inside load(), this.status = "loading" happens synchronously before the Promise constructor's executor returns β so by the time the second console.log(loader.status) runs (still synchronous), it's already "loading". The setTimeout callback is an arrow function, so this correctly refers to the loader instance (lexical scoping), updating status to "done" and resolving with it β both logged last, after the microtask/macrotask delay.
console.log(0 == "0");
console.log(0 == "");
console.log(0 == []);
console.log("" == []);
console.log(null == undefined);
console.log(null == 0);
console.log(NaN == NaN);
console.log([1] == [1]);
console.log([1] == "1");π Click to reveal answer
true
true
true
true
true
false
false
false
true
Why: With ==, both sides convert toward numbers/primitives. "0" β 0 β
. "" β 0 β
. [] β "" β 0 β
(so 0 == [] is true!). "" == [] β both become "" β true. null == undefined is a special-cased true, but null == 0 is false because null ONLY equals undefined and itself in loose equality β it does NOT convert to 0. NaN == NaN is always false (NaN is never equal to anything, including itself). [1] == [1] β false because they're different object references β == doesn't deep-compare arrays. [1] == "1" β [1] becomes "1" via toString(), then "1" == "1" β true.
var length = 4;
function callback() {
console.log(this.length);
}
const obj = {
length: 5,
method(callback) {
callback();
arguments[0]();
[1, 2, 3].forEach(callback);
(() => {
console.log(this.length);
})();
}
};
obj.method(callback);π Click to reveal answer
4
3
undefined
undefined
undefined
5
Why β break it down:
callback()β called as a bare function,this= global object (window/globalThis), sothis.length= the globalvar length = 4β4.arguments[0]()βarguments[0]IScallback, but called as a property ofarguments, sothis=argumentsobject.arguments.length= number of args passed tomethod, which is1(justcallback)... but wait, that gives1not3. Correction:arguments.lengthreflects arguments passed tomethod(callback)=1argument β so this actually logs1, not3. (This is intentionally the trickiest line β even experienced devs getarguments.lengthconfused with array lengths!)[1,2,3].forEach(callback)βforEachcallscallbackwiththis = undefined(in strict mode) or global object (non-strict). In a class/module (strict),this.lengthonundefinedwould throw β but in plain script (non-strict, browser global),this=window, andwindow.lengthis the number of frames (0typically), orundefinedif not in a browser. In Node, logsundefined. Runs 3 times (once per array element), each logging the same thing.- The trailing arrow function
(() => { console.log(this.length) })()β arrow function inheritsthisfrommethod's scope, wherethis=obj. Sothis.length=5.
Realistic final output (Node.js, non-strict-ish):
4
1
undefined
undefined
undefined
5
This question is designed to be argued about in the comments β the arguments[0]() line especially trips up 90% of senior developers.
- 0β5 correct: You're human. Don't worry, even JS engine authors get tripped up.
- 6β12 correct: Solid! You know your fundamentals.
- 13β18 correct: You've clearly been burned by these in production before.
- 19β20 correct: Either you're lying, or you ARE the JS spec. π
Drop your score in the comments and tell us which question wrecked you the most! π₯
Don't forget to subscribe for more "Guess the Output" challenges every week. π