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.
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 itEach 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 pattern | What <code>this</code> is | Example |
|---|---|---|
| Method call | the object before the dot | obj.fn() → this === obj |
| Plain function call | undefined (strict) / window (sloppy) | fn() → not the object you expected |
new call | the freshly created instance | new Foo() → this = the new object |
call/apply/bind | whatever you pass explicitly | fn.call(ctx) → this === ctx |
| Arrow function | inherited from the enclosing scope (lexical) | ignores all of the above |
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:
| Declaration | Hoisted? | Usable before its line? | Notes |
|---|---|---|---|
var | yes | yes — reads as undefined | initialized to undefined at the top of its function |
let / const | yes | no — throws ReferenceError | in the temporal dead zone until the declaration runs |
| function declaration | yes — fully | yes — callable | the whole function is available before its line |
| function expression / arrow | only the variable | no | the variable hoists by its var/let rules; the function value doesn't |
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/awaitcontinuations,queueMicrotask,MutationObserver. After the current synchronous code finishes, the loop drains the entire microtask queue before doing anything else. - Macrotasks —
setTimeout/setIntervalcallbacks, I/O, UI events. The loop takes exactly one macrotask, then drains all microtasks again, then (if needed) renders, then loops.
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, 4Both 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.
// 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 done02 Curated reading
03 Knowledge check
- 01medium
An arrow function's `this` is:
- 02medium
Attaching ONE listener to a parent instead of many to children is:
- 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.
-
What does this print, and why? for (var i = 0; i < 3; i++) { setTimeout(() => console.log(i), 1); }
It prints
3,3,3.varis function-scoped, so all three callbacks close over the samei. ThesetTimeoutcallbacks run after the synchronous loop finishes, by which pointihas been incremented to3.Fixes: use
let(block-scoped — each iteration gets a fresh binding, printing0 1 2); or capture per-iteration with an IIFE(j => setTimeout(() => console.log(j), 1))(i).Follow-ups they push on- Change var to let — what prints now and why?
- How does the IIFE version create a separate closure per iteration?
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) ↗ -
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
20and thenNaN.diameteris a regular method: called asshape.diameter(),thisisshape, sothis.radiusis10→20.perimeteris an arrow function: arrows do not get their ownthis; they use the lexically enclosingthis(here the module/global scope), whereradiusis undefined.2 * Math.PI * undefined→NaN.Follow-ups they push on- Rewrite perimeter so it works.
- Why are arrow functions a bad choice for object methods but a good choice for callbacks?
Red flag Assuming the arrow's `this` is the object. Arrows ignore the call site and bind `this` lexically.
source: lydiahallie/javascript-questions (Q3) ↗ -
What does this print? function sayHi() { console.log(name); console.log(age); var name = "Lydia"; let age = 21; } sayHi();
It logs
undefined, then throws aReferenceError.var nameis hoisted and initialized toundefined, so the first log readsundefined.let ageis hoisted too but not initialized — it sits in the temporal dead zone until its declaration runs. Accessing it before that line throwsReferenceError: Cannot access 'age' before initialization, so the second log never completes.Follow-ups they push on- What exactly is the temporal dead zone?
- How does hoisting differ for function declarations vs function expressions?
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) ↗ -
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(); // 1—nis private and persists.Follow-ups they push on- How do closures cause memory leaks if you are not careful?
- How does debounce use a closure to remember the timer ID?
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 ↗ -
How is `this` determined at call time? Walk through the binding rules.
For a normal function,
thisdepends on how it is called, checked in priority order:1.
new Fn()—thisis the freshly created object.
2.fn.call/apply/bind(obj)—thisis the explicitobj.
3.obj.fn()—thisis the receiverobj(implicit binding).
4. Plainfn()—thisisundefinedin strict mode, else the global object.Arrow functions ignore all of the above: they capture
thislexically from where they were defined. That is why arrows are handy in callbacks but wrong as object methods.Follow-ups they push on- Why does passing obj.method as a callback lose `this`?
- What does bind return, and can you re-bind a bound function?
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 ↗ -
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, then4.When the stack is empty, the event loop drains the entire microtask queue before any macrotask.
Promise.thenis a microtask →3.setTimeoutis a macrotask →2, runs last.So: sync (
1,4) → all microtasks (3) → next macrotask (2).Follow-ups they push on- Where do queueMicrotask, MutationObserver, and requestAnimationFrame fit?
- Why can a runaway chain of microtasks starve rendering and timers?
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 ↗ -
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.targetto 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.targetfor the actual origin andevent.currentTargetfor the element the listener is on.Follow-ups they push on- What is the difference between event.target and event.currentTarget?
- Which events do not bubble, and how do you delegate those (capture phase / focusin)?
Red flag Confusing target with currentTarget, or assuming every event bubbles (focus/blur do not; focusin/focusout do).
source: MDN — Event bubbling and delegation ↗ -
Implement a debounce function.
Debounce delays calling
fnuntilwaitms 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, andfn.apply(this, args)forwards both. Common in search-as-you-type and resize handlers.Follow-ups they push on- How does throttle differ from debounce?
- Add a leading-edge (immediate) option.
- Why must the wrapper forward `this` and `args`?
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 ↗ -
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 hitsnull.prototypeis a property on constructor functions: when you donew Fn(), the new object's__proto__is set toFn.prototype. So instances delegate toFn.prototypefor shared methods.Mnemonic:
prototypelives on the constructor;__proto__(better:Object.getPrototypeOf) lives on instances and points at the constructor'sprototype.Follow-ups they push on- How do ES6 classes map onto prototypes under the hood?
- Why put methods on the prototype instead of in the constructor?
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 ↗ -
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 == ""istrue,0 == "0"istrue, but"" == "0"isfalse(not transitive).null == undefinedistrue, yetnull == 0isfalse.NaN === NaNisfalse.Rule: default to
===; the one common, intentional==isx == nullto catch bothnullandundefined.Follow-ups they push on- Why is NaN not equal to itself, and how do you test for it?
- What does the abstract equality algorithm do for object vs primitive comparisons?
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 ↗ -
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 ofifor each iteration, so each arrow closes over a differentiholding that iteration's value. (countis a red herring — it's never touched.)If this used
varinstead, all three closures would share one function-scopedi, and after the loop finishediwould be3, so it would log[3, 3, 3]. This is the canonical demonstration of whyletfixed the classic loop-closure bug.What a strong answer coversletgives 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-checkWhat is logged?
-
Correct — `let` creates a fresh `i` binding each iteration, captured by each closure.
-
That's the `var` result; `let` doesn't share one binding.
-
The arrows return `i` as it was each iteration: 0, 1, 2 — not shifted by one.
-
Each closure returns a valid captured number, not undefined.
Follow-ups they push on- Rewrite this with var to get [3, 3, 3], then explain the fix.
- How does this relate to the setTimeout-in-a-loop classic?
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) ↗ -
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,thisis the global object, wherenameis''; in strict mode/modulesthisisundefinedand it would throw).The inner
functionpassed tosetTimeoutis a plain function called by the timer, not as a method ofobj. Itsthisis therefore notobj— implicit binding only happens forobj.method()call syntax. The timer invokes it as a bare function.Fixes: use an arrow function in the timeout (inherits
greet'sthis), captureconst self = this, or.bind(this). This is the single most commonthis-loss bug in callbacks.What a strong answer coversthisis set by the call site; the timer calls the callback as a plain function.Plain-function
thisis the global object (sloppy mode) orundefined(strict/module).An arrow function in setTimeout inherits the enclosing method's
this(=obj).Alternatives:
const self = thiscapture, or.bind(this).
Quick self-checkWhat logs (assume a non-strict browser global where name is '')?
-
Only true if the callback were an arrow function or bound; a plain function loses `this`.
-
Correct — the timer calls the plain function with global (or undefined) `this`, not `obj`.
-
In sloppy mode `this` is the global object, so no error — it reads the global `name`.
-
`this` defaults to the global object in sloppy mode, never null.
Follow-ups they push on- Rewrite greet so it logs 'obj'.
- Why does an arrow function fix this but a regular function doesn't?
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) ↗ -
What is the difference between call, apply, and bind?
All three set a function's
thisexplicitly; 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 withthis(and any leading args) permanently fixed. You call that later. A bound function cannot be re-bound, andnewon it ignores the boundthis.With spread,
call(...args)covers most apply cases today.What a strong answer coverscall: 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
thiscan't be overridden by a later call/bind.
Follow-ups they push on- Can you re-bind a function that's already bound?
- How does spread syntax make apply less necessary?
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() ↗ -
Implement a throttle function, and explain how it differs from debounce.
Throttle guarantees
fnruns at most once perwaitwindow, no matter how often it's called — good for scroll/resize/mousemove. Debounce waits until calls *stop* forwaitms, 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 coversThrottle: at most one call per time window (steady cadence under continuous events).
Debounce: fires only after calls go quiet for
waitms.Throttle suits scroll/resize; debounce suits typeahead/validation.
The closure holds the last-run timestamp; forward
this/argsvia apply.
Follow-ups they push on- Add a trailing-edge call so the final event isn't dropped.
- When would you choose throttle over debounce for a scroll handler?
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 ↗ -
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/.finallycallbacks,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 coversOrder 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-checkWhich of these schedules a MICROTASK?
-
A macrotask (task) — runs after the microtask queue drains.
-
Correct — `.then` callbacks are microtasks.
-
A repeating macrotask, not a microtask.
-
A render-phase callback (before paint), not a microtask.
Follow-ups they push on- Why can microtasks starve the UI but a queue of setTimeouts can't as easily?
- Where does requestAnimationFrame sit relative to micro/macrotasks?
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 ↗ -
What is the difference between null and undefined, and what does typeof return for each?
undefinedmeans a variable has been declared but not assigned, a missing function argument, a missing object property, or a function with noreturn. The engine produces it.nullis an intentional 'no value' that *you* assign to signal emptiness.The famous quirk:
typeof undefinedis"undefined", buttypeof nullis"object"— a long-standing bug kept for backward compatibility. They are loosely equal (null == undefinedistrue) but not strictly equal (null === undefinedisfalse).Use
x == nullto test for both at once, or??(nullish coalescing) which treats onlynull/undefinedas missing.What a strong answer coversundefined: engine-produced 'not assigned / missing'.null: developer-assigned 'intentionally empty'.typeof undefined === 'undefined';typeof null === 'object'(a historical bug).null == undefinedis true;null === undefinedis false.??treats only null/undefined as missing, unlike||which also catches 0/''/false.
Quick self-checkWhat does typeof null evaluate to?
-
There is no 'null' typeof string; this is a common wrong guess.
-
That's typeof undefined, not typeof null.
-
Correct — a historical bug kept for backward compatibility.
-
typeof never throws here; it simply returns 'object'.
Follow-ups they push on- Why does ?? differ from || for falsy values like 0 and ''?
- How do you reliably check that a value is null or undefined but not 0/''?
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 ↗ -
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), orarr.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 hackJSON.parse(JSON.stringify(obj))works only for plain JSON-safe data — it drops functions,undefined, andSymbols, and turnsDateinto 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 coversShallow copy shares nested references; spread/
Object.assign/sliceare 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.
Follow-ups they push on- Why does the spread operator not deep-copy nested arrays?
- What types does JSON.stringify silently drop or mangle?
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) ↗ -
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. Withconst/let, the binding is hoisted but in the temporal dead zone, so calling it early throwsReferenceError. Withvar, the variable hoists asundefined, so calling it early throwsTypeError: foo is not a function(it'sundefined, not callable yet).So declarations are usable before their line; expressions are not, and the error you get depends on
varvslet/const.What a strong answer coversFunction declarations are fully hoisted (callable before their definition).
Function expressions follow the variable's hoisting: TDZ for
let/const,undefinedforvar.Calling a
var-assigned expression early →TypeError(not a function).Calling a
let/constexpression early →ReferenceError(TDZ).
Quick self-checkWhat happens? foo(); var foo = function () { return 1; };
-
The expression isn't assigned yet at the call site.
-
`var foo` is hoisted, so foo exists — it's just undefined.
-
Correct — `var foo` hoists as undefined; calling undefined throws a TypeError.
-
The code is syntactically valid; the error is at runtime.
Follow-ups they push on- What error do you get calling a var function expression before assignment, and why?
- Are named function expressions hoisted by their name? (No — only inside their own scope.)
Red flag Assuming all functions are hoisted. Only declarations are; expressions hoist per their variable's rules (TDZ or undefined).
source: MDN — Hoisting ↗