{}The Interview
Handbook

Tracks / JavaScript

What does this print?

seniorSignal round 10 questions · 13 min read event-loopclosurescoercionsemantics

Questions in this set 10
  1. 01Predict the output, and explain the ordering rule.
  2. 02Why do these two loops behave differently?
  3. 03What is logged?
  4. 04What is the output, and why is it not what people expect?
  5. 05Predict the output of this async loop.
  6. 06Why does this cause a memory leak?
  7. 07What does this print, and what does it tell you about const?
  8. 08Explain why the second version fixes the race.
  9. 09What is the difference in behaviour here?
  10. 10Last one — what is the value of x?

These are live-coding warm-ups that senior interviewers use because they are fast, unambiguous, and impossible to bluff. Getting the output right is worth little on its own; the score comes from the mechanism you cite. Try each before reading on — the value is in the diff between your prediction and the answer.

01

Predict the output, and explain the ordering rule.

js
console.log("1");
setTimeout(() => console.log("2"), 0);
Promise.resolve().then(() => {
  console.log("3");
  setTimeout(() => console.log("4"), 0);
});
queueMicrotask(() => console.log("5"));
(async () => { console.log("6"); await null; console.log("7"); })();
console.log("8");

1 6 8 3 5 7 2 4

The rule: run all synchronous code, then drain the entire microtask queue, then take one macrotask, then drain microtasks again, and repeat.

  • 1 — synchronous.
  • 6 — an async function body runs synchronously up to its first await. This is the part people miss.
  • 8 — synchronous; the rest of the file finishes.
  • Microtasks now drain in FIFO order: 3 (the .then was queued first), then 5 (queueMicrotask), then 7 (the continuation after await null, queued when the async function suspended — which happened after the .then and queueMicrotask calls were registered).
  • Macrotasks: 2, then 4 (queued later, from inside the microtask).
02

Why do these two loops behave differently?

js
for (var i = 0; i < 3; i++) setTimeout(() => console.log(i));
for (let j = 0; j < 3; j++) setTimeout(() => console.log(j));

3 3 3 then 0 1 2.

var is function-scoped: there is exactly one binding, all three closures capture the same one, and by the time the timers fire the loop has finished and i is 3. let creates a fresh binding per iteration — the spec copies the value into a new environment record at the top of each iteration — so each closure captures its own j.

03

What is logged?

js
const obj = {
  name: "outer",
  regular() { return this.name; },
  arrow: () => this?.name,
  nested() {
    const inner = function () { return this?.name; };
    const innerArrow = () => this.name;
    return [inner(), innerArrow()];
  },
};
console.log(obj.regular());          // ?
console.log(obj.arrow());            // ?
console.log(obj.nested());           // ?
const detached = obj.regular;
console.log(detached?.());           // ?

"outer" · undefined · [undefined, "outer"] · undefined (in a module or strict mode).

  • obj.regular() — method call form, so this is obj.
  • obj.arrow() — arrow functions have no this binding; it resolves lexically to the enclosing scope, which is the module, not the object. Defining an arrow as an object method is almost always a bug.
  • nested() — inside, inner() is a plain call, so this is undefined in strict mode (globalThis in sloppy mode). innerArrow inherits this from nested, which was called as a method, so it sees obj.
  • detached() — the function lost its receiver when assigned to a variable. Same reason setTimeout(obj.regular) prints undefined and setTimeout(() => obj.regular()) does not.
04

What is the output, and why is it not what people expect?

js
console.log([1, 2, 3] + [4, 5]);
console.log([] + {});
console.log(typeof NaN, NaN === NaN, [NaN].includes(NaN), [NaN].indexOf(NaN));
console.log(0.1 + 0.2 === 0.3, 0.1 + 0.2);
console.log([1, 10, 9, 2].sort());
console.log(["10", "10", "10"].map(parseInt));

"1,2,34,5" · "[object Object]" · "number" false true -1 · false 0.30000000000000004 · [1, 10, 2, 9] · [10, NaN, 2]

Each has a specific mechanism worth naming:

  • + with an array coerces via toString, which joins with commas — so you get string concatenation, not element-wise anything.
  • typeof NaN is "number" because NaN is a floating-point value. NaN === NaN is false per IEEE 754. includes uses SameValueZero, which treats NaN as equal to itself; indexOf uses strict equality, which does not. That inconsistency is a genuine gotcha in real code.
  • Floating point: 0.1 and 0.2 have no exact binary representation. Compare with a tolerance, or use integers (cents, not dollars) for money.
  • Array.prototype.sort converts elements to strings by default and sorts lexicographically. [1,10,9,2].sort((a,b)=>a-b) is the fix, and forgetting it is one of the most common bugs in real JavaScript.
  • map passes (element, index, array), so you are calling parseInt("10", 0), parseInt("10", 1), parseInt("10", 2). Radix 0 is treated as 10 → 10; radix 1 is invalid → NaN; radix 2 is binary → 2. The fix is map(s => parseInt(s, 10)) or map(Number).
05

Predict the output of this async loop.

js
const ids = [1, 2, 3];

async function run() {
  ids.forEach(async (id) => {
    const r = await fetchThing(id);       // takes 100ms each
    console.log("done", id);
  });
  console.log("all finished");
}
run();

"all finished" prints first, then done 1/2/3 in whatever order they resolve.

forEach ignores the return value of its callback. Each async callback returns a promise that nobody awaits, so forEach returns immediately and run continues. Consequences beyond the ordering: run() resolves before the work is done (so a caller awaiting it proceeds too early), errors inside the callbacks become unhandled rejections rather than propagating, and in a serverless function the runtime may freeze or terminate the process before the work completes — which is how this bug turns into silently dropped writes.

js
// sequential, if order matters
for (const id of ids) { await fetchThing(id); }

// concurrent, waits for all, propagates the first error
await Promise.all(ids.map(id => fetchThing(id)));

// concurrent, bounded — what you actually want against a real dependency
const sem = new Semaphore(5);
await Promise.all(ids.map(id => sem.run(() => fetchThing(id))));
06

Why does this cause a memory leak?

js
function attach() {
  const bigData = new Array(1_000_000).fill("x");
  const el = document.getElementById("btn");
  el.addEventListener("click", function handler() {
    console.log("clicked");                      // never touches bigData
  });
}

The handler closes over the scope containing bigData. Whether bigData is actually retained depends on the engine's optimisation: V8 will usually drop unreferenced variables from the closure's context — but not if anything in the same scope uses eval, and not reliably if another closure in the same scope does reference bigData. That last case is the practical one:

js
function attach() {
  const bigData = new Array(1_000_000).fill("x");
  const el = document.getElementById("btn");
  el.addEventListener("click", () => console.log("clicked"));      // 
  el.addEventListener("dblclick", () => console.log(bigData[0]));  // pins the whole array
}

Both handlers share one context object, so the second one keeps bigData alive for as long as either listener is attached.

The larger, certain leak is separate and worse: the listener is never removed. If attach() runs on every route change, listeners accumulate on the element, each with its own closure. And if the element is later removed from the DOM while a JS reference to it survives, you have a detached node retaining everything.

js
const controller = new AbortController();
el.addEventListener("click", handler, { signal: controller.signal });
// later, one call removes every listener registered with this signal
controller.abort();
07

What does this print, and what does it tell you about const?

js
const config = { retries: 3, nested: { timeout: 5 } };
config.retries = 5;
config.nested.timeout = 10;
console.log(config);

const frozen = Object.freeze({ retries: 3, nested: { timeout: 5 } });
frozen.retries = 99;
frozen.nested.timeout = 99;
console.log(frozen);

{retries: 5, nested: {timeout: 10}} then {retries: 3, nested: {timeout: 99}}.

const prevents rebinding the name, not mutating the value. Object.freeze prevents adding, removing or changing the object's own properties — and it is shallow, so nested objects remain mutable. In non-strict mode the assignment fails silently; in strict mode (including modules) frozen.retries = 99 throws a TypeError, which is a good reason to be in strict mode.

For a genuinely immutable config you need a deep freeze (recursive, with cycle handling) or structuredClone at the boundary — and in TypeScript, as const plus readonly gives you compile-time enforcement with zero runtime cost, which is usually the better trade.

08

Explain why the second version fixes the race.

js
// version A
let latest;
input.addEventListener("input", async (e) => {
  const results = await search(e.target.value);
  render(results);                          // sometimes shows results for an older query
});

// version B
let controller;
input.addEventListener("input", async (e) => {
  controller?.abort();
  controller = new AbortController();
  try {
    render(await search(e.target.value, { signal: controller.signal }));
  } catch (err) { if (err.name !== "AbortError") throw err; }
});

In version A, the user types "re", then "react". Two requests are in flight. If the "re" request (a broader query, likely slower) resolves after the "react" one, render is called with the stale results last, and the UI shows results for a query the user has already replaced. On a fast local network this essentially never happens; on a real one it happens constantly.

Version B cancels the previous request before starting a new one, so a superseded response never arrives. The alternative fix, when the request cannot be cancelled, is a sequence guard:

js
let seq = 0;
const mine = ++seq;
const results = await search(value);
if (mine === seq) render(results);      // ignore anything that is no longer the latest

Both are correct; aborting is better because it also stops wasting server work and network. Use the sequence guard as a backstop for APIs that ignore signals.

09

What is the difference in behaviour here?

js
class A {
  value = 1;                       // class field
  constructor() { this.init(); }
  init() { console.log(this.value); }
}
class B extends A {
  value = 2;                       // subclass field
  init() { console.log("B", this.value); }
}
new B();

Prints B undefined.

The initialisation order is the whole answer: B's constructor implicitly calls super() first. A's constructor initialises A's fields (value = 1), then calls this.init() — which resolves to B's override, because this is a B. But B's own field initialisers have not run yet; they run after super() returns. So this.value is undefined at that moment.

This is the JavaScript version of the "do not call an overridable method from a constructor" rule that exists in Java, C++ and C#. The fix is to not call overridable methods during construction — use an explicit init() invoked by the caller, or a static factory:

js
class B extends A {
  static create() { const b = new B(); b.init(); return b; }
}
10

Last one — what is the value of x?

js
let x = 0;
const p = new Promise((resolve) => { x = 1; resolve(); x = 2; });
p.then(() => { x = 3; });
x = 4;
console.log(x);
setTimeout(() => console.log(x));

Logs 4, then 3.

The executor passed to new Promise runs synchronously, so x becomes 1, then resolve() is called (which only schedules the reaction, it does not stop execution), then x becomes 2 — the code after resolve() still runs, which surprises people. Then p.then(...) registers a callback, x = 4 executes, and console.log(x) prints 4. The .then callback runs as a microtask after the synchronous code, setting x = 3; the timer callback is a macrotask and therefore later still, so it sees 3.

The practical lesson worth stating: resolve() is not return. Code after it executes, and side effects there are a real source of bugs — always return resolve(v) or put nothing after it.