ESC

Type to search the knowledge base.

JavaScript Output-Based Questions

How to dry-run this, closures, hoisting, and the event loop on a whiteboard. A method, not a dump of 50 puzzles you will forget.

intermediate5 min read
  • interview
  • javascript
  • output-questions

Output questions fail when you stare at the snippet and guess “promise then timeout.” The interviewer wants a trace. If you cannot point at the call stack, the microtask queue, and the timer queue, you will miss the third console.log and then argue with the runtime.

This is a method for dry-running this, closures, hoisting / TDZ, and the event loop on a whiteboard. Three original traces sit below so you can practice the method. It is not a dump of fifty puzzles.

Key takeaways

  • Annotate: stack, then microtasks, then timers. Never skip the current task.
  • this is call-site for ordinary functions, lexical for arrows. Promise jobs are not method calls.
  • typeof does not save you from the TDZ. var is undefined, let throws.
  • Write the output list before you defend it. Then check one footgun.

Narrate the stack, then the queues

Keep four columns. Update them after every statement that schedules work.

Column Holds
Stack What is running now (LIFO)
Microtasks Promise reactions, queueMicrotask, await continuations
Timers / tasks setTimeout, many DOM events
Output What console.log already printed

Rules you actually need (browser-simplified; the HTML spec is more detailed):

  1. Run the current task until the stack is empty.
  2. Drain all microtasks, including ones queued while draining.
  3. Optionally render.
  4. Take the next timer/task.

Sync console.log prints during step 1. Promise.then prints in step 2. setTimeout(0) prints in step 4. Details: the event loop, queueMicrotask vs setTimeout.

Procedure on the whiteboard:

  1. Circle every declaration. Mark var (initialized undefined), let/const (TDZ until the line), functions (hoisted as callable).
  2. Number the logs. Do not number scheduled callbacks until they run.
  3. When you see fn(), decide this at that call site.
  4. When you see await, freeze the async function and enqueue its continuation as a microtask.
  5. Only then drain queues in order.

Example 1: await vs a sibling microtask

async function paint() {
  console.log('1 enter');
  await null;
  console.log('4 after await');
  setTimeout(() => console.log('6 timeout'), 0);
  await Promise.resolve();
  console.log('5 after second await');
}

console.log('0 start');
paint();
Promise.resolve().then(() => console.log('3 sibling microtask'));
console.log('2 sync end');

Trace:

  • 0 start (stack)
  • paint() runs, 1 enter. await null wraps with Promise.resolve and enqueues the continuation. paint returns a pending promise.
  • Sibling .then enqueues after that continuation.
  • 2 sync end
  • Microtask 1: 4 after await, schedule timeout, await Promise.resolve() enqueues the second continuation
  • Microtask 2: 3 sibling microtask
  • Microtask 3: 5 after second await
  • Timer: 6 timeout

Output:

0 start
1 enter
2 sync end
4 after await
3 sibling microtask
5 after second await
6 timeout

The footgun: people print 3 before 4 because “promises are FIFO and I wrote the sibling second.” The await continuation was queued first. await on a non-thenable is still a microtask tick. See promises.

Example 2: this inside then

const api = {
  label: 'checkout',
  ping() {
    console.log('sync', this.label);
    Promise.resolve(this.label).then(function (v) {
      console.log('then fn', this && this.label, v);
    });
    Promise.resolve(this.label).then((v) => {
      console.log('then arrow', this.label, v);
    });
  },
};

api.ping();

Trace:

  • api.ping() is a method call → this === api. sync checkout
  • Both then callbacks are scheduled as microtasks. The value v is already 'checkout' (captured from this.label on the stack).
  • Ordinary function as a reaction: not called as a method, not new, not bind. In strict code (modules), this is undefined. this && this.label is undefined (does not throw).
  • Arrow: lexical this from ping, still api.

Output:

sync checkout
then fn undefined checkout
then arrow checkout checkout

If someone wrote this.label in the ordinary function, it throws in strict mode and you never see the arrow log. Call-site rules: this binding, call, apply, and bind.

Example 3: TDZ typeof and let in a loop

function quiz() {
  console.log(typeof a);
  try {
    console.log(typeof b);
  } catch (e) {
    console.log('tdz');
  }
  var a = 1;
  let b = 2;
  const fns = [];
  for (let i = 0; i < 2; i++) {
    fns.push(() => i);
  }
  console.log(fns[0](), fns[1]());
}

quiz();

Trace:

  • var a is hoisted and set to undefined. typeof a is 'undefined'.
  • let b is hoisted into TDZ. typeof b is still a read of b → ReferenceError. Catch prints tdz.
  • After init, a === 1, b === 2.
  • for (let i ...) creates a new i binding per iteration. The arrows close over 0 and 1, not the final 2.

Output:

undefined
tdz
0 1

typeof is not a safe probe for let/const. That is the whole TDZ lesson. See temporal dead zone, hoisting, var vs let vs const, closures.

If the loop had used var i, both arrows would print 2. Do not mix that rewrite mid-trace. Finish the snippet you were given.

Footguns (beyond the three traces)

  • setTimeout(fn, 0) is not “next line.” It is the next task.
  • Microtasks can starve rendering if you keep queueing more microtasks in a loop.
  • finally on a promise still runs as a reaction. A return value from finally is discarded; the original fulfillment or rejection is what the next then sees. If finally throws (or returns a rejecting thenable), that error replaces the original.
  • Extracting obj.method and calling it later drops this.
  • Default parameters read other bindings in the TDZ if you reference a later let.

When the snippet mixes two systems (TDZ + timers), finish declarations and sync logs first. Queues only run after the current task.

Interview out-loud answer

“I treat output questions as a trace, not a quiz. I mark bindings, then run the stack to empty, then drain microtasks, then timers. Await continuations enqueue when the await is evaluated, so they can beat a Promise.then written later in the same task. For this, I use call-site for functions and lexical for arrows. For let, I assume ReferenceError until the initializer, including under typeof.”

Practice more event-loop mixes after you can recap those three without looking. Then add strict mode and arrow functions if the snippet uses them.

Further reading