DEV Community

Cover image for 6 Advanced JavaScript Questions That Separate Seniors from Mid-Levels
Vitaly Obolensky
Vitaly Obolensky

Posted on

6 Advanced JavaScript Questions That Separate Seniors from Mid-Levels

1. Stale closure & primitive capture

What is the output of the following code?

function createIncrement() {
  let count = 0;
  const message = `Count is ${count}`;

  function increment() {
    count++;
  }

  function log() {
    console.log(message);
  }

  return { increment, log };
}

const { increment, log } = createIncrement();
increment();
increment();
log();
Enter fullscreen mode Exit fullscreen mode

Test your understanding of closures, lexical scope, and primitive value capture.

āœ… Output

Count is 0
Enter fullscreen mode Exit fullscreen mode

🧠 Explanation

This is a classic stale closure trap — but not in the way most developers expect.

Step-by-step execution:

  1. createIncrement() is invoked → new lexical environment created:
    • count = 0 (mutable binding)
    • message = "Count is 0" (primitive string, interpolated immediately at assignment)
  2. Inner functions increment and log are defined. Both close over the same lexical environment.
  3. increment() is called twice:
    • count mutates: 0 → 1 → 2 āœ“
    • This works as expected.
  4. log() is called:
    • It references the variable message
    • message still holds the original string value "Count is 0"
    • The template literal was evaluated once, at the moment of assignment — not re-evaluated when log() runs.

šŸ”‘ Core Concept

> Closures capture variables, not expressions.
> But if a variable holds a primitive value (string, number, boolean), that value is fixed at assignment time.

message is not a live reference to count. It is a snapshot.

šŸ›  How to fix it (if dynamic output is desired)

Re-evaluate the template literal inside log():

function log() {
  console.log(`Count is ${count}`);
}
Enter fullscreen mode Exit fullscreen mode

šŸŽÆ What this question tests

Concept Why it matters
Template literal evaluation timing They run at assignment, not at access
Primitive vs reference types Primitives are copied by value; objects/arrays are referenced
Closure capture semantics Closures close over bindings, but the value of a primitive is immutable once assigned
Mental model of "live" variables Not all variables in a closure are "live views" — only the bindings themselves are

2. JavaScript context trap: losing this

What will this code output when user.greet() is called?

const user = {
  name: "Alex",
  greet() {
    console.log(`Hello, ${this.name}!`);

    const innerNormal = function () {
      console.log(`Normal: ${this.name}`);
    };

    const innerArrow = () => {
      console.log(`Arrow: ${this.name}`);
    };

    innerNormal();
    innerArrow();
  },
};

user.greet();
Enter fullscreen mode Exit fullscreen mode

Test your understanding of execution context, function invocation patterns, and arrow function lexical binding.

āœ… Output

Hello, Alex!
Normal: undefined
Arrow: Alex
Enter fullscreen mode Exit fullscreen mode

🧠 Explanation

This question tests three different ways this is resolved in JavaScript:

1. Method call: greet()

  • Called as user.greet()
  • this is bound to the object preceding the dot → user
  • Output: Hello, Alex!

2. Regular function: innerNormal()

  • Called as a standalone function: innerNormal()
  • No object precedes the call. In strict mode (default in modern JS/modules), this is undefined. In non-strict browser environments, it falls back to window.
  • undefined.name evaluates to undefined (or window.name which is typically "")
  • Output: Normal: undefined

3. Arrow function: innerArrow()

  • Arrow functions do not have their own this binding
  • They capture this lexically from the enclosing execution context at definition time
  • The enclosing context is greet(), where this === user
  • Output: Arrow: Alex

šŸ”‘ Core Concept

> Regular functions resolve this at call time (dynamic binding).
> Arrow functions capture this at definition time (lexical binding).

šŸ›  How to fix innerNormal if you want it to see user

// Option 1: Explicit binding at call time
innerNormal.call(this);

// Option 2: Explicit binding at definition time
const innerNormal = function () {
  console.log(this.name);
}.bind(this);

// Option 3: Lexical capture via closure (older pattern)
const self = this;
const innerNormal = function () {
  console.log(self.name);
};
Enter fullscreen mode Exit fullscreen mode

šŸŽÆ What this question tests

Concept Why it matters
Implicit this binding Regular functions lose context when called without an explicit receiver
Lexical this capture Arrow functions inherit this from their parent scope
Strict vs non-strict mode Changes fallback behavior (undefined vs global object)
Mental model of execution context this is dynamic for regular functions, static for arrows

3. Illusion of an "own" property on mutation

What will the code log to the console at the end?

const grandparent = {
  heritage: ["gold", "land"],
  coins: 100,
};

const parent = Object.create(grandparent);
const child = Object.create(parent);

child.coins += 50;
child.heritage.push("debts");

console.log(grandparent.coins); // (1) ?
console.log(grandparent.heritage); // (2) ?
console.log(child.coins); // (3) ?
console.log(child.heritage); // (4) ?
Enter fullscreen mode Exit fullscreen mode

Test your understanding of the difference between reading a property from the prototype chain and writing a property on an object.

āœ… Output

100
["gold", "land", "debts"]
150
["gold", "land", "debts"]
Enter fullscreen mode Exit fullscreen mode

🧠 Explanation

This is the mental trap:

For coins

The expression child.coins += 50 desugars to child.coins = child.coins + 50.

  1. JavaScript reads child.coins. It is not found on child, so the engine walks the prototype chain to grandparent and reads 100.
  2. It adds 50 → 150.
  3. It writes the result to child.coins.

Writes always land on the object that received the assignment. child gets its own coins: 150, while grandparent.coins stays 100.

For heritage

The expression child.heritage.push("debts") does not assign to heritage.

  1. JavaScript reads child.heritage, finds the array on grandparent, and keeps a reference to that same array in the heap.
  2. .push("debts") mutates that shared array.

child never gets an own heritage property — it still resolves to the grandparent's array, which now includes "debts".

šŸ”‘ Core Concept

> Read vs write behave differently on the prototype chain.
> Assignment creates (or updates) an own property on the target object.
> Mutating a referenced object affects the shared value visible to every object in the chain that points to it.

šŸ›  How to avoid accidental prototype mutation

// Option 1: Assign a new array instead of mutating the inherited one
child.heritage = [...child.heritage, "debts"];

// Option 2: Copy mutable values when creating the child
const child = Object.create(parent);
Object.assign(child, {
  coins: grandparent.coins,
  heritage: [...grandparent.heritage],
});
child.coins += 50;
child.heritage.push("debts"); // now only child's copy is affected
Enter fullscreen mode Exit fullscreen mode

šŸŽÆ What this question tests

Concept Why it matters
Prototype property lookup Reads fall through the chain until a property is found
Assignment vs mutation += writes locally; .push() mutates a shared reference
Own vs inherited properties hasOwnProperty and debugging surprises depend on this distinction
Reference types on prototypes Shared mutable state on a prototype affects all descendants

4. JavaScript coercion & precedence trap

What will this code output?

console.log(+"5" + [1] + !"0");
Enter fullscreen mode Exit fullscreen mode

Test your understanding of operator precedence, implicit type conversion, and left-to-right evaluation.

āœ… Output

"51false"
Enter fullscreen mode Exit fullscreen mode

🧠 Explanation

This expression combines unary operators, object-to-primitive coercion, and left-to-right associativity. Here's the exact execution flow:

Step 1: Unary operators (highest precedence)

Unary + and ! are evaluated before binary +.

  • +"5" → ToNumber conversion → 5
  • !"0" → "0" is truthy → !true → false
  • Expression becomes: 5 + [1] + false

Step 2: Left-to-right evaluation & first +

Binary + is left-associative. It evaluates 5 + [1] first.

  • One operand is a Number, the other is an Object.
  • JS invokes ToPrimitive([1]) → calls Array.toString() → "1"
  • Since one operand is now a string, + switches to string concatenation
  • "5" + "1" → "51"
  • Expression becomes: "51" + false

Step 3: Second + & final coercion

  • "51" (string) + false (boolean)
  • Boolean false is coerced to string → "false"
  • Concatenation: "51" + "false" → "51false"

šŸ”‘ Core Concept

> Precedence decides what runs first.
> Associativity decides direction (left → right for +).
> Coercion decides how types interact when mismatched.

The + operator is unique in JS: it performs both addition and concatenation. If either operand becomes a string during ToPrimitive, the entire operation switches to string concatenation.

šŸ›  Common pitfalls to avoid

Mistake Why it's wrong
Thinking 5 + [1] equals 6 [1] is not a number; it's coerced to "1"
Thinking !"0" equals true Only "", 0, null, undefined, NaN are falsy. "0" is a non-empty string → truthy
Assuming right-to-left evaluation + is strictly left-associative in JS

šŸŽÆ What this question tests

Concept Why it matters
Operator precedence table Determines evaluation order before execution begins
ToPrimitive & ToString algorithms How objects/arrays convert in mixed-type expressions
Left-to-right associativity Critical for chaining + operations with side effects or type shifts
Falsy/truthy rules Essential for !, &&, `

5. JavaScript event loop & queue priority trap

In what order will the numbers be printed to the console?

console.log("1");

setTimeout(() => {
  console.log("2");
  Promise.resolve().then(() => {
    console.log("3");
  });
}, 0);

new Promise((resolve) => {
  console.log("4");
  resolve();
}).then(() => {
  console.log("5");
  setTimeout(() => {
    console.log("6");
  }, 0);
});

console.log("7");
Enter fullscreen mode Exit fullscreen mode

Test your understanding of the call stack, microtask queue, and macrotask queue execution order.

āœ… Output

1
4
7
5
2
3
6
Enter fullscreen mode Exit fullscreen mode

🧠 Explanation

This question tests the exact execution order defined by the JavaScript event loop.

Step 1: Synchronous execution (call stack)

  • console.log("1") runs immediately → outputs 1
  • setTimeout schedules its callback as a macrotask and exits
  • new Promise executor runs synchronously → console.log("4") outputs 4. resolve() is called
  • .then() schedules its callback as a microtask
  • console.log("7") runs immediately → outputs 7
  • Call stack is now empty

Step 2: Microtask queue (priority over macrotasks)

  • The event loop checks the microtask queue before picking the next macrotask
  • Microtask 1 runs: console.log("5") → outputs 5
  • Inside it, setTimeout schedules a new callback as macrotask 2 (appended to the macrotask queue)
  • Microtask queue is now empty

Step 3: Macrotask queue (first cycle)

  • Event loop picks macrotask 1 (the first setTimeout)
  • console.log("2") → outputs 2
  • Promise.resolve().then(...) schedules microtask 2

Step 4: Microtask queue (again)

  • Before moving to the next macrotask, the event loop must completely drain the microtask queue
  • Microtask 2 runs: console.log("3") → outputs 3
  • Microtask queue is empty

Step 5: Macrotask queue (second cycle)

  • Event loop picks macrotask 2 (the second setTimeout)
  • console.log("6") → outputs 6

šŸ”‘ Core Concept

> Execution order: synchronous code → microtasks (Promise, queueMicrotask) → macrotasks (setTimeout, setInterval, I/O) → repeat.
>
> Every time a macrotask finishes, the event loop must completely drain the microtask queue before proceeding to the next macrotask. Nested microtasks created during a macrotask will delay the next macrotask.

šŸŽÆ What this question tests

Concept Why it matters
Promise executor timing Runs synchronously during construction, not asynchronously
Microtask vs macrotask priority Microtasks always clear before the next macrotask runs
Nested async scheduling New tasks are appended to the back of their respective queues
Event loop phases Critical for debugging race conditions, UI freezes, and API batching

6. Microtask order: async/await vs promise chains

In what exact order will the logs be printed to the console?

async function asyncFunc() {
  console.log("2");
  await Promise.resolve();
  console.log("3");
}

console.log("1");
asyncFunc();

Promise.resolve()
  .then(() => {
    console.log("4");
  })
  .then(() => {
    console.log("5");
  });

console.log("6");
Enter fullscreen mode Exit fullscreen mode

Test your understanding of how await is compiled under the hood and its exact scheduling priority compared to .then().

āœ… Output

1
2
6
3
4
5
Enter fullscreen mode Exit fullscreen mode

🧠 Explanation

This question reveals exactly how async/await is translated into promise chains and how the event loop schedules continuations.

Step 1: Synchronous phase

  • console.log("1") executes → outputs 1
  • asyncFunc() is invoked. Code runs synchronously until the first await
  • console.log("2") executes → outputs 2
  • await Promise.resolve() is encountered. The expression on the right evaluates to an already-resolved promise. The engine wraps the remaining function body (console.log("3")) in a microtask and suspends execution. This microtask is queued
  • Control returns to the global scope
  • Promise.resolve().then(...) registers a callback. This creates microtask 2 (logs 4). The chained .then (logs 5) is not queued yet; it waits for microtask 2 to resolve
  • console.log("6") executes → outputs 6
  • Call stack is empty. Event loop switches to the microtask queue

Step 2: Microtask queue processing (FIFO order)

  • Microtask 1 (from await continuation): runs console.log("3") → outputs 3
  • Microtask 2 (from first .then): runs console.log("4") → outputs 4. Its resolution immediately queues the next .then callback as microtask 3
  • Microtask 3 (from chained .then): runs console.log("5") → outputs 5
  • Microtask queue is empty

šŸ”‘ Core Concept

await is syntactic sugar for .then().
The code after await is compiled into a .then() callback and scheduled as a microtask.
Microtasks are processed in strict FIFO order based on when they were registered during synchronous execution.

Want more? I’m maintaining a curated repository with tricky JS questions and edge cases. Check the full list here: [https://github.com/Skillhacker-io/javascript-interview-questions/blob/main/questions/top-javascript-questions.md]

Top comments (0)