JavaScript Interview Questions in 2026: The Ones That Actually Get Asked at Senior Level

Event loop execution order, closure traps, prototype chain questions, Promise microtask queue, this binding in arrow functions, generator functions, WeakMap vs Map — the questions that separate “I’ve used JavaScript” from “I understand JavaScript,” with the exact answers that land the job.


Senior JavaScript interviews don’t test whether you know the syntax. Everyone applying knows the syntax. They test whether you understand the runtime — what happens when code executes, in what order, why behaviour that looks surprising is actually consistent with the rules. The questions in this post are the ones that appear repeatedly in senior-level interviews at product companies in 2026, distilled from interview reports, technical screens, and direct conversations with engineers who conduct them. Each question is followed by the answer that demonstrates understanding, not just knowledge.


1. Event Loop Execution Order

The question:

console.log('1')

setTimeout(() => console.log('2'), 0)

Promise.resolve().then(() => console.log('3'))

console.log('4')

What is the output, and why?

The answer:

1
4
3
2

The explanation that demonstrates understanding:

JavaScript’s runtime has a call stack and an event loop. The event loop manages two types of tasks: macrotasks (setTimeout, setInterval, I/O) and microtasks (Promise callbacks, queueMicrotask).

Execution order:

  1. Synchronous code runs first — the call stack is processed completely. console.log('1') and console.log('4') execute.
  2. After each macrotask (including the initial script), all microtasks in the microtask queue are drained completely before the next macrotask runs. Promise.resolve().then() adds a callback to the microtask queue. It runs next: 3.
  3. setTimeout(() => ..., 0) adds a macrotask to the task queue. It runs after all microtasks are complete: 2.

The critical rule: the microtask queue is fully drained after every macrotask, including the initial script execution. A microtask that queues another microtask is processed in the same drain cycle. A macrotask that queues a microtask will have that microtask processed before the next macrotask.

The follow-up question:

setTimeout(() => {
    console.log('timeout')
    Promise.resolve().then(() => console.log('promise inside timeout'))
}, 0)

Promise.resolve().then(() => {
    console.log('promise')
    setTimeout(() => console.log('timeout inside promise'), 0)
})

Output?

promise
timeout
promise inside timeout
timeout inside promise

Explanation:

  1. Synchronous code runs. Two callbacks are queued — one microtask, one macrotask.
  2. Microtask queue drains: promise. Inside, a new macrotask is queued.
  3. First macrotask runs: timeout. Inside, a new microtask is queued.
  4. Microtask queue drains after the macrotask: promise inside timeout.
  5. Next macrotask runs: timeout inside promise.

The pattern: after every macrotask, drain all microtasks before the next macrotask.


2. Closure Traps

The question:

for (var i = 0; i < 3; i++) {
    setTimeout(() => console.log(i), 100)
}

What does this log?

The answer: 3 3 3

The explanation: var is function-scoped, not block-scoped. All three setTimeout callbacks close over the same i variable. By the time any callback executes (100ms later), the loop has finished and i is 3. Each callback reads the current value of i, which is 3.

The follow-up: how to fix it

Three approaches, each demonstrating different understanding:

// Fix 1: let (block-scoped) — each iteration creates a new binding
for (let i = 0; i < 3; i++) {
    setTimeout(() => console.log(i), 100)
}
// 0, 1, 2

// Fix 2: IIFE — captures the current value immediately
for (var i = 0; i < 3; i++) {
    (function(capturedI) {
        setTimeout(() => console.log(capturedI), 100)
    })(i)
}
// 0, 1, 2

// Fix 3: bind — passes the value as an argument
for (var i = 0; i < 3; i++) {
    setTimeout(console.log.bind(null, i), 100)
}
// 0, 1, 2

The answer that demonstrates senior understanding: “The underlying issue is that var declarations are hoisted to the function scope and all callbacks share the same reference. The let fix works because let creates a new binding in each iteration of the block scope — it’s not just syntactic sugar, it creates a separate variable per iteration. The IIFE fix was the pre-ES6 idiom for the same reason: it creates a new function scope per iteration that captures the current value, not the reference.”


3. this Binding in Different Contexts

The question:

const obj = {
    name: 'Object',
    regularMethod() {
        console.log(this.name)
    },
    arrowMethod: () => {
        console.log(this.name)
    },
}

obj.regularMethod()
obj.arrowMethod()

What does each log?

The answer:

  • obj.regularMethod() logs 'Object'
  • obj.arrowMethod() logs undefined (or the outer this.name if in a context where this has name)

The explanation: this in a regular method is determined at call time by who’s calling the method. When called as obj.regularMethod(), this is obj.

Arrow functions don’t have their own this. They capture this from the surrounding lexical scope at definition time. arrowMethod is defined in the object literal, but object literals don’t create a new this scope — the this at that point is whatever this is in the outer context (the module/global scope, where this.name is undefined).

The follow-up:

const obj = {
    name: 'Object',
    regularMethod() {
        const inner = () => {
            console.log(this.name)
        }
        inner()
    },
}

obj.regularMethod()

What does this log?

Answer: 'Object'

The arrow function inner captures this from its surrounding lexical scope — the regularMethod function. When regularMethod is called as obj.regularMethod(), this is obj. The arrow inner inherits that this. This is the primary use case for arrow functions in methods: capturing this for callbacks inside a method without binding.

The class method variation that catches people:

class Counter {
    count = 0

    increment() {
        this.count++
        console.log(this.count)
    }
}

const counter = new Counter()
const { increment } = counter  // Destructure the method

increment()  // What happens?

Answer: TypeError: Cannot read properties of undefined (reading 'count') (in strict mode) or incorrect behaviour in non-strict mode.

When you destructure increment from counter, you lose the binding. increment() is called without a receiver, so this is undefined in strict mode. The fix: bind in the constructor, or use a class field with an arrow function:

class Counter {
    count = 0
    // Arrow function class field captures this at construction time
    increment = () => {
        this.count++
        console.log(this.count)
    }
}

const counter = new Counter()
const { increment } = counter
increment() // Works: 1

4. Prototype Chain

The question:

function Animal(name) {
    this.name = name
}

Animal.prototype.speak = function() {
    return `${this.name} makes a sound.`
}

function Dog(name) {
    Animal.call(this, name)
}

Dog.prototype = Object.create(Animal.prototype)
Dog.prototype.constructor = Dog

Dog.prototype.bark = function() {
    return `${this.name} barks.`
}

const dog = new Dog('Rex')

console.log(dog.speak())
console.log(dog instanceof Dog)
console.log(dog instanceof Animal)
console.log(Object.getPrototypeOf(dog) === Dog.prototype)
console.log(Object.getPrototypeOf(Dog.prototype) === Animal.prototype)

What does each line log?

The answer:

'Rex makes a sound.'
true
true
true
true

The explanation of the prototype chain:

dog
  → __proto__ = Dog.prototype (has 'bark')
      → __proto__ = Animal.prototype (has 'speak')
          → __proto__ = Object.prototype (has 'toString', 'hasOwnProperty', etc.)
              → __proto__ = null

When dog.speak() is called:

  1. JavaScript looks for speak on dog — not found (dog only has name)
  2. Looks on dog.__proto__ (Dog.prototype) — not found (only has bark)
  3. Looks on dog.__proto__.__proto__ (Animal.prototype) — found

instanceof checks whether Animal.prototype exists anywhere in dog‘s prototype chain. It does — so both instanceof Dog and instanceof Animal are true.

The critical line: Dog.prototype = Object.create(Animal.prototype) creates a new object whose [[Prototype]] is Animal.prototype. Without Object.create(Animal.prototype), Dog instances wouldn’t have access to Animal’s prototype methods.

The follow-up about constructor restoration: Dog.prototype.constructor = Dog is necessary because Object.create(Animal.prototype) creates a fresh object with constructor pointing to Animal, not Dog. Without the restoration, dog.constructor === Animal.

The ES6 class equivalent:

class Animal {
    constructor(name) { this.name = name }
    speak() { return `${this.name} makes a sound.` }
}

class Dog extends Animal {
    bark() { return `${this.name} barks.` }
}

extends does the prototype wiring automatically. The prototype chain is identical. Classes are syntactic sugar — they don’t change how JavaScript’s prototype inheritance works.


5. Promise Microtask Queue

The question:

async function first() {
    console.log('first start')
    await Promise.resolve()
    console.log('first end')
}

async function second() {
    console.log('second start')
    await Promise.resolve()
    console.log('second end')
}

first()
second()
console.log('sync')

What is the output?

The answer:

first start
second start
sync
first end
second end

The explanation:

  1. first() is called. It runs synchronously until the await.
    • 'first start' logs.
    • await Promise.resolve() suspends first and schedules its continuation as a microtask.
  2. Execution returns to the caller. second() is called.
    • 'second start' logs.
    • await Promise.resolve() suspends second.
  3. console.log('sync') runs — synchronous code completes.
  4. Microtask queue drains:
    • first‘s continuation: 'first end'
    • second‘s continuation: 'second end'

The key insight: await suspends the async function but doesn’t block the calling code. Control returns to the caller immediately, allowing second() and the synchronous console.log('sync') to run before either async function resumes.

The subtle variant with nested awaits:

async function outer() {
    console.log('outer start')
    await inner()
    console.log('outer end')
}

async function inner() {
    console.log('inner start')
    await Promise.resolve()
    console.log('inner end')
}

outer()
console.log('sync')

Output:

outer start
inner start
sync
inner end
outer end

outer calls inner synchronously. inner runs until its await, logs 'inner start', suspends. outer‘s await inner() is waiting for the promise inner() returns — but inner hasn’t resolved yet. Control returns to outer‘s caller. 'sync' logs. Microtask queue: first inner resumes ('inner end'), then inner‘s promise resolves, which triggers outer to resume ('outer end').


6. WeakMap vs Map

The question: What are the differences between Map and WeakMap, and when would you use each?

The answer:

// Map — strong references, iterable, any key type
const map = new Map()
let key = { id: 1 }
map.set(key, 'value')

console.log(map.size) // 1
console.log(map.has(key)) // true

key = null // The original object is NOT garbage collected
           // because Map holds a strong reference to it
console.log(map.size) // Still 1

// WeakMap — weak references, not iterable, object keys only
const weakMap = new WeakMap()
let wKey = { id: 1 }
weakMap.set(wKey, 'value')

wKey = null // The original object CAN be garbage collected
            // because WeakMap holds only a weak reference
// weakMap.size → TypeError: WeakMap has no size property
// weakMap.keys() → TypeError: WeakMap is not iterable

The practical differences:

Map:
  → Keys can be any type (primitives, objects, functions)
  → Iterable (forEach, for...of, .keys(), .values(), .entries())
  → Has .size property
  → Strong references — prevents garbage collection
  → Use when: you need iteration, when keys are primitives,
    when you want to know the collection size

WeakMap:
  → Keys must be objects (or non-registered symbols in ES2023+)
  → Not iterable — no way to enumerate entries
  → No .size property
  → Weak references — doesn't prevent garbage collection
  → Use when: associating data with DOM elements or objects
    without creating memory leaks

The real-world use case for WeakMap:

// Associating private data with class instances
// Without WeakMap: data lives forever even after the instance is garbage collected
const privateData = new WeakMap()

class User {
    constructor(name, sensitiveData) {
        // 'sensitiveData' is stored in WeakMap, not on the instance
        privateData.set(this, { name, sensitiveData })
    }

    getName() {
        return privateData.get(this).name
    }
}

let user = new User('Sadique', 'SSN: 123-45-6789')
console.log(user.getName()) // 'Sadique'

user = null
// When user is garbage collected, the WeakMap entry is automatically
// removed — sensitiveData doesn't linger in memory

// DOM element metadata — classic WeakMap use case
const elementData = new WeakMap()

function attachData(element, data) {
    elementData.set(element, data)
}

// When the element is removed from the DOM and all references dropped,
// the WeakMap entry is collected automatically
// With a regular Map, you'd have to manually clean up to avoid leaks

7. Generator Functions

The question:

function* counter() {
    let i = 0
    while (true) {
        yield i++
    }
}

const gen = counter()
console.log(gen.next())
console.log(gen.next())
console.log(gen.next())

What does this output, and what would happen without yield?

The answer:

{ value: 0, done: false }
{ value: 1, done: false }
{ value: 2, done: false }

Without yield, the while (true) would be an infinite loop that blocks the thread permanently.

The explanation: generators are functions that can pause execution. yield suspends the function and returns a value to the caller. Calling .next() resumes execution from where it paused.

The generator maintains its own execution context — i persists between .next() calls because the generator’s stack frame is preserved while suspended.

The practical use of generators:

// Lazy evaluation — generate values on demand
function* range(start, end, step = 1) {
    for (let i = start; i < end; i += step) {
        yield i
    }
}

// Doesn't create an array of 1000 numbers in memory
// Generates each value only when consumed
for (const n of range(0, 1000)) {
    if (n > 5) break  // Stops immediately — no wasted computation
}

// The spread operator consumes the generator:
console.log([...range(0, 5)]) // [0, 1, 2, 3, 4]

// Async generators for streaming data:
async function* streamData(url) {
    const response = await fetch(url)
    const reader = response.body.getReader()

    while (true) {
        const { value, done } = await reader.read()
        if (done) break
        yield new TextDecoder().decode(value)
    }
}

for await (const chunk of streamData('/api/stream')) {
    process(chunk)
}

The two-way communication aspect most candidates miss:

function* dialog() {
    const firstName = yield 'What is your first name?'
    const lastName  = yield `Hello ${firstName}! What is your last name?`
    return `Full name: ${firstName} ${lastName}`
}

const gen = dialog()
console.log(gen.next().value)           // 'What is your first name?'
console.log(gen.next('Sadique').value)  // 'Hello Sadique! What is your last name?'
console.log(gen.next('Ali').value)      // 'Full name: Sadique Ali'

.next(value) sends a value into the generator — it becomes the result of the yield expression. This two-way communication is the basis for how async/await was originally implemented using generators.


8. Scope, Hoisting, and the Temporal Dead Zone

The question:

console.log(a)  // ?
console.log(b)  // ?
console.log(c)  // ?

var a = 1
let b = 2
const c = 3

The answer:

undefined    // var is hoisted and initialized to undefined
ReferenceError: Cannot access 'b' before initialization
ReferenceError: Cannot access 'c' before initialization

The third console.log is never reached due to the second error.

The explanation distinguishes three hoisting behaviours:

  • var: declaration is hoisted AND initialized to undefined. You can access it before the assignment.
  • let and const: declaration is hoisted but NOT initialized. The period between the hoisting and the declaration line is called the Temporal Dead Zone (TDZ). Accessing in the TDZ throws ReferenceError.
  • Function declarations: fully hoisted (both declaration and definition).
// Function declaration — fully hoisted
greet() // Works: 'Hello'
function greet() { console.log('Hello') }

// Function expression — only the variable is hoisted (as undefined)
greet2() // TypeError: greet2 is not a function
var greet2 = function() { console.log('Hello') }

The TDZ trap with class:

// Classes are also in the TDZ
const instance = new MyClass() // ReferenceError
class MyClass {}

Class declarations are hoisted but not initialized — identical TDZ behaviour to let and const. This surprises candidates who assume classes behave like function declarations.


9. Currying and Partial Application

The question: Implement a curry function.

// curry(f) should return a curried version of f
// curry(add)(1)(2)(3) === add(1, 2, 3) === 6

function add(a, b, c) {
    return a + b + c
}

const curriedAdd = curry(add)
curriedAdd(1)(2)(3) // 6
curriedAdd(1, 2)(3) // 6
curriedAdd(1)(2, 3) // 6

The answer:

function curry(fn) {
    return function curried(...args) {
        // If we have enough arguments, call the original function
        if (args.length >= fn.length) {
            return fn.apply(this, args)
        }

        // Otherwise, return a function that collects more arguments
        return function(...moreArgs) {
            return curried.apply(this, args.concat(moreArgs))
        }
    }
}

The explanation: fn.length is the arity (number of declared parameters) of the original function. The curried version collects arguments until it has at least as many as the original function expected, then calls it. Each partial application creates a new closure that remembers the accumulated arguments.

The test that reveals whether the candidate truly understands:

const curriedAdd = curry(add)

const add1  = curriedAdd(1)        // Returns a function waiting for 2 more args
const add12 = add1(2)              // Returns a function waiting for 1 more arg
const result = add12(3)            // Calls add(1, 2, 3)

console.log(result) // 6
console.log(typeof add1)  // 'function'
console.log(typeof add12) // 'function'

10. The typeof Null Quirk and Type Coercion

The question:

console.log(typeof null)
console.log(null == undefined)
console.log(null === undefined)
console.log(null + 1)
console.log(undefined + 1)
console.log([] + [])
console.log([] + {})
console.log({} + [])

What does each log?

The answer:

'object'         // typeof null — historical bug, null's type tag was 0 (same as objects)
true             // == does type coercion: null == undefined is a special case in the spec
false            // === no coercion: different types
1                // null coerces to 0 in arithmetic: 0 + 1 = 1
NaN              // undefined coerces to NaN: NaN + 1 = NaN
''               // [] + []: both arrays call toString() → '' + '' = ''
'[object Object]' // [] + {}: [] → '', {} → '[object Object]'
0                // {} + []: {} parsed as empty block, +[] coerces [] to 0

The last one is the trap. When {} appears at the start of a statement, JavaScript parses it as an empty block, not an object literal. Then +[] is a unary + applied to an empty array. +[] coerces the array to a number: +'' = 0.

This is why expressions like ({} + []) evaluate differently from {} + [] as a statement — the parentheses force {} to be an expression (object literal).

The senior-level answer goes beyond reciting the outputs: “These are consequences of JavaScript’s type coercion algorithm. The == operator applies abstract equality comparison, which has special handling for null/undefined. Arithmetic operators call ToNumber() on operands. The + operator calls ToPrimitive() first — for arrays, that means toString(), for objects, it tries valueOf() then toString(). The {} + [] statement case is a parsing ambiguity that produces counterintuitive results. In production code, none of these coercions should appear — use === and explicit type conversion.”


11. Memory Leaks in JavaScript

The question: Name three common causes of memory leaks in JavaScript applications and how to prevent them.

The answer:

Leak 1: Forgotten event listeners

// ❌ Event listener added, never removed
// If the button is removed from the DOM, the listener still holds a reference
// to the component/closure, preventing garbage collection
function addHandler() {
    const button = document.getElementById('submit')
    const data   = { largeObject: new Array(1000000).fill('x') }

    button.addEventListener('click', () => {
        console.log(data.largeObject.length)
    })
    // When addHandler returns, 'data' can't be collected
    // because the event listener closes over it
}

// ✅ Remove listeners when done
function addHandler() {
    const button = document.getElementById('submit')
    const data   = { largeObject: new Array(1000000).fill('x') }

    function handleClick() {
        console.log(data.largeObject.length)
        // Remove after first use, or when component unmounts
        button.removeEventListener('click', handleClick)
    }

    button.addEventListener('click', handleClick)
}

Leak 2: Closures holding references to large objects

// ❌ Cache that grows unboundedly
function createCache() {
    const cache = {}  // Grows forever

    return function(key, value) {
        cache[key] = value
        return cache[key]
    }
}

// ✅ LRU cache with size limit, or use WeakMap
const cache = new WeakMap()
function cache(element, data) {
    weakCache.set(element, data)  // Collected when element is removed
}

Leak 3: Timers that are never cleared

// ❌ Interval runs forever, holds reference to callback and its closure
function startPolling() {
    const data = fetchLargeDataset()

    setInterval(() => {
        processData(data)  // 'data' can never be collected
    }, 1000)
}

// ✅ Store the ID and clear on cleanup
function startPolling() {
    const data = fetchLargeDataset()
    const intervalId = setInterval(() => {
        processData(data)
    }, 1000)

    // In a Vue component:
    onUnmounted(() => clearInterval(intervalId))
    // In a class:
    this.cleanup = () => clearInterval(intervalId)
}

The senior-level addition: “In framework components (Vue, React), memory leaks from event listeners and timers are most common because component lifecycle methods (onUnmounted/useEffect cleanup) are easy to forget. The Detached Elements problem in Vue/React — components removed from the DOM but still referenced in closures or stores — is harder to detect and requires heap snapshots in Chrome DevTools to diagnose.”


12. Structural Difference: == vs === and When == Is Acceptable

The question: When, if ever, is == preferable to ===?

The answer that demonstrates nuance:

Almost never. But the one well-known case:

// Checking for null or undefined in one check
// == treats null and undefined as equal to each other
// and not equal to anything else

if (value == null) {
    // This catches both null AND undefined
    // Equivalent to: if (value === null || value === undefined)
}

// This is the only widely accepted use of == in modern JavaScript
// It's shorter and explicit about the intent (catch both null and undefined)

Everything else: use ===. The type coercion rules for == are complex enough that even experienced developers are surprised by edge cases ('' == 0 is true, 0 == '0' is true, '' == '0' is false). The cognitive overhead of remembering them isn’t worth avoiding an explicit type conversion.


The Question That Comes at the End

Every senior interview eventually lands on a question like this:

“Tell me about a JavaScript performance problem you’ve diagnosed and fixed.”

The answer isn’t about knowing the answer — it’s about demonstrating the debugging process:

1. Measure first (Chrome DevTools Performance tab, not guessing)
2. Identify the bottleneck (rendering, JavaScript execution, network, memory)
3. Apply a targeted fix
4. Measure again to verify

Common findings:
→ Long tasks blocking the main thread → code splitting, moving work to Web Workers
→ Layout thrashing (read DOM, write DOM, read DOM, write DOM) → batch DOM reads, use requestAnimationFrame
→ N+1 equivalent: rendering a 1,000-item list without virtualisation → virtual scroll
→ Memory growth → heap snapshot, find detached elements or growing collections

The process matters more than any specific anecdote. An interviewer wants to see that you reach for profiling tools before applying fixes, that you measure after fixing, and that you understand the relationship between the JS runtime and browser rendering.

Leave a Reply

Your email address will not be published. Required fields are marked *