> cs·fundamentals
interview 0% 22m read
5.3 ★ core [J][I] 18 interview Q's

JavaScript that matters for the frontend

Closures, this-binding (and arrow-function lexical this), prototypes, hoisting, plus the browser event loop and event delegation.

A handful of JavaScript mechanics show up constantly in real frontend bugs and in every interview: closures, how this is resolved, the prototype chain, hoisting, and the browser’s event loop. Get these five right and most “weird JS behavior” stops being weird.

Closures

A closure is created every time a function is defined inside another function: the inner function “closes over” the outer function’s variables and keeps them alive. This is the foundation of private state, factory functions, and almost every callback that remembers something.

A counter with private state
function makeCounter() {
  let count = 0;                 // private — no outside access
  return () => ++count;          // closes over `count`
}

const next = makeCounter();
next(); // 1
next(); // 2
// `count` lives on because the returned function still references it

Each call to makeCounter creates a fresh count, fully encapsulated. There’s no way to read or reset it except through the returned function — that’s a closure providing genuine privacy.

this binding

The value of this is decided by how a function is called, not where it’s defined. There are four call patterns, plus the arrow-function exception:

Call patternWhat <code>this</code> isExample
Method callthe object before the dotobj.fn()this === obj
Plain function callundefined (strict) / window (sloppy)fn() → not the object you expected
new callthe freshly created instancenew Foo()this = the new object
call/apply/bindwhatever you pass explicitlyfn.call(ctx)this === ctx
Arrow functioninherited from the enclosing scope (lexical)ignores all of the above
Arrow functions have no `this` of their own — they capture it from where they're defined.
Why a callback loses `this` — and why arrows fix it
class Timer {
  constructor() { this.seconds = 0; }

  start() {
    // BUG: a plain function callback is called by the timer, not via `this.timer`,
    // so `this` is undefined and `this.seconds` throws.
    setInterval(function () { this.seconds++; }, 1000);

    // FIX: an arrow captures `this` lexically from `start`, where it is the instance.
    setInterval(() => { this.seconds++; }, 1000);
  }
}

The arrow function doesn’t get its own this — it reuses start’s, which is the Timer instance. This is the single most common reason arrows are reached for in class methods and event handlers.

Prototypes

JavaScript inheritance is delegation, not classes. When you read obj.foo, the engine checks obj, then obj’s prototype, then its prototype, walking the chain until it finds foo or hits null. The class keyword is syntactic sugar over exactly this — methods defined in a class body live on the prototype, shared by all instances rather than copied onto each.

const animal = {
  describe() { return `a ${this.kind}`; }
};
const dog = Object.create(animal); // dog's prototype is `animal`
dog.kind = "dog";
dog.describe(); // "a dog" — `describe` found one hop up the chain; `this` is `dog`

The practical payoff: methods aren’t duplicated per instance, and this inside a prototype method still refers to the object you called it on.

Hoisting and the temporal dead zone

Before any code runs, the engine processes declarations. The behavior differs sharply by keyword:

DeclarationHoisted?Usable before its line?Notes
varyesyes — reads as undefinedinitialized to undefined at the top of its function
let / constyesno — throws ReferenceErrorin the temporal dead zone until the declaration runs
function declarationyes — fullyyes — callablethe whole function is available before its line
function expression / arrowonly the variablenothe variable hoists by its var/let rules; the function value doesn't
`var` defaults to `undefined`; `let`/`const` throw if touched early — the temporal dead zone.

The browser event loop

Browser JavaScript is single-threaded: one call stack runs your code to completion. Async work (timers, fetch, events) is handed off, and when it finishes, a callback is queued. The event loop decides the order in which those queued callbacks run, and there are two queues with different priorities.

  • Microtasks — Promise .then/await continuations, queueMicrotask, MutationObserver. After the current synchronous code finishes, the loop drains the entire microtask queue before doing anything else.
  • MacrotaskssetTimeout/setInterval callbacks, I/O, UI events. The loop takes exactly one macrotask, then drains all microtasks again, then (if needed) renders, then loops.
A cycle: the call stack runs to empty, then the entire microtask queue drains, then exactly one macrotask is taken, then an optional render, looping back to the start.Call stackrun sync to emptyMicrotasksdrain ALL (Promises)Macrotasktake exactly ONERender~60fps, if neededloop back ↺
FIG 1 · one turn of the event loop Run the call stack to empty → drain the WHOLE microtask queue → take exactly ONE macrotask → maybe render → repeat. Microtasks always beat the next macrotask.
Predicting the output order
console.log("1: sync");
setTimeout(() => console.log("4: timeout (macrotask)"), 0);
Promise.resolve().then(() => console.log("3: promise (microtask)"));
console.log("2: sync");

// Output: 1, 2, 3, 4

Both synchronous lines run first (1, 2). The stack empties, so the loop drains microtasks — the Promise callback (3) — before it ever reaches the timeout (4), even though the timeout was scheduled with 0 ms. Microtasks always beat the next macrotask.

Event delegation

Instead of attaching a listener to every child, attach one listener to a common ancestor and use event bubbling: an event fired on a child propagates up through its parents, so the ancestor’s handler sees it and can inspect event.target to find which child triggered it.

A DOM event actually travels in three phases: it captures down from document to the target, fires at the target, then bubbles back up to document. Listeners added the normal way (addEventListener(type, fn)) fire during the bubble phase — which is exactly what makes delegation work.

Three nested boxes — document, ul, li — with a capture arrow going down from document to the li target and a bubble arrow going back up, where the ul has the single delegated listener.document<ul>one listener here<li>event.target(clicked)capture ↓bubble ↑
FIG 2 · event propagation A click on the <li> captures down from document to the target, then bubbles back up. A single listener on <ul> hears it on the way up and reads event.target.
One listener for a whole list
// Instead of N listeners, one on the parent.
document.querySelector("#todo-list").addEventListener("click", (e) => {
  const item = e.target.closest("li"); // which child was clicked?
  if (!item) return;                   // click landed outside any <li>
  item.classList.toggle("done");
});

Two wins: memory — one handler instead of hundreds — and dynamic elements<li>s added later are handled automatically, with no need to re-bind. closest() walks up from the actual target so clicks on nested elements still resolve to the right item.

01 Learning objectives

0 / 2 done

02 Curated reading

03 Knowledge check

knowledge check3 questions · pass ≥ 70%
  1. 01medium

    An arrow function's `this` is:

  2. 02medium

    Attaching ONE listener to a parent instead of many to children is:

  3. 03medium

    A closure lets an inner function access variables from its outer function after that outer function has returned.

04 Interview questions

browse all ↗

What gets asked on this topic — tap a card for how to approach it, the follow-ups, and the trap. Company tags are best-effort & sourced.

  • Meta mid debug very common What does this print, and why? for (var i = 0; i < 3; i++) { setTimeout(() => console.log(i), 1); }

    It prints 3, 3, 3.

    var is function-scoped, so all three callbacks close over the same i. The setTimeout callbacks run after the synchronous loop finishes, by which point i has been incremented to 3.

    Fixes: use let (block-scoped — each iteration gets a fresh binding, printing 0 1 2); or capture per-iteration with an IIFE (j => setTimeout(() => console.log(j), 1))(i).

    Red flag Answering 0 1 2 for the var version. The classic mistake is forgetting var is shared and the timers fire after the loop.

    source: lydiahallie/javascript-questions (Q2) ↗
  • Commonly asked mid debug very common What does this print? const shape = { radius: 10, diameter() { return this.radius * 2; }, perimeter: () => 2 * Math.PI * this.radius }; console.log(shape.diameter()); console.log(shape.perimeter());

    It prints 20 and then NaN.

    diameter is a regular method: called as shape.diameter(), this is shape, so this.radius is 1020.

    perimeter is an arrow function: arrows do not get their own this; they use the lexically enclosing this (here the module/global scope), where radius is undefined. 2 * Math.PI * undefinedNaN.

    Red flag Assuming the arrow's `this` is the object. Arrows ignore the call site and bind `this` lexically.

    source: lydiahallie/javascript-questions (Q3) ↗
  • Commonly asked mid debug very common What does this print? function sayHi() { console.log(name); console.log(age); var name = "Lydia"; let age = 21; } sayHi();

    It logs undefined, then throws a ReferenceError.

    var name is hoisted and initialized to undefined, so the first log reads undefined.

    let age is hoisted too but not initialized — it sits in the temporal dead zone until its declaration runs. Accessing it before that line throws ReferenceError: Cannot access 'age' before initialization, so the second log never completes.

    Red flag Saying both are undefined, or that let is 'not hoisted at all'. It is hoisted but uninitialized (TDZ).

    source: lydiahallie/javascript-questions (Q1) ↗
  • Amazon mid concept very common What is a closure? Give a practical use case.

    A closure is a function bundled with references to the variables from the scope where it was defined. The inner function keeps those variables alive even after the outer function returns.

    Use cases: private state (a counter factory where the count is inaccessible from outside), partial application / currying, memoization caches, and stateful callbacks like the timer ID inside a debounce.

    Example:

    function makeCounter() { let n = 0; return () => ++n; }
    const c = makeCounter(); c(); // 1n is private and persists.

    Red flag Defining a closure only as 'a function inside a function' without mentioning that it captures and persists the enclosing variables.

    source: MDN — Closures ↗
  • Meta senior concept common How is `this` determined at call time? Walk through the binding rules.

    For a normal function, this depends on how it is called, checked in priority order:

    1. new Fn()this is the freshly created object.
    2. fn.call/apply/bind(obj)this is the explicit obj.
    3. obj.fn()this is the receiver obj (implicit binding).
    4. Plain fn()this is undefined in strict mode, else the global object.

    Arrow functions ignore all of the above: they capture this lexically from where they were defined. That is why arrows are handy in callbacks but wrong as object methods.

    Red flag Saying `this` is fixed by where a function is defined (true only for arrows). For normal functions it is the call site.

    source: MDN — this ↗
  • Meta senior debug very common Explain the event loop, the call stack, and the difference between microtasks and macrotasks. What prints? console.log(1); setTimeout(() => console.log(2), 0); Promise.resolve().then(() => console.log(3)); console.log(4);

    It prints 1, 4, 3, 2.

    Synchronous code runs first on the call stack: 1, then 4.

    When the stack is empty, the event loop drains the entire microtask queue before any macrotask. Promise.then is a microtask3. setTimeout is a macrotask2, runs last.

    So: sync (1, 4) → all microtasks (3) → next macrotask (2).

    Red flag Predicting `1 4 2 3`. The trap is thinking setTimeout(0) beats a resolved Promise — microtasks always drain first.

    source: MDN — In depth: Microtasks and the JavaScript runtime environment ↗
  • Meta mid concept very common What is event delegation, and why attach one listener to a parent instead of many to children?

    Event delegation exploits bubbling: instead of binding a listener to every child, you bind one to a common ancestor and inspect event.target to find which child triggered it.

    Benefits: fewer listeners (lower memory), and it automatically handles dynamically added children without rebinding.

    Example:

    list.addEventListener("click", (e) => { const li = e.target.closest("li"); if (li) handle(li.dataset.id); });

    Use event.target for the actual origin and event.currentTarget for the element the listener is on.

    Red flag Confusing target with currentTarget, or assuming every event bubbles (focus/blur do not; focusin/focusout do).

    source: MDN — Event bubbling and delegation ↗
  • AmazonGoogle mid coding very common Implement a debounce function.

    Debounce delays calling fn until wait ms have passed since the last call; every new call resets the timer. The timer id lives in a closure.

    function debounce(fn, wait) {
    let t;
    return function (...args) {
    clearTimeout(t);
    t = setTimeout(() => fn.apply(this, args), wait);
    };
    }

    Using a normal function (not an arrow) for the wrapper preserves the caller's this, and fn.apply(this, args) forwards both. Common in search-as-you-type and resize handlers.

    Red flag Hoisting the timer outside the returned function incorrectly (shared across instances), or dropping `this`/`args` so the debounced fn loses context.

    source: GreatFrontend — Debounce ↗
  • Commonly asked senior concept common How does prototypal inheritance work? What is the difference between __proto__ and prototype?

    Every object has an internal link ([[Prototype]], exposed as __proto__) to another object. Property lookups walk this prototype chain until found or it hits null.

    prototype is a property on constructor functions: when you do new Fn(), the new object's __proto__ is set to Fn.prototype. So instances delegate to Fn.prototype for shared methods.

    Mnemonic: prototype lives on the constructor; __proto__ (better: Object.getPrototypeOf) lives on instances and points at the constructor's prototype.

    Red flag Mixing up `prototype` (on the constructor) and `__proto__` (on the instance), or thinking class syntax is not prototype-based — it is sugar.

    source: MDN — Inheritance and the prototype chain ↗
  • Commonly asked junior trick common What is the difference between == and ===, and name a coercion gotcha.

    === is strict equality: no type coercion — different types are never equal. == is loose equality: it coerces operands to a common type first, which produces surprising results.

    Gotchas: 0 == "" is true, 0 == "0" is true, but "" == "0" is false (not transitive). null == undefined is true, yet null == 0 is false. NaN === NaN is false.

    Rule: default to ===; the one common, intentional == is x == null to catch both null and undefined.

    Red flag Claiming == is just === plus 'minor type stuff', then getting tripped by the non-transitive empty-string/zero cases.

    source: MDN — Equality comparisons and sameness ↗
  • Commonly asked mid debug common What does this print, and why? let count = 0; const fns = []; for (let i = 0; i < 3; i++) { fns.push(() => i); } console.log(fns.map((f) => f()));

    It logs [0, 1, 2].

    With let, the loop creates a fresh binding of i for each iteration, so each arrow closes over a different i holding that iteration's value. (count is a red herring — it's never touched.)

    If this used var instead, all three closures would share one function-scoped i, and after the loop finished i would be 3, so it would log [3, 3, 3]. This is the canonical demonstration of why let fixed the classic loop-closure bug.

    What a strong answer covers
    • let gives each iteration its own binding of the loop variable.

    • Each closure captures its iteration's i, so the result is [0, 1, 2].

    • With var (function-scoped, one shared binding) it would be [3, 3, 3].

    • Closures capture variables (bindings), not snapshot values.

    Quick self-check

    What is logged?

    Red flag Answering [3, 3, 3] for the `let` version — that's the `var` behavior. let creates a new binding per iteration.

    source: MDN — Closures (creating closures in loops) ↗
  • Commonly asked mid debug common What does this print, and why? const obj = { name: 'obj', greet() { setTimeout(function () { console.log(this.name); }, 0); }, }; obj.greet();

    It logs undefined (in a browser, this is the global object, where name is ''; in strict mode/modules this is undefined and it would throw).

    The inner function passed to setTimeout is a plain function called by the timer, not as a method of obj. Its this is therefore not obj — implicit binding only happens for obj.method() call syntax. The timer invokes it as a bare function.

    Fixes: use an arrow function in the timeout (inherits greet's this), capture const self = this, or .bind(this). This is the single most common this-loss bug in callbacks.

    What a strong answer covers
    • this is set by the call site; the timer calls the callback as a plain function.

    • Plain-function this is the global object (sloppy mode) or undefined (strict/module).

    • An arrow function in setTimeout inherits the enclosing method's this (= obj).

    • Alternatives: const self = this capture, or .bind(this).

    Quick self-check

    What logs (assume a non-strict browser global where name is '')?

    Red flag Assuming the callback inherits `obj` as `this` because it's defined inside a method. Only the call site sets a normal function's `this`.

    source: MDN — this (callbacks) ↗
  • Commonly asked mid concept common What is the difference between call, apply, and bind?

    All three set a function's this explicitly; they differ in when it runs and how arguments are passed.

    call(thisArg, a, b) — invokes immediately, arguments passed individually.

    apply(thisArg, [a, b]) — invokes immediately, arguments passed as an array. (Mnemonic: Apply = Array.)

    bind(thisArg, a) — does not invoke; returns a new function with this (and any leading args) permanently fixed. You call that later. A bound function cannot be re-bound, and new on it ignores the bound this.

    With spread, call(...args) covers most apply cases today.

    What a strong answer covers
    • call: invoke now, args listed individually.

    • apply: invoke now, args as an array (Apply = Array).

    • bind: returns a new permanently-bound function; doesn't invoke.

    • A bound function's this can't be overridden by a later call/bind.

    Red flag Mixing up apply (array) and call (list), or thinking bind invokes the function immediately — it returns a new one.

    source: MDN — Function.prototype.bind() ↗
  • Commonly asked mid coding common Implement a throttle function, and explain how it differs from debounce.

    Throttle guarantees fn runs at most once per wait window, no matter how often it's called — good for scroll/resize/mousemove. Debounce waits until calls *stop* for wait ms, then fires once — good for search-as-you-type.

    function throttle(fn, wait) {
    let last = 0;
    return function (...args) {
    const now = Date.now();
    if (now - last >= wait) {
    last = now;
    fn.apply(this, args);
    }
    };
    }

    This is a leading-edge throttle: it fires immediately, then ignores calls until the window elapses. The timestamp lives in a closure, and fn.apply(this, args) forwards context and arguments.

    What a strong answer covers
    • Throttle: at most one call per time window (steady cadence under continuous events).

    • Debounce: fires only after calls go quiet for wait ms.

    • Throttle suits scroll/resize; debounce suits typeahead/validation.

    • The closure holds the last-run timestamp; forward this/args via apply.

    Red flag Implementing debounce and calling it throttle (resetting a timer on each call is debounce). Also dropping the trailing call so the last event is lost.

    source: GreatFrontend — Throttle ↗
  • Commonly asked senior concept common What is the difference between a microtask and a macrotask, and which queue drains first?

    After each macrotask (and after the current synchronous run-to-completion finishes), the event loop drains the entire microtask queue before taking the next macrotask or rendering.

    Microtasks: Promise .then/.catch/.finally callbacks, queueMicrotask, MutationObserver. They run as soon as the stack is empty, ahead of any timer.

    Macrotasks (tasks): setTimeout, setInterval, I/O, message events, UI events. One per loop turn.

    Consequence: a resolved Promise always runs before a setTimeout(0). And an unbounded chain of microtasks can starve rendering and timers, because the loop won't move on until the microtask queue is empty.

    What a strong answer covers
    • Order each turn: run a macrotask → drain all microtasks → (maybe render) → next macrotask.

    • Microtasks: Promise callbacks, queueMicrotask, MutationObserver.

    • Macrotasks: setTimeout/setInterval, I/O, UI/message events.

    • Resolved Promise beats setTimeout(0); runaway microtasks can starve render/timers.

    Quick self-check

    Which of these schedules a MICROTASK?

    Red flag Thinking setTimeout(0) runs before a resolved Promise. Microtasks always drain fully before the next macrotask.

    source: MDN — In depth: Microtasks and the JavaScript runtime environment ↗
  • Commonly asked junior trick common What is the difference between null and undefined, and what does typeof return for each?

    undefined means a variable has been declared but not assigned, a missing function argument, a missing object property, or a function with no return. The engine produces it.

    null is an intentional 'no value' that *you* assign to signal emptiness.

    The famous quirk: typeof undefined is "undefined", but typeof null is "object" — a long-standing bug kept for backward compatibility. They are loosely equal (null == undefined is true) but not strictly equal (null === undefined is false).

    Use x == null to test for both at once, or ?? (nullish coalescing) which treats only null/undefined as missing.

    What a strong answer covers
    • undefined: engine-produced 'not assigned / missing'. null: developer-assigned 'intentionally empty'.

    • typeof undefined === 'undefined'; typeof null === 'object' (a historical bug).

    • null == undefined is true; null === undefined is false.

    • ?? treats only null/undefined as missing, unlike || which also catches 0/''/false.

    Quick self-check

    What does typeof null evaluate to?

    Red flag Expecting typeof null to be 'null' (it's 'object'), or using || where ?? is needed and accidentally treating 0/'' as missing.

    source: MDN — null ↗
  • Commonly asked mid concept common What is the difference between a shallow copy and a deep copy, and how do you make each?

    A shallow copy duplicates only the top level; nested objects/arrays are still shared references. So mutating a nested value affects both copies. Make one with {...obj}, Object.assign({}, obj), or arr.slice().

    A deep copy recursively clones every level, so the copy is fully independent. Modern way: structuredClone(obj) (handles Dates, Maps, Sets, cyclic refs). The old hack JSON.parse(JSON.stringify(obj)) works only for plain JSON-safe data — it drops functions, undefined, and Symbols, and turns Date into a string.

    Key point: spread is shallow, so a nested array inside a spread copy is still linked to the original.

    What a strong answer covers
    • Shallow copy shares nested references; spread/Object.assign/slice are shallow.

    • Deep copy clones every level into an independent structure.

    • structuredClone() is the modern deep-copy API (handles Dates/Maps/Sets/cycles).

    • JSON.parse(JSON.stringify(x)) loses functions, undefined, Symbols, and Dates.

    Red flag Believing spread or Object.assign deep-copies — nested objects stay shared. Reaching for JSON round-trip on data containing Dates/functions/undefined.

    source: MDN — Shallow copy / Deep copy (structuredClone) ↗
  • Commonly asked mid concept common What is the difference between function declarations and function expressions with respect to hoisting?

    A function declaration (function foo() {}) is hoisted whole — both its name and body — so you can call it on a line *above* where it's written.

    A function expression (const foo = function () {} or an arrow) follows variable hoisting rules. With const/let, the binding is hoisted but in the temporal dead zone, so calling it early throws ReferenceError. With var, the variable hoists as undefined, so calling it early throws TypeError: foo is not a function (it's undefined, not callable yet).

    So declarations are usable before their line; expressions are not, and the error you get depends on var vs let/const.

    What a strong answer covers
    • Function declarations are fully hoisted (callable before their definition).

    • Function expressions follow the variable's hoisting: TDZ for let/const, undefined for var.

    • Calling a var-assigned expression early → TypeError (not a function).

    • Calling a let/const expression early → ReferenceError (TDZ).

    Quick self-check

    What happens? foo(); var foo = function () { return 1; };

    Red flag Assuming all functions are hoisted. Only declarations are; expressions hoist per their variable's rules (TDZ or undefined).

    source: MDN — Hoisting ↗