This page is for anyone facing a JavaScript round, from a first front-end job to a senior role. Most rounds open with var, let and const, hoisting and closures, then test this, prototypes and the event loop, and push hard on promises and async/await. Expect a few small coding tasks, like debounce, flattening an array or writing a polyfill, plus questions on DOM events and memory leaks. Senior rounds add a real story and a judgement call. Each question shows what the interviewer is checking, the shape of a strong answer and a short answer you can say out loud. Practise them, then swap in your own stories.
Search all questions by round, difficulty and level, or save the ones you want to practise.
Scope: var is function-scoped; let and const are block-scoped.
Hoisting: var starts as undefined; let and const sit in the temporal dead zone until declared.
Reassignment: const blocks reassigning the name, but objects it points to can still change.
Default: const first, let when you must reassign, no var in new code.
"var is function-scoped, so a var declared inside an if block is visible in the whole function. let and const are block-scoped: they only exist inside the nearest curly braces. var can also be declared twice in the same scope without complaint, which hides mistakes, while let and const throw an error. All three are hoisted, but a var starts out as undefined, while let and const sit in the temporal dead zone until their line runs, so touching them early throws a ReferenceError. const means the name can't be pointed at something else, not that the value is frozen, so I can still push into a const array. My default is const, and I switch to let only when I really need to reassign, like a counter. I don't write var in new code."
const list = [1, 2];
list.push(3); // fine: the array itself can change
// list = []; // TypeError: assignment to constant variable
function demo() {
if (true) { var a = 1; let b = 2; }
console.log(a); // 1
// console.log(b); // ReferenceError: b is not defined
}
Saying const makes objects immutable, or that let and const are not hoisted at all.
Setup phase: the engine registers every declaration in a scope before running it.
Functions: declarations are hoisted with their whole body, so early calls work.
var: only the name is hoisted, as undefined, so calling it early is a TypeError.
let, const, class: hoisted but uninitialised, so early use is a ReferenceError.
"Before any code in a scope runs, the engine sets up that scope and registers every declaration in it. A function declaration is registered with its whole body, so calling it earlier in the file works. A var is registered too, but only the name, set to undefined; the assignment stays on its own line. So if greet is a function expression stored in a var, calling greet early gives a TypeError, greet is not a function, because at that moment it's still undefined. With let or const the name exists but isn't initialised, that's the temporal dead zone, so using it early gives a ReferenceError instead. Classes behave like let here. In practice I don't lean on hoisting, apart from calling helper function declarations that sit lower in the same file."
sayHi(); // works: the whole declaration is hoisted
console.log(n); // undefined: only the var name is hoisted
// greet(); // TypeError: greet is not a function
// console.log(t); // ReferenceError: cannot access 't' before initialization
var n = 5;
var greet = function () { return 'hello'; };
let t = 10;
function sayHi() { return 'hi'; }
Saying hoisting physically moves code to the top, or that let and const are not hoisted at all.
Definition: a function plus the variables from the scope where it was created.
Lifetime: those variables stay alive as long as the function can still be called.
Uses: private state, factories, debounce, memoisation, handlers that remember config.
Cost: a long-lived closure can keep large objects in memory.
"A closure is a function together with the variables from the scope it was created in. In JavaScript an inner function keeps a live link to its outer scope, so even after the outer function has returned, the inner one can still read and change those variables. They aren't copied; they're shared, and they stay alive as long as something can still call the function. I use this on purpose for private state. A makeCounter function declares a count variable and returns increment and current functions, and nothing outside can touch count except through them. Closures are also what make debounce, memoisation and event handlers that remember some settings work. The cost is memory: a long-lived handler that closes over a big object keeps that object from being collected."
function makeCounter() {
let count = 0;
return {
increment: () => ++count,
current: () => count,
};
}
const counter = makeCounter();
counter.increment();
counter.increment();
console.log(counter.current()); // 2
Describing a closure only as "a function inside a function" without saying it keeps access to outer variables after the outer function returns.
Cause: var gives one shared i; every callback closes over the same variable.
Timing: the callbacks run after the loop has finished, when i is already 3.
Fix 1: use let, which creates a fresh binding for each iteration.
Fix 2: capture the value in a new scope or pass it as an argument.
"There's only one i here, because var is function-scoped. The three arrow functions all close over that same variable, and none of them runs until the loop has finished, since setTimeout only queues them for later. By then i has been bumped to 3, so all three print 3. The cleanest fix is let: in a for loop, let gives every iteration its own fresh binding, so each callback captures a different one. The older fix is to create a new scope per iteration, with a function that takes the value as a parameter, or to pass i as the extra argument to setTimeout, which hands the current value to the callback. The lesson I take from it is that closures capture variables, not values."
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 0); // 3, 3, 3
}
// Fix 1: let gives each iteration its own binding
for (let j = 0; j < 3; j++) {
setTimeout(() => console.log(j), 0); // 0, 1, 2
}
// Fix 2: pass the current value in
for (var k = 0; k < 3; k++) {
setTimeout((n) => console.log(n), 0, k); // 0, 1, 2
}
Blaming setTimeout alone without explaining that all the callbacks share one variable.
new: this is the newly created object.
call, apply, bind: this is whatever you pass in.
Method call: obj.fn() sets this to obj; a plain call gives undefined in strict mode.
Arrow functions: no own this; they use the surrounding one.
"this is set by how a function is called, not where it's written, with arrow functions as the exception. If I call it with new, this is the brand-new object. If I use call, apply or bind, this is whatever I pass in. If I call it as a method, like user.greet(), this is the object before the dot. If it's a plain call, just greet(), this is undefined in strict mode and the global object in sloppy mode. Arrow functions don't get their own this at all; they use the this of the code around them. The classic bug is passing a method as a callback, like setTimeout(user.greet, 100). It loses its object, because it's now called plainly. I fix that with bind, or by wrapping it in an arrow function."
'use strict';
const user = {
name: 'Asha',
greet() { return 'Hi, ' + this.name; },
};
user.greet(); // 'Hi, Asha'
const loose = user.greet;
// loose(); // TypeError: cannot read properties of undefined
setTimeout(user.greet.bind(user)); // bound: this stays user
setTimeout(() => user.greet()); // also fine: called as a method
Saying this refers to the function itself, or to wherever the function was defined.
Missing pieces: no own this, arguments or super; they come from the outer scope.
Not constructors: cannot be called with new and have no prototype property.
Good for: callbacks inside methods, where you want the outer this.
Avoid for: object methods that use this, and handlers that need the element as this.
"Arrow functions are shorter, but the real difference is what they don't have. They don't get their own this, arguments or super; they borrow them from the surrounding scope. They also can't be called with new, and they have no prototype property. That makes them great for callbacks inside a method, like mapping over an array inside a class method, because this still points at the instance. It's also why I avoid them for object methods that rely on this: an arrow defined as a method in an object literal gets the outer this, not the object. Same for an event handler where I want this to be the element, and for anything I'd call with new. If I need all the arguments inside an arrow, I use rest parameters instead."
Saying the only difference is the shorter syntax.
Return a function: bind never calls the original straight away.
Context and args: remember this and leading arguments, merge them with later ones.
Call it: use apply with the saved context.
new: if the bound function is called with new, the saved this is ignored.
"bind returns a new function that remembers a this value and any leading arguments. Inside myBind, this is the original function, because myBind is called on it, so I save it first and check it really is a function. The function I return merges the saved arguments with whatever it's called with later, and calls the original with apply, passing the saved context. That covers partial application too, like fixing the first argument. The edge case interviewers like is new: a bound function can still be used as a constructor, and then the saved this is ignored. I detect that with new.target and call the original with new instead. A full polyfill also makes instanceof work against the bound function and sets its name and length, which I'd mention rather than write out on a whiteboard."
Function.prototype.myBind = function (context, ...boundArgs) {
const fn = this;
if (typeof fn !== 'function') {
throw new TypeError('myBind must be called on a function');
}
return function bound(...args) {
if (new.target) return new fn(...boundArgs, ...args); // used as constructor
return fn.apply(context, [...boundArgs, ...args]);
};
};
function greet(greeting, mark) {
return greeting + ', ' + this.name + mark;
}
const hello = greet.myBind({ name: 'Ravi' }, 'Hello');
console.log(hello('!')); // 'Hello, Ravi!'
Calling the function immediately instead of returning a new one, or forgetting the partial arguments.
Link: every object has a hidden link to a prototype object.
Lookup: a missing property is searched up the chain until found or null.
Writes: assigning normally creates an own property that shadows the inherited one.
Classes: extends wires the same chain; it is not a separate system.
"Every object has a hidden link to another object, its prototype. When I read a property, the engine checks the object itself first. If it isn't there, it follows the link to the prototype, then that object's prototype, and so on, until it finds the property or reaches null, and then I get undefined. That chain is how methods are shared: every array gets map and filter from Array.prototype instead of each array carrying its own copy. Writing works differently. Assigning a property normally creates it on the object itself and shadows the inherited one; it doesn't change the prototype. The class keyword sits on top of this same mechanism, so class Dog extends Animal links Dog.prototype to Animal.prototype, and links Dog to Animal so static methods are inherited too. I can inspect the link with Object.getPrototypeOf and create an object with a chosen prototype using Object.create."
Saying JavaScript classes copy methods into every instance the way people imagine class-based languages do.
Create: a fresh empty object.
Link: its prototype is set to the function's prototype property.
Run: the function runs with this set to the new object.
Return: the new object, unless the function returns an object of its own.
"Four things happen. First, a fresh empty object is created. Second, its prototype is linked to the function's prototype property, which is how every instance gets the shared methods. Third, the function runs with this set to that new object, so a line like this.name = name puts a field on it. Fourth, if the function itself returns an object, that object is the result; otherwise the new object comes back automatically, which is why constructors usually have no return statement. I can write a small version of new myself, using Object.create for the first two steps and apply for the third. Classes follow the same steps, with one extra rule: calling a class without new throws a TypeError, while an old-style constructor function called without new just runs with the wrong this."
function myNew(Ctor, ...args) {
const obj = Object.create(Ctor.prototype);
const result = Ctor.apply(obj, args);
const isObject = result !== null &&
(typeof result === 'object' || typeof result === 'function');
return isObject ? result : obj;
}
function Person(name) { this.name = name; }
Person.prototype.hello = function () { return "Hi, I'm " + this.name; };
console.log(myNew(Person, 'Mei').hello()); // "Hi, I'm Mei"
Leaving out the prototype link, so the object you describe would never get the shared methods.
Shallow: new top level, nested objects still shared; spread and Object.assign.
Deep: every level duplicated; structuredClone is the built-in choice.
Limits: structuredClone rejects functions and DOM nodes; the JSON trick loses dates and undefined.
Often better: copy only the path you change.
"A shallow copy makes a new top-level object, but nested objects are still shared, so changing user.address.city on the copy changes the original too. Spread syntax, Object.assign and Array.from all make shallow copies. A deep copy duplicates every level, so the two are fully independent. My first choice for that now is structuredClone, which is built into modern browsers and recent Node versions. It handles dates, maps, sets and even circular references, but it can't copy functions or DOM nodes and throws if it meets them. The old trick of JSON.stringify then JSON.parse only suits plain data: dates turn into strings, undefined values and functions vanish, and a circular reference throws. Often I don't need a deep copy at all; I copy just the path I'm changing, which is how immutable updates work."
const original = { name: 'Lin', address: { city: 'Oslo' }, joined: new Date() };
const shallow = { ...original };
shallow.address.city = 'Lagos';
console.log(original.address.city); // 'Lagos': nested object is shared
const deep = structuredClone(original);
deep.address.city = 'Lima';
console.log(original.address.city); // still 'Lagos'
console.log(deep.joined instanceof Date); // true
Believing spread syntax makes a deep copy, or recommending the JSON trick without knowing what it loses.
Risk: it changes every array in the page, including inside libraries.
Future clash: a later standard method with the same name may behave differently.
Alternative: an exported helper function in a utilities module.
Exception: a faithful polyfill for a missing standard method.
"I'd ask for a change, and explain why rather than just block it. Changing a built-in prototype affects every array on the page, including arrays inside third-party code we don't control. If a future version of the language adds a method with the same name but different behaviour, ours clashes with it. That has actually happened on the web, and it's why a few standard methods ended up with less obvious names. If the property is added by plain assignment, it's also enumerable, so it shows up in any for...in loop over an array. The better option is simple: a normal exported helper, like chunk(list, size) in a utilities module. It's explicit, easy to test and easy to find. The one case I'd accept is a well-known polyfill that adds a standard method only when it's missing and matches the spec exactly."
Approving it because it works today, with no thought for libraries or future versions of the language.
Sync first: the current script runs to the end on one call stack.
Microtasks: promise reactions, queueMicrotask and code after await.
Tasks: timers, I/O and UI events, one at a time.
Rule: after each task the whole microtask queue drains before the next task.
"The output is A, E, G, C, D, F, B. JavaScript runs one thing at a time on a single call stack, so the synchronous code runs first: A, then E, because an async function runs synchronously up to its first await, then G. Everything else waits in queues. Promise reactions, queueMicrotask callbacks and the rest of a function after an await go into the microtask queue. setTimeout, I/O and user events are tasks, often called macrotasks. The rule is that once the stack is empty, the engine drains the entire microtask queue, including any new microtasks added along the way, before it takes the next task. So C, D and F run in the order they were queued, and only then does the timer print B, even with a zero delay. In a browser, rendering happens between tasks, which is why an endless chain of microtasks can freeze the page."
console.log('A');
setTimeout(() => console.log('B'), 0);
Promise.resolve().then(() => console.log('C'));
queueMicrotask(() => console.log('D'));
(async () => {
console.log('E');
await null;
console.log('F');
})();
console.log('G');
// Output: A E G C D F B
Saying a zero-delay setTimeout runs immediately, or that promises run on a separate thread.
States: pending, then settled once as fulfilled or rejected.
Chaining: then returns a new promise, so steps can be linked.
Errors: a throw or rejection skips ahead to the next catch.
Traps: a missing return inside then, and chains with no catch.
"A promise is an object that stands in for a value that isn't ready yet, like the result of a network request. It starts pending, then settles exactly once, either fulfilled with a value or rejected with a reason, and after that it never changes. then registers what to do with the result, and it returns a new promise, which is what lets me chain steps. Whatever a handler returns becomes the next step's value, and if it returns a promise, the chain waits for it. If any step throws or returns a rejected promise, the chain skips the following success handlers until it reaches a catch. finally runs either way, which is handy for hiding a spinner. The two mistakes I watch for are forgetting to return the inner promise, which breaks the chain, and ending a chain with no catch at all."
Thinking a promise can resolve twice, or that a try/catch around a non-awaited chain will catch its rejection.
all: every result in input order; rejects on the first failure.
allSettled: never rejects; a status and value or reason for each.
race: settles with whichever settles first, success or failure.
any: first success wins; rejects only if all fail, with an AggregateError.
"All four take a list of promises and give back one promise. Promise.all waits for every one to fulfil and gives me the results in the same order as the input, not the order they finished. If any one rejects, it rejects straight away with that error. I use it when I need everything, like loading a user and their settings before rendering. allSettled never rejects: it waits for all of them and gives an array of objects with a status and either a value or a reason, which suits a dashboard where one failed widget shouldn't blank the page. race settles with whichever promise settles first, success or failure, so it's the classic way to add a timeout. any gives the first success and only rejects, with an AggregateError, when every one fails. One thing people miss: when all rejects, the other requests keep running."
Believing Promise.all cancels the other operations when one fails, or that it returns results in finishing order.
Model: an async function returns a promise; await pauses only that function.
Errors: a rejected await throws, so try/catch works, or let it bubble up.
Parallel: start independent calls together and await Promise.all.
Loops: forEach does not wait; use for...of or map plus Promise.all.
"async/await is syntax on top of promises. An async function always returns a promise, and await pauses that one function, not the whole thread, until the promise settles. If the promise rejects, await throws, so I handle errors with an ordinary try/catch, or let them bubble up to a caller that knows what to do. The common performance mistake is awaiting independent calls one after the other: each waits for the previous one, so the total time is the sum. If they don't depend on each other, I start them together and await Promise.all, so the time is roughly the slowest one. Another trap is await inside forEach. forEach doesn't wait for async callbacks, so I use a for...of loop when order matters, or map plus Promise.all when it doesn't."
async function loadDashboard(userId) {
try {
// Slow: the second call waits for the first
// const user = await getUser(userId);
// const orders = await getOrders(userId);
// Faster: start both, then wait for both
const [user, orders] = await Promise.all([
getUser(userId),
getOrders(userId),
]);
return { user, orders };
} catch (err) {
console.error('Dashboard failed to load', err);
throw err;
}
}
Saying await blocks the whole program, or using await inside forEach and expecting the loop to wait.
Wrap: return a new promise; wrap each item with Promise.resolve so plain values work.
Order: store each value at its own index, never push.
Finish: count completions and resolve when the count reaches the length.
Edges: reject on the first failure; resolve right away for an empty list.
"I return a new promise and keep two things: a results array the same length as the input, and a counter of how many items have finished. For each item I call Promise.resolve on it, so plain values work too. When one fulfils, I store its value at its own index rather than pushing, because they finish in any order. When the counter reaches the length, I resolve with the array. For failure I pass reject straight in, so the first rejection rejects the whole thing, and any later resolve or reject calls are ignored, because a promise settles only once. The empty list needs its own check, or the promise would stay pending forever. I copy the input with Array.from so it accepts any iterable, like the real method does."
function promiseAll(items) {
return new Promise((resolve, reject) => {
const list = Array.from(items);
const results = new Array(list.length);
let done = 0;
if (list.length === 0) return resolve(results);
list.forEach((item, i) => {
Promise.resolve(item).then((value) => {
results[i] = value;
done += 1;
if (done === list.length) resolve(results);
}, reject);
});
});
}
promiseAll([1, Promise.resolve(2), new Promise((r) => setTimeout(r, 50, 3))])
.then(console.log); // [1, 2, 3]
Pushing results in completion order, or forgetting the empty list so the promise never settles.
Keep the net: a global handler that reports, never one that hides.
Find sources: group reports and trace them to the code that dropped the promise.
Decide ownership: show, retry or deliberately ignore, with a reason.
Prevent: lint for floating promises and review for missing returns.
"I'd agree we want a global handler, but as a safety net that reports, not one that hides. In the browser, the unhandledrejection event can send the error, stack and page to our error monitoring. Silencing them hides real failures, like a save that failed while the user thinks it worked. Then I'd group the reports by where they come from and fix the sources. Usually it's a chain with no catch, an async call fired from an event handler that nobody awaits, or a then that forgot to return the inner promise, so its failure isn't connected to anything. For each one we decide who owns the error: show a message, retry, or ignore it on purpose with a comment saying why. Then I'd add a lint rule that flags promises nobody handles, so new ones don't creep back in."
Agreeing to silence the rejections globally so the dashboard looks clean.
Strict: === never converts; different types are simply not equal.
Loose: == converts first, which gives surprising results.
Rule: use === by default; value == null is the one common exception.
NaN: equal to nothing, so use Number.isNaN or Object.is.
"Triple equals is strict: if the two sides are different types, the answer is false, with no conversion. Double equals converts first when the types differ, using rules that are easy to forget. A string compared with a number becomes a number, a boolean becomes a number, and null and undefined equal each other but nothing else. That's how 0 == '' ends up true, and '0' == false is true as well. So I use triple equals everywhere by default. The one place some teams allow double equals is value == null, because it checks for null and undefined in one go. Even there I'd often use the nullish operators instead. And one oddity neither operator fixes: NaN isn't equal to itself, so I check it with Number.isNaN, or use Object.is when I need exact sameness."
Saying == compares values and === compares types, without being able to explain what the conversion does.
Plus: if either side becomes a string, it joins strings.
Other maths: minus, times and divide convert both sides to numbers.
Objects: turned into primitives first; [] becomes an empty string.
Truthiness: a short fixed list is falsy; everything else is truthy.
"Coercion is JavaScript converting a value to another type because an operator needs it. Plus is the tricky one: if either side ends up a string, it joins them, so '5' + 2 is the string '52'. Minus, times and divide only make sense for numbers, so '5' - 2 is the number 3. With objects, the engine first turns them into primitives: an empty array becomes an empty string and a plain object becomes the string '[object Object]', so [] + {} is '[object Object]'. Then there's truthiness in an if. The falsy values are false, 0, minus zero, 0n, the empty string, null, undefined and NaN, and everything else is truthy, including an empty array, an empty object and the string '0'. In real code I don't lean on any of this; I convert on purpose with Number or String and compare strictly."
Treating an empty array or the string '0' as falsy.
forEach: side effects only, returns undefined.
map and filter: return new arrays; transform or keep matching items.
reduce: folds the array into one value; always give a starting value.
Mutators: push, pop, shift, unshift, splice, sort, reverse, fill.
"forEach runs a function for each item and returns undefined, so it's only for side effects like logging. map returns a new array of the same length with each item transformed. filter returns a new array with only the items where my callback returned something truthy. reduce walks the array carrying an accumulator and returns one final value, like a total or an object grouped by key. I always pass a starting value, because without one reduce throws on an empty array. None of those four change the original. The ones that do are push, pop, shift, unshift, splice, sort, reverse and fill. sort catches people twice: it works in place, and without a compare function it compares items as strings, so 10 lands before 9. Newer engines also offer toSorted and toReversed, which return copies instead."
const nums = [10, 9, 1];
nums.sort(); // [1, 10, 9]: compared as strings, in place
nums.sort((a, b) => a - b); // [1, 9, 10]
const total = nums.reduce((sum, n) => sum + n, 0); // 20
const big = nums.filter((n) => n > 5); // [9, 10]
Using map only for side effects and ignoring its result, or not knowing that sort changes the array in place.
Walk: loop over the items one by one.
Recurse: if an item is an array and depth remains, walk into it with one less depth.
Detect: use Array.isArray, since typeof an array is just object.
Cost: push into one shared array to stay linear; very deep input can overflow the stack, so an explicit stack avoids it.
"I'd start with recursion because it's the clearest. An inner walk function loops over the items. If an item is an array and I still have depth left, it walks into that array with one less depth; otherwise it pushes the item onto one shared output array. Using Array.isArray matters, because typeof on an array just says object. Pushing into one array, instead of spreading each nested result into its parent, means every element is copied once, so it stays linear in the total number of elements. The depth parameter mirrors the real flat method, which defaults to one level, while mine defaults to fully flat, since that's what's usually asked. The trade-off is the call stack: extremely deep nesting could overflow it, so for that I'd switch to an iterative version with my own stack, taking items off the end, pushing an array's contents back on, and reversing the result at the end."
function flatten(arr, depth = Infinity) {
const out = [];
(function walk(items, d) {
for (const item of items) {
if (Array.isArray(item) && d > 0) walk(item, d - 1);
else out.push(item);
}
})(arr, depth);
return out;
}
console.log(flatten([1, [2, [3, [4]], 5]])); // [1, 2, 3, 4, 5]
console.log(flatten([1, [2, [3, [4]], 5]], 1)); // [1, 2, [3, [4]], 5]
Using typeof to detect arrays, or only handling one level of nesting.
Scope: each module has its own scope, no shared globals.
Explicit graph: import and export declare dependencies instead of script order.
Behaviour: strict mode by default, run once, deferred in the browser.
Exports: many named exports, one optional default; imports are live bindings.
"With plain script tags, every top-level variable lands in one shared global scope, and the order of the tags decides whether things work. ES modules fix that. Each file has its own scope, it says what it needs with import and what it offers with export, and the browser or bundler builds the dependency graph from that. Modules are in strict mode automatically, each module runs only once no matter how many files import it, and module scripts are deferred by default. Named exports are imported in braces using the exported name, and a file can have many. A default export is one main value that the importer can call anything. I lean towards named exports because tooling catches renames and editors auto-import them reliably. Imports are also live bindings, not copies: if the exporting module changes the value, importers see the new value."
Thinking an imported value is a copy, or that importing a module in two places runs it twice.
Capture: the event travels down from the window to the target.
Target and bubble: it fires on the button, then travels back up.
stopPropagation: stops it reaching further elements.
preventDefault: stops the browser action, such as following a link; propagation continues.
"A click travels in three phases. First the capture phase, from the window down through the document and the button's ancestors. Then the target phase on the button itself. Then the bubble phase, back up through the div, the body, the document and the window. By default addEventListener listens during bubbling, so the button's handler runs before the div's. If I pass capture as true, that listener runs on the way down instead. stopPropagation stops the event travelling any further, so outer elements never hear it. preventDefault is a different thing: it cancels the browser's default action, like following a link or submitting a form, but the event still bubbles normally. I use stopPropagation sparingly, because it quietly breaks other code listening higher up, like a menu that closes when you click outside it."
Mixing up stopPropagation and preventDefault.
Idea: one listener on the parent; bubbling brings child events to it.
Find the target: event.target.closest with a selector, then check it is inside.
Wins: fewer listeners, less memory, new items work with no extra setup.
Limits: only for events that bubble.
"Event delegation means attaching one listener to a parent instead of one to every child, and letting bubbling carry the children's events up to it. Inside the handler I work out what was actually clicked. I use closest with a selector rather than trusting event.target directly, because the click might land on an icon inside the button. I also check the match is inside my container. For a long list that gives me three wins: one listener instead of hundreds, less memory, and rows added later work automatically, with no need to attach listeners to new rows or remove them from deleted ones. Data attributes on the rows tell the handler which item and which action it's dealing with. The main limit is that it only works for events that bubble, so focus, for example, needs focusin instead."
const list = document.querySelector('#todo-list');
list.addEventListener('click', (event) => {
const button = event.target.closest('button[data-action="delete"]');
if (!button || !list.contains(button)) return;
const row = button.closest('li');
removeTodo(row.dataset.id);
});
Checking only event.target, so clicks on an icon inside the button are missed.
Debounce: reset a timer on every call; run once things go quiet.
Details: keep the timer in a closure; pass this and arguments through.
Throttle: run at most once per interval while events keep coming.
Choice: debounce search and autosave; throttle scroll and resize.
"Debounce waits for things to go quiet. Every call resets a timer, and the real function only runs once no new call has come in for the wait time. That's right for a search box: I don't want a request per keystroke, I want one after the user pauses. My version keeps the timer in a closure, clears it on every call and starts a new one. I pass this and the arguments through with apply, so it still works as a method or an event handler. Throttle is different: it lets the function run at most once per interval while events keep firing, so the user still gets steady updates. I'd throttle a scroll or resize handler, like updating a reading progress bar, and debounce things that only matter at the end, like search or autosave."
function debounce(fn, wait) {
let timer;
return function (...args) {
clearTimeout(timer);
timer = setTimeout(() => fn.apply(this, args), wait);
};
}
const onSearch = debounce((event) => fetchResults(event.target.value), 300);
searchInput.addEventListener('input', onSearch);
Mixing up debounce and throttle, or losing this and the arguments of the original call.
Meaning: something still reachable that should not be.
Causes: listeners never removed, running timers, detached DOM nodes, ever-growing caches, accidental globals.
Find: repeat the action, compare heap snapshots, follow the retainers.
Fix: clean up on teardown, AbortController for listeners, WeakMap for object-keyed caches.
"The garbage collector frees anything that's no longer reachable, so a leak in JavaScript means something still holds a reference I forgot about. The usual causes are event listeners added and never removed, especially on window or document; intervals that keep running and close over big objects; detached DOM nodes that were removed from the page but are still referenced from a variable or a cache; maps used as caches that only ever grow; and accidental globals. In single-page apps it's often a screen that sets something up when it opens and never cleans up when it closes. To find one, I repeat the suspect action several times, take heap snapshots in the browser dev tools before and after, compare them, and follow the retainers to see what's holding the growing objects. Then I remove listeners, clear timers, and use a WeakMap for caches keyed by objects."
Saying JavaScript cannot leak memory because it has a garbage collector.
Symptom: what users saw and why it was hard to reproduce.
Reproduce: how you made the timing happen on demand.
Cause and fix: the real ordering problem and a fix that holds.
Guard: a test and what the team learned.
"At my last company, a product listing page sometimes showed results for the wrong filter. It only happened on slow connections, so at first nobody could reproduce it. I throttled the network in the browser dev tools and clicked filters quickly, and it showed up straight away. Two requests were in flight, the older one came back last, and it overwrote the newer results, because the code simply rendered whichever response arrived. I fixed it in two layers. Each new request aborted the previous one with an AbortController, and the handler also checked that a response belonged to the latest request before using it, in case an abort came too late. I added a test that resolved two fake requests out of order. The lesson I shared with the team was that any async result can arrive after the user has moved on."
Fixing a race condition by adding a setTimeout delay and calling it done.
Problem: what was slow and who felt it.
Measure: the profiler recording and what it showed.
Fix: the specific changes, tied to what you measured.
Confirm: a second measurement, and the lesson.
"At my last job we had an admin table that froze for a couple of seconds whenever someone typed in the filter box, with a few thousand rows loaded. Instead of guessing, I recorded it in the browser's performance panel. Most of the time was in our own code. On every keystroke we filtered the full list, sorted it again and rebuilt every row's DOM from scratch, and inside that loop we read an element's height right after changing styles, which forced the browser to recalculate layout over and over. I debounced the input, kept the sorted list instead of re-sorting, only updated rows that changed, and moved the height read out of the loop. A second recording showed the freeze had gone, and users stopped mentioning it. My first guess had been the sorting, which turned out to be a small part, so now I always measure first."
Describing optimisations made on a hunch with no measurement before or after.
Starting point: what the code looked like and why it hurt.
Safety net: tests around current behaviour before changing it.
Steps: wrap callbacks in promises, move files to modules one at a time.
Outcome: what improved and what you caught along the way.
"Yes. We had a checkout script written years earlier, with deeply nested callbacks and a lot of shared globals. Rewriting it in one go felt too risky, so I did it in slices. First I wrote tests around what it did today, including the error paths, because the callbacks often swallowed errors silently. Then I wrapped the callback-based helpers in functions that return promises, so new code could use async/await while old callers kept working. After that I moved files into modules one at a time, which turned hidden globals into explicit imports and exposed two places that depended on script load order. Each step shipped on its own with the tests green. The code ended up much shorter and easier to follow, and the one bug we introduced, a missing return in a then chain, was caught in review."
Describing a big-bang rewrite with no tests and no way to ship it in steps.
Readable trace: map the minified line back with source maps.
Context: browsers, pages, frequency, and which release it started with.
Cause: find which value was undefined and why that data differed.
Fix: handle the real case, add a test, avoid blanket optional chaining.
"First I'd make the stack trace readable. If our error monitoring has the source maps for that release, the minified line maps straight back to the real file and line; if it doesn't, fixing that is part of the job. Then I'd gather context: which browsers, which pages, how often, and whether it started with a particular release. With the real line I can see which value was undefined, and usually the cause is data I assumed would always be there, like an API field that's missing for some accounts, or code running before something has finished loading. I'd reproduce it with that data, fix the real case, either by handling the missing value properly or fixing the order things run in, and add a test for it. I wouldn't scatter optional chaining everywhere, because that just turns a loud error into a silently wrong screen."
Wrapping the code in try/catch or adding optional chaining everywhere without finding out why the value was missing.
ClapAssist is an AI interview assistant for Mac and Windows. It listens to the interview on your computer and shows you what to say, in short lines you can read while you talk. Your resume and notes are never stored on our servers. It stays out of screen share on every plan; only you can see it.