← Blog

Interview prep

JavaScript Interview Questions: Event Loop, Closures, this and Promises

The JavaScript questions interviewers keep asking, answered with short code we ran in Node 25, predicted outputs, and the trade-offs behind each rule.

Matthew M

15 min read

A calm, softly lit workspace with a notebook and a pen beside a window, shallow depth of field, no text visible.

The JavaScript interview questions that come up most are the event loop, closures, how this gets its value, promises with async/await, and the quirks of equality and type coercion. A good answer gives the rule, a short example whose output you can predict, and the trade-off. Every snippet below was run in Node 25; the language behaviour is the same in current LTS releases and browsers.

A note on method. JavaScript questions are often 'what does this print?' puzzles, so the skill is predicting output, not memorising definitions. Where we show output, it is what the code actually printed. Try predicting each result before you read the answer; that is exactly the muscle an interviewer tests.

How does the JavaScript event loop work?

JavaScript runs your code on a single thread using a call stack. When the stack is empty, the runtime takes the next task from a queue. Timers, I/O callbacks and UI events are tasks. Promise reactions and queueMicrotask callbacks go on a separate microtask queue, and the whole microtask queue is drained after each task, before the next task starts.

That ordering is the standard question. Given console.log('1 sync'), then setTimeout(() => console.log('timeout'), 0), then Promise.resolve().then(() => console.log('promise')), then queueMicrotask(() => console.log('microtask')), then console.log('2 sync end'), the output is: 1 sync, 2 sync end, promise, microtask, timeout. Synchronous code first, microtasks in the order they were queued, then the timer, even though its delay was zero.

Node adds process.nextTick, which runs before promise microtasks. In our run, a nextTick callback printed between the last sync line and the promise line. That is Node-specific, so say so rather than presenting it as JavaScript in general.

Two consequences are worth stating. First, a long synchronous loop blocks everything. We ran a 100 ms busy loop after scheduling a zero-delay timer, and the timer could only fire afterwards. Second, microtasks can starve the event loop: a promise chain that keeps queueing new promise reactions never lets timers or rendering run. Both arguments explain why heavy computation belongs in a Worker or a chunked loop.

Await is built on this. In async function f(), code after await null runs as a microtask. If you call f() and then Promise.resolve().then(...), you will see 'f start', then synchronous lines after the call, then 'f after await', then the .then callback, because they were queued in that order.

What is the difference between var, let and const, and what is the temporal dead zone?

var is function-scoped and hoisted with the value undefined. let and const are block-scoped and hoisted but not initialised, so touching them before their declaration throws a ReferenceError. That window between the start of the block and the declaration is the temporal dead zone. In our check, console.log(a) followed by let a = 1 threw ReferenceError, while typeof hoisted printed undefined for a var assigned later and function declarations were callable before their definition.

const prevents rebinding, not mutation. const list = [] still lets you push. Object.freeze makes an object shallowly immutable. Defaulting to const, using let when you reassign, and avoiding var is the answer most interviewers expect, with the reason that block scope removes a whole class of bugs.

A frequent follow-up is function declarations versus function expressions. A declaration such as function greet() {} is hoisted whole, so you can call it above its definition. An expression such as var late = function () {} only hoists the variable, so typeof late printed undefined before the assignment line, and calling it there would throw a TypeError. With let or const the same early access throws a ReferenceError instead. Explaining which error you would see, and why, shows you understand hoisting rather than just repeating the word.

What is a closure, and why does the loop example print 3, 3, 3?

A closure is a function together with the variables from the scope where it was created. Even after the outer function returns, the inner function can read and update those variables. A counter factory shows it: makeCounter holds let n = 0 and returns an object with inc and get. After two inc calls, get returns 2, and nothing outside can touch n directly. That is the pattern behind private state, memoisation and debouncing.

The loop trap: with var i in a for loop, pushing () => i into an array and calling each function printed [3, 3, 3], because all three closures share one function-scoped i, which ends at 3. Switching to let j gave [0, 1, 2], because let creates a fresh binding for each iteration. The pre-ES6 fix was wrapping the body in an immediately invoked function; modern answers just use let.

Trade-off to mention: closures keep their captured variables alive. A closure that holds a reference to a large array can prevent it from being collected, which is a common cause of leaks in long-lived pages and event handlers you forget to remove.

How does this get its value?

For ordinary functions, this is decided by how the function is called, not where it was written. Method call obj.method() sets this to obj. A plain call method() has no receiver, so this is undefined in strict mode (ES modules and classes are always strict) and the global object in sloppy mode. call, apply and bind set it explicitly. new sets it to a fresh object.

Our ES-module run showed each case. user.regular() with return this?.name gave Ada. Detaching it as const loose = user.regular and calling loose() gave undefined. loose.call({ name: 'Grace' }) gave Grace. A function made with bind keeps its binding permanently: bound.call({ name: 'Ignored' }) still returned Bound.

Arrow functions have no this of their own. They use the this of the surrounding scope at creation time. Inside a method, [1].map(() => this.name) returned ['Ada'], but the same map with a regular function callback returned [undefined]. Class fields written as arrow functions, like click = () => this.label, stay bound when destructured: click() returned 'ok' while a normal method plain() pulled off the same instance returned undefined.

The sentence to say aloud: 'Arrow functions capture this lexically; everything else binds this at call time.' Mention that arrows also cannot be used with new and have no arguments object.

What is the difference between == and ===, and what coercion surprises matter?

=== compares without converting types. == converts operands first using a set of rules, which produces results such as 0 == '' being true, [1] == 1 being true and [] + [] being the empty string. null == undefined is true but null === undefined is false. The usual guidance is to use === everywhere, with one accepted exception: value == null checks for both null and undefined.

A few more that interviewers love, all verified in our run: typeof null is 'object', a historic quirk that cannot be fixed without breaking the web. NaN === NaN is false, so use Number.isNaN or Object.is(NaN, NaN), which is true. [NaN].includes(NaN) is true but [NaN].indexOf(NaN) is -1, because includes uses a different equality algorithm. null >= 0 is true while null == 0 is false.

Also expect numbers. 0.1 + 0.2 printed 0.30000000000000004, because JavaScript numbers are IEEE 754 doubles. Compare with a tolerance such as Math.abs(a - b) < Number.EPSILON, or use integers for money. Beyond 2 to the 53rd, integers lose precision, and 9007199254740993 printed as 9007199254740992. BigInt handles larger values: 2n ** 64n printed 18446744073709551616n. Default sort is another trap: [10, 9, 1].sort() gave [1, 10, 9] because it compares strings, and you need (a, b) => a - b for numbers.

The classic parseInt puzzle: ['1', '2', '3'].map(parseInt) gave [1, NaN, NaN]. map passes the index as the second argument, and parseInt treats it as the radix.

Promises and async/await: what do interviewers actually test?

A promise represents a value that will settle once, as fulfilled or rejected. then, catch and finally return new promises, so you can chain them. async functions always return a promise, and await pauses only that function while the rest of the program keeps running.

Sequential versus parallel

The most common practical mistake is awaiting independent calls one after another. With a helper that resolves after 100 ms, awaiting two in a row took about 200 ms, while await Promise.all([a, b]) took about 100 ms. Use parallel when calls do not depend on each other, and sequential when one needs the previous result or when you must limit load on a server.

The four combinators

  • Promise.all rejects as soon as any input rejects. In our run, a 50 ms rejection beat a 100 ms success and we got the error.
  • Promise.allSettled waits for everything and returns status objects; for one rejection and one success it gave ['rejected', 'fulfilled'].
  • Promise.race settles with the first input to settle, whether success or failure, and is the usual base for timeouts.
  • Promise.any fulfils with the first success and only rejects if all fail; a fast rejection followed by a slower success returned the success.

Know the trade-off: Promise.all fails fast but does not cancel the other work. The remaining promises keep running, so pair it with AbortController when the work is real network traffic.

The forEach trap

forEach does not await async callbacks. We ran [1, 2, 3].forEach(async n => { await sleep(10); out.push(n); }) and immediately printed the array: it was still empty. A for...of loop with await inside filled it in order. If you want parallelism, map to promises and await Promise.all; if you want order, use for...of.

Error handling

Wrap await in try/catch, or end a chain with .catch. A rejected promise with no handler becomes an unhandled rejection, which in modern Node terminates the process by default. Remember that a throw inside a .then turns into a rejection of the returned promise, so a later catch handles it: the chain we ran, with a throw in the second then, resolved to the catch value.

Promise.withResolvers() (ES2024, available in Node 22 and recent browsers) returns a promise with its resolve and reject functions, which replaces the old pattern of capturing them from the executor.

How do prototypes and classes relate?

Every object has an internal link to another object, its prototype. Property lookup walks that chain until it finds the name or reaches null. A class is syntax over this system: methods defined in a class body live on ClassName.prototype, shared by all instances. We confirmed that Object.getPrototypeOf(a) === A.prototype is true, a.hasOwnProperty('greet') is false because the method is inherited, and typeof A is 'function'.

Interviewers like to ask why methods go on the prototype. The answer is memory: one function shared across instances instead of one copy per object. The counterpoint is class fields written as arrow functions, which create a new function for every instance, in exchange for a stable this.

Shallow versus deep copy in JavaScript

The spread operator and Object.assign copy one level. With const o = { a: { b: 1 } } and const sh = { ...o }, setting sh.a.b = 2 changed o.a.b too, which printed 2. For a deep copy of plain data, structuredClone is built into modern runtimes and handles nested objects, Maps, Sets and Dates; our test kept a Date as a Date. It throws DataCloneError for functions.

The JSON.parse(JSON.stringify(x)) shortcut loses information: functions and undefined values are dropped, and a Date turns into a string. Our object with a method and a Date serialised without the method, and the date came back as an ISO text string instead of a Date. Mention it as a known shortcut with sharp edges.

Debounce, throttle and memoise: the utility questions

Debounce delays a function until activity stops, which suits search-as-you-type. The implementation is a closure over a timer: clearTimeout the old one, set a new one. We called a debounced function three times in a row and it ran once. Throttle runs at most once per interval, which suits scroll and resize handlers. Memoise caches results by argument, typically with a Map. Our memoised square function was called twice with 3 and computed once.

State the edge cases: debounce needs a decision about leading versus trailing execution, memoise needs a cache-key strategy for object arguments, and all three can leak if they close over large data.

What is event delegation, and how does bubbling work?

When you click an element in the browser, the event travels in three phases: capturing down from the window to the target, the target itself, then bubbling back up through the ancestors. Listeners registered the normal way run during bubbling. Passing true (or { capture: true }) as the option runs them during capture instead.

Event delegation uses bubbling: attach one listener to a parent and inspect event.target to see which child was clicked. For a list with a thousand rows, one listener on the list replaces a thousand, and rows added later work automatically. The usual implementation calls event.target.closest('li') so that clicks on a nested span still map to the row. Trade-offs to state: some events do not bubble, focus and blur being the usual examples (focusin and focusout do), and calling stopPropagation in a child can silently break delegated handlers higher up.

How do you keep a page responsive during heavy work?

Because one thread runs your code and also renders the page in a browser, a long task freezes the interface. Three standard answers: move computation into a Web Worker, which has its own thread and communicates by messages; split the work into small chunks scheduled between frames; or avoid doing it at all by memoising or precomputing. Workers cannot touch the DOM, so the trade-off is the cost of copying data across the boundary. structuredClone semantics apply to messages, and transferable objects such as ArrayBuffer can be moved instead of copied.

In Node the equivalent is worker_threads for CPU-bound work, or a separate process. The same rule from the event-loop section applies: asynchronous I/O is cheap on one thread, and CPU work is what blocks.

Smaller questions that keep showing up

  • Map versus plain object: Map accepts any key type, keeps insertion order and has a size. Plain objects order integer-like keys first; Object.keys({ b: 1, 2: 1, 1: 1, a: 1 }) printed ['1', '2', 'b', 'a'].
  • Nullish coalescing versus OR: 0 || 'd' gives 'd', but 0 ?? 'd' gives 0, because ?? only replaces null and undefined.
  • Optional chaining: null?.x returns undefined instead of throwing.
  • Generators: function* with yield produces lazy sequences. An infinite natural-number generator plus a take helper gave [0, 1, 2].
  • Array.isArray is the reliable array check, since typeof [] is 'object'.
  • ES modules versus CommonJS: import and export are static and use strict mode; require is dynamic and synchronous. Top-level this differs between them, which is why we ran our this examples as modules.

How to rehearse JavaScript answers out loud

Reading these answers will not make them come out fluently at 10 a.m. with a stranger watching. The practice that helps is predicting output aloud, then explaining the rule in one breath. Narrating while typing is a separate skill; the post on narrating a live coding problem under a timer gives a structure for it.

URHIRED.AI has a Live coding surface and mock sessions where you rehearse this kind of explanation; you speak or type every answer yourself. The how-to-setup guide covers getting the desktop app running on macOS or Windows.

Where to go next

Frequently asked questions

What are the most common JavaScript interview questions?

The most common are the event loop and microtask ordering, closures including the var-in-a-loop trap, how this is bound, == versus ===, promises with async/await, prototypes and classes, and copying objects. Most are asked as predict-the-output puzzles followed by a request to explain the rule behind the result.

What is the order of setTimeout, promises and synchronous code?

Synchronous code runs first. Then all queued microtasks run, including promise callbacks and queueMicrotask, in the order they were queued. Only after that does the next task run, such as a setTimeout callback, even with a delay of zero. In Node, process.nextTick callbacks run before promise microtasks.

Why does an arrow function not have its own this?

Arrow functions were designed to take this from the scope where they are written, so callbacks inside methods keep the method's this. They also lack their own arguments object and cannot be used with new. Regular functions get this at call time from how they are invoked: a receiver, call, apply, bind or new.

Should I use Promise.all or await in a loop?

Use Promise.all when the calls are independent, since total time is roughly the slowest call instead of the sum. Use await in a for...of loop when each step needs the previous result or when you must limit how many requests hit a server at once. Note that forEach does not wait for async callbacks.

Do I need to know ES2024 features for interviews?

Rarely. Interviewers focus on fundamentals: scope, closures, this, the event loop and promises. Knowing a few recent additions, such as structuredClone, Promise.withResolvers or Promise.any, shows you keep current, but explaining the core model clearly matters far more than listing the newest syntax.

← All posts

Ready for the live round?

Create a free account, then download the desktop app.

No credit card required·Works with all meeting platforms·macOS and Windows