JavaScript — Concept Notes
The language itself, in depth — the parts interviews actually probe. Print this page for a PDF.
1. How JavaScript Actually Runs
Single-threaded means JavaScript does one thing at a time — there's one worker, not a team. Compiled means your code gets translated into instructions the computer runs directly, rather than being read line by line forever.
The engine is the program that reads and runs your JavaScript. Chrome and Node use one called V8.
Think of a chef who first reads the whole recipe and memorises the tricky parts they cook often, instead of re-reading every line each time.
Three words describe how JavaScript runs:
- Single-threaded — one line of work at a time.
- Synchronous at its core — each line finishes before the next one starts.
- Just-in-time (JIT) compiled — the code is turned into machine code while the program runs, not ahead of time.
If an interviewer asks "is JS interpreted?", the old answer was yes. The modern answer: engines compile it, just in time.
Inside the engine (V8)
Here is the path your code takes inside V8, in plain words:
- The parser reads your text and builds an AST (Abstract Syntax Tree — a tree-shaped map of your code's structure).
- Ignition (the interpreter) turns the AST into bytecode (simple, compact instructions) and starts running it straight away.
- If a function runs a lot (it's "hot"), TurboFan (the optimiser) compiles it into fast machine code.
- TurboFan's speed-ups rely on guesses, like "this variable is always a number". If a guess turns out wrong, the code is deoptimised — thrown back to the slower bytecode.
[1, "two", 3]), forces deoptimisation — the engine has to drop its fast version.The runtime is not the engine
The engine only understands the language itself: variables, functions, loops, objects.
Everything else — setTimeout, fetch, the DOM (the page's elements), console — is supplied by the host environment. The host is whatever program is running the engine: a browser or Node.
That's why setTimeout isn't in the ECMAScript spec (the official JavaScript rulebook), and why window doesn't exist in Node — Node is a different host.
The engine is a car's motor. The host is the whole car around it — the wheels, radio and lights. Same motor, different car, different extras.
2. Types & Memory
A primitive is a simple, single value — a number, a piece of text, true/false. An object is a container that holds several values together.
The big difference: copying a primitive gives you a real copy. Copying an object only gives you directions to the same thing.
Writing a phone number on a new piece of paper = a copy (primitive). Giving someone your house address = you both still walk to the same house (object).
Seven primitives + object
There are seven primitive types: string, number, boolean, null, undefined, symbol, bigint.
Everything else is an object — including arrays, functions, dates and regexes (regular expressions, the /pattern/ text-matching tool).
Stack vs heap — the key mental model
JavaScript keeps values in two areas of memory:
- The stack — a small, fast, tidy area. Primitives live here, stored by value (the actual value itself).
- The heap — a big, loose storage area. Objects live here. A variable only holds a reference — an address that points to where the object sits in the heap.
let a = 10; let b = a; b = 20; // a is still 10
let o = { n: 1 }; let p = o; p.n = 2; // o.n is now 2 ← same object
let q = { n: 1 };
o === q; // false — different objects, same shape
Copying properly
A shallow copy copies only the top level. Any objects nested inside are still shared with the original. A deep copy copies every level, so nothing is shared.
const shallow = { ...obj }; // one level only
const shallow2 = Object.assign({}, obj);
const deep = structuredClone(obj); // built-in deep copy (handles Map/Set/Date)
// JSON.parse(JSON.stringify(obj)) — loses undefined, functions, Dates, Infinity
typeof gotchas
typeof tells you a value's type as a string. It has a few surprising answers you should memorise. (A "gotcha" is a trap that catches people out.)
typeof null // "object" ← historic bug, never fixed
typeof [] // "object" → use Array.isArray([])
typeof function(){} // "function"
typeof NaN // "number"
typeof undeclaredVar // "undefined" — doesn't throw, unlike let in TDZ
3. Coercion & Equality
Coercion means JavaScript silently converting one type into another to make a comparison or calculation work — turning the text "5" into the number 5, for example.
It's trying to be helpful, but the rules are strange enough that it causes bugs. That's why you use ===, which does no converting at all.
Like a shop assistant who, when you hand over dollars, quietly swaps them for euros without asking. Handy sometimes — but you may not get what you expected.
== vs ===
=== (strict equality) checks that the type and the value are the same. It never converts anything. So 5 === "5" is false.
== (loose equality) converts the two sides to a common type first, then compares. So 5 == "5" is true. Its conversion rules give some famously odd results:
0 == ""; // true — "" → 0
0 == "0"; // true — "0" → 0
"" == "0"; // false — both strings, compared directly. Not transitive!
[] == false; // true — [] → "" → 0, false → 0
[] == ![]; // true — ![] is false, then as above
null == undefined; // true — special-cased
null == 0; // false — null only equals undefined
NaN == NaN; // false — NaN equals nothing, including itself
===. The one sensible exception is x == null. It checks "is x null or undefined?" in a single comparison.The coercion rules, briefly
- Object → primitive: when an object must become a simple value, JS first calls
valueOf(), thentoString(). Arrays turn into strings by joining their items with commas:[1,2] + ""→"1,2";[]→"";{}→"[object Object]". +is overloaded (it has two jobs): if either side is a string, it concatenates (joins text together); otherwise it adds numbers.- Every other maths operator (
-,*,/,%) always converts both sides to numbers.
1 + "2"; // "12" string wins for +
1 - "2"; // -1 other operators go numeric
"3" * "4"; // 12
[] + {}; // "[object Object]"
[] + []; // ""
true + true; // 2 booleans → 1/0
"5" - - "2"; // 7 unary minus coerces first
Object.is
Object.is is like === but fixes its two odd cases: it says NaN equals NaN, and it treats 0 and -0 as different. (NaN means "Not a Number" — the result of bad maths like "abc" * 2.)
Object.is(NaN, NaN); // true ← unlike ===
Object.is(0, -0); // false ← unlike ===
Number.isNaN(NaN); // true — the safe NaN check
isNaN("abc"); // true — coerces first, avoid it
4. Operators & Truthiness
Truthy and falsy describe what a value becomes when JavaScript needs a yes/no answer — like inside an if.
Eight values count as "no" (falsy). Everything else counts as "yes" — including an empty array and an empty object, which surprises most people.
Like a bouncer with a short "not allowed in" list. If your name isn't one of the eight on that list, you get in — even if you look empty.
The eight falsy values
false, 0, -0, 0n, "", null, undefined, NaN. Everything else is truthy — including [], {}, "0", "false", and function(){}.
Logical operators return values, not booleans
|| (OR) and && (AND) don't always give back true or false. They give back one of the two values you gave them.
They also short-circuit: they stop checking as soon as the answer is known. || stops at the first truthy value; && stops at the first falsy value. That's what makes the "guard" and "default" tricks below work.
"a" || "b"; // "a" — returns the first TRUTHY, else the last
null || "b"; // "b"
"a" && "b"; // "b" — returns the first FALSY, else the last
null && "b"; // null
// Short-circuiting is the point
user && user.name; // guard before access
const name = input || "Guest"; // default
?? vs || — a real bug source
?? is the nullish coalescing operator. "Nullish" just means null or undefined. It uses the fallback only for those two — so real values like 0 or "" are kept.
const count = input ?? 10; // falls back only on null/undefined
const count = input || 10; // ALSO falls back on 0, "" and false ✗
// input = 0 → ?? gives 0 (correct), || gives 10 (wrong)
Optional chaining & logical assignment
Optional chaining (?.) means "only continue if the thing on the left exists". Logical assignment (||=, &&=, ??=) is a short way to write "assign only if…".
user?.address?.city; // undefined instead of a TypeError
user?.greet?.(); // call only if it exists
arr?.[0]; // optional index access
a ||= b; // a = a || b — assign if falsy
a &&= b; // a = a && b — assign if truthy
a ??= b; // a = a ?? b — assign only if null/undefined
?. stops the whole chain and returns undefined the moment it hits null/undefined. But it only protects the spot where you wrote it. For example, user?.address.city still throws if user exists but address is missing — you'd need user?.address?.city.5. Execution Context & the Call Stack
An execution context is the workspace JavaScript sets up each time a function runs — a box holding that function's variables.
The call stack is the pile of those boxes. When a function calls another, a new box goes on top. When a function finishes, its box is removed.
Like a stack of plates: you add to the top and remove from the top. The bottom plate can't leave until everything above it is gone — which is why a function that calls itself forever crashes with "maximum call stack size exceeded".
Every time a function is called, the engine creates an execution context and pushes it (puts it on top) onto the call stack. When the function returns, the context is popped (taken off the top).
Each context holds three things:
- a variable environment — the function's own variables and parameters;
- a scope chain — a link to the outer code, so it can find variables defined outside;
- a
thisbinding — which objectthisrefers to for this call.
The stack is LIFO — "last in, first out". The last function called is the first one to finish.
Two phases per context
Each context is built in two steps:
- Creation — the engine scans the code first. It sets aside memory for every declared variable and function, links up the scope chain, and decides
this. No code runs yet. This is where hoisting happens (section 6). - Execution — the engine now runs the code line by line, filling in the real values.
Like setting a table before a meal: first you lay out a labelled plate for each guest (creation), then the food actually arrives (execution).
6. Hoisting & the Temporal Dead Zone
Hoisting means JavaScript notices all your variable and function names before running the code — so it already knows they exist.
But knowing a name exists isn't the same as knowing its value. var gets a temporary undefined; let and const get nothing at all, and using them early throws an error. That error zone is the Temporal Dead Zone (TDZ).
Like a guest list read out before the party: the name is on the list, but the person hasn't arrived yet. var lets you shout at an empty chair; let stops you and says "not here yet".
During the creation phase (section 5), every declaration is noted down before any code runs. What happens next depends on the keyword:
varis initialised (given a starting value) toundefined.letandconstare noted but left uninitialised — no value at all. From the start of the scope until their declaration line, they're in the TDZ. Touching them there throws aReferenceError.
undefined bug into a loud error you can see and fix.console.log(a); // undefined — var is hoisted AND initialised
console.log(b); // ReferenceError — b is in the TDZ
var a = 1;
let b = 2;
greet(); // "hi" — function declarations hoist completely
function greet(){ console.log("hi"); }
notYet(); // TypeError: notYet is not a function
var notYet = () => {}; // only the var binding hoisted, not the function
var | let | const | |
|---|---|---|---|
| Scope | Function | Block | Block |
| Hoisted as | undefined | TDZ | TDZ |
| Redeclare | Yes | No | No |
| Reassign | Yes | Yes | No |
Global window prop | Yes | No | No |
A binding is the link between a name and a value. const locks the binding, not the value. You can't point o at something else, but you can still change what's inside it: const o = {}; o.x = 1; is legal.
To lock the contents too, use Object.freeze(o). It is shallow — only the top level is frozen; objects nested inside can still change.
7. The Scope Chain
Scope means "where a variable can be seen from". The scope chain is the search path JavaScript follows when looking for a variable: check here, then just outside, then further out, until it finds it or gives up.
Lexical is the word for "decided by where you wrote the code", not by who called the function. You can tell the path just by looking at the file.
Like looking for scissors: your desk, then the room, then the whole house. You stop at the first pair you find.
When you read a variable, the engine looks in the current scope first. If it isn't there, it looks one level out, then the next, all the way to the global scope (the outermost level of the file). If it's still not found, you get a ReferenceError.
This path is fixed by where the code is written (lexical scoping), not by who calls the function.
const g = "global";
function outer() {
const o = "outer";
function inner() {
const i = "inner";
console.log(i, o, g); // reaches all three by walking up
}
inner();
}
// inner → outer → global → ReferenceError if still not found
Block scope in practice
A block is any code between curly braces { } — an if, a loop, and so on. let and const stay inside their block. var ignores blocks and only respects function boundaries.
if (true) { var v = 1; let l = 2; }
console.log(v); // 1 — var ignores blocks
console.log(l); // ReferenceError
for (var i = 0; i < 3; i++) {}
console.log(i); // 3 — leaked to function scope
for (let j = 0; j < 3; j++) {}
console.log(j); // ReferenceError — contained
const x = 1; function f(){ const x = 2; } — inside f, x is 2. It's legal, but in a long function it makes code hard to read.8. Closures
A closure is a function that remembers the variables from where it was created — even long after that surrounding code has finished.
Normally when a function ends, its variables are thrown away. But if an inner function still needs them, JavaScript keeps them alive just for it.
Like taking a photo of your desk before leaving the office. The desk is gone, but you can still read what was on it — and nobody else can see your photo.
The formal definition: a closure is a function bundled together with the lexical environment where it was defined. "Lexical environment" just means the variables that were in scope at the spot where the function was written.
When the outer function returns, its variables would normally be garbage collected (thrown away to free memory). But the inner function still holds a live reference to them — a real link, not a copy — so they survive. If the closure changes them, the next call sees the change.
function counter() {
let count = 0; // private
return {
inc: () => ++count,
get: () => count,
};
}
const c = counter();
c.inc(); c.inc();
c.get(); // 2
c.count; // undefined — genuinely inaccessible
The loop question, explained
for (var i = 0; i < 3; i++) setTimeout(() => console.log(i)); // 3 3 3
for (let i = 0; i < 3; i++) setTimeout(() => console.log(i)); // 0 1 2
var creates one variable i shared by the whole loop. All three callbacks "close over" (remember) that same variable. The timers only run after the loop has finished, and by then i is 3 — so all three print 3.
let creates a fresh variable for each iteration (each pass through the loop). So each callback remembers its own i: 0, 1 and 2.
Before let existed (pre-ES6, i.e. before 2015), people used an IIFE — an Immediately Invoked Function Expression, a function that runs the moment it's defined — to create a new scope on each pass:
// The pre-ES6 fix — an IIFE to force a new scope per iteration
for (var i = 0; i < 3; i++) {
(function (j) { setTimeout(() => console.log(j)); })(i);
}
Closures in real code
Each helper below returns a new function that remembers a private variable (timer, waiting, called, cache) between calls. That remembered variable is the closure.
- Debounce — wait until calls stop for a moment, then run once.
- Throttle — run at most once per time window, however often it's called.
- Once — run only the first time; later calls return the first result.
- Memoize — remember (cache) results so the same input isn't worked out twice.
// Debounce — fire only after activity stops
function debounce(fn, delay = 300) {
let timer; // ← closed over
return (...args) => {
clearTimeout(timer);
timer = setTimeout(() => fn.apply(this, args), delay);
};
}
// Throttle — at most once per interval
function throttle(fn, limit = 300) {
let waiting = false;
return (...args) => {
if (waiting) return;
fn.apply(this, args);
waiting = true;
setTimeout(() => { waiting = false; }, limit);
};
}
// Once — run a function a single time
function once(fn) {
let called = false, result;
return (...args) => called ? result : (called = true, result = fn(...args));
}
// Memoize — cache results by argument
function memoize(fn) {
const cache = new Map();
return (...args) => {
const key = JSON.stringify(args);
if (!cache.has(key)) cache.set(key, fn(...args));
return cache.get(key);
};
}
9. Functions in Depth
Functions in JavaScript are values, just like numbers and strings. You can store one in a variable, pass it to another function, or return it.
A higher-order function is simply one that takes or returns another function — map, filter and setTimeout all are.
Like handing someone a set of instructions instead of doing the task yourself — they decide when to follow it.
Declaration vs expression vs arrow
There are three common ways to write a function:
- A declaration starts with the word
functionon its own line. - An expression creates a function and stores it in a variable.
- An arrow function uses the short
=>syntax.
"Newable" below means "can be called with new to build an object".
function decl() {} // hoisted fully, has its own this/arguments
const expr = function () {}; // only the binding hoists
const arrow = () => {}; // no this, no arguments, no prototype, not newable
| Regular | Arrow | |
|---|---|---|
this | Call site decides | Inherited lexically, fixed |
arguments | Yes | No — use rest ...args |
new | Constructible | Throws TypeError |
prototype | Has one | None |
| Hoisting | Declarations hoist | TDZ like any const |
Parameters
Parameters are the named inputs in a function's definition. A default value is used when the caller leaves that input out. f.length tells you how many parameters the function expects.
function f(a, b = a * 2, ...rest) {} // defaults can reference earlier params
f.length; // 1 — counts params BEFORE the first default
function g({ id, name = "?" } = {}) {} // destructured params with a safe default
First-class functions
"First-class" means functions are treated like any other value. You can assign them to variables, pass them into other functions, return them, and store them in arrays and objects.
A higher-order function takes or returns a function. That's all map, filter, debounce and middleware (functions that sit in the middle of a request, like in Express) really are.
IIFE and pure functions
An IIFE (Immediately Invoked Function Expression) is a function that runs the moment it's written. It gives you a private scope. A pure function always returns the same output for the same input and has no side effects (it doesn't change anything outside itself).
(function () { /* private scope */ })();
(() => { /* modern form */ })();
// Pure: same input → same output, no side effects. Easy to test and cache.
const add = (a, b) => a + b; // pure
const addToCart = (item) => cart.push(item); // impure — mutates outside state
10. this & Binding
this means "the object I'm currently working on". The catch: its value is decided by how the function gets called, not where it was written.
The same function can have a different this on two different lines. That's why it confuses people — and why arrow functions, which never get their own this, are often the simpler choice.
Like the word "here". What it means depends entirely on where you're standing when you say it.
this is decided by how a function is called (the call site — the line where it's called), not where it's defined. To work it out, check the rules below in order. The first one that matches wins.
this. Arrow functions come first because they never get their own this — they borrow it from the code around them. ("Strict mode" in rule 5 is a safer mode of JS, switched on with "use strict" and always on inside modules and classes.)const user = {
name: "Ada",
regular() { return this.name; },
arrow: () => this?.name,
};
user.regular(); // "Ada"
user.arrow(); // undefined — `this` came from the module scope
const fn = user.regular;
fn(); // undefined — lost the object on assignment
fn.call(user); // "Ada"
fn.bind(user)(); // "Ada" — permanently bound
The callback trap
When you pass a regular function as a callback, the code that calls it later calls it plainly — not as obj.fn(). So this is lost. An arrow function fixes it because it keeps the this of the surrounding method.
class Timer {
constructor() { this.secs = 0; }
broken() { setInterval(function () { this.secs++; }, 1000); } // this = window
fixed() { setInterval(() => { this.secs++; }, 1000); } // arrow inherits
}
call vs apply vs bind
All three let you choose this yourself. call and apply run the function right now; bind gives you back a new function to run later. Partial application means fixing some arguments in advance.
Memory trick: Apply takes an Array; Call takes Commas.
fn.call(ctx, a, b); // invoke now, args listed
fn.apply(ctx, [a, b]); // invoke now, args in an array
const bound = fn.bind(ctx, a); // returns a NEW function; partial application
// bind is permanent — bound.call(other) still uses ctx
this happens whenever a method is separated from its object — passed as a callback, pulled out with destructuring, or stored in a variable. Once separated, there's no "object before the dot" any more. The fix: wrap it in an arrow (() => obj.fn()) or use bind.11. Objects in Depth
An object is a set of labelled values — a key (the label) and a value, repeated.
Two things worth knowing early: keys are always text underneath (so obj[1] and obj["1"] are the same key), and objects are compared by identity, not contents — two objects with identical values are still not equal.
Two identical twins are still two different people. {a:1} === {a:1} is false for the same reason — same looks, different objects.
The example below shows the main ways to write keys. A computed key uses a variable's value as the key name. A getter is a property that runs a function when you read it; a setter runs a function when you assign to it.
const key = "dynamic";
const obj = {
a: 1,
[key]: 2, // computed key
method() {}, // shorthand
get full() { return this.a; }, // getter
set full(v) { this.a = v; }, // setter
};
obj.a; obj["a"]; // dot vs bracket (bracket for dynamic keys)
delete obj.a;
"a" in obj; // true — includes inherited
Object.hasOwn(obj, "a"); // own properties only (modern)
obj.hasOwnProperty("a"); // older form
Iterating
Object.keys(o); Object.values(o); Object.entries(o);
Object.fromEntries(entries); // rebuild from [k,v] pairs
for (const [k, v] of Object.entries(o)) {}
// for...in also walks the prototype chain — usually not what you want
for (const k in o) if (Object.hasOwn(o, k)) {}
Property descriptors
Every property has hidden settings called descriptors: writable (can the value change?), enumerable (does it show up in loops and Object.keys?), and configurable (can it be deleted or its settings changed?). freeze blocks all changes; seal blocks adding or removing keys but still lets you edit existing values.
Object.defineProperty(o, "id", {
value: 1, writable: false, enumerable: false, configurable: false
});
Object.freeze(o); // shallow — nested objects stay mutable
Object.seal(o); // can edit existing props, can't add or delete
Object.isFrozen(o);
Merging & comparing
const merged = { ...a, ...b }; // b wins on conflicts, shallow
const merged2 = Object.assign({}, a, b);
// Objects compare by reference — never by content
{ a: 1 } === { a: 1 }; // false
JSON.stringify(a) === JSON.stringify(b);// crude deep compare (key order matters)
obj[1] and obj["1"] are the same key. When you loop over an object, keys that look like whole numbers come first, sorted smallest to largest. Other string keys follow, in the order you added them. Example: {b:1, 2:1, a:1, 1:1} loops as 1, 2, b, a.12. Prototypes & the Prototype Chain
A prototype is a backup object. When you ask an object for something it doesn't have, JavaScript checks its backup — then that backup's backup, and so on. That search path is the prototype chain.
This is how one object borrows abilities from another, and it's the only inheritance JavaScript actually has.
Like asking your older sibling when you don't know something, who asks a parent, who asks a grandparent. Eventually someone answers — or nobody does, and you get undefined.
Every object has a hidden link to another object — its prototype (written [[Prototype]] in the spec). When you read a property, JS checks the object itself, then follows this link, then the next one, until it finds a match or reaches null (the end).
Inheritance means one object getting abilities from another. This chain of links is JavaScript's entire inheritance model.
this still points at the original object (rex). A missing property returns undefined only after the whole chain has been searched.const animal = { speak() { return `${this.name} makes a sound`; } };
const dog = Object.create(animal); // dog.__proto__ === animal
dog.name = "Rex";
dog.speak(); // "Rex makes a sound"
Object.getPrototypeOf(dog) === animal; // true — the modern accessor
dog.hasOwnProperty("speak"); // false — it's inherited
"speak" in dog; // true — in follows the chain
Constructor functions (the pre-class form)
A constructor function is a normal function meant to be called with new to build objects. This is how "classes" were written before the class keyword existed.
function Animal(name) { this.name = name; }
Animal.prototype.speak = function () { return this.name + " speaks"; };
const a = new Animal("Rex");
// `new` does four things:
// 1. create {} 2. link its [[Prototype]] to Animal.prototype
// 3. bind this and run 4. return this (unless you return an object)
Shadowing
If you give an object its own property with the same name as one on its prototype, the object's own one wins. The prototype's version is hidden, not changed.
dog.speak = () => "custom"; // writes an OWN property; the prototype is untouched
delete dog.speak; // removes the shadow, the inherited one is visible again
13. Classes
A class is a blueprint for making objects that all share the same shape and abilities.
"Syntactic sugar" means it's a nicer way to write something that already existed — classes are just prototypes (section 12) with cleaner spelling. Nothing new was added to the language underneath.
A cookie cutter. The class is the cutter; each cookie you make with it is an instance.
Classes are syntactic sugar over prototypes — a nicer way of writing, not a new system underneath. Proof: typeof Dog === "function" (a class is really a function), and its methods still live on Dog.prototype.
Key words in the example: static puts something on the class itself rather than on each object; # makes a field truly private (unreadable from outside); extends makes one class inherit from another; super calls the parent class.
class Animal {
static count = 0; // on the class itself
#secret = "private"; // true private — enforced by the engine
constructor(name) { this.name = name; Animal.count++; }
speak() { return `${this.name} speaks`; } // → Animal.prototype
get label() { return this.name.toUpperCase(); } // getter
static create(n) { return new Animal(n); } // called as Animal.create()
#hidden() {} // private method
}
class Dog extends Animal {
constructor(name, breed) {
super(name); // MUST come before using `this`
this.breed = breed;
}
speak() { return `${super.speak()} — woof`; }
}
Differences from functions
- You can't use a class before the line that declares it. It's hoisted but sits in the TDZ, like
let. - Code inside a class always runs in strict mode (JS's stricter, safer mode).
- Calling a class without
newthrows a TypeError. #privatefields truly can't be reached from outside. The older_underscorenaming habit was only a polite "please don't touch" — anyone could still read it.
Class fields and this
A normal method lives on the prototype, so it loses this when pulled off the object. An arrow function stored as a field is created per object when it's built, and it locks in that object as this.
class Button {
label = "Click"; // instance field
handleA() { console.log(this.label); } // on prototype — `this` can be lost
handleB = () => console.log(this.label); // field arrow — `this` always bound
}
const b = new Button();
const { handleA, handleB } = b;
handleA(); // TypeError — this is undefined
handleB(); // "Click" — arrow captured this at construction
14. Arrays & Iteration
The important split is between methods that change the original array (called mutating — to mutate means to change) and ones that return a new array and leave the original alone.
push, splice, sort and reverse change the original. map, filter and slice don't. Mixing these up causes bugs where data changes somewhere you didn't expect — especially in React, which relies on the original staying untouched.
| Method | Returns | Mutates? |
|---|---|---|
map | New array, same length | No |
filter | New array, subset | No |
reduce | Any single value | No |
find / findIndex | First match / index | No |
some / every | Boolean | No |
slice(s, e) | Copy of a range | No |
splice(s, n, ...add) | Removed items | Yes |
sort / reverse | The same array | Yes |
push/pop/shift/unshift | Length / element | Yes |
flat(d) / flatMap | Flattened copy | No |
at(-1) | Element, negative OK | No |
toSorted/toReversed/with | New array | No — modern, safe |
reduce is the general case
reduce walks through the array and builds up one result as it goes. That running result is called the accumulator (sum or acc below). The last argument (0, {}, []) is its starting value.
const total = items.reduce((sum, i) => sum + i.price, 0);
// group by a key
const byRole = users.reduce((acc, u) => {
(acc[u.role] ||= []).push(u);
return acc;
}, {});
// map and filter are both reduce underneath
const doubled = nums.reduce((a, n) => [...a, n * 2], []);
sort — the classic trap
By default, sort turns every item into a string and sorts alphabetically. So "10" comes before "9", just as "apple" comes before "banana". Pass a compare function to sort numbers properly.
[10, 9, 1].sort(); // [1, 10, 9] — sorts as STRINGS
[10, 9, 1].sort((a, b) => a - b); // [1, 9, 10]
users.sort((a, b) => a.name.localeCompare(b.name)); // proper string sort
const copy = [...items].sort(fn); // sort mutates — copy first
Loop choice
for (const v of arr) {} // values; supports break/continue and await
for (const k in obj) {} // KEYS, includes inherited — avoid on arrays
arr.forEach(fn); // can't break; IGNORES async callbacks
// The forEach async bug
items.forEach(async i => { await save(i); });
console.log("done"); // prints BEFORE any save finishes ✗
for (const i of items) await save(i); // sequential ✓
await Promise.all(items.map(i => save(i))); // parallel ✓
Sparse arrays & holes
A sparse array has "holes" — slots that were never filled in at all (different from holding undefined). Most array methods skip holes, which can be confusing.
const a = [1, , 3];
a.length; // 3
a.map(x => x * 2); // [2, <1 empty>, 6] — map SKIPS holes
Array.from({length: 3}, (_, i) => i); // [0,1,2] — the safe way to build
new Array(3).fill(0); // [0,0,0]
15. Map, Set, Symbol & friends
A Map is a lookup table — like an object, but its keys can be anything, not just text, and it remembers the order you added things.
A Set is a list that automatically refuses duplicates. Add the same value twice and it's still only there once.
A Symbol is a guaranteed-unique label, used for keys nobody will accidentally overwrite.
A Set is like a guest list — writing the same name twice doesn't invite that person twice. A Map is like a coat check — you can hand in anything (even an object) and get a ticket for it.
| Object | Map | |
|---|---|---|
| Keys | String / Symbol only | Any type, including objects |
| Order | Integer keys first, then insertion | Pure insertion order |
| Size | Object.keys(o).length | map.size |
| Iterating | Needs Object.entries | Directly iterable |
| Performance | Fine for fixed shapes | Better for frequent add/delete |
const m = new Map();
m.set("a", 1).set(objKey, 2);
m.get("a"); m.has("a"); m.delete("a"); m.size;
for (const [k, v] of m) {}
Object.fromEntries(m); // → plain object
const s = new Set([1, 2, 2, 3]);
s.size; // 3 — duplicates dropped
[...new Set(arr)]; // deduplicate an array
s.add(4); s.has(2); s.delete(1);
WeakMap & WeakSet
In a WeakMap or WeakSet, keys must be objects, and they are held weakly. That means the WeakMap doesn't keep the key alive: if nothing else in your code points to the key object, the entry is garbage collected (removed automatically).
Because entries can vanish at any time, you can't loop over them (not iterable) and there's no .size.
They're perfect for attaching metadata (extra info about something) to DOM nodes without causing a memory leak (memory that's never freed).
const meta = new WeakMap();
meta.set(domNode, { clicks: 0 });
// when domNode is removed and dereferenced, the entry disappears automatically
Symbols
Every Symbol() is unique, even if two have the same description. Symbol keys are hidden from normal listing. Well-known symbols like Symbol.iterator are special keys that let your object plug into built-in language features, such as for...of and spread.
const id = Symbol("id"); // always unique, even with the same description
obj[id] = 123; // hidden from Object.keys and JSON.stringify
Symbol.for("app"); // global registry — same symbol every call
// Well-known symbols hook into language behaviour
obj[Symbol.iterator] = function* () { yield 1; }; // makes obj spreadable
16. Destructuring, Spread & Rest
Destructuring means unpacking values out of an object or array into their own variables, in one line instead of several.
Spread (...) pours the contents of one thing into another — copying or combining.
Rest (also ...) does the opposite: it gathers leftover items into a single group. Same three dots, opposite direction — which side of the = it sits on tells you which one it is.
Spread is emptying a bag of shopping onto the table. Rest is sweeping whatever's left on the table back into one bag.
// objects
const { a, b: renamed, c = 10, ...rest } = obj;
const { user: { address: { city } = {} } = {} } = data; // nested, with guards
// arrays
const [first, , third = 0] = arr; // skip with a bare comma
[x, y] = [y, x]; // swap without a temp
// parameters
function f({ id, name = "?" } = {}) {}
const handler = ({ target: { value } }) => value; // destructure an event
Spread vs rest — same ..., opposite jobs
// SPREAD — expands
const merged = { ...a, ...b };
const combined = [...arr1, ...arr2];
Math.max(...nums);
const copy = [...arr]; // shallow copy
// REST — collects
function sum(...nums) {} // must be the LAST parameter
const [head, ...tail] = arr;
const { id, ...others } = obj; // omit a key cleanly
{...user} copies user.address as the same reference (the same nested object). So changing copy.address.city also changes the original's city.17. The Event Loop
JavaScript can only do one thing at a time. So when something slow comes up — waiting for a server, a timer — it hands that job to the browser and carries on.
The event loop is the system that checks "am I free now?" and, if so, runs the next finished job's callback.
A microtask is a high-priority job (promises). A macrotask is a normal one (setTimeout, clicks). All microtasks run before the next macrotask — that one rule answers most "what prints first?" questions.
A chef who puts a cake in the oven and keeps cooking instead of standing there watching. When the timer dings, they deal with it — but only once they've finished what's in their hands.
JavaScript has one call stack, so it does one thing at a time.
So how does it juggle many jobs at once (concurrency)? It hands slow work — timers, network requests, I/O (input/output, like reading files) — to the host environment (the browser or Node). The host does the waiting. When the work is done, it puts the callback (the function to run next) into a queue — a waiting line. The event loop runs it once the stack is free.
The interview question
console.log("1");
setTimeout(() => console.log("2"), 0);
Promise.resolve().then(() => console.log("3"));
queueMicrotask(() => console.log("4"));
console.log("5");
// 1, 5, 3, 4, 2
Step by step:
- Normal (synchronous) code runs first, top to bottom:
1,5. - The stack is now empty, so all microtasks run ("drain"):
3,4. - Only then does one macrotask run:
2.
That's why setTimeout(fn, 0) never really means 0 ms. It means "as soon as everything else is done".
A harder one
console.log("A");
setTimeout(() => {
console.log("B");
Promise.resolve().then(() => console.log("C"));
}, 0);
Promise.resolve().then(() => {
console.log("D");
setTimeout(() => console.log("E"), 0);
});
console.log("F");
// A, F, D, B, C, E
Walk through it: A and F are normal code. Then the microtask runs: D (and it schedules timer E). Then the first timer (macrotask) runs: B, which queues microtask C.
Microtasks added during a macrotask run before the next macrotask. So C comes right after B, and E waits for the next turn.
Node's extras
Node has its own event loop with named phases (stages it cycles through), plus two extra scheduling tools:
process.nextTick(fn); // runs BEFORE other microtasks — highest priority
setImmediate(fn); // check phase, after I/O callbacks
// Node phases: timers → pending → poll → check → close
18. Callbacks
A callback is a function you hand to someone else, to be run later — usually when a slow job finishes.
Callback hell is what happens when each callback needs another one inside it. The code drifts further right with every step and error handling has to be repeated at every level. Promises exist to fix exactly this.
Leaving your number and saying "call me when it's ready" — instead of standing at the counter waiting.
// Node-style: error first, result second
fs.readFile("a.txt", (err, data) => {
if (err) return handle(err);
use(data);
});
Callback hell
getUser(id, (err, user) => {
getOrders(user.id, (err, orders) => {
getItems(orders[0].id, (err, items) => {
// nesting grows sideways; error handling repeats at every level
});
});
});
Three problems:
- Unreadable nesting — the code forms a sideways pyramid.
- Repeated error handling — every level needs its own
if (err). - Inversion of control — you hand your callback to someone else's code and simply trust it to call it once, and only once. You've given up control. A buggy library might call it twice, or never.
Promises fix all three.
19. Promises
A promise is a receipt for a value you don't have yet. It doesn't hold the result — it holds the guarantee that a result is coming, whether that turns out to be success or failure.
It has three states: pending (still waiting), fulfilled (worked), rejected (failed). Once it settles it never changes again.
A restaurant buzzer. You don't have food yet, but you have something that will tell you when it's ready — or that the kitchen ran out.
A promise is an object standing in for a value that isn't ready yet. It has three states. It settles (finishes, one way or the other) exactly once. After that it's immutable — it can never change again.
const p = new Promise((resolve, reject) => {
setTimeout(() => resolve("done"), 1000);
});
p.then(v => v.toUpperCase()) // each .then returns a NEW promise
.then(v => console.log(v))
.catch(err => console.error(err)) // catches any rejection above it
.finally(() => stopSpinner()); // runs either way, passes the value through
Chaining rules
Chaining means linking .then calls one after another. Each .then creates a new promise, and what you do inside it decides what happens to that new promise:
- Returning a value from
.thenfulfils (successfully completes) the next promise with that value. - Returning a promise makes the chain wait for it. This is how you avoid nesting — each step sits on its own line instead of inside the last.
- Throwing inside
.thenrejects the next promise. The error skips down to the nearest.catch. - A
.thenplaced after a.catchstill runs — the chain recovers and carries on.
getUser(id)
.then(user => getOrders(user.id)) // return a promise → chain waits
.then(orders => getItems(orders[0]))
.catch(err => console.error(err)); // one handler for the whole chain
Combinators
Combinators are helpers that take several promises and combine them into one. "Fail fast" means: give up as soon as one fails.
| Method | Fulfils when | Rejects when | Use for |
|---|---|---|---|
Promise.all | All fulfil → array | Any rejects (fail fast) | All-or-nothing parallel work |
Promise.allSettled | All settle → {status,…}[] | Never | Independent calls; partial failure OK |
Promise.race | First to settle | If that first one rejected | Timeouts |
Promise.any | First to fulfil | Only if all reject | Fastest mirror wins |
// Sequential — 3s
const a = await getA(); const b = await getB(); const c = await getC();
// Parallel — 1s. Use this whenever the calls are independent.
const [a, b, c] = await Promise.all([getA(), getB(), getC()]);
// Don't let one failure kill everything
const results = await Promise.allSettled([getA(), getB()]);
const ok = results.filter(r => r.status === "fulfilled").map(r => r.value);
// Timeout with race
const withTimeout = (p, ms) => Promise.race([
p,
new Promise((_, rej) => setTimeout(() => rej(new Error("timeout")), ms)),
]);
Promise.all rejects on the first failure, but the other promises keep running in the background — they aren't cancelled. (2) An unhandled rejection (a failed promise with no .catch) is a silent bug in browsers, and crashes Node. Always attach a .catch.20. async / await
async/await lets you write waiting code that reads top-to-bottom, like normal code, instead of chaining .then() calls.
await means "pause here until this finishes". Important: it only pauses that one function — the rest of your page keeps running perfectly well.
Waiting for the kettle. You stop making tea, but the world doesn't stop — other people carry on around you.
async/await is just a nicer way to write promises — the same thing underneath.
- An
asyncfunction always returns a promise, even if you return a plain value. awaitpauses that function only — not the whole thread (the single line of work JS runs on) — until the promise settles.
async function load(id) {
try {
const res = await fetch(`/api/users/${id}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`); // fetch does NOT throw on 404/500
return await res.json();
} catch (err) {
console.error(err);
throw err; // rethrow, or the caller thinks it succeeded
} finally {
stopSpinner();
}
}
What await actually does
Three things happen at an await:
- The function is suspended (paused and set aside).
- The rest of the function — the continuation — is scheduled as a microtask.
- Control goes back to whoever called the function, and their code keeps running.
Everything after the await picks up again once the promise settles and the stack is clear.
async function f() {
console.log("1");
await null; // even awaiting a non-promise defers the rest
console.log("3"); // ← this became a microtask
}
f();
console.log("2");
// 1, 2, 3
Common mistakes
// ✗ sequential when it could be parallel
const a = await getA(); const b = await getB();
// ✓
const [a, b] = await Promise.all([getA(), getB()]);
// ✗ await inside forEach does nothing
items.forEach(async i => { await save(i); });
// ✓ sequential
for (const i of items) await save(i);
// ✓ parallel
await Promise.all(items.map(save));
// ✗ top-level await only works in ES modules
// ✓ or wrap in an async IIFE
(async () => { await main(); })();
Error handling choices
Wrapping every await in try/catch gets noisy. A small helper can turn a promise into a tuple (a fixed pair of values) of [error, data], so you check the error with a simple if.
// try/catch per call is noisy — a tuple helper reads better in loops
const to = (p) => p.then(d => [null, d]).catch(e => [e, null]);
const [err, user] = await to(getUser(id));
if (err) return handle(err);
21. Async Patterns
Sequential means one after another — each waits for the last. Parallel means all started at once, so the total time is just the slowest one rather than the sum.
If two requests don't depend on each other, running them sequentially is wasted time. That single choice is often the biggest easy speed win in a codebase.
Sequential is one cashier serving three customers in turn. Parallel is three cashiers serving all three at once.
Sequential vs parallel vs limited concurrency
Limited concurrency is the middle ground: run several at once, but never more than N. This protects rate limits (how many requests a server allows per second) and memory.
// Run at most N at a time — protects rate limits and memory
async function pool(items, worker, limit = 5) {
const results = [];
const running = new Set();
for (const item of items) {
const p = Promise.resolve().then(() => worker(item));
results.push(p);
running.add(p);
p.finally(() => running.delete(p));
if (running.size >= limit) await Promise.race(running);
}
return Promise.all(results);
}
Retry with backoff
Backoff means waiting longer after each failed try (here the wait doubles), so you don't hammer a struggling server.
async function retry(fn, attempts = 3, delay = 300) {
for (let i = 0; i < attempts; i++) {
try { return await fn(); }
catch (err) {
if (i === attempts - 1) throw err;
await new Promise(r => setTimeout(r, delay * 2 ** i)); // 300, 600, 1200…
}
}
}
Cancellation
An AbortController gives you a signal to pass into fetch. Calling abort() cancels the request — useful when the result is no longer needed.
const ac = new AbortController();
fetch(url, { signal: ac.signal })
.catch(e => { if (e.name !== "AbortError") throw e; });
ac.abort(); // cancel a stale search or an unmounted component's request
Promisifying a callback API
"Promisifying" means wrapping an old callback-style function so it returns a promise instead — then you can await it.
const readFile = (path) => new Promise((resolve, reject) =>
fs.readFile(path, (err, data) => err ? reject(err) : resolve(data)));
A sleep helper
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
await sleep(1000);
22. Iterators & Generators
Iterable just means "you can loop over it one item at a time". Arrays and strings are iterable; plain objects are not, which is why for...of works on one and not the other.
A generator is a function that can pause in the middle, hand you a value, and continue later from exactly where it stopped.
A vending machine: it gives you one item, then waits. It doesn't dump the whole stock on the floor — which is how a generator can represent an endless list without running out of memory.
The rule (the "contract") is short. An object is iterable if it has a Symbol.iterator method. That method must return an iterator — an object with a next() method. Each call to next() returns { value, done }: the next item, and whether the list is finished.
That's all for...of, spread and destructuring need to work.
const range = {
from: 1, to: 3,
[Symbol.iterator]() {
let cur = this.from, last = this.to;
return { next: () => cur <= last
? { value: cur++, done: false }
: { value: undefined, done: true } };
},
};
[...range]; // [1, 2, 3]
for (const n of range) {} // works now
Generators
A generator function is written function* (note the star). It can pause at each yield and resume later from the same spot. Calling it doesn't run the body — it hands you an iterator, and each next() runs until the next yield.
Generators are lazy: they only work out a value when you ask for it. yield* hands over to another generator until it's finished.
function* idGen() {
let id = 1;
while (true) yield id++; // infinite, but lazy — nothing computed ahead
}
const ids = idGen();
ids.next().value; // 1
ids.next().value; // 2
function* range(a, b) { for (let i = a; i <= b; i++) yield i; }
[...range(1, 5)]; // [1,2,3,4,5]
// delegate to another generator
function* all() { yield* range(1, 3); yield "done"; }
Two-way communication
You can also send a value into a generator. Whatever you pass to next(x) becomes the result of the yield where the generator is paused.
function* dialogue() {
const name = yield "What's your name?"; // yield RECEIVES the next next() arg
yield `Hello ${name}`;
}
const d = dialogue();
d.next().value; // "What's your name?"
d.next("Ada").value; // "Hello Ada"
Async iteration
An async function* can await and yield. You loop over it with for await. Great for paginated APIs — ones that return results a page at a time.
async function* pages(url) {
let next = url;
while (next) {
const res = await fetch(next);
const data = await res.json();
yield* data.items;
next = data.nextUrl;
}
}
for await (const item of pages("/api/items")) console.log(item);
23. Error Handling
try means "attempt this". catch means "if it goes wrong, do this instead of crashing". finally means "do this either way" — usually cleanup.
Throwing is how code signals that something went wrong and it can't continue. The error travels up until something catches it — or the program stops.
A safety net. The fall still happens; the net decides whether it's a disaster.
try {
risky();
} catch (err) { // `catch {}` without a binding is legal
if (err instanceof TypeError) {}
console.error(err.name, err.message, err.stack);
} finally {
cleanup(); // runs even if you return inside try
}
Built-in error types
TypeError— a value is the wrong type for what you did. E.g. calling something that isn't a function, or reading a property ofundefined.ReferenceError— the variable doesn't exist, or you used it in the TDZ (before itslet/constline).SyntaxError— the code itself is written wrongly. It's thrown at parse time (when the engine first reads the file, before anything runs), so atry/catcharound it can't help.RangeError— a number is outside the allowed range, e.g. an invalid array length, or a stack overflow (too many nested calls).
Custom errors
You can make your own error types by extending Error. This lets callers check err instanceof ValidationError and carry extra details, like which field failed.
class ValidationError extends Error {
constructor(field, message) {
super(message);
this.name = "ValidationError";
this.field = field;
Error.captureStackTrace?.(this, ValidationError);
}
}
throw new ValidationError("email", "Invalid email");
Async errors
A try/catch only catches errors thrown while it's on the call stack. A timer callback runs later, after the try block has already finished — so the error slips past. Catch it inside the callback, use promises, or add a global handler as a last resort.
// ✗ try/catch does NOT catch async callbacks
try { setTimeout(() => { throw new Error("boom"); }, 0); } catch (e) {}
// ✓ inside the callback, or via promises
process.on("unhandledRejection", handler); // Node
window.addEventListener("unhandledrejection", handler); // browser
window.addEventListener("error", handler); // sync errors
Error objects, never strings. Only an Error carries a stack trace (the list of function calls that led to the error — your map for debugging). And err.message is undefined when you throw a plain string.24. Modules
A module is one file that keeps its contents private unless it deliberately shares them.
export means "other files may use this". import means "give me that". Anything not exported is invisible outside the file — which is what stops large codebases turning into a soup of clashing global names.
A shop: the shelves are public, the stockroom isn't.
// named — many per file, imported by exact name
export const PI = 3.14;
export function area(r) { return PI * r * r; }
import { PI, area as circleArea } from "./math.js";
// default — one per file, any name on import
export default class Button {}
import Btn from "./Button.js";
import * as math from "./math.js"; // namespace object
export { x } from "./other.js"; // re-export
const mod = await import("./heavy.js");// dynamic — returns a promise
A named export is imported by its exact name, in curly braces. A default export is the file's one main thing, and you can call it anything when importing. A dynamic import loads a file only when that line runs — handy for loading heavy code on demand.
ESM vs CommonJS
There are two module systems. ESM (ES Modules) is the modern standard, using import/export. CommonJS is Node's older system, using require.
- Static means imports are known before the code runs (they must be at the top level). Dynamic means
requirecan be called anywhere, at runtime. - Live bindings: if the exporting file later changes a value, importers see the new value. CommonJS gives you a copy taken at the moment you required it.
- Tree shaking: a build tool removes exports nobody imports, making the final bundle smaller. It needs static imports to work.
| ESM | CommonJS | |
|---|---|---|
| Syntax | import/export | require/module.exports |
| Loading | Static, asynchronous | Dynamic, synchronous |
| Bindings | Live — reflect later changes | A copy at require time |
| Tree shaking | Yes | No |
| Top-level await | Yes | No |
Modules are singletons — there's only ever one of each. A module runs once, the first time it's imported, and the result is cached (stored). Every other file that imports it gets that same shared copy.
Modules also always run in strict mode, and in the browser they are deferred by default — they wait until the HTML has finished parsing before running.
The module pattern (pre-ESM, still seen)
Before real modules existed, people used an IIFE plus a closure to get private variables and a public interface.
const Counter = (function () {
let count = 0; // private via closure
return { inc: () => ++count, get: () => count };
})();
25. Memory & Garbage Collection
Garbage collection is JavaScript automatically freeing memory you're no longer using. You never do this by hand.
It decides by reachability: can this value still be reached from code that's running? If yes, keep it. If nothing points to it any more, throw it away.
A memory leak is when you accidentally keep pointing at something you've finished with — so it can never be cleaned up.
Like clearing a table: plates nobody is still holding get taken away. A leak is holding onto a plate you forgot you had.
JavaScript frees memory using reachability. It starts from the roots — global variables, the current call stack, and closures. Anything it can reach by following links from those roots is kept. Everything else is collected.
Modern engines use mark-and-sweep: first mark everything reachable, then sweep away everything unmarked. It's generational: new objects are checked often (most die young), while long-lived objects are checked less often.
The four classic leaks
Each of these keeps a reference alive by accident, so the garbage collector can never free it. A detached DOM node is an element removed from the page that your code still holds on to.
// 1. Accidental globals
function f() { leaked = "oops"; } // no declaration → window.leaked
"use strict"; // makes this a ReferenceError
// 2. Forgotten timers
const id = setInterval(poll, 1000);
clearInterval(id); // ← without this it runs forever
// 3. Detached DOM nodes
const cache = [];
cache.push(document.getElementById("big"));
document.body.removeChild(el); // removed from the page, but cache
// still references it → never collected
// 4. Listeners on long-lived objects
window.addEventListener("resize", handler);
window.removeEventListener("resize", handler); // same reference required
Closures hold more than you think
function makeHandler() {
const hugeData = new Array(1e6).fill("x");
return () => console.log("clicked"); // still keeps hugeData alive in some engines
}
// Null out what you don't need, or don't capture it in the first place
Use WeakMap/WeakSet when you want an entry to disappear along with its key.
If you suspect a leak, open the Memory panel in browser DevTools. Take a heap snapshot (a picture of everything in memory), do the action a few times, take another, and compare. Anything that keeps growing is your suspect.
26. Functional Patterns
A pure function always gives the same answer for the same input and changes nothing outside itself. Easy to test, easy to trust.
Immutable means "never edited in place" — instead of changing an object, you make a new one with the change applied.
Currying means taking a function's arguments one at a time instead of all at once, so you can fill in some now and the rest later.
Composition means joining small functions into a pipeline, where each one's output becomes the next one's input.
Composition is an assembly line: wash → cut → pack. Each station does one small job and passes the result on.
Pure functions & immutability
// pure — same input, same output, no side effects
const add = (a, b) => a + b;
// immutable updates
const next = { ...state, count: state.count + 1 };
const added = [...items, item];
const removed = items.filter(i => i.id !== id);
const updated = items.map(i => i.id === id ? { ...i, done: true } : i);
Composition & currying
pipe runs functions left to right; compose runs them right to left. Same idea, opposite order.
const compose = (...fns) => (x) => fns.reduceRight((v, f) => f(v), x);
const pipe = (...fns) => (x) => fns.reduce((v, f) => f(v), x);
const slug = pipe(s => s.trim(), s => s.toLowerCase(), s => s.replace(/\s+/g, "-"));
slug(" Hello World "); // "hello-world"
// currying — one argument at a time
const curry = (fn) => (...args) =>
args.length >= fn.length ? fn(...args) : curry(fn.bind(null, ...args));
const add3 = curry((a, b, c) => a + b + c);
add3(1)(2)(3); // 6
add3(1, 2)(3); // 6
Partial application
Partial application means fixing some of a function's arguments now and getting back a smaller function that takes the rest.
const log = (level, msg) => console.log(`[${level}] ${msg}`);
const error = log.bind(null, "ERROR");
error("failed"); // [ERROR] failed
27. Modern JS Features
These are newer conveniences added over the last few years. None of them let you do anything that was impossible before — they just make common tasks shorter and less error-prone.
Worth learning because you'll meet them constantly in other people's code, even if you never reach for them yourself.
// Optional chaining & nullish (ES2020)
obj?.a?.b ?? "default";
// Logical assignment (ES2021)
a ||= b; a &&= b; a ??= b;
// Numeric separators & BigInt
const million = 1_000_000;
const big = 9007199254740993n;
// String helpers
"abc".at(-1); "abc".replaceAll("a", "b");
" x ".trimStart(); "5".padStart(3, "0"); // "005"
// Array helpers (ES2022–2023)
arr.at(-1);
arr.findLast(fn); arr.findLastIndex(fn);
arr.toSorted(fn); arr.toReversed(); arr.with(0, "new"); // non-mutating
Object.groupBy(items, i => i.type);
// Object & structured data
Object.hasOwn(o, "k");
structuredClone(obj);
Array.prototype.flat(Infinity);
// Top-level await (ES modules only)
const data = await fetch(url).then(r => r.json());
(The "ES2020" style labels are the year each feature was added to the language. ES = ECMAScript, the official name of the JavaScript standard.)
Labelled templates & tagged literals
Template literals are strings in backticks that can include ${expressions}. A tagged template puts a function name in front of the backticks. That function receives the text pieces and the values separately, so it can process them — for example, escaping user input to make HTML safe.
const name = "Ada";
`Hello ${name}, ${1 + 1} items`; // interpolation
String.raw`C:\new\path`; // no escape processing
function html(strings, ...values) { // tagged template
return strings.reduce((out, s, i) => out + s + (escape(values[i]) ?? ""), "");
}
28. The JavaScript Lifecycle
A lifecycle is the list of stages something goes through, from being created to being thrown away.
In JavaScript, lots of things have one: the whole script, each variable, each function call, the web page itself, and every promise. Knowing the stages in order ties together most of the other sections on this page — hoisting, the call stack, the event loop and garbage collection are all just stages of the same story.
Think of a restaurant order. The order arrives (download), the chef reads it (parse), plans the steps (compile), lays out the ingredients (creation phase), cooks (execution). Slow jobs like the oven are handed off (Web APIs) and picked up when the timer dings (event loop). At the end, the dirty plates are cleared away (garbage collection).
(a) The lifecycle of a script
Here is everything that happens from the moment the browser finds a <script> tag to the moment memory is cleaned up:
- Load / download — the browser fetches the
.jsfile over the network (or reads the inline<script>). A normal script pauses HTML parsing while it downloads and runs;deferandasynclet the page keep parsing. - Parse — the engine reads the text and checks the grammar. If the code is written wrongly, you get a
SyntaxErrorhere, and none of the file runs. The result is an AST (a tree-shaped map of the code). - Compile (JIT) — the AST is turned into bytecode, and later, hot functions are turned into fast machine code (section 1).
- Global execution context — creation phase — before any line runs, the engine creates the global context. It sets aside memory for every variable and function (this is hoisting):
vargetsundefined,let/constgo into the TDZ, function declarations are stored whole. It also sets upthisand the scope chain (sections 5–6). - Execution phase — now the code runs top to bottom, putting real values into those variables.
- The call stack runs synchronous code — every function call pushes a new context onto the stack; every return pops it off. Only the top one runs.
- Async work is handed off — timers,
fetch, file reads and event listeners are given to the host (Web APIs in the browser,libuv— Node's built-in helper library — in Node). JS doesn't wait; it carries on with the next line. - The event loop — when the stack is empty, it runs all waiting microtasks (promise callbacks), then one macrotask (a timer or event callback), then checks again. Each callback it runs goes back onto the call stack (step 6). This cycle continues for as long as the page is open (section 17).
- Garbage collection — in the background, the engine finds memory that nothing can reach any more and frees it (section 25).
| Stage | What happens | What can go wrong |
|---|---|---|
| Load | File is downloaded | Network error; a blocking script delays the page |
| Parse | Text → AST, grammar is checked | SyntaxError — nothing in the file runs |
| Compile | AST → bytecode → optimised machine code | Changing types forces deoptimisation (slower) |
| Creation phase | Memory set up, hoisting, this, scope chain | Later: undefined from var, TDZ errors from let/const |
| Execution phase | Code runs line by line on the call stack | TypeError, ReferenceError, stack overflow |
| Async hand-off | Host does the slow waiting | Errors thrown in callbacks escape try/catch |
| Event loop | All microtasks, then one macrotask, repeat | Long tasks freeze the page; endless microtasks block rendering |
| Garbage collection | Unreachable memory is freed | Memory leaks from references you forgot about |
(b) The lifecycle of a variable, and of a function call
A single variable goes through six stages:
- Created — during the creation phase, the engine notes the name and reserves space for it.
- Hoisted — the name is known from the top of its scope, before its line is reached.
- Initialised — it gets its first value. For
varthat'sundefinedstraight away; forlet/constit's only when the declaration line runs (until then: TDZ). - Used — read and (unless it's
const) reassigned while the code runs. - Out of scope — the block or function it lives in finishes, so no code can name it any more.
- Garbage collected — once nothing (including a closure) can reach its value, the memory is freed.
function demo() {
// created + hoisted: count exists here but is in the TDZ
let count = 0; // initialised
count++; // used
return count;
} // function ends → count is out of scope
demo(); // nothing references count now → garbage collected
A function call has its own shorter lifecycle:
- A new execution context is created and pushed onto the call stack.
- Its creation phase runs: parameters and local variables are set up (hoisting), and
thisis decided. - Its execution phase runs the body line by line.
- It returns, and its context is popped off the stack. Control goes back to the caller.
- Its local variables become garbage — unless a closure still references them (section 8).
(c) The browser page lifecycle
The web page itself also goes through stages, and the browser fires an event (a signal your code can listen for) at each one:
- HTML parsing — the browser reads the HTML top to bottom and builds the DOM (the tree of elements your JS can work with). Normal
<script>tags pause parsing while they run. DOMContentLoaded— the HTML is fully parsed and the DOM is ready (anddeferscripts have run). Images and stylesheets may still be loading. This is the earliest safe moment to find and change elements.load— everything has finished loading: images, stylesheets, iframes, fonts.visibilitychange— the user switched tabs, minimised the window, or came back. Checkdocument.visibilityState("visible"or"hidden"). Good for pausing videos or saving progress.pagehide/beforeunload— the user is leaving the page.beforeunloadcan show the "Leave site? Changes you made may not be saved" prompt.pagehideis the reliable place for last-moment cleanup. (The olderunloadevent is unreliable, especially on mobile — avoid it.)
document.addEventListener("DOMContentLoaded", () => {
console.log("DOM ready — safe to query elements");
});
window.addEventListener("load", () => {
console.log("Everything loaded, including images");
});
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "hidden") saveDraft(); // user left the tab
});
window.addEventListener("beforeunload", (e) => {
if (hasUnsavedChanges) e.preventDefault(); // asks "Leave site?"
});
window.addEventListener("pagehide", () => {
navigator.sendBeacon("/log", "page closed"); // survives the page closing
});
Moving into a house: the walls go up (HTML parsing), the rooms are usable (DOMContentLoaded), the furniture has all arrived (load), you step outside for a while (visibilitychange), and finally you move out (pagehide).
(d) The Promise lifecycle
A promise has the shortest lifecycle of all (see section 19 for the diagram):
- Pending — created, the work is still going on. No value yet.
- Fulfilled (success, via
resolve(value)) or rejected (failure, viareject(error)or a thrown error). - Settled — the word for "either fulfilled or rejected". It's the end of the line: a settled promise never changes again. Any later
resolve/rejectcall is ignored.
const p = new Promise((resolve) => {
// pending…
setTimeout(() => resolve("ok"), 1000); // → fulfilled after 1s
});
p.then(v => console.log(v)) // runs as a microtask once settled
.finally(() => console.log("settled"));
Tricky Outputs — predict before you read
Cover the comment on each line, guess the output, then check. Each one tests a rule from the sections above.
// 1
console.log(typeof null, typeof NaN, typeof []);
// "object" "number" "object"
// 2
console.log(0.1 + 0.2 === 0.3);
// false — IEEE 754 floats (computers store decimals in binary, so tiny rounding errors creep in).
// Use Math.abs(a-b) < Number.EPSILON
// 3
console.log([1,2,3] + [4,5]);
// "1,2,34,5" — both stringify, then concatenate
// 4
var x = 1;
{ var x = 2; } console.log(x); // 2 — var ignores blocks
let y = 1;
{ let y = 2; } console.log(y); // 1 — let is block scoped
// 5
console.log(a); var a = 5; // undefined (hoisted)
console.log(b); let b = 5; // ReferenceError (TDZ)
// 6
for (var i=0;i<3;i++) setTimeout(()=>console.log(i)); // 3 3 3
for (let i=0;i<3;i++) setTimeout(()=>console.log(i)); // 0 1 2
// 7
const obj = { a: 1, b: { c: 2 } };
const copy = { ...obj };
copy.b.c = 99; console.log(obj.b.c); // 99 — shallow copy shares nested refs
// 8
console.log([..."hello"]); // ["h","e","l","l","o"] — strings are iterable
// 9
const o = { n: 1, get double(){ return this.n * 2; } };
const { double } = o; console.log(double); // 2 — the getter ran once, at the moment of destructuring
// 10
console.log([1,2,3].map(parseInt));
// [1, NaN, NaN] — map passes (value, index); parseInt reads index as the number base (radix):
// parseInt(2,1) and parseInt(3,2) are invalid → NaN
// 11
setTimeout(() => console.log("t"), 0);
Promise.resolve().then(() => console.log("p"));
console.log("s"); // s, p, t
// 12
console.log(1 < 2 < 3, 3 > 2 > 1);
// true, false — (3>2) is true → true > 1 → 1 > 1 → false
// 13
const arr = [1,2,3]; arr.length = 0; console.log(arr); // [] — truncates
// 14
console.log("5" - 3, "5" + 3); // 2, "53"
// 15
(function(){ console.log(typeof arguments); })(); // "object", not "array"
Interview Q&A — quick answers
Fundamentals
- Is JS interpreted or compiled? JIT-compiled (compiled just in time, while it runs). V8 parses the code into an AST (a tree of its structure), runs it as bytecode with the Ignition interpreter, then optimises hot (frequently run) functions into machine code with TurboFan.
- == vs ===? Loose equality (
==) converts the types first, then compares. Strict equality (===) compares type and value with no converting. Always use===, except forx == null. - Primitive vs reference types? Primitives are copied by value (a real copy). Objects copy only the reference (the address), so two variables can point at one object.
- Shallow vs deep copy? Spread/
Object.assigncopy one level only — nested objects are still shared.structuredClonecopies every level. - Falsy values?
false, 0, -0, 0n, "", null, undefined, NaN— eight of them. Everything else is truthy. nullvsundefined?undefined= never given a value;null= deliberately set to "empty".
Scope & functions
- What is hoisting? Before code runs (the creation phase), declarations are registered.
varstarts asundefined;let/consthave no value yet and sit in the TDZ (using them throws); function declarations are hoisted completely, so you can call them early. - What is a closure? A function plus the variables from where it was defined (its lexical environment), kept alive even after the outer function returns. It powers private state, debounce, memoize and the module pattern.
- var vs let vs const? Function scope vs block scope;
undefinedvs TDZ when hoisted;constcan't be reassigned, but the contents of aconstobject can still change. - How does
thiswork? It depends on how the function is called: arrow → taken from the surrounding code;new→ the new object;call/bind→ the object you pass;obj.fn()→ obj; plain call → undefined (strict mode) or the global object. - call vs apply vs bind? Run now with arguments listed / run now with arguments in an array / return a new function with
thislocked in permanently. - Arrow vs regular function? Arrows have no own
this, noarguments, noprototype, and can't be used withnew. - Debounce vs throttle? Debounce waits until activity stops, then runs once; throttle runs at most once per time window.
- IIFE? An Immediately Invoked Function Expression — a function that runs as soon as it's defined, to create a private scope. The pre-module way to avoid global variables.
Objects
- Prototypal inheritance? Objects link to other objects. When a property is missing, JS follows the links up the chain until it finds it or reaches
null. Classes are just nicer syntax over this. __proto__vsprototype?__proto__(better:Object.getPrototypeOf) is the link on an instance, pointing to its prototype.prototypeis a property on a constructor function: the object that every instance it creates will link to.- What does
newdo? Four steps: creates an empty object, links its prototype, runs the constructor withthisset to that object, and returns it (unless the constructor returns a different object). - Map vs Object? Map accepts any type as a key, keeps insertion order, has
.size, and is faster for frequent adding/removing. - WeakMap use case? Storing extra data about objects/DOM nodes that should disappear automatically when the object is garbage collected — avoids memory leaks.
Async
- Explain the event loop. Wait until the call stack is empty → run all microtasks → redraw the screen if needed → run one macrotask → repeat.
- Microtask vs macrotask? Microtasks: promises,
await,queueMicrotask. Macrotasks:setTimeout,setInterval, events. Microtasks always go first. - Why isn't
setTimeout(fn, 0)immediate? It queues a macrotask, which has to wait for the stack to clear and every microtask to finish. - Promise states? Pending → fulfilled or rejected. It settles exactly once, and can't change after that.
- all vs allSettled vs race vs any? Fails as soon as one fails / never rejects, reports every result / first to finish either way / first to succeed.
- Does
awaitblock the thread? No — it pauses only that one function and schedules the rest of it as a microtask. Other code keeps running. - Callback hell & the fix? Deeply nested callbacks, with error handling repeated at each level and inversion of control (trusting other code to call you correctly). Fixed by promise chaining and async/await.
Advanced
- Generators? Functions that can pause and resume (
function*+yield). They produce values lazily, one at a time — good for infinite sequences and pagination. - Common memory leaks? Accidental globals, timers never cleared, references kept to removed DOM elements, and event listeners never removed.
- Currying? Turning
f(a,b,c)intof(a)(b)(c)— lets you fill in some arguments now and reuse the result. - ESM vs CommonJS? ESM: static imports, loaded asynchronously, live bindings, supports tree shaking. CommonJS: dynamic
require, loaded synchronously, copies values. - Why is
0.1 + 0.2 !== 0.3? Computers store numbers in binary floating point, which can't represent those decimals exactly, so tiny rounding errors appear. Compare withinNumber.EPSILONinstead.
Lifecycle
- Walk me through what happens when a script runs. The file is downloaded, parsed into an AST, compiled to bytecode (JIT). A global execution context is created (creation phase: hoisting, memory set up), then the code runs line by line on the call stack. Async work is handed to Web APIs/Node; when it finishes, the event loop runs microtasks, then macrotasks. Memory nothing can reach any more is freed by the garbage collector.
- DOMContentLoaded vs load?
DOMContentLoadedfires when the HTML is fully parsed and the DOM is built (deferred scripts have run).loadfires later, once images, stylesheets, iframes and other resources have also finished. - What's the lifecycle of a function call? A new execution context is created and pushed on the call stack → creation phase (hoisting,
this) → execution phase → return → popped off the stack. Its local variables become garbage unless a closure still references them. - Where should you save state before a user leaves the page? On
visibilitychange(whendocument.visibilityStatebecomes"hidden") orpagehide— notunload, which is unreliable, especially on mobile. Usenavigator.sendBeaconto send data safely.
Polyfills to Know
A polyfill is your own code that recreates a built-in feature — originally so older browsers that lacked it could still run modern code.
Interviewers often ask you to write one from scratch. It's a standard way to test whether you understand this, closures and the prototype chain.
// map
Array.prototype.myMap = function (cb, thisArg) {
const out = [];
for (let i = 0; i < this.length; i++)
if (i in this) out[i] = cb.call(thisArg, this[i], i, this);
return out;
};
// filter
Array.prototype.myFilter = function (cb, thisArg) {
const out = [];
for (let i = 0; i < this.length; i++)
if (i in this && cb.call(thisArg, this[i], i, this)) out.push(this[i]);
return out;
};
// reduce
Array.prototype.myReduce = function (cb, init) {
let acc = init, start = 0;
if (acc === undefined) { acc = this[0]; start = 1; }
for (let i = start; i < this.length; i++) acc = cb(acc, this[i], i, this);
return acc;
};
// bind
Function.prototype.myBind = function (ctx, ...bound) {
const fn = this;
return function (...args) {
return fn.apply(this instanceof fn ? this : ctx, [...bound, ...args]);
};
};
// call
Function.prototype.myCall = function (ctx, ...args) {
ctx = ctx ?? globalThis;
const key = Symbol();
ctx[key] = this;
const result = ctx[key](...args);
delete ctx[key];
return result;
};
// Promise.all
function myAll(promises) {
return new Promise((resolve, reject) => {
const out = []; let done = 0;
if (!promises.length) return resolve(out);
promises.forEach((p, i) => {
Promise.resolve(p).then(v => {
out[i] = v;
if (++done === promises.length) resolve(out);
}, reject);
});
});
}
// deep clone
function deepClone(v, seen = new WeakMap()) {
if (v === null || typeof v !== "object") return v;
if (seen.has(v)) return seen.get(v); // handles circular refs
const out = Array.isArray(v) ? [] : {};
seen.set(v, out);
for (const k of Object.keys(v)) out[k] = deepClone(v[k], seen);
return out;
}
// flatten
const flatten = (arr, depth = 1) =>
depth < 1 ? arr.slice()
: arr.reduce((a, v) =>
a.concat(Array.isArray(v) ? flatten(v, depth - 1) : v), []);