JavaScript Interview Questions · 2026

JavaScript Interview Questions (2026): 30 Must-Know Q&A

68.8 percent of professional developers used JavaScript for extensive development work in the past year, more than any other language, according to the 2025 Stack Overflow Developer Survey (Stack Overflow, 2025). It's been the most-used language for over a decade now, which is exactly why JavaScript interview questions have drifted away from syntax and toward the parts of the language that only bite you once your codebase has more than a few files talking to each other.

Here's an opinion that might be wrong: most JavaScript interview prep spends too long on ES6+ syntax, destructuring, arrow functions, template literals, and not nearly enough time on why a variable declared with var inside a loop behaves nothing like the same variable declared with let. Syntax questions are a five-minute warm-up. The questions that separate a candidate who's used the language for a year from one who's used it for five are about closures, the event loop, and what this actually points to at the moment a function gets called, not when it was written.

This page covers JavaScript interview questions across ten areas: variable declarations and the temporal dead zone, closures, scope and this binding, prototypes, the event loop and its two task queues, async patterns from callbacks through async/await, equality and type coercion, higher-order functions and currying, ES6+ features like destructuring and modules, and debounce/throttle as a closing coding question. Every example is plain JavaScript, no framework, since the goal here is the language itself.

52Questions
Closures & Event LoopCore Topic
Live Coding + TraceFormat
68.8%2025 Pro Dev Usage

var, let, const, and the hoisting behavior that actually causes bugs

Almost every JavaScript interview opens near here, even for a senior candidate. It sounds basic, but a shaky answer on hoisting sets a bad tone for the thirty minutes that follow.

Easy questions

15

var is function-scoped, or global if it's declared outside any function; it doesn't care about the block, if statement, or loop it sits inside. let and const are block-scoped, confined to whichever curly braces they're declared in.

const doesn't mean immutable. It means the binding itself can't be reassigned. An object or array declared with const can still have its properties or elements changed freely, you just can't point that same variable name at a different object entirely.

A closure is a function that remembers the variables from the scope it was created in, even after the outer function that created it has already finished running and returned.

That "remembers" part is doing real work. The inner function doesn't get a copy of the outer variable's value at creation time, it keeps a live reference to the variable itself, which is exactly why closures can produce surprising results in loops.

Global scope is anything declared outside every function and block, reachable from anywhere in the file. Function scope, which is all var respects, means a variable is visible anywhere inside the function it was declared in, regardless of nested blocks. Block scope, what let and const respect, confines a variable to the nearest enclosing curly braces, an if, a for loop, a bare block.

Every JavaScript object has an internal link to another object, its prototype, and when you access a property that isn't found on the object itself, the engine walks up that link and checks the prototype, then the prototype's prototype, and so on until it hits null.

Nesting, mostly, plus error handling. Chaining several async steps with callbacks means nesting a callback inside a callback inside a callback, "callback hell," and each level needs its own error check because there's no shared error path. Promises flatten that into a .then() chain and give every step in the chain one shared path for errors to travel down to a single .catch().

=== compares both type and value with no conversion at all, if the operand types differ, it's immediately false. == first attempts to convert the operands to a matching type, then compares, which is exactly what MDN means when it says == "attempts to convert and compare operands that are of different types," unlike ===, which "does not attempt type conversion" (MDN, Equality (==)).

A function that either takes another function as an argument, returns a function, or both. map, filter, and reduce are the ones everyone reaches for daily, they all accept a callback and apply it across an array, but any function that hands you back another function, a factory, an event handler builder, counts too.

ES modules are statically analyzable, the engine can figure out every import and export by reading the file without running it, which is what makes tree-shaking possible in the first place. CommonJS's require() is a normal function call that runs at execution time, so imports can be conditional or computed in ways a bundler can't always see ahead of time.

The other real difference: ES module imports are live bindings, if the source module reassigns an exported variable, every importer sees the new value automatically. CommonJS copies the value of a primitive export at the moment require() runs, so a later reassignment in the source file won't be reflected wherever it was already imported, only object references stay live because you're still pointing at the same object.

NaN stands for "not a number" and it's the result of a numeric operation that has no meaningful value, like 0/0, parseInt('abc'), or Math.sqrt(-1). It's technically a number type in JS (typeof NaN is 'number'), and it's the only value that isn't equal to itself, so NaN === NaN is false.

The global isNaN() function coerces its argument to a number before checking, so isNaN('hello') first converts 'hello' to NaN and then reports true. Number.isNaN() does no coercion at all, it only returns true if the value is literally the NaN value, so Number.isNaN('hello') is false because a string isn't NaN, it's just a string. In production code you almost always want Number.isNaN(), because the coercing version gives false positives for anything that isn't a number, including undefined and objects.

undefined means a variable has been declared but never assigned, a function parameter wasn't passed, or an object property doesn't exist. It's the default JavaScript gives you when there's nothing there yet. null is a value someone deliberately assigned to say "this is intentionally empty."

Both are falsy, both loosely equal each other (null == undefined is true) but they're never strictly equal (null === undefined is false), and typeof null is famously 'object', a bug baked into the language since 1995 that can never be fixed without breaking the web. In practice, I use undefined for things the language leaves unset and null for state I've explicitly cleared, like resetting a selected user to null when nothing is selected versus a field that's simply never been populated.

There are exactly eight: false, 0, -0, 0n (BigInt zero), '', null, undefined, and NaN. Everything else, including '0', [], and {}, is truthy, which trips people up constantly because an empty array or object looks "empty" but is truthy.

The classic bug is writing if (!count) when count can legitimately be 0, like a shopping cart quantity or a page number. That code treats a real, valid 0 the same as null or undefined, so "no value set" and "value is zero" collapse into the same branch. The fix is usually an explicit check like count == null or count === undefined, rather than relying on the general falsy coercion.

Template literals, written with backticks, let you embed expressions directly with ${} instead of breaking out of the string with plus signs, so a greeting built from user.name and count reads far closer to the final output than chaining string plus variable plus string over and over. They also support real multi-line strings without escaping newlines, and they handle nested expressions, function calls, and even other template literals inside the interpolation.

The other feature people forget is tagged templates, where you prefix a template literal with a function name, like html`

${value}
`, and that function gets the raw string pieces and the interpolated values separately, which is exactly how libraries like styled-components and some HTML-escaping utilities protect against injection.

Strict mode turns a handful of silent JavaScript mistakes into thrown errors. Assigning to an undeclared variable, which normally leaks a global, throws instead. Assigning to a read-only property or a getter-only property throws instead of failing silently. Duplicate parameter names in a function become a syntax error.

It also changes what this refers to inside a plain function call: without strict mode, this defaults to the global object, and with strict mode it stays undefined, which is why you'll get a "cannot read property of undefined" error instead of quietly mutating window. ES modules and class bodies are strict mode automatically, which is part of why so much modern code doesn't bother writing the directive explicitly anymore.

forEach just runs a callback for each element and always returns undefined, it's built for side effects like logging or pushing into an external array. map runs a callback for each element too, but it collects the return value of that callback into a brand new array of the same length and gives that array back to you, leaving the original untouched.

The practical rule is: if you're building a new array of transformed values, use map, and if you're doing something like a network call per item or updating a DOM node, forEach communicates intent better since there's no array coming back to accidentally ignore. A common bug is using forEach and expecting it to return something, or using map purely for side effects and creating a throwaway array nobody uses.

JSON.stringify drops any property whose value is undefined, a function, or a Symbol, it just skips them entirely in objects, though inside arrays it converts those same values to null so the array length stays intact. Dates get converted to ISO strings, and when you parse that string back, you get a plain string, not a Date instance, so you have to re-hydrate it yourself with new Date(). NaN and Infinity both serialize to the string "null" surprisingly.

And if the object has a circular reference, where something points back to itself, JSON.stringify throws a "Converting circular structure to JSON" error rather than handling it gracefully. That's exactly why "deep clone with JSON.parse(JSON.stringify(x))" is a popular hack but a fragile one, it silently loses functions, dates become strings, and it blows up on any circular data.

Medium questions

25

They are hoisted, and this trips up a lot of candidates who half-remember the rule. All three declaration types get hoisted to the top of their scope during compilation. What's different is initialization.

javascript
console.log(a); // undefined, not an error
var a = 1;

console.log(b); // ReferenceError: Cannot access 'b' before initialization
let b = 2;

var gets hoisted and immediately initialized to undefined, so reading it early just gives you undefined. let and const get hoisted but stay uninitialized until execution reaches the actual declaration line. That gap is the temporal dead zone, and it's a deliberate design choice, not a bug (MDN, let).

MDN defines it as the span "from the start of the block until code execution reaches the place where the variable is declared and initialized." Any attempt to touch that variable inside that window throws a ReferenceError, not because the variable doesn't exist yet, but because it exists without a usable value.

I think TDZ is one of the most memorized-without-understood terms in JS interviews. People can say "TDZ equals error" but can't say why the spec bothered adding it. The honest answer: it catches a real class of bugs, using a variable before its declaration line ran, that var quietly let slide by handing back undefined instead of failing loudly.

It's an immediately-invoked function expression that returns an object exposing only the methods you want public. Anything declared inside the IIFE but not attached to the returned object stays closed over and unreachable from outside.

javascript
const counter = (function () {
  let count = 0; // not accessible from outside
  return {
    increment: () => ++count,
    getCount: () => count,
  };
})();

counter.increment();
counter.getCount(); // 1
counter.count; // undefined

Real private class fields (the #count syntax) do the same job more directly now, but the module pattern predates them by well over a decade and it's still common enough in older codebases that interviewers expect you to recognize it on sight.

For a regular function, this is determined entirely by how the function gets called, its call site, not where it's defined. Call it as a bare function and this is undefined in strict mode (the global object otherwise). Call it as obj.method() and this is obj. Call it with new and this is the freshly created instance.

Arrow functions opt out of all of that. They don't get their own this binding at all, they capture this lexically from whatever scope surrounded them when they were defined, the exact same rule closures use for regular variables. That's why arrow functions are the standard fix for this-losing callbacks inside class methods and event handlers.

All three let you explicitly set what this refers to inside a function, but they differ in how arguments get passed and whether the function runs immediately.

javascript
function greet(greeting) {
  return `${greeting}, ${this.name}`;
}
const user = { name: "Priya" };

greet.call(user, "Hi");        // runs now, args listed individually
greet.apply(user, ["Hi"]);     // runs now, args as an array
const bound = greet.bind(user); // doesn't run, returns a new function
bound("Hi");

call and apply both invoke the function right away, the only difference is how you pass the arguments. bind doesn't call the function at all, it returns a brand-new function permanently bound to whatever this you gave it, which is the one you reach for when you're passing a method as a callback and can't control how it gets invoked later.

They sound like the same word but point at different things. __proto__ is the actual link an object follows up the chain, the internal reference every object has. prototype is a property that only exists on functions (used as constructors), and it's the object that gets assigned as the __proto__ of any instance created with new.

So Dog.prototype isn't Dog's own prototype, it's the object every new Dog() instance will link to. Confusing the two is a common enough slip that interviewers will sometimes ask it just to see if you catch yourself.

Mostly sugar, but not entirely. Under the hood, class methods still end up on the constructor's prototype, same mechanism as manually assigning Dog.prototype.bark = function() {}. The real differences: class declarations are hoisted like let and const, so they land in a temporal dead zone rather than being usable before their line runs; class bodies always run in strict mode regardless of the surrounding file; methods defined in a class are non-enumerable by default, so they don't show up in a for-in loop the way manually assigned prototype methods do; and calling a class constructor without new throws a TypeError instead of quietly running with the wrong this.

JavaScript runs on a single call stack, one thing executing at a time. The event loop's job is to check whether that stack is empty, and if it is, pull the next item off a queue and push it onto the stack to run. There are two queues that matter: the macrotask queue (also just called the task queue), which holds things like setTimeout callbacks and I/O completions, and the microtask queue, which holds promise callbacks and queueMicrotask calls.

MDN puts it plainly: "the microtask queue is drained first before the task queue is pulled" (MDN, Event loop). After every single macrotask finishes, the engine empties the microtask queue completely, running every promise callback queued so far, including ones queued by other microtasks, before it goes back for the next macrotask or lets the browser repaint.

Practical consequence: a promise chain that keeps generating new .then() callbacks can, in theory, starve setTimeout callbacks and rendering indefinitely, since the microtask queue only has to be empty once, and a chain that keeps refilling it never lets that happen. It's an edge case you're more likely to be asked about than to actually hit in production code.

Pending, fulfilled, and rejected. A promise starts pending and moves to exactly one of the other two states when it settles, fulfilled if the operation succeeded, rejected if it failed, and once it's settled, it's locked there permanently. You can't resolve an already-rejected promise, and calling .then() or .catch() on a settled promise just runs the matching handler with whatever value or reason it already has.

Mostly the former. An async function always returns a promise, even if you return a plain value inside it, JavaScript wraps it for you. await pauses execution of that function, and only that function, at the line it's on until the awaited promise settles, then resumes with either the resolved value or a thrown error.

What's easy to miss: await doesn't block the whole thread while it waits. Other code, other event handlers, other timers, keeps running normally. Under the hood it's built on generators and the same microtask machinery promises already use, so the mental model from the event loop section still applies exactly.

javascript
"" == 0;          // true, "" -> 0
[] == false;       // true, [] -> "" -> 0, false -> 0
null == undefined; // true, special case in the spec
NaN === NaN;        // false, NaN is never equal to itself

The first two both funnel through number coercion, an empty string converts to 0, and an empty array converts to an empty string first, then to 0, so both sides end up 0 == 0. null == undefined is a special case written directly into the spec, they're loosely equal to each other and to nothing else. NaN is the odd one out entirely, by IEEE 754 definition it doesn't equal itself under either operator, which is exactly why Number.isNaN() exists instead of comparing a value to NaN directly.

Almost always avoid it, but there's one deliberate exception plenty of style guides carve out: value == null, which catches both null and undefined in a single comparison, since that's the one pair == treats as loosely equal to each other and nothing else.

I'll admit this is a minority position among linters, most default configs (ESLint's eqeqeq rule included) still flag it. My take, which could be wrong: it's a readable shortcut for "this value is missing," and writing value === null || value === undefined every time doesn't buy you enough safety to be worth the extra line.

javascript
function curry(fn) {
  return function curried(...args) {
    if (args.length >= fn.length) {
      return fn(...args);
    }
    return (...more) => curried(...args, ...more);
  };
}

const add = curry((a, b, c) => a + b + c);
add(1)(2)(3); // 6
add(1, 2)(3); // 6
add(1)(2, 3); // 6

fn.length reads how many parameters the original function declared, so curried keeps collecting arguments across as many calls as it takes until it has enough, then finally invokes fn with all of them at once. The three call shapes above all reach the same result because curried doesn't care how the arguments got split up, only that it eventually has enough.

Honest opinion: I think currying gets asked about far more than it gets used. Most day-to-day JS never needs it explicitly. Where it does show up for real is partial application, pinning some arguments now and leaving the rest for later, like a logger factory: const errorLog = curry(log)('error'), which bakes in the level and hands back a function that only needs a message.

Redux's connect and a fair amount of middleware-style code lean on this exact pattern, a function that takes config now and returns a function that takes the rest later. If you've never written curry by hand outside an interview, that's fine, most engineers haven't, but recognizing the pattern when you see it in a library matters more than being able to implement it cold.

javascript
// spread: expands an iterable into individual elements
const nums = [1, 2, 3];
const copy = [...nums, 4]; // [1, 2, 3, 4]

// rest: collects the remaining elements into an array
function sum(first, ...rest) {
  return first + rest.reduce((a, b) => a + b, 0);
}
sum(1, 2, 3, 4); // 10

// destructuring with defaults and renaming
const { name: userName = "guest", age } = { age: 30 };

Same syntax, opposite direction. Spread takes something that's already grouped and pulls it apart into individual values, rest does the reverse, gathering loose values into one array. Which one you're looking at comes down entirely to whether the three dots sit on the left or right side of an assignment, or inside a function's parameter list versus a call.

for...in iterates over enumerable property keys, including ones inherited through the prototype chain, and it works on any object, not just arrays. for...of iterates over values by calling the iterator protocol (Symbol.iterator), which arrays, strings, Maps, Sets, and NodeLists all implement, but plain objects don't, so for...of throws on a plain object unless you add an iterator yourself.

Using for...in on an array is a known trap because the keys come back as strings ('0', '1', '2'), the order isn't guaranteed to match insertion order in every environment, and if any library has added a property to Array.prototype, that shows up in the loop too. For arrays you almost always want for...of, or one of the array methods like forEach and map, and you reserve for...in for genuinely enumerating an object's own and inherited keys, usually guarded with hasOwnProperty to skip inherited junk.

Promise.all waits for every promise to resolve and gives you an array of results in the same order, but it rejects immediately as soon as any single promise rejects, discarding the results of the ones that succeeded. Promise.allSettled also waits for everything, but it never rejects, instead giving you an array of status and value (or status and reason) objects for every promise, which is what you want when you're firing off several independent API calls and want to know which ones failed without losing the ones that worked.

Promise.race resolves or rejects as soon as the first promise settles, whichever way it settles, which is commonly used for implementing a timeout by racing your real request against a promise that rejects after a few seconds. Promise.any is the newer one, it resolves as soon as any promise fulfills, and only rejects if all of them reject, bundling the individual errors into an AggregateError, which is the right tool when you have several redundant sources and just need one to succeed.

A shallow copy duplicates the top level of an object or array but keeps references to the same nested objects, so spreading an object into a new one gives you a new outer object, but if a property is itself an object, both the original and the copy point to the exact same nested object in memory. Mutate the copy's nested field and you've silently mutated the original's nested field too, since they're the same reference.

A deep copy recursively duplicates every nested level so there's no shared reference anywhere. The spread operator, Object.assign, and array destructuring are all shallow. For real deep cloning, structuredClone(obj) is now built into every modern browser and Node 17+, and it correctly handles Dates, Maps, Sets, and circular references, which the old JSON.parse(JSON.stringify(obj)) trick can't do. structuredClone still can't clone functions or DOM nodes though, it throws a DataCloneError on those, so for objects with methods you're back to a library like lodash's cloneDeep or writing the recursive copy yourself.

|| falls back to its right-hand side whenever the left side is falsy, so count || 10 replaces 0, an empty string, and false with 10, which is usually not what you want if 0 is a legitimate value. ?? only falls back when the left side is specifically null or undefined, so count ?? 10 correctly keeps 0 or an empty string and only substitutes 10 when count is genuinely missing.

Optional chaining, ?., short-circuits a property access chain the moment it hits null or undefined, returning undefined instead of throwing, so a deeply nested lookup like user's profile's address's city won't throw "cannot read property of undefined" if profile or address happens to be missing, it just evaluates to undefined at whichever step goes null. You can combine both, digging into a possibly missing structure and only supplying a real default when the final value is nullish, not just falsy.

A Map lets you use any value as a key, including objects and functions, not just strings and Symbols like a plain object, and it preserves insertion order reliably, has a real .size property instead of counting Object.keys(obj).length, and doesn't get polluted by inherited prototype properties. That matters when you're building something like a cache keyed by DOM node references or a lookup table keyed by object identity, where a plain object would coerce your key to the string "[object Object]" and every key would collide.

A Set stores unique values with fast lookup via .has(), which makes deduplicating an array as simple as spreading a Set built from it, and it's noticeably clearer intent than looping and checking indexOf every time. The tradeoff is that Maps and Sets don't serialize to JSON directly, JSON.stringify on a Map just gives you an empty object, so if you need to send the data over the wire or store it, you convert to an array of entries first and reconstruct on the other end.

Event delegation relies on the fact that events bubble up from the element they happened on through every ancestor up to the document. Instead of attaching a click listener to each of 500 rows in a table, you attach one listener to the table itself, and inside it you check event.target (or event.target.closest('tr')) to figure out which row was actually clicked.

That's a big win for performance and memory when the list is large, and it also solves a problem static listeners can't: if new rows get added to the table dynamically after page load, they automatically work with the delegated listener because you never had to attach anything to them individually, whereas listeners bound directly to the original elements would need to be reattached to every new node. The one thing to watch for is event.target versus event.currentTarget: target is whatever the user actually clicked, which could be a span or icon nested inside the row, so you typically need closest() to walk up to the element you actually care about.

A regular Map holds a strong reference to every key you put in it, which means if you use a DOM node as a key, that node can never be garbage collected as long as the Map exists, even after it's removed from the page, because the Map is still holding onto it. That's a classic memory leak source in long-running single page apps.

A WeakMap holds its keys weakly, meaning the garbage collector is still allowed to collect an object even while it's a WeakMap key, as long as nothing else references it, and once collected, that entry just disappears from the WeakMap on its own. That makes WeakMap useful for attaching private metadata to an object, like caching computed data per DOM node, without you having to remember to clean up manually. The tradeoff is WeakMap keys must be objects, not primitives, and it's not iterable, no .keys(), no .size, no for...of, because the garbage collector could remove entries at any moment and an iterable snapshot would be unreliable.

The finally block's return always wins, and it silently overrides whatever the try or catch block was about to return, even if that means swallowing a genuine value or a thrown error. A function that returns 1 in the try block but returns 2 in the finally block will return 2, no error, no warning.

It gets worse with exceptions: if the try block throws, and the finally block has its own return statement, the exception is completely discarded, as if it never happened, because a return inside finally always takes precedence over both a pending return and a pending throw from the try or catch. This is a real bug I've seen in production, usually when someone adds cleanup logic in finally and, out of habit, returns a status from that cleanup, not realizing it silently eats whatever the actual business logic was trying to return or throw. The rule of thumb is: never put a return, break, or continue inside finally unless you deliberately want it to override everything above it.

A basic version keys the cache off a serialized version of the arguments:

javascript
function memoize(fn) {
  const cache = new Map();
  return function (...args) {
    const key = JSON.stringify(args);
    if (cache.has(key)) return cache.get(key);
    const result = fn.apply(this, args);
    cache.set(key, result);
    return result;
  };
}

This works fine for pure functions with primitive or simple, serializable arguments, like a memoized Fibonacci or a function that formats a date given a timestamp. The JSON.stringify key generation is the weak point though: it breaks on arguments that don't serialize predictably, like functions or objects with the same values but different key order, and it can't tell two different object instances with identical shapes apart from actual reference equality if that's what you cared about. It also has no cache eviction, so a memoized function called with thousands of unique inputs over a long-running process will leak memory indefinitely, which is why real-world memoization utilities add an LRU cache with a max size or a TTL, and only memoize functions you know are pure, since caching a function with side effects or one that depends on external mutable state gives you stale, wrong answers.

Hard questions

12

Normally, yes, typeof is the one safe way to check for a completely undeclared variable without a ReferenceError. But that safety only applies to variables that don't exist in any reachable scope at all.

javascript
typeof neverDeclared; // "undefined", totally safe

function example() {
  console.log(typeof x); // ReferenceError, not "undefined"
  let x = 5;
}
example();

Inside the TDZ, typeof stops being safe. x has already been hoisted into the function's scope, so it's not undeclared, it's just uninitialized, and touching it any way, including through typeof, throws. This is the kind of question that separates people who memorized "typeof is safe" from people who understand what TDZ actually is.

Because var isn't block-scoped, there's only one i for the whole loop, shared by every callback. By the time any of the three timeouts actually fires, the loop has already finished and i sits at its final value, 3, so all three callbacks read the same variable and print the same number.

javascript
for (var i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 0);
}
// logs: 3, 3, 3

for (let j = 0; j < 3; j++) {
  setTimeout(() => console.log(j), 0);
}
// logs: 0, 1, 2

Swapping var for let fixes it because the spec gives for-loops a fresh binding of the loop variable on every single iteration, so each closure captures its own separate j instead of one shared one. Before let existed, the fix was an IIFE that took i as an argument, forcing a new local copy per iteration by hand.

Passing obj.method as a reference strips it away from obj entirely. By the time the browser calls it, it's just a bare function, no longer obj.method(), so the implicit binding rule that made this equal obj never applies.

javascript
const obj = {
  name: "widget",
  handleClick() {
    console.log(this.name); // undefined when called this way
  },
};

button.addEventListener("click", obj.handleClick); // loses binding

// fix 1: bind
button.addEventListener("click", obj.handleClick.bind(obj));
// fix 2: arrow wrapper
button.addEventListener("click", () => obj.handleClick());
javascript
console.log("start");

setTimeout(() => console.log("timeout"), 0);

Promise.resolve().then(() => console.log("promise"));

console.log("end");

// start, end, promise, timeout

start and end log first because they're synchronous, they run on the main stack with nothing queued yet. Once the stack empties, the event loop drains the entire microtask queue before it touches the macrotask queue even once, so the promise callback runs before the timeout, regardless of the fact that setTimeout was called with 0ms. A 0ms delay is a floor, not a guarantee, and it doesn't cut in line ahead of microtasks either way.

Wrap the await in try/catch, same as you'd catch a synchronous throw. It works because a rejected promise inside an async function gets converted into a thrown error at the await line.

javascript
async function loadUser(id) {
  try {
    const res = await fetch(`/api/users/${id}`);
    if (!res.ok) throw new Error("bad response");
    return await res.json();
  } catch (err) {
    console.error("failed to load user:", err.message);
    return null;
  }
}

Forget the try/catch, and the async function's returned promise simply rejects instead of throwing where you'd expect. If nothing downstream calls .catch() on that returned promise, or awaits it inside another try/catch, you get an unhandled promise rejection, which Node and modern browsers will both warn about loudly in the console but won't crash your program over by default.

javascript
function debounce(fn, delay) {
  let timer;
  return function (...args) {
    clearTimeout(timer);
    timer = setTimeout(() => fn.apply(this, args), delay);
  };
}

const onSearch = debounce((query) => fetchResults(query), 300);
input.addEventListener("input", (e) => onSearch(e.target.value));

Every call clears whatever timer is pending and starts a fresh one, so fn only actually runs once the caller has gone quiet for a full delay window, one search request per pause in typing, not one per keystroke. Throttle solves a different problem: it guarantees fn runs at most once every delay window regardless of how many events fire during it, which is what you want for a scroll or resize handler that should still fire periodically while the event keeps happening continuously, not just once it stops. Debounce waits for silence. Throttle enforces a steady drip.

V8 and other modern engines use a generational mark and sweep collector. New objects are allocated in a small "young generation" space and most objects die young, since a lot of what you allocate is short-lived, like intermediate variables in a function call, so that space gets collected frequently and cheaply. Objects that survive a couple of collection cycles get promoted to the "old generation," which is collected less often using a full mark and sweep pass, because scanning the whole heap is expensive. The collector works by starting from root references, the global object, currently executing stack frames, closures still in scope, and marking everything reachable from those roots, then sweeping away everything that wasn't marked, on the assumption that unreachable means unused.

The leak pattern I've debugged most often is detached DOM nodes held alive by closures: you remove a modal from the page with element.remove(), but a click handler or a setInterval callback defined inside that component's scope still references the element, so the DOM node lives on in memory even though it's no longer in the document, invisible visually but still consuming memory and showing up in a heap snapshot as a detached HTMLDivElement. The fix is always the same, explicitly clear the interval, remove the event listener, and null out any long-lived reference to the removed node in the component's cleanup logic, whether that's a class-based teardown method or a React useEffect cleanup function.

Getters and setters intercept access to a specific, pre-defined property that you named in advance, so you have to know property names up front. A Proxy wraps an entire object and intercepts operations at a structural level through traps like get, set, has, deleteProperty, and ownKeys, so it can react to any property access, even ones that don't exist yet on the target object, including dynamically named ones you never anticipated. That's how Vue 3's reactivity system works under the hood: instead of walking every property of a data object and manually defining a getter and setter pair for each one, which is what Vue 2 did and why Vue 2 couldn't detect new properties added after initialization, Vue 3 wraps the whole object in a Proxy, and any get intercepts dependency tracking while any set triggers reactivity, so adding a brand new property just works, no special API needed.

Reflect is the companion API that provides the default behavior for each of those same operations, and you use it inside a Proxy trap to forward to the default behavior once you've done your custom logic, rather than manually replicating what the engine would have done anyway. A concrete example: a validation proxy that throws when you try to set a property to the wrong type, but otherwise defers to Reflect.set so normal assignment still works exactly as expected. The tradeoff with Proxy is a real performance cost, since every property access goes through the trap function instead of being a direct memory read, so you don't wrap hot-path objects in a tight loop with a Proxy just for convenience.

A generator function, marked with function*, doesn't run its body when you call it, calling it just creates a generator object with an internal state machine and a paused execution context. Each call to .next() resumes execution from wherever the last yield left off, runs until it hits the next yield expression, and returns an object with a value and a done flag, capturing the entire call stack and local variable state in between, which is really the engine keeping the generator's execution context alive on the heap instead of freeing it, unlike a normal function call that unwinds completely when it returns.

Before async/await was standardized, libraries like co, used heavily with early Koa, exploited this pause and resume behavior to fake asynchronous code that reads synchronously. You'd write a generator function where every yield handed back a Promise instead of a plain value, and the co runner would call .next(), see a Promise come out, attach a .then() to it, and only call .next() again with the resolved value once that promise resolved, feeding the resolved value back into the generator at the exact point it paused. That's structurally identical to what async/await does today, an async function is really syntactic sugar over exactly this generator plus promise runner pattern, except the runner is now built into the JS engine instead of being a userland library, and await is just yield with the promise handling wired in automatically.

JavaScript itself is single-threaded, but a Web Worker runs on a genuinely separate OS thread with its own event loop, its own call stack, and its own global scope, it just can't touch the DOM or any variables from the main thread directly. The two threads communicate exclusively through message passing: postMessage() on one side and an onmessage handler on the other, and by default, whatever data you pass through postMessage gets copied using the structured clone algorithm, not shared by reference, so mutating an object inside the worker never touches the original object on the main thread and vice versa.

That copying has a real cost for large payloads, like a big typed array of image or audio data, which is why postMessage also supports transferable objects such as ArrayBuffer, MessagePort, and ImageBitmap, where instead of copying the data, ownership is physically transferred to the receiving thread and the sending thread's reference becomes unusable, which is nearly instantaneous regardless of size because no bytes actually move. What you fundamentally can't share is anything that isn't structured cloneable or transferable, functions, DOM nodes, and closures over the outer scope don't survive the trip, postMessage will throw a DataCloneError if you try to pass a function or a live DOM reference. You also can't access window, document, or the DOM at all from inside a worker, since the DOM isn't thread safe, which is exactly why workers exist for CPU-heavy tasks like parsing large JSON, image processing, or running a search index, moving the blocking work off the main thread so scrolling and clicks stay responsive while the worker crunches in the background.

The event loop only moves on to render a frame or process the next macrotask, like a setTimeout callback, a UI event, or an I/O completion, after the current microtask queue is completely empty. Microtasks are things like .then() callbacks and queueMicrotask calls, and normally that queue drains in a few milliseconds and rendering proceeds fine. But if a piece of code recursively schedules a new microtask from within a microtask, for example a function that resolves a promise and immediately chains another .then() back into itself, forming a very long or infinite chain, the browser keeps draining that queue and never gets to the point where it's empty enough to paint a frame or handle a click, because every microtask it processes immediately queues another one right behind it.

This is a real, reproducible bug, not a theoretical one, and I've seen it happen from something as innocent-looking as a recursive polling function written with async/await and no delay, where an awaited status check resolves synchronously fast enough, say from a cache, that the recursive await never actually yields back to the macrotask queue, so the page becomes completely unresponsive, scrollbar frozen, clicks ignored, while CPU usage in devtools shows the main thread pegged at 100 percent purely on Promise resolution. The fix is to force a hop out to the macrotask queue on each iteration, wrapping the recursive call in a setTimeout with a zero delay or an equivalent scheduling call, which guarantees the browser gets a chance to paint and handle input between iterations instead of getting stuck in an ever-deepening microtask chain.

The first thing I check is whether every .then() in the chain has a matching .catch(), or whether the chain is inside an async function without a surrounding try/catch, because an unhandled rejection in a promise chain doesn't throw the way a synchronous error does, it just gets logged as an unhandled promise rejection warning in most environments, or silently swallowed entirely if you're inside an async function nobody's awaiting. The most common root cause is a .then() that transforms data but has a bug, like reading a property one level too deep for the actual response shape, which throws inside that specific callback, and if there's no .catch() anywhere downstream of it, the rejection just propagates to the end of the chain with nothing listening.

To actually find it, I add a listener for the unhandledrejection event in the browser (or the process-level unhandledRejection event in Node) at the top of the app during debugging, which surfaces every swallowed rejection with its actual error object instead of you guessing. Then I bisect the chain by adding a .catch() after each individual .then() that logs which step failed and re-throws so the chain still behaves the same way in production, which quickly narrows down exactly which async step is failing. Once you've found it, the real fix is almost always adding proper error handling at each async boundary rather than one single .catch() at the very end, because a single top-level catch tells you something failed somewhere in a five-step chain, but not which step, and that's the information you actually need to fix the bug instead of just suppressing the symptom.

How to prepare for a JavaScript interview in 2026

Skip re-reading MDN pages passively and open a console instead. Type the loop-and-setTimeout example above yourself with var, watch it print the same number three times, then change one word to let and watch the output change. Break your own closures on purpose: write a counter with a bug in it, then fix it. Predicting output before running code, and being right about why, builds the kind of intuition flashcards don't.

Across mock interviews run through LastRoundAI tagged frontend or generalist JavaScript, the this-binding and event loop questions trip up more candidates than closures does, even though closures gets more space in most prep guides. I don't have a clean percentage to put on that, only that it's a pattern that keeps showing up in review, and it lines up with what I'd expect: this and the event loop both require reasoning about timing and call sites, not just recognizing a pattern.

If an interviewer hands you a broken chunk of async code, live, resist the urge to guess. Trace it out loud: what's synchronous, what's on the microtask queue, what's on the macrotask queue, in that order. That single habit covers a wide slice of JavaScript interview questions asked past the junior level.

Get the reps in before the real thing

Reading an answer isn't the same as defending it once an interviewer changes one detail on you, swaps a var for a let, adds a second await, nests one more promise. LastRoundAI's mock interview mode runs live JavaScript coding rounds with follow-up questions that adapt to what you actually said, and the free plan includes 15 credits a month that reset monthly rather than piling up. Starter is $19/mo if fifteen sessions isn't enough runway some months.

If the slower part of your job hunt is finding enough frontend or full-stack roles to interview for in the first place, rather than passing once you land one, Auto-Apply queues tailored applications for your review, 10 a month on the free plan, up to 400 a month on the Ultimate plan, and nothing goes out until you approve it.

Questions about either product go to contact@lastroundai.com. That's the only inbox we check.

Leave a Reply

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