Closures, Scope & the Classic setTimeout Loop
What lexical scope and closures really are, why the var + setTimeout loop prints 3 3 3, three ways to fix it, and where closures earn their keep in real code.
The language every web interview starts with: scope, closures, this, prototypes, async code and the event loop.
What lexical scope and closures really are, why the var + setTimeout loop prints 3 3 3, three ways to fix it, and where closures earn their keep in real code.
How the call stack, task queue and microtask queue fit together, where rendering happens, how async/await schedules work, and output puzzles with answers.
How JavaScript decides what this refers to: default, implicit, explicit and new binding, the order they win in, why arrow functions ignore them, and the bugs that follow.
What the engine sets up before your code runs, why var reads as undefined while let throws, how the temporal dead zone works, and why const doesn't mean immutable.
How the prototype chain resolves properties, what __proto__ and prototype each mean, what new and class do under the hood, and how to wire up inheritance by hand.
Build debounce and throttle the way interviewers expect: timelines, this and args, cancel(), leading and trailing edges, and how to test them.
Implement Promise.all with native semantics, then allSettled, race, any and a concurrency-limited pool, plus the edge cases interviewers probe.
A closure is a function bundled together with its lexical environment: the variables that were in scope where the function was defined. When an inner function is returned or passed elsewhere, it keeps access to those outer variables even after the outer function has returned, because the engine keeps them alive as long as something can still reach them.
In the example, counter() returns an arrow function that closes over n. Each call to counter() creates a fresh n, so the counters are independent and nothing outside can modify n directly.
Practical uses:
once(), debounce and throttle timersThe classic gotcha: var in a for loop with setTimeout logs the final value every time, because all callbacks share one function-scoped variable; let creates a new binding per iteration. Closures can also keep large objects in memory longer than you intended.
function counter() {
let n = 0; // private: only the returned function can reach it
return () => ++n;
}
const a = counter();
const b = counter();
a(); a(); // 1, 2
b(); // 1 (b has its own n)once()var-in-loop gotcha; can retain memory unintentionallyLikely follow-up: How would you implement once(fn) using a closure? · Why does a var loop with setTimeout log 3, 3, 3?
var, let and const?easyThey differ in scope, hoisting, redeclaration and reassignment:
var is function-scoped (it ignores blocks like if and for), is hoisted and initialized to undefined, can be redeclared, and a top-level var in a classic browser script becomes a property of window.let is block-scoped, is hoisted but not initialized (reading it before its declaration throws a ReferenceError, the temporal dead zone), can't be redeclared in the same scope, but can be reassigned.const behaves like let but must be initialized and can't be reassigned. It does not make the value immutable: you can still mutate a const object or array. Use Object.freeze for that.In a for loop, let creates a fresh binding per iteration, which fixes the classic closure-in-a-loop bug.
My default is const everywhere, let only when I need to reassign, and no var in modern code.
var is function-scoped; let/const are block-scopedlet/const sit in the TDZ until their declaration runsvar can be redeclared; let/const cannotconst prevents reassignment, not mutationconst, then let; avoid varLikely follow-up: What is the temporal dead zone? · Why does let fix the setTimeout-in-a-loop bug?
Hoisting is the effect of JavaScript creating all the bindings of a scope before it runs any code in that scope, so declarations behave as if they were moved to the top. What differs is how each kind is initialized:
var is hoisted and initialized to undefined, so reading it early gives undefined rather than an error.let, const and class are hoisted but left uninitialized. From the start of the scope until the declaration runs, the binding is in the temporal dead zone, and any access, even typeof, throws a ReferenceError.var fn = function () {} too early throws TypeError: fn is not a function.The TDZ exists to surface use-before-initialization bugs instead of silently handing you undefined.
console.log(sayHi()); // "hi": declaration hoisted with its body
console.log(x); // undefined: var hoisted and initialized
console.log(y); // ReferenceError: Cannot access 'y' before initialization
function sayHi() { return 'hi'; }
var x = 1;
let y = 2;var is initialized to undefinedlet/const/class are uninitialized: access throws ReferenceErrortypeof is not safe inside the TDZLikely follow-up: Are classes hoisted? · What does typeof return for an undeclared variable versus one in the TDZ?
== and ===? Give some surprising coercion examples.easy=== is strict equality: it's true only when both operands have the same type and the same value, with no conversion. == is loose equality: when the types differ it coerces the operands first, following the Abstract Equality rules, and that produces surprises:
'' == 0, '0' == false and [] == false are all true, because both sides end up converted to numbers.null == undefined is true, but null == 0 is false: null and undefined only loosely equal each other.NaN is not equal to anything, including itself, with either operator.{} === {} is false.In practice I use === everywhere. The one idiom some teams allow is x == null, which checks for null and undefined in one go. For edge cases there's Object.is, which treats NaN as equal to itself and distinguishes +0 from -0.
=== compares type and value without coercion== coerces operands, usually toward numbersnull == undefined is true, but null == 0 is falseNaN never equals itself; use Number.isNaNObject.is handles NaN and -0Likely follow-up: Why is [] == ![] true?
JavaScript runs your code on a single thread with one call stack. Slow work such as timers, network requests and I/O is handed to the host environment (the browser's Web APIs, or libuv in Node), which does it outside the stack and queues a callback when it finishes.
The event loop coordinates the queues:
then/catch/finally, code after await), queueMicrotask and MutationObserver callbacks.requestAnimationFrame callbacks run just before style, layout and paint.That's why the snippet logs the synchronous lines first, then the promise, then the timeout, even with a 0 ms delay. And because everything shares one thread, a long synchronous loop blocks clicks, timers and rendering.
console.log('start');
setTimeout(() => console.log('timeout'), 0);
Promise.resolve().then(() => console.log('promise'));
console.log('end');
// start, end, promise, timeoutLikely follow-up: What happens if a microtask keeps scheduling another microtask? · Is setTimeout(fn, 0) really zero milliseconds?
this determined in JavaScript?midFor regular functions, this is decided by how the function is called, not where it's defined. The rules, from highest to lowest precedence:
new binding: new Foo() sets this to the newly created object.fn.call(obj), fn.apply(obj) or a function made with fn.bind(obj) use obj.obj.method() uses the object before the dot.fn() gets undefined in strict mode (which includes modules and class bodies), or the global object in sloppy mode.Arrow functions ignore all of these: they have no this of their own and use the this of the enclosing scope.
The classic bug is losing the receiver: const greet = user.greet; greet(), or passing user.greet as a callback, turns an implicit call into a default one. Fix it with bind, a wrapping arrow function, or a class field holding an arrow function.
'use strict';
const user = {
name: 'Ada',
greet() { return this?.name; },
};
user.greet(); // "Ada" (implicit binding)
const greet = user.greet;
greet(); // undefined (default binding, strict mode)
user.greet.call({ name: 'Bob' }); // "Bob" (explicit binding)new, then call/apply/bind, then method call, then defaultundefined in strict mode, global object otherwisethis from the enclosing scopethisLikely follow-up: Why does this break when you pass a class method as an event handler? · Can you change the this of a bound function with call?
A Promise is an object representing the eventual result of an asynchronous operation. It is in one of three states: pending, fulfilled (with a value) or rejected (with a reason). Once fulfilled or rejected it is settled and can never change again; later resolve or reject calls are ignored.
Key behaviors:
new Promise(...) runs synchronously; only the reactions are asynchronous.then, catch and finally callbacks always run as microtasks, even if the promise is already settled.then returns a new promise, resolved with whatever the callback returns. Returning a promise makes the chain wait for it; throwing rejects it.then handlers until a catch handles it, and the chain continues normally after it recovers.That's what gives you a flat chain instead of nested callbacks, with one catch at the end handling errors from every step.
then returns a new promise, enabling chainingcatchLikely follow-up: What happens to a rejected promise that nobody handles? · How would you wrap a callback-based API in a promise?
Arrow functions are more than shorter syntax; they behave differently:
this: they capture this from the surrounding scope, and call, apply and bind can't change it. Perfect for callbacks inside methods, wrong for object methods that need this.arguments: use rest parameters (...args) instead.new on an arrow function throws a TypeError, and they have no prototype property.super or new.target of their own, and they can't be generators.x => x * 2. To return an object literal, wrap it in parentheses: () => ({ a: 1 }).Regular functions get a dynamic this from the call site and can be used with new. Function declarations are also hoisted, whereas arrow functions are always expressions, so they follow the hoisting rules of the variable they're assigned to.
this, not changeable by call/apply/bindarguments object; use rest parametersnew; no prototypethisLikely follow-up: When should you not use an arrow function?
call, apply and bind?midAll three set this explicitly for a regular function:
fn.call(thisArg, a, b) invokes the function immediately, with the arguments listed individually.fn.apply(thisArg, [a, b]) invokes it immediately, with the arguments as an array or array-like. Mnemonic: A for array. Before spread syntax, Math.max.apply(null, nums) was the idiom.fn.bind(thisArg, a) does not call the function. It returns a new bound function with this fixed and, optionally, some leading arguments pre-filled (partial application).A bound function's this is permanent: call, apply or binding it again won't change it. The exception is new: using a bound function as a constructor ignores the bound this and creates a fresh object.
Typical uses are borrowing methods (Array.prototype.slice.call(arguments)), fixing this for callbacks and event handlers, and partial application. None of them can change an arrow function's this.
function greet(greeting, punct) {
return `${greeting}, ${this.name}${punct}`;
}
const ann = { name: 'Ann' };
greet.call(ann, 'Hi', '!'); // "Hi, Ann!"
greet.apply(ann, ['Hi', '!']); // "Hi, Ann!"
const hiAnn = greet.bind(ann, 'Hi');
hiAnn('?'); // "Hi, Ann?"
hiAnn.call({ name: 'Zed' }, '.'); // "Hi, Ann." (bound this wins)call: invoke now, arguments listed individuallyapply: invoke now, arguments as an arraybind: returns a new function with this fixedthis can't be overridden, except by newLikely follow-up: Write a simple polyfill for Function.prototype.bind.
Every object has an internal [[Prototype]] link to another object or to null. When you read a property, the engine checks the object itself, then follows the link to its prototype, then that object's prototype, and so on until it finds the property or reaches null and returns undefined. That's the prototype chain. Assigning a property normally creates it on the object itself, shadowing any inherited one.
Two things are often confused:
Object.getPrototypeOf(obj) (or the legacy obj.__proto__) is an object's actual prototype.Fn.prototype is a property of functions: the object that becomes the prototype of instances created with new Fn().So methods defined on Person.prototype are shared by every instance rather than copied into each one. You can set up inheritance with Object.create(proto), constructor functions, or class ... extends, which is syntax over the same mechanism. Built-ins work the same way: [].map comes from Array.prototype.
const animal = {
speak() { return `${this.name} makes a sound`; },
};
const dog = Object.create(animal); // dog's prototype is animal
dog.name = 'Rex';
dog.speak(); // "Rex makes a sound"
dog.hasOwnProperty('speak'); // false: inherited
Object.getPrototypeOf(dog) === animal; // true
Object.getPrototypeOf(Object.prototype); // null: end of the chain[[Prototype]]null__proto__ is the link; Fn.prototype is for instancesclass is syntax over prototypesLikely follow-up: What is the difference between __proto__ and prototype? · How do you create an object with no prototype at all?
async/await work, and how does it relate to promises?midasync/await is syntax built on top of promises that lets asynchronous code read like synchronous code.
async function always returns a promise: returning a value fulfills it, throwing rejects it.await pauses that function until the promise settles, then resumes with the value or throws the rejection reason, so ordinary try/catch works. Awaiting a non-promise simply wraps it in a resolved promise.await.Common mistakes:
await Promise.all([...]).await inside forEach doesn't wait; forEach ignores the returned promises. Use for...of for sequential work or Promise.all with map for parallel work.await, so a rejection escapes your try/catch.await is allowed inside async functions and at the top level of ES modules.
// Assume each call takes about 1 second
const user = await getUser(); // sequential: ~2s total
const posts = await getPosts();
// Parallel: ~1s total
const [user2, posts2] = await Promise.all([getUser(), getPosts()]);
// Does NOT wait for the saves:
ids.forEach(async (id) => await save(id));
// Waits for each one, in order:
for (const id of ids) await save(id);await pauses the function, not the threadtry/catchPromise.allawait inside forEach does not waitLikely follow-up: How do you handle errors from several parallel awaits? · What happens if you forget await inside a try block?
It logs 1 6 8 3 5 7 4 2.
1, then the async IIFE runs synchronously up to its first await and logs 6, then 8.then callback, the queueMicrotask callback, and the continuation after await null. Draining it logs 3, 5, 7.3 callback fulfilled the promise returned by the first then, which queued the second then callback at the end of the queue. It runs in the same drain: 4.2.The takeaways: an async function is synchronous until its first await; microtasks run in FIFO order, and microtasks queued during the drain still run before any macrotask; setTimeout(fn, 0) always waits for the whole microtask queue to empty.
console.log('1');
setTimeout(() => console.log('2'), 0);
Promise.resolve()
.then(() => console.log('3'))
.then(() => console.log('4'));
queueMicrotask(() => console.log('5'));
(async () => {
console.log('6');
await null;
console.log('7');
})();
console.log('8');awaitthen, queueMicrotask, await continuationsthen is queued only when the previous one finishesLikely follow-up: Where would a setTimeout scheduled inside the 3 callback run? · What changes if the IIFE awaited a promise that resolves after 100 ms?
Both limit how often a function runs during a burst of events, but in different ways:
delay ms of silence, so 50 quick keystrokes produce one call at the end. Use it for search-as-you-type requests, autosave, input validation, or recalculating after a window resize finishes.Details interviewers probe: preserving this and the arguments with fn.apply(this, args), clearing the old timer, leading versus trailing execution (a throttle that drops the last event can leave the UI stale), and a cancel() method for cleanup when a component unmounts. For purely visual updates, requestAnimationFrame is often a better throttle.
function debounce(fn, delay) {
let timer;
return function (...args) {
clearTimeout(timer);
timer = setTimeout(() => fn.apply(this, args), delay);
};
}
const onSearch = debounce((q) => fetchResults(q), 300);
input.addEventListener('input', (e) => onSearch(e.target.value));delay msthis and args; leading/trailing; cancelLikely follow-up: Implement throttle so the last call in a burst isn't lost. · How would you add cancel() and flush() to your debounce?
When an event fires on a DOM element, it travels in three phases:
window down through the ancestors toward the target.window.addEventListener listeners run in the bubbling phase by default; pass { capture: true } (or true) to run during capturing instead. Most events bubble, but some don't, such as focus, blur, mouseenter and mouseleave (focusin and focusout are the bubbling alternatives).
Event delegation takes advantage of bubbling: instead of attaching a listener to every child, attach one to a common ancestor and inspect event.target to find what was actually clicked, typically with event.target.closest(selector). event.currentTarget is the element the listener is attached to.
The benefits are far fewer listeners and automatic support for children added later, which is ideal for lists, tables and menus. closest also handles clicks on nested elements inside an item, such as an icon inside a button.
const list = document.querySelector('#todo-list');
list.addEventListener('click', (event) => {
const button = event.target.closest('button.delete');
if (!button || !list.contains(button)) return; // clicked something else
button.closest('li').remove();
});
// Also works for items added to the list later.{ capture: true } for capturingevent.targettarget is the origin; currentTarget has the listenerLikely follow-up: What is the difference between event.target and event.currentTarget?
JavaScript has seven primitive types: string, number, bigint, boolean, undefined, symbol and null. Primitives are immutable and are compared by value: assigning one copies the value, and methods like toUpperCase() return a new string rather than changing the original.
Everything else is an object (including arrays, functions, dates and maps). A variable holding an object holds a reference to it, so assigning or passing it copies the reference, and both names point at the same object. Mutating through either one is visible through both, and === compares identity: two objects with identical contents are not equal.
Strictly speaking, JavaScript is always pass-by-value; for objects, the value being passed is a reference (sometimes called "call by sharing"). That's why a function can mutate an object you pass in, but reassigning the parameter has no effect on the caller's variable.
This matters for React state, default parameters and caches: copy objects instead of mutating shared ones.
Likely follow-up: How would you compare two objects by their contents?
A shallow copy creates a new top-level object but copies nested values by reference, so nested objects are still shared with the original. Spread ({ ...obj }, [...arr]), Object.assign, Array.from and slice all make shallow copies. A deep copy recursively copies every nested object, so the two are completely independent.
Options for deep copying:
structuredClone(value) is the built-in choice. It handles nested objects and arrays, Date, Map, Set, RegExp, typed arrays and even circular references. It throws a DataCloneError for functions and DOM nodes, and class instances come back as plain objects, because prototypes aren't preserved.JSON.parse(JSON.stringify(value)) is the old trick, but lossy: functions, undefined and symbols disappear, a Date becomes a string, Map and Set become {}, NaN and Infinity become null, and circular references or BigInt values throw.cloneDeep, or a recursive function when you need custom behavior.const original = { user: { name: 'Ada' }, when: new Date(0) };
const shallow = { ...original };
shallow.user.name = 'Grace';
console.log(original.user.name); // "Grace": nested object is shared
const deep = structuredClone(original);
deep.user.name = 'Linus';
console.log(original.user.name); // "Grace": fully independent
console.log(deep.when instanceof Date); // trueObject.assign and slice are shallowstructuredClone handles Date, Map, Set and cyclesundefined, Dates, Map/SetstructuredClone throws on functions and drops prototypesLikely follow-up: Write a recursive deep clone that handles circular references.
null and undefined?easyBoth mean "no value", but they signal different things:
undefined means a value hasn't been assigned. You get it from a declared but uninitialized variable, a missing object property, a missing function argument, or a function without a return.null is an intentional empty value that code assigns explicitly, for example "this user has no manager".Practical differences:
typeof undefined is "undefined", but typeof null is "object", a historical bug kept for compatibility.null == undefined is true, but null === undefined is false.null converts to 0 and undefined to NaN.undefined, not for null.JSON.stringify drops properties whose value is undefined but keeps null.To handle both at once, use ??, ?. or x == null.
undefined: not assigned; null: intentionally emptytypeof null is "object", a legacy bugundefinednull and drops undefined propertiesLikely follow-up: How do you reliably check for null or undefined in one condition?
map, filter and reduce. When would you use each one?easyAll three are array methods that take a callback, return something new without mutating the original array, and let you describe transformations declaratively:
map(fn) returns a new array of the same length, where each element is fn(item, index, array). Use it to transform: prices into formatted strings, API rows into view models.filter(fn) returns a new array with only the elements for which fn returns a truthy value. Use it to select: active users, items matching a search.reduce(fn, initial) walks the array carrying an accumulator and returns a single value, which can be a number, an object or another array. Use it for sums, counts, grouping or building a lookup by id.Gotchas: always pass an initial value to reduce, because calling it on an empty array without one throws a TypeError. ['1', '2', '3'].map(parseInt) returns [1, NaN, NaN], because map passes the index as parseInt's radix. Chaining is readable but loops several times; on hot paths a single loop can be faster.
const orders = [
{ total: 40, paid: true },
{ total: 15, paid: false },
{ total: 25, paid: true },
];
const revenue = orders
.filter((o) => o.paid) // the two paid orders
.map((o) => o.total) // [40, 25]
.reduce((sum, t) => sum + t, 0); // 65map: transform each item, same lengthfilter: keep items that pass a predicatereduce: fold into one value with an accumulatorreduce an initial valueLikely follow-up: Implement map and filter using reduce. · Why does ['1', '2', '3'].map(parseInt) return [1, NaN, NaN]?
Promise.all, Promise.allSettled, Promise.race and Promise.any.midAll four take an iterable of promises (plain values count as already fulfilled) and return one promise:
Promise.all fulfills with an array of every value, in input order, once all of them fulfill. It rejects as soon as any one rejects, with that reason. Use it for independent requests you need together.Promise.allSettled waits for all of them and never rejects. It returns objects shaped like { status: 'fulfilled', value } or { status: 'rejected', reason }. Use it when partial failure is fine, such as a dashboard of independent widgets.Promise.race settles like the first promise to settle, whether it fulfills or rejects. The classic use is a timeout.Promise.any fulfills with the first fulfilled value and ignores rejections. If every promise rejects, it rejects with an AggregateError whose errors array holds the reasons. Use it for fallbacks and redundant sources.None of them cancel the losing promises; that work keeps running unless you abort it, e.g. with an AbortController. Edge cases: Promise.all([]) fulfills with [], while Promise.race([]) stays pending forever.
const timeout = (ms) =>
new Promise((_, reject) => setTimeout(() => reject(new Error('Timeout')), ms));
// Fail if the request takes longer than 5 seconds
const res = await Promise.race([fetch('/api/data'), timeout(5000)]);
// Load everything, even if some requests fail
const results = await Promise.allSettled(urls.map((u) => fetch(u)));
const ok = results.filter((r) => r.status === 'fulfilled').map((r) => r.value);all: every value, rejects on the first rejectionallSettled: never rejects, reports each outcomerace: settles like the first to settleany: first fulfillment; AggregateError if all rejectLikely follow-up: Implement Promise.all from scratch. · How would you run 100 requests with at most 5 in flight?
They share the ... syntax but do opposite jobs:
fn(...args), [...a, ...b], { ...defaults, ...options }. Array spread works with any iterable (strings, Set, Map), while object spread copies an object's own enumerable properties, with later keys overwriting earlier ones.function log(level, ...messages)) and in destructuring (const [first, ...others] = arr, const { id, ...props } = obj). It must come last, otherwise it's a SyntaxError.Rule of thumb: in a call or on the right-hand side of an assignment, it's spread; in a parameter list or a destructuring pattern, it's rest.
Both produce shallow copies. Rest parameters give you a real array, unlike the old arguments object, which is only array-like and doesn't exist in arrow functions. One edge case: { ...null } is fine and gives {}, but [...null] throws a TypeError.
arguments objectLikely follow-up: How would you remove a property from an object without mutating it?
Scope determines where a variable is visible. JavaScript has:
var.let, const and class declared inside { }, such as an if or for block.JavaScript uses lexical (static) scoping: a function's scope is determined by where it is written, not where it is called. When you reference a variable, the engine looks in the current scope, then the enclosing one, and so on outward to the global scope. That lookup path is the scope chain. If the name isn't found, you get a ReferenceError; in sloppy mode, assigning to an undeclared name silently creates a global instead, which strict mode forbids.
An inner variable with the same name shadows the outer one. Lexical scoping is also what makes closures possible: a function keeps its scope chain wherever it goes.
ReferenceError; the basis of closuresLikely follow-up: What is the difference between lexical and dynamic scope?
forEach and map?easyBoth call a function once for each array element, but they serve different purposes:
map returns a new array of the callback's return values, with the same length as the original. It's for transforming data, and it's chainable: arr.map(...).filter(...).forEach returns undefined. It's for side effects: logging, updating the DOM, writing into another structure. You can't chain anything after it.Using map only for side effects and ignoring the result is a code smell, and using forEach plus push to build a new array usually means you wanted map or filter.
Shared limitations:
break out of either one, except by throwing. For an early exit use for...of, some, every or find.async callbacks: forEach ignores the returned promises, while map gives you an array of promises you can pass to Promise.all.Neither mutates the array by itself, although the callback can.
map returns a new array; forEach returns undefinedmap for transformations, forEach for side effectsbreak; use for...of or somemap is chainableLikely follow-up: How would you stop iterating early?
Destructuring unpacks values from arrays, or properties from objects, into variables using a pattern that mirrors the data's shape.
const { name, age } = user. You can rename ({ name: userName }), set defaults ({ role = 'guest' }), nest ({ address: { city } }) and collect the remainder ({ id, ...others }).const [first, second] = list. You can skip items ([, second]), use defaults and collect the rest ([head, ...tail]). It works with any iterable, including strings and Map entries.Handy patterns:
[a, b] = [b, a].function draw({ width = 100, color = 'red' } = {}), where the trailing = {} lets callers omit the argument entirely.const [count, setCount] = useState(0) in React.Gotchas: defaults apply only when the value is undefined, not null, and destructuring null or undefined throws a TypeError.
undefinednull or undefined throws a TypeErrorWhen a value is used in a boolean context, such as an if, a ternary, &&, || or !, it is converted to true or false. Only a handful of values are falsy:
false0, -0 and 0n'' (the empty string)null and undefinedNaNEverything else is truthy, including some surprising values: '0', 'false', ' ', [], {}, every function, and new Boolean(false), which is an object.
Why it matters in practice:
if (items.length) is a common idiom, but if (items) is always true for an array, even an empty one.count || 10 replaces a legitimate 0 with 10; use count ?? 10 when only null and undefined should fall back.!!value or Boolean(value) converts explicitly.== true: [] is truthy, yet [] == false is true, because == follows different coercion rules.false, 0, -0, 0n, '', null, undefined, NaN[], {} and '0'|| replaces any falsy value; ?? only nullish ones!! or Boolean()Likely follow-up: Why is [] == false true when [] is truthy?
A callback is a function passed to another function to be called later: when an event fires, a timer finishes or an async operation completes. Callbacks can be synchronous (array.map(fn)) or asynchronous (setTimeout(fn), addEventListener('click', fn)). Node's classic convention is the error-first callback, (err, result) => {}.
Callback hell, or the "pyramid of doom", is what you get when async steps depend on each other and each one is nested inside the previous callback. The problems:
try/catch.The fixes: extract named functions to flatten the nesting, move to promises (a flat chain with one catch), and ideally async/await, which reads top to bottom with normal try/catch. In Node, util.promisify converts error-first APIs.
getUser(id, (err, user) => {
if (err) return handle(err);
getOrders(user, (err, orders) => {
if (err) return handle(err);
getInvoice(orders[0], (err, invoice) => { /* ...and deeper */ });
});
});
// The same flow with promise-based APIs and async/await
const user = await getUser(id);
const orders = await getOrders(user);
const invoice = await getInvoice(orders[0]);(err, result)async/awaitLikely follow-up: How would you convert a callback-based function into one that returns a promise?
A higher-order function is a function that takes another function as an argument, returns a function, or both. That works because functions in JavaScript are first-class values: you can store them in variables, pass them around and return them like any other value.
Examples:
map, filter, reduce, sort with a comparator, setTimeout, addEventListener. They separate what to do with each item from the mechanics of looping or scheduling.multiplier(3), and utilities like debounce, throttle, memoize, once and curry, which wrap a function and return an enhanced version.compose(f, g) returns x => f(g(x)), and wrappers or decorators such as withLogging(fn).The benefits are reusable, composable, declarative code with less duplication. The main cost is readability when the abstractions get too clever.
map, filter, reduce, setTimeoutonce, composecurry function?midCurrying transforms a function that takes several arguments, f(a, b, c), into a chain of functions that each take one argument, f(a)(b)(c). It's related to, but not the same as, partial application, which fixes some arguments and returns a function that expects the rest; bind does partial application.
Why it's useful: you can derive specialized functions from general ones, e.g. const logError = curriedLog('error'), and curried functions compose nicely with map and other higher-order functions.
A generic implementation relies on fn.length, the number of declared parameters. The curried function collects arguments across calls; once it has at least fn.length of them it calls the original, otherwise it returns a function waiting for more. This version also accepts several arguments per call, like cAdd(1, 2)(3), as most library versions do.
Caveat: fn.length doesn't count a rest parameter or any parameter from the first default value onward, so variadic functions need an explicit arity.
function curry(fn) {
return function curried(...args) {
if (args.length >= fn.length) return fn.apply(this, args);
return (...more) => curried.apply(this, [...args, ...more]);
};
}
const add = (a, b, c) => a + b + c;
const cAdd = curry(add);
cAdd(1)(2)(3); // 6
cAdd(1, 2)(3); // 6f(a, b, c) into f(a)(b)(c)fn.length is reachedfn.length excludes rest and defaulted parametersLikely follow-up: How would you support an open-ended curry like sum(1)(2)(3)()?
memoize function.midMemoization caches a function's results by its inputs, so repeated calls with the same arguments return the stored result instead of recomputing it. It trades memory for speed.
A basic memoize is a higher-order function that closes over a cache (a Map), builds a key from the arguments, and returns the cached value if there is one; otherwise it computes, stores and returns the result.
Points interviewers want to hear:
JSON.stringify(args) is simple but slow for big inputs and can't tell apart objects with equal contents; a single primitive argument can be the key directly; a WeakMap suits object arguments.function memoize(fn) {
const cache = new Map();
return function (...args) {
const key = JSON.stringify(args);
if (!cache.has(key)) cache.set(key, fn.apply(this, args));
return cache.get(key);
};
}
const fib = memoize((n) => (n < 2 ? n : fib(n - 1) + fib(n - 2)));
fib(50); // 12586269025, returned almost instantlyJSON.stringify, WeakMapLikely follow-up: How would you add a maximum cache size?
A class is largely syntax over prototypes: methods in the class body are placed on ClassName.prototype, extends links the prototype chains, and typeof MyClass is "function". But classes aren't only sugar; they differ from constructor functions in real ways:
new; calling one as a plain function throws a TypeError.for...in loops.super() before touching this in the constructor, or you get a ReferenceError.Classes also bring features without a clean older equivalent: truly private fields and methods (#sound), static members and static initialization blocks, and public class fields. Inside a method, super.method() calls the parent's version of an overridden method.
class Animal {
#sound; // private field
static kind = 'animal'; // static member: Animal.kind
constructor(name, sound) {
this.name = name;
this.#sound = sound;
}
speak() { return `${this.name} says ${this.#sound}`; }
}
class Dog extends Animal {
constructor(name) { super(name, 'woof'); } // must call super() first
}extends links prototypesnew; the body is strictsuper() before using this in derived constructors#fields, static members, class fieldsLikely follow-up: How would you get private state without # fields?
?.) and nullish coalescing (??) do?easyOptional chaining (?.) safely accesses something that might be null or undefined. If the value to its left is nullish, the whole expression short-circuits to undefined instead of throwing a TypeError. Its three forms are obj?.prop, obj?.[key] and fn?.(), which calls a function only if it exists.
Nullish coalescing (??) returns its right-hand side only when the left side is null or undefined. That's the key difference from ||, which falls back on any falsy value, so 0, '' and false get replaced. settings.volume ?? 50 keeps a volume of 0; settings.volume || 50 turns it into 50.
Details worth knowing:
?. only guards the value on its left: if a exists but a.b is missing, a?.b.c still throws.obj?.x = 1 is a SyntaxError.?? with || or && requires parentheses.??= assigns only when the target is nullish.Don't overuse it: if a value must exist, a loud error beats a silent undefined.
?. short-circuits to undefined on null or undefineda?.b, a?.[key], fn?.()?? falls back only for null or undefined|| also replaces 0, '' and false?.; parenthesize ?? with ||new? Could you implement it yourself?hardnew Foo(...args) does four things:
[[Prototype]] is set to Foo.prototype, which is how instances share methods.Foo with this bound to the new object and the given arguments, so the constructor can add properties.You can emulate it with Object.create and apply, as in the snippet. The real operator does a bit more: inside the constructor, new.target refers to the function that was invoked with new, and for derived classes the object is created by the base constructor, which is why they must call super() before using this. That also means myNew can't construct classes, since class constructors throw when called without new.
Not every function is a constructor: arrow functions and shorthand object methods throw a TypeError when used with new.
function myNew(Ctor, ...args) {
const obj = Object.create(Ctor.prototype); // create + link prototype
const result = Ctor.apply(obj, args); // run constructor with this = obj
const isObject = result !== null &&
(typeof result === 'object' || typeof result === 'function');
return isObject ? result : obj; // an explicit object return wins
}
function Person(name) { this.name = name; }
Person.prototype.hi = function () { return `Hi, ${this.name}`; };
myNew(Person, 'Ada').hi(); // "Hi, Ada"Constructor.prototypethis set to the new objectnewLikely follow-up: What is new.target used for?
CommonJS (require and module.exports) is Node's original module system. ES modules (import and export) are the language standard, used by browsers, bundlers and modern Node.
import declarations are static and hoisted, so the dependency graph is known before any code runs. That enables tree-shaking and early errors for missing named exports. require is an ordinary function call evaluated at runtime, so it can be conditional or computed. ESM offers import() for dynamic, promise-based loading.require loads and executes a module synchronously. ESM is designed for asynchronous loading and supports top-level await.let, importers see the new value. With CommonJS you get whatever module.exports holds, so an exported primitive is a copy.this is undefined, and there's no require or __dirname; use import.meta.url instead.In Node, the .mjs extension or "type": "module" in package.json selects ESM. ESM can import CommonJS modules directly; the reverse has traditionally required a dynamic import().
// counter.mjs
export let count = 0;
export function increment() { count++; }
// main.mjs
import { count, increment } from './counter.mjs';
increment();
console.log(count); // 1: live binding
// A CommonJS module doing `module.exports = { count, increment }`
// exports a copy of `count`, so the importer would still see 0.require runs at runtimeawait, no __dirnameimport() loads ES modules dynamicallyLikely follow-up: Why does tree-shaking work better with ES modules?
With promise chains you handle errors with .catch(); with async/await you use ordinary try/catch, because await rethrows the rejection reason. finally runs cleanup either way without swallowing the error.
Common pitfalls:
try/catch can't catch errors thrown in callbacks that run later, such as inside a setTimeout, because the try block has already finished. Handle the error inside the callback, or promisify it.await: return fetchData() inside a try hands back the promise without waiting, so its rejection bypasses your catch. Use return await there.unhandledrejection in browsers and, by default, crashes the process in Node. Always await or attach a catch, even for fire-and-forget calls.catch that only logs turns failures into silent undefined values. Rethrow, or wrap with new Error('context', { cause: err }).Promise.all: use allSettled when you need every outcome.Also, fetch only rejects on network failures, so check response.ok for HTTP errors.
async function loadUser() {
try {
const res = await fetch('/api/user');
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return await res.json(); // `return await` keeps rejections inside this try
} catch (err) {
throw new Error('Failed to load user', { cause: err });
} finally {
hideSpinner();
}
}try/catch with await; .catch() on promise chainstry can't catch errors from callbacks that run laterreturn await inside try blocksfetch doesn't reject on HTTP error statusesLikely follow-up: How would you implement a retry with exponential backoff?
event.preventDefault() and event.stopPropagation()?easyThey control two independent things:
preventDefault() cancels the browser's default action for the event: following a link, submitting a form and reloading the page, toggling a checkbox, opening the context menu. The event still propagates, so ancestor listeners still run. It only works for cancelable events, and it's ignored inside listeners registered with { passive: true }.stopPropagation() stops the event from travelling any further along the capture and bubble path, so listeners on other elements (usually ancestors) never see it. The default action still happens, and other listeners on the same element still run.stopImmediatePropagation() additionally skips the remaining listeners on the same element.A common confusion: returning false from an addEventListener callback does nothing. It does prevent the default in an onclick handler property, and in jQuery it means both methods.
Use stopPropagation sparingly, because it breaks event delegation and "click outside to close" logic higher up the tree. Check event.defaultPrevented to see whether the default was already cancelled.
preventDefault cancels the browser's default actionstopPropagation stops the event reaching other elementsstopImmediatePropagation also skips same-element listenersreturn false does nothing in addEventListenertypeof return for different values, and what are its quirks?easytypeof returns one of eight strings: "undefined", "boolean", "number", "bigint", "string", "symbol", "function" or "object". The quirks:
typeof null is "object", a bug from the first version of JavaScript that can't be fixed without breaking the web. Check with value === null instead."object". Use Array.isArray(value) for arrays, and instanceof or Object.prototype.toString.call(value) for finer detail."function", even though functions are objects.typeof NaN is "number".typeof new String('a') is "object", while typeof 'a' is "string".typeof undeclaredVariable returns "undefined" instead of throwing, which made it popular for feature detection. However, a let or const variable in its temporal dead zone does throw a ReferenceError.A common check for "a real object, not null or an array" is value !== null && typeof value === 'object' && !Array.isArray(value).
"bigint" and "symbol"typeof null is "object": check === null"object": use Array.isArray"function"Map instead of a plain object?midBoth store key-value pairs, but a Map is built to be a dictionary, while objects are built to be records.
Reasons to choose a Map:
NaN. Object keys are always strings or symbols, so obj[someObject] silently becomes the key "[object Object]".Map iterates in insertion order. Objects mostly do too, but integer-like keys are listed first, in ascending order.Object.prototype, so keys like constructor or __proto__ can collide. Object.create(null) avoids that; a Map never has the problem.size, has, delete, clear, and direct iteration with for...of.Map for this, while objects are optimized for a stable shape.Reasons to keep an object: a fixed set of known fields, JSON serialization (JSON.stringify(map) gives "{}"), destructuring, and literal syntax.
Map keys can be any type; object keys are strings or symbolsMap keeps insertion order for every keyMapsize, has, direct iteration, cheap add and deleteLikely follow-up: When would you use a WeakMap instead?
Set, and when would you use one?easyA Set is a collection of unique values of any type; adding a value that's already there does nothing. Uniqueness uses the SameValueZero comparison, which is like === except that NaN equals NaN. Objects are compared by reference, so two different objects with identical contents are both kept.
The API: add, has, delete, clear, size, and iteration in insertion order with for...of, forEach or spread.
Typical uses:
[...new Set(array)].set.has(x) takes roughly constant time on average, while array.includes(x) scans the whole array. That matters for large collections checked repeatedly, such as visited nodes in a graph traversal or selected ids in a UI.union, intersection, difference and isSubsetOf; otherwise, combine spread with filter and has.A Set has no index access and doesn't serialize to JSON directly; convert it with Array.from(set) first. For objects that the collection shouldn't keep alive, use a WeakSet.
NaN equals NaN; objects by reference[...new Set(arr)]has is fast compared with array.includesPromise.all from scratch.hardPromise.all returns a new promise that fulfills with an array of results, in input order, once every input fulfills, and rejects as soon as any input rejects. My implementation:
[] right away; otherwise the promise would never settle.Promise.resolve(item), so plain values and thenables work too.remaining counter and resolve when it reaches zero. Checking results.length doesn't work, because the preallocated array already has its full length.reject as the rejection handler, so the first rejection rejects the result. Later settlements are harmless, since a promise can only settle once.Note that the inputs are already running when promiseAll is called; it only waits for them, which is why they run in parallel, and it doesn't cancel anything on failure.
function promiseAll(iterable) {
return new Promise((resolve, reject) => {
const items = [...iterable]; // throwing here rejects the promise
const results = new Array(items.length);
let remaining = items.length;
if (remaining === 0) resolve(results);
items.forEach((item, i) => Promise.resolve(item).then((value) => {
results[i] = value;
if (--remaining === 0) resolve(results);
}, reject));
});
}[] for empty inputPromise.resolve to accept plain valuesLikely follow-up: How would you change it to implement Promise.allSettled? · How would you limit it to N promises in flight at once?
JavaScript engines manage memory with a tracing garbage collector, conceptually mark-and-sweep: starting from the roots (the global object and the variables on the current call stack), the GC marks everything reachable, and anything unreachable gets freed. Because it's based on reachability, circular references between otherwise unreachable objects are still collected. Modern engines are also generational: most objects die young, so new objects are collected often and cheaply.
A leak is memory that's still reachable but no longer needed. Common causes:
window or from an unmounted component.To diagnose, use the DevTools Memory panel: take heap snapshots before and after repeating an action and look for growing object counts. Fixes include cleanup functions, removeEventListener or an AbortController signal, WeakMap, and bounded caches.
Likely follow-up: How would you find a leak using Chrome DevTools?
WeakMap and WeakSet, and when would you use them?hardWeakMap and WeakSet are like Map and Set, except that they hold their keys weakly: an entry doesn't stop its key from being garbage collected. Once nothing else references the key object, the entry can disappear automatically.
That design has consequences:
WeakSet values) must be objects; primitives throw a TypeError, because they can't be garbage collected. Newer engines also accept non-registered symbols.size or clear, since their contents depend on when the GC runs. The API is just get, set, has and delete (add, has and delete for WeakSet).Use cases:
WeakSet of processed nodes, or of visited objects to detect cycles in a deep clone.Before #private fields existed, a module-level WeakMap was the standard way to store private instance state.
size or clearLikely follow-up: Why can't you iterate over a WeakMap?
Strict mode is an opt-in, more restrictive variant of JavaScript that turns silent mistakes into errors and removes some confusing features. You enable it with 'use strict'; at the top of a script or a function. ES modules and class bodies are strict automatically, so most modern code already runs in strict mode.
The main changes:
ReferenceError instead of creating a global.this is undefined in plain function calls instead of the global object.TypeError instead of failing silently.with statement and legacy octal literals like 010 are syntax errors.eval can't create variables in the surrounding scope, and arguments no longer stays in sync with the named parameters.let, static, implements and private can't be used as identifiers.One gotcha: a function with default, rest or destructured parameters can't contain its own 'use strict' directive.
'use strict'; modules and classes are strict by defaultReferenceErrorthis is undefined in plain function callsTypeErrorswith, duplicate parameters and octal literalsAn IIFE (Immediately Invoked Function Expression) is a function that's defined and called in one step: (function () { ... })(). The wrapping parentheses make the parser treat it as an expression rather than a function declaration, which couldn't be invoked directly.
Why it became popular:
let, const and modules, the only scope boundary was a function. Wrapping code in an IIFE kept its variables out of the global scope and avoided collisions between scripts.var, by passing the current value in as an argument.Today ES modules give every file its own scope, and block scoping fixes the loop problem, so IIFEs are much rarer. They're still handy as an async IIFE where top-level await isn't available, for one-off setup code with its own local variables, and in bundler output.
awaitObject.freeze differ from const?midconst and Object.freeze protect different things. const makes the binding immutable: the variable can't be reassigned, but the object it points to can still change. Object.freeze(obj) makes the object immutable: you can't add, remove or change its properties. In strict mode, such attempts throw a TypeError; in sloppy mode, they fail silently.
Important details:
Object.freeze is shallow: nested objects and arrays inside a frozen object stay mutable. Full immutability needs a recursive deep freeze.Object.seal prevents adding and deleting properties but allows changing existing ones; Object.preventExtensions only prevents adding.Object.isFrozen, Object.isSealed and Object.isExtensible.Day to day, immutability is usually a convention rather than enforcement: create new values instead of mutating, using spread, map, filter, and non-mutating array methods like toSorted, toReversed, toSpliced and with. That makes change detection by reference (prev !== next) reliable, which React, Redux and Angular's OnPush rely on. Libraries like Immer let you write "mutating" code that produces immutable updates.
const locks the binding, not the objectObject.freeze blocks adding, removing and changing propertiesmap, toSortedLikely follow-up: What is the difference between Object.freeze and Object.seal?
A pure function has two properties:
A side effect is any observable interaction with the outside world: mutating an argument or a global variable, updating the DOM, making a network request, logging, reading localStorage, or calling Date.now() or Math.random().
Why pure functions matter:
A common accidental impurity is mutating an argument: arr.sort() sorts in place, so calling it on an input changes the caller's array; toSorted() or [...arr].sort() avoids that.
Real apps need side effects, of course. The goal is to keep core logic pure and push effects to the edges: event handlers, effects and services.
0.1 + 0.2 === 0.3 evaluate to false, and how do you deal with it?easyJavaScript numbers are IEEE 754 double-precision binary floating-point values. Just as 1/3 can't be written exactly in decimal, most decimal fractions, including 0.1 and 0.2, can't be represented exactly in binary. Each is stored as the nearest representable value, the tiny errors combine, and 0.1 + 0.2 gives 0.30000000000000004. It isn't a JavaScript bug: every language that uses doubles behaves the same way.
How to deal with it:
===, e.g. Math.abs(a - b) < Number.EPSILON for values around 1, or a relative tolerance for larger numbers.toFixed(2) or Intl.NumberFormat. Note that toFixed returns a string and has its own surprises: (1.005).toFixed(2) is "1.00", because 1.005 is actually stored slightly below that value.Number.MAX_SAFE_INTEGER (2 ** 53 - 1); beyond that, use BigInt.Number.EPSILONNumber.MAX_SAFE_INTEGERWhen the DOM or styles change, the browser runs a rendering pipeline: style calculation, layout (also called reflow: computing each element's size and position), paint (drawing the pixels) and compositing the layers together.
color, background or visibility.transform and opacity can often be handled by the compositor alone, skipping layout and paint, which is why animations should use them.The classic trap is layout thrashing: reading a layout property like offsetHeight, getBoundingClientRect() or scrollTop right after a write forces the browser to recalculate layout synchronously, and doing that in a loop is very slow.
Best practices: batch all reads before all writes, toggle a CSS class instead of setting many inline styles, build nodes in a DocumentFragment and insert them once, schedule visual updates with requestAnimationFrame, and virtualize very long lists.
const boxes = [...document.querySelectorAll('.box')];
// Layout thrashing: each read forces layout after the previous write
for (const box of boxes) {
box.style.width = `${box.offsetWidth + 10}px`;
}
// Better: read everything first, then write everything
const widths = boxes.map((box) => box.offsetWidth);
boxes.forEach((box, i) => {
box.style.width = `${widths[i] + 10}px`;
});transform/opacity; use fragments and requestAnimationFrameLikely follow-up: Which CSS properties can be animated without triggering layout?
Under the iterator protocol, an iterator is an object with a next() method that returns { value, done }. An iterable is an object with a [Symbol.iterator]() method that returns an iterator. Arrays, strings, Map, Set and NodeList are iterable, and that's what for...of, spread, destructuring and Array.from consume. Plain objects aren't iterable.
A generator function (function*) is the easy way to create iterators. Calling it doesn't run the body; it returns a generator object that is both an iterator and an iterable. Each next() runs the body up to the next yield, pauses there, and returns the yielded value. A return value arrives with done: true, and for...of and spread ignore it.
Use cases:
*[Symbol.iterator]() method works with for...of.next(value) sends a value back in as the result of yield, the idea behind redux-saga.async function*) with for await...of for streaming data, such as paginated APIs.function* idGenerator() {
let id = 1;
while (true) yield id++; // infinite, but lazy
}
const ids = idGenerator();
ids.next(); // { value: 1, done: false }
class Range {
constructor(from, to) { this.from = from; this.to = to; }
*[Symbol.iterator]() { for (let i = this.from; i <= this.to; i++) yield i; }
}
[...new Range(1, 3)]; // [1, 2, 3]next() returns { value, done }[Symbol.iterator]() methodfunction* returns a pausable generator; the body runs lazilyfor...of, spread and destructuring consume iterablesLikely follow-up: How would you make a plain object iterable?
Symbol, and what is it used for?hardA Symbol is a primitive whose values are guaranteed to be unique: Symbol('id') === Symbol('id') is false, and the string is only a description for debugging. You create one with Symbol() (new Symbol() throws), or use Symbol.for(key) to get a shared symbol from a global registry.
Main uses:
for...in, Object.keys and JSON.stringify. They are not private: Object.getOwnPropertySymbols and Reflect.ownKeys reveal them, and spread and Object.assign copy them. Use #private fields for real privacy.Symbol.iterator makes an object iterable, Symbol.asyncIterator enables for await, Symbol.toPrimitive controls type conversion, Symbol.toStringTag changes what Object.prototype.toString reports, and Symbol.hasInstance customizes instanceof.A gotcha: symbols don't convert to strings implicitly, so '' + sym or a template literal throws a TypeError. Use String(sym) or sym.description.
Object.keys and JSONgetOwnPropertySymbols reveals themSymbol.iterator customize behaviorSymbol.for uses a global registry; no implicit string conversionObject.defineProperty work? What are property descriptors?hardGetters and setters are accessor properties: reading obj.prop calls a get function, and assigning to it calls a set function. You define them with get/set syntax in object literals and classes, and use them for computed values, validation, lazy initialization, or keeping a public API stable while the internals change.
Every property has a descriptor, which you can inspect with Object.getOwnPropertyDescriptor:
value and writable.get and/or set, and can't also have value or writable.enumerable (visible to Object.keys, for...in and JSON) and configurable (can be deleted or redefined).Object.defineProperty(obj, key, descriptor) gives precise control. The catch: attributes you omit default to false, whereas normal assignment creates a property that is writable, enumerable and configurable. So a property defined with only a value is read-only, hidden from Object.keys, and can't be deleted. Writing to it fails silently in sloppy mode and throws in strict mode.
Vue 2's reactivity was built on Object.defineProperty; Vue 3 moved to Proxy, which can also detect added and deleted properties.
const temp = {
celsius: 0,
get fahrenheit() { return this.celsius * 9 / 5 + 32; },
set fahrenheit(f) { this.celsius = (f - 32) * 5 / 9; },
};
temp.fahrenheit = 212;
temp.celsius; // 100
const obj = {};
Object.defineProperty(obj, 'id', { value: 1 }); // omitted flags default to false
Object.getOwnPropertyDescriptor(obj, 'id');
// { value: 1, writable: false, enumerable: false, configurable: false }value, writable) or accessor (get, set)enumerable and configurable apply to both kindsdefineProperty defaults omitted flags to falseJSON.stringify and JSON.parse?midJSON only supports strings, numbers, booleans, null, arrays and plain objects, so a round trip through JSON.stringify and JSON.parse loses or changes a lot:
undefined, a function or a symbol, plus all symbol keys. Inside arrays those values become null instead.NaN and Infinity become null; a Date becomes an ISO string via its toJSON method and does not come back as a Date; Map and Set become {}.BigInt values throw a TypeError.Number.MAX_SAFE_INTEGER, such as 64-bit ids, lose precision when parsed; send them as strings.SyntaxError on invalid input, including single quotes, trailing commas and the empty string, so wrap untrusted input in try/catch.Useful tools: a replacer or a toJSON method to customize output, and a reviver to restore types like dates while parsing. For deep cloning, prefer structuredClone.
const data = { when: new Date(0), skip: undefined, fn() {}, n: NaN, list: [undefined] };
const json = JSON.stringify(data);
// '{"when":"1970-01-01T00:00:00.000Z","n":null,"list":[null]}'
const parsed = JSON.parse(json, (key, value) =>
key === 'when' ? new Date(value) : value, // reviver restores the Date
);
parsed.when instanceof Date; // true
JSON.parse("{'a': 1}"); // SyntaxError: single quotes aren't valid JSONundefined, functions and symbols are dropped (or null in arrays)Map/Set become {}; NaN becomes nullBigInt throwJSON.parse throws SyntaxError; wrap untrusted inputtoJSON or a reviver to customizeBrowsers enforce the same-origin policy: scripts can only read responses from the same origin, meaning the same scheme, host and port. A page on https://app.example.com calling https://api.example.com is making a cross-origin request. CORS (Cross-Origin Resource Sharing) lets the server opt in to being read cross-origin, using HTTP headers.
How it works:
Origin header. If the response contains a matching Access-Control-Allow-Origin (that exact origin, or *), the script can read it; otherwise the browser blocks access and logs a CORS error.PUT or DELETE, a JSON Content-Type, or custom headers like Authorization, first trigger a preflight OPTIONS request, and the server must answer with suitable Access-Control-Allow-Methods and Access-Control-Allow-Headers.credentials: 'include', and the server must send Access-Control-Allow-Credentials: true with a specific origin rather than *.The key insight: CORS is enforced by the browser and fixed on the server, or with a same-origin proxy during development. Tools like curl ignore it, so CORS doesn't protect an API from non-browser clients.
Access-Control-Allow-OriginOPTIONS preflightAllow-Credentials and a specific originLikely follow-up: Which requests count as "simple" and skip the preflight?
Cross-site scripting is an attack where untrusted input ends up running as code in another user's page. The script runs with the site's privileges: it can read the DOM, non-HttpOnly cookies and localStorage tokens, and act as the user.
The three main kinds:
location, postMessage or storage and writes it into a dangerous sink like innerHTML.Prevention:
textContent or framework interpolation, which React, Angular and Vue escape by default, instead of innerHTML, document.write or insertAdjacentHTML.dangerouslySetInnerHTML and v-html.eval and new Function, and validate URLs so javascript: links can't reach an href.HttpOnly session cookies.const name = new URLSearchParams(location.search).get('name');
// Vulnerable: ?name=<img src=x onerror=alert(document.cookie)>
greeting.innerHTML = `Hello, ${name}`;
// Safe: the browser treats the value as text, not markup
greeting.textContent = `Hello, ${name}`;textContent or framework escaping, not innerHTMLHttpOnly cookiesLikely follow-up: How does a Content Security Policy mitigate XSS?
Date in JavaScript?midA Date is a timestamp (milliseconds since the Unix epoch) that you read either in the local time zone or in UTC, and most bugs come from that split:
new Date(2024, 0, 15) is January 15th, while days of the month start at 1.'2024-03-10' is parsed as UTC, but a date-time string without an offset like '2024-03-10T09:00' is parsed as local time. Non-ISO formats such as '03/10/2024' are implementation-dependent.setDate and friends change the object in place, so copy before modifying.new Date(2024, 0, 32) rolls over to February 1st, and bad input produces an Invalid Date whose getTime() is NaN.setDate(d.getDate() + 1).Store and send UTC ISO strings, convert to local time only for display with Intl.DateTimeFormat, and consider date-fns or Temporal where available.
new Date(2024, 0, 15); // January 15, 2024: months start at 0
new Date(2024, 0, 32); // February 1, 2024: overflow is silent
new Date('2024-03-10'); // midnight UTC (date-only ISO string)
new Date('2024-03-10T09:00'); // 09:00 local time (no offset given)
new Date('nope').getTime(); // NaN: an Invalid Date
// In New York (behind UTC), UTC midnight is still the previous evening:
new Date('2024-03-10').getDate(); // 9NaNIntl.DateTimeFormatWeb Workers run JavaScript on a background thread, separate from the main thread that handles rendering and user input. Since a long computation on the main thread freezes the UI, moving it into a worker keeps the page responsive.
How they work:
new Worker(url) (add { type: 'module' } for import) and communicate by message passing: postMessage to send and a message listener to receive.ArrayBuffer can be transferred instead of copied, which is nearly free. SharedArrayBuffer allows real shared memory but requires the page to be cross-origin isolated.window, but they can use fetch, timers, WebSockets and IndexedDB.Use cases: parsing large files, image processing, compression, encryption and heavy chart calculations. For small tasks, the cost of starting a worker and copying data isn't worth it.
Don't confuse them with Service Workers, which act as a network proxy for offline support and caching.
postMessage; data is structured-clonedArrayBuffers to avoid copyingLikely follow-up: How does a Service Worker differ from a Web Worker?
Proxy and Reflect, and what can you build with them?hardA Proxy wraps a target object and lets you intercept fundamental operations on it through a handler object with traps: get, set, has (the in operator), deleteProperty, ownKeys, apply (function calls), construct (new) and more.
Reflect is a namespace of static methods that mirror those traps, such as Reflect.get and Reflect.set, and perform the default behavior. Inside a trap, calling the matching Reflect method forwards the operation correctly, including the receiver that getters and inheritance depend on. Reflect methods also return booleans where older Object methods throw, e.g. Reflect.defineProperty.
What people build with proxies:
Caveats: every operation pays some overhead; a proxy is not === to its target; objects relying on internal slots or private fields, like Map, Date or class #fields, throw when their methods run through a proxy unless you bind them to the target; and a set trap returning false throws a TypeError in strict mode.
const user = new Proxy({ name: 'Ada', age: 36 }, {
get(target, key, receiver) {
return key in target ? Reflect.get(target, key, receiver) : 'n/a';
},
set(target, key, value, receiver) {
if (key === 'age' && !Number.isInteger(value)) throw new TypeError('age must be an integer');
return Reflect.set(target, key, value, receiver); // default behavior
},
});
user.email; // "n/a"
user.age = 37; // ok
user.age = 'old'; // TypeError: age must be an integerProxy intercepts operations through handler trapsget, set, has, deleteProperty, applyReflect performs the default behavior inside trapsMap, Date and #private without bindingsetTimeout, setInterval and requestAnimationFrame differ? Which would you use for animations?hardAll three schedule callbacks, with different guarantees:
setTimeout(fn, ms) runs fn once, no sooner than ms milliseconds later. The delay is a minimum: the callback is a macrotask that waits for the call stack and the microtask queue to empty. Browsers also enforce at least about 4 ms once timers are nested several levels deep, and throttle timers in background tabs.setInterval(fn, ms) repeats every ms milliseconds without waiting for your work to finish, so slow callbacks can bunch up, and it runs until you call clearInterval. A recursive setTimeout guarantees a gap between runs, which is often safer for polling.requestAnimationFrame(fn) runs fn once, just before the browser's next repaint, usually in step with the display's refresh rate. It receives a high-resolution timestamp and pauses in hidden tabs.For animations, use requestAnimationFrame: timers aren't synchronized with the display, so they drop or double frames and cause jank. Compute movement from the elapsed time rather than assuming a frame rate, and request the next frame from inside the callback. Simple transitions are often better done in CSS.
setTimeout delay is a minimum, not a guaranteesetInterval repeats regardless of how long callbacks takesetTimeout guarantees a gap between runsrequestAnimationFrame runs before the next repaint; paused when hiddenrequestAnimationFrame, based on elapsed timeNo questions match that filter.