Top 100 JavaScript Interview Questions
Top 100 JavaScript interview questions covering core concepts, ES6+, async programming, closures, prototypes, DOM, and modern JS patterns.
JavaScript is a lightweight, interpreted, high-level programming language with first-class functions. It is the only programming language natively supported by all web browsers, making it the backbone of interactive web experiences. Originally created by Brendan Eich at Netscape in 1995 in just 10 days, JavaScript has grown from a simple scripting language into a versatile, full-stack language used for frontend (React, Vue, Angular), backend (Node.js), mobile (React Native), and desktop (Electron) development. It follows the ECMAScript specification (the latest being ES2024). JavaScript is single-threaded, dynamically typed, and prototype-based, supporting event-driven, functional, and object-oriented programming paradigms.
var is function-scoped (or global if declared outside a function), can be re-declared and reassigned, and is hoisted to the top of its scope with an initial value of undefined. Its function-scope behavior leads to bugs in loops and conditionals. let (ES6) is block-scoped (limited to the {} block it is declared in), cannot be re-declared in the same scope, can be reassigned, and is hoisted but NOT initialized (accessing it before declaration throws a ReferenceError — the "temporal dead zone"). const (ES6) is also block-scoped, cannot be re-declared or reassigned, but the value it holds can be mutated (a const array can have items pushed to it; a const object can have properties changed). Best practice: always use const by default; use let when reassignment is needed; avoid var.
JavaScript has 8 data types. Seven are primitive (immutable, stored by value): Number (64-bit float, includes integers and floats — NaN and Infinity are also Numbers), BigInt (arbitrary precision integers — 9007199254740991n), String (immutable sequence of characters), Boolean (true/false), undefined (variable declared but not assigned), null (intentional absence of a value), and Symbol (unique, immutable value used as object property keys). The eighth is Object (mutable, stored by reference) — which includes plain objects, arrays, functions, dates, maps, sets, etc. Use typeof to check types: note that typeof null === "object" is a historical bug in JavaScript.
== (loose equality) compares values after performing type coercion — JavaScript converts both sides to a common type before comparing. This leads to surprising results: "5" == 5 is true, 0 == false is true, null == undefined is true, "" == 0 is true. === (strict equality) compares both value AND type without any coercion: "5" === 5 is false. Always use === in production code to avoid coercion bugs. The only common exception where == is acceptable is checking for both null and undefined simultaneously: value == null returns true for both null and undefined. The non-equality counterparts are != and !==.
Hoisting is JavaScript's behavior of moving declarations to the top of their scope during the compilation phase, before code execution. For var declarations: the variable is hoisted and initialized to undefined — you can access it before the declaration line without an error, but its value is undefined. For function declarations: the entire function (name + body) is hoisted — you can call it before it appears in the code. For let and const: they are hoisted but NOT initialized — accessing them before the declaration throws a ReferenceError (they exist in the "temporal dead zone"). For function expressions and arrow functions (stored in variables): only the variable declaration is hoisted, not the assignment — calling them before the assignment throws a TypeError.
A closure is a function that remembers and can access variables from its outer (enclosing) scope even after the outer function has finished executing. This happens because inner functions maintain a reference to the variables of their enclosing scope (the lexical environment). Example: function counter() { let count = 0; return function() { return ++count; }; } const inc = counter(); inc(); // 1, inc(); // 2. The returned function is a closure — it "closes over" the count variable. Closures are fundamental to JavaScript and enable: data encapsulation/privacy (the only way to have private variables), the module pattern, memoization, currying, partial application, and factory functions. Every function in JavaScript is a closure over at least the global scope.
Both represent the absence of a value but differ in intent and origin. undefined means a variable has been declared but not yet assigned a value — it is the default value JavaScript gives to uninitialized variables, missing function parameters, and functions that do not explicitly return. It signals "value not yet set." null is an intentional assignment meaning "no value" — a developer explicitly sets it to indicate the absence of a value. Use it to signal "this value exists but is intentionally empty." Typeof difference: typeof undefined === "undefined" but typeof null === "object" (a historical bug). In loose equality: null == undefined is true, but null === undefined is false. Best practice: return null from functions to indicate "intentionally no result"; let variables default to undefined.
NaN (Not a Number) is a special value of the Number type that represents the result of an operation that should return a number but cannot produce a meaningful numeric result. Examples: parseInt("hello"), 0 / 0, Math.sqrt(-1), undefined + 1. The peculiarity of NaN is that it is the only value in JavaScript that is not equal to itself: NaN === NaN is false. To check if a value is NaN, use Number.isNaN(value) (strict — only returns true for actual NaN values, not for non-numeric strings) or isNaN(value) (coerces to number first, then checks — so isNaN("hello") is true). typeof NaN === "number" — NaN is ironically of type number.
In JavaScript, every value has an inherent boolean value used in boolean contexts (if statements, logical operators). Falsy values are the six values that evaluate to false: false, 0 (and -0 and 0n), "" (empty string), null, undefined, and NaN. Everything else is truthy — including: true, any non-zero number, any non-empty string (even "false" or "0"), objects (even empty ones {}), arrays (even empty ones []), and functions. This enables concise patterns: if (user) { }, const name = user.name || "Guest". The nullish coalescing operator (??) only treats null and undefined as falsy (not 0 or ""), which is often more appropriate for default values.
A function declaration defines a named function using the function keyword as a statement: function greet(name) { return "Hello, " + name; }. Function declarations are fully hoisted — they can be called before they appear in the code. A function expression assigns a function (named or anonymous) to a variable: const greet = function(name) { return "Hello, " + name; };. Function expressions are NOT hoisted (only the variable declaration is) — calling them before the assignment throws a TypeError. Function expressions can be anonymous (no function name) or named (the name is only accessible inside the function). Arrow functions are always function expressions. Named function expressions are useful for recursion and better stack traces. Prefer function declarations for main named functions and function expressions/arrows for callbacks.
Arrow functions (ES6) provide a shorter syntax for writing functions: (params) => expression or (params) => { statements }. Single parameter: x => x * 2. The key difference from regular functions is how they handle this: arrow functions do NOT have their own this — they inherit this lexically from the enclosing scope where they are defined. This makes them ideal for callbacks inside methods (where you want to preserve the outer this). Arrow functions also do not have arguments object, cannot be used as constructors (no new), and cannot be used as generators. They should NOT be used for: object methods (if you need this to refer to the object), event handlers that need dynamic this, or prototype methods. Implicit return: single-expression arrows return without the return keyword.
The this keyword refers to the object that is executing the current function — its value depends on how the function is called, not where it is defined. In a regular function call: this is undefined in strict mode or the global object (window) in sloppy mode. In a method call: this is the object before the dot. With new: this is the newly created object. In an event handler: this is the DOM element. With call(), apply(), bind(): this is explicitly set. Arrow functions inherit this from their lexical enclosing scope and cannot be rebound. bind(obj) creates a new function with this permanently bound to obj. Understanding this is one of the most common JavaScript interview topics.
Template literals (ES6) are string literals enclosed in backticks (`) that support multi-line strings and embedded expressions. String interpolation: embed any expression with \${expression} — `Hello, \${name}! You are \${age + 1} years old.`. Multi-line: template literals preserve newlines without needing \n. Tagged templates: a function can be placed before the template literal to process it — sql`SELECT * FROM users WHERE id = \${userId}`. The tag function receives the string parts as an array and the interpolated values separately, enabling safe SQL queries, styled components, or custom DSLs. Template literals are far more readable than string concatenation with + and are now the standard for all string building in modern JavaScript.
Destructuring (ES6) unpacks values from arrays or properties from objects into distinct variables. Array destructuring: const [first, second, ...rest] = [1, 2, 3, 4] — first=1, second=2, rest=[3,4]. Skip elements: const [,, third] = arr. Default values: const [a = 10] = []. Object destructuring: const { name, age, city = "Unknown" } = user. Rename: const { name: userName } = user. Nested: const { address: { street } } = user. In function parameters: function greet({ name, age }) { }. Destructuring makes code cleaner, reduces repetition, and is widely used with React hooks, API responses, and module imports. Swap variables: [a, b] = [b, a].
The spread operator (..., ES6) expands an iterable (array, string, or object with ES2018) into individual elements. Arrays: copy — const copy = [...arr]; merge — const merged = [...arr1, ...arr2]; pass array as function arguments — Math.max(...numbers). Objects: copy — const copy = {...obj}; merge/override — const updated = {...defaults, ...overrides}. String to array of characters: [..."hello"] → ["h","e","l","l","o"]. The spread operator creates shallow copies — nested objects are still referenced, not deep-copied. Do not confuse with the rest parameter (function fn(...args)) which collects remaining arguments into an array. Spread is used in contexts where multiple items are expected; rest is used in function parameter lists to capture remaining arguments.
A Promise is an object representing the eventual completion or failure of an asynchronous operation. A Promise is in one of three states: pending (initial, neither fulfilled nor rejected), fulfilled (operation completed successfully, has a value), or rejected (operation failed, has a reason). Create: new Promise((resolve, reject) => { /* async work */ resolve(value); /* or */ reject(error); }). Consume with chaining: promise.then(value => { }).catch(error => { }).finally(() => { }). then() returns a new Promise, enabling chains. Promise.all([p1, p2, p3]) waits for all and rejects if any fails. Promise.allSettled() waits for all regardless of rejection. Promise.race() resolves/rejects with the first settled. Promise.any() resolves with the first fulfilled. Promises solve "callback hell" and are the foundation of async/await.
async/await (ES8) is syntactic sugar over Promises that allows writing asynchronous code in a synchronous-looking style. Mark a function with async — it automatically returns a Promise. Use await inside an async function to pause execution until the awaited Promise settles, then resume with the resolved value. Example: async function fetchUser(id) { try { const response = await fetch(`/users/\${id}`); const data = await response.json(); return data; } catch(err) { console.error(err); } }. Error handling uses try/catch instead of .catch(). Run Promises concurrently: const [user, posts] = await Promise.all([fetchUser(), fetchPosts()]). Await can only be used inside async functions (top-level await is supported in ES modules). async/await makes async code significantly more readable and easier to debug.
JavaScript is single-threaded — it can only execute one thing at a time. The event loop is the mechanism that enables non-blocking asynchronous behavior. Key components: the Call Stack (where synchronous code executes — LIFO), the Web APIs (browser provides setTimeout, fetch, DOM events — async operations happen here outside the call stack), the Callback Queue/Task Queue (callbacks from Web APIs wait here), and the Microtask Queue (Promise callbacks and queueMicrotask — higher priority than task queue). The event loop continuously checks: if the call stack is empty, it first processes ALL microtasks, then takes ONE task from the callback queue, processes it, then repeats. This explains: console.log("1"); setTimeout(()=>console.log("2"), 0); Promise.resolve().then(()=>console.log("3")); console.log("4") outputs: 1, 4, 3, 2 — synchronous first, then microtasks (Promise), then macrotasks (setTimeout).
The DOM (Document Object Model) is a programming interface for HTML and XML documents. It represents the document as a tree of objects (nodes) where each element, attribute, and text is a node. JavaScript can access and manipulate this tree to dynamically change content, structure, and styles. Common operations: document.getElementById("id"), document.querySelector(".class"), document.querySelectorAll("div") (returns NodeList). Modify content: element.textContent = "text", element.innerHTML = "<b>bold</b>" (careful — XSS risk). Attributes: element.setAttribute("href", "url"), element.getAttribute(). CSS: element.style.color = "red", element.classList.add("active"), element.classList.toggle(). Create/insert elements: document.createElement(), parentEl.appendChild(child), element.insertAdjacentHTML(). Remove: element.remove().
JavaScript events are actions that occur in the browser — user interactions or browser-triggered occurrences — that JavaScript can respond to. Add event listeners: element.addEventListener("click", handler). Remove: element.removeEventListener("click", handler) (same reference required). The event handler receives an Event object with properties: event.target (element that triggered the event), event.currentTarget (element the listener is attached to), event.type, event.preventDefault() (prevent default browser action, e.g., form submit or link navigation), event.stopPropagation() (stop bubbling). Common events: click, input, change, submit, keydown, mouseover, load, DOMContentLoaded, scroll, resize. Use { once: true } option to auto-remove after first trigger.
When an event occurs on a DOM element, it does not stop there — it travels through the DOM tree in three phases. Capture phase: the event travels from window down through ancestors to the target element. Target phase: the event reaches the target element. Bubble phase: the event bubbles back up from the target through ancestors to window. By default, addEventListener registers listeners in the bubble phase. Pass true as the third argument to register in the capture phase. Event delegation leverages bubbling — instead of adding a listener to every child element, add one listener to the parent and use event.target to identify which child was clicked. This is memory-efficient for dynamic lists: ul.addEventListener("click", e => { if (e.target.tagName === "LI") handleClick(e.target); }). stopPropagation() stops the event from traveling further.
Both are Web Storage APIs for storing key-value pairs in the browser as strings, but they differ in persistence. localStorage persists data with no expiration — it survives browser restarts and lasts until explicitly cleared by JavaScript or the user. sessionStorage persists data only for the current browser tab/session — data is cleared when the tab is closed. Both have the same API: localStorage.setItem("key", "value"), localStorage.getItem("key"), localStorage.removeItem("key"), localStorage.clear(), localStorage.length. Only strings are stored — serialize objects with JSON.stringify() and parse with JSON.parse(). Storage limit is typically 5-10 MB. Both are synchronous (can block the main thread for large data) and accessible only to same-origin pages. Never store sensitive data (passwords, tokens) in Web Storage — it is accessible to JavaScript and vulnerable to XSS attacks. Use secure, HttpOnly cookies for auth tokens.
JSON (JavaScript Object Notation) is a lightweight data interchange format that is easy for humans to read and write and for machines to parse. Despite its name, JSON is language-independent and used across all programming languages. JSON supports: objects ({}), arrays ([]), strings (double quotes only), numbers, booleans (true/false), and null. Serialize (JS object → JSON string): JSON.stringify(obj). Pretty print: JSON.stringify(obj, null, 2). Parse (JSON string → JS object): JSON.parse(jsonString). Both can throw errors — wrap in try/catch. JSON.stringify ignores: functions, undefined, Symbol-keyed properties. Replace values during serialization with the second argument (replacer function/array). Common use: API communication, localStorage, configuration files.
The map() method creates a new array by calling a callback function on every element of the original array and collecting the return values. The original array is not modified. Syntax: const doubled = [1, 2, 3].map(x => x * 2) → [2, 4, 6]. The callback receives three arguments: the current element, its index, and the full array. Use map when you want to transform every element: users.map(user => user.name), data.map(item => ({...item, active: true})). The returned array always has the same length as the original. For side effects only (no transformation), use forEach(). Map is chainable: arr.filter(...).map(...).reduce(...). Since map returns a new array, it is a pure functional operation — great for immutable data patterns in React state management.
The filter() method creates a new array containing only the elements for which the callback returns a truthy value. The original array is not modified and the result may be shorter than the original. Syntax: const adults = users.filter(user => user.age >= 18). The callback receives element, index, and array. Chain with map: users.filter(u => u.active).map(u => u.name). Remove falsy values: arr.filter(Boolean) — passes Boolean as a function, removing 0, "", null, undefined, NaN. To remove a specific item by value: arr.filter(item => item !== toRemove). Unlike Array.find() which returns the first matching element (or undefined), filter returns all matches as an array. For checking if any/all elements match a condition, use some() and every() instead.
The reduce() method reduces an array to a single value by executing a callback for each element, accumulating the result. Syntax: array.reduce((accumulator, currentValue, index, array) => newAccumulator, initialValue). Sum: [1,2,3,4].reduce((sum, n) => sum + n, 0) → 10. Count occurrences: words.reduce((counts, word) => ({...counts, [word]: (counts[word] || 0) + 1}), {}). Flatten: arr.reduce((flat, sub) => flat.concat(sub), []). Always provide the initial value as the second argument — without it, the first element is used as the initial accumulator and the callback starts from index 1, which causes bugs on empty arrays. Reduce is the most powerful array method — map and filter can both be implemented using reduce, though using the specialized methods is clearer.
forEach() iterates over each element and executes a callback for its side effects — it always returns undefined and cannot be chained. Use it when you want to do something with each element (log it, send a request, update external state) without caring about the return value. map() iterates over each element and collects the return value of the callback into a new array — use it when you want to transform data. The new array is always the same length as the original. Key difference: map returns a new transformed array; forEach returns nothing. You should never use map purely for side effects (it creates an array you then discard — wasteful). You should never use forEach when you need the transformed values. Neither method modifies the original array. Both skip empty (sparse) array slots.
The typeof operator returns a string indicating the type of the operand. Results: typeof "hello" → "string", typeof 42 → "number", typeof true → "boolean", typeof undefined → "undefined", typeof Symbol() → "symbol", typeof 42n → "bigint", typeof function(){} → "function", typeof {} → "object", typeof [] → "object", typeof null → "object" (historical bug). To check for arrays, use Array.isArray(value). To check for null: value === null. To check for an object (excluding null): typeof value === "object" && value !== null. instanceof checks the prototype chain: [] instanceof Array is true. Object.prototype.toString.call(value) gives the most accurate type string.
JavaScript arrays are ordered, zero-indexed, dynamic collections. Essential methods: Mutating: push()/pop() (add/remove from end), unshift()/shift() (add/remove from front), splice(start, deleteCount, ...items) (add/remove anywhere), sort(compareFn), reverse(), fill(). Non-mutating (return new array): slice(start, end), concat(), flat(depth), flatMap(), map(), filter(). Search: indexOf(), lastIndexOf(), find() (first match), findIndex(), includes(). Test: some() (any match?), every() (all match?). Iterate: forEach(), entries(), keys(), values(). Reduce: reduce(), reduceRight(). Create: Array.from(), Array.of(), Array(n).fill(). Join to string: join(separator).
slice(start, end) returns a shallow copy of a portion of the array from start up to (but not including) end. It does not modify the original array. Negative indices count from the end. arr.slice(1, 3) returns elements at index 1 and 2. arr.slice(-2) returns the last 2 elements. arr.slice() (no args) creates a shallow copy of the full array. splice(start, deleteCount, ...items) modifies the original array by removing, replacing, or inserting elements in place. It returns an array of removed elements. arr.splice(1, 2) removes 2 elements starting at index 1. arr.splice(1, 0, "new") inserts "new" at index 1 without removing anything. arr.splice(1, 1, "replacement") replaces 1 element. Memory trick: slice is nice (non-destructive); splice delices (destructive).
A JavaScript object is a collection of key-value pairs (properties) where keys are strings (or Symbols) and values can be any type. Create: object literal const user = { name: "Alice", age: 25, greet() { return "Hi"; } }. Access properties: dot notation user.name or bracket notation user["name"] (bracket notation allows dynamic keys). Add/update: user.email = "alice@example.com". Delete: delete user.email. Check property: "name" in user or user.hasOwnProperty("name"). Iterate: Object.keys(obj) (own enumerable keys), Object.values(obj), Object.entries(obj) (key-value pairs), for...in loop (includes inherited properties — use hasOwnProperty). Merge/clone: Object.assign({}, obj1, obj2) or spread {...obj1, ...obj2}. Freeze: Object.freeze(obj) prevents modification.
The optional chaining operator (?., ES2020) allows accessing deeply nested properties without throwing an error if an intermediate value is null or undefined — it short-circuits and returns undefined. Before optional chaining: const city = user && user.address && user.address.city. With optional chaining: const city = user?.address?.city. It works for: property access (obj?.prop), bracket access (obj?.[key]), method calls (obj?.method()), and function calls (fn?.()). Combine with nullish coalescing for defaults: const city = user?.address?.city ?? "Unknown". Optional chaining is particularly useful when working with API responses where some fields may be absent, and when accessing deeply nested configuration or DOM properties. It avoids verbose null checks and makes code more readable.
The nullish coalescing operator (??, ES2020) returns its right-hand side operand when the left-hand side is null or undefined — otherwise returns the left. const value = input ?? "default". This is different from the logical OR || operator which returns the right side for ANY falsy value (including 0, "", false). Example: const count = userCount ?? 0 — if userCount is 0, it stays 0 (0 is a valid value, not null/undefined). With ||, 0 || 0 would incorrectly return 0... wait, 0 || "default" would return "default" which is wrong if 0 is valid. The nullish assignment operator (??=): a ??= b assigns b to a only if a is null/undefined. Combine with optional chaining: user?.preferences?.theme ?? "dark".
for...in iterates over the enumerable property keys of an object (including inherited ones from the prototype chain). It is designed for objects, not arrays — though it works on arrays, it can iterate inherited properties and the order is not guaranteed. Best used for plain objects: for (const key in obj) { if (obj.hasOwnProperty(key)) { console.log(key, obj[key]); } }. for...of (ES6) iterates over the values of any iterable (arrays, strings, Maps, Sets, generators, NodeLists). It respects the iteration protocol (Symbol.iterator). Use it for arrays: for (const item of array) { }. It does NOT work on plain objects (they are not iterable). For arrays, for...of is cleaner than for...in. For both key and value: for (const [key, value] of Object.entries(obj)) or for (const [i, val] of array.entries()).
Default parameters (ES6) allow function parameters to have default values when no argument is provided or when undefined is passed. function greet(name = "World") { return "Hello, " + name; }. Passing null does NOT trigger the default — only undefined or omitting the argument does. Default values are evaluated at call time, not at definition time — allowing dynamic defaults: function createUser(id, timestamp = Date.now()) { }. Default values can reference previous parameters: function fn(a, b = a * 2) { }. Default values work with destructuring: function config({ timeout = 3000, retry = true } = {}) { } — the = {} makes the whole object optional. Default parameters completely replace the old pattern of name = name || "World", which had the falsy value problem.
Synchronous code executes line by line — each line waits for the previous to complete before running. The call stack is blocked during this time. For quick operations this is fine, but for slow operations (network requests, file I/O, timers) it would freeze the browser. Asynchronous code allows long-running operations to be initiated and then set aside — JavaScript continues executing other code and comes back to handle the result via a callback, Promise, or async/await when the operation completes. The browser's Web APIs (fetch, setTimeout, DOM events) handle the actual waiting outside the main thread. The event loop then delivers the results back to JavaScript when ready. Example: fetch(url) immediately returns a Promise (asynchronous) — JavaScript does not block waiting for the network. When the response arrives, the .then() callback is queued and executed. All I/O in JavaScript is asynchronous by design to prevent blocking the single thread.
JavaScript modules (ES6) allow splitting code into reusable files. Named exports: export multiple items by name — export const PI = 3.14; export function add(a, b) { return a + b; }. Import: import { PI, add } from "./math.js". Rename: import { add as sum } from "./math.js". Import all: import * as math from "./math.js". Default export: one default per module — export default function() { } or export default class { }. Import: import myFunction from "./utils.js" (any name). Re-export: export { something } from "./module.js". Modules are: always in strict mode, executed once (cached after first import), have their own scope (no global scope pollution), and use static analysis (imports are resolved at compile time, not runtime). Dynamic import: const module = await import("./module.js") — lazy loading.
Every JavaScript object has an internal link to another object called its prototype. When you access a property on an object and it is not found directly on the object, JavaScript looks up the prototype chain — checking the prototype, then the prototype's prototype, and so on until it reaches null. This is JavaScript's inheritance mechanism. Object.getPrototypeOf(obj) returns the prototype. For objects created with object literals, the prototype is Object.prototype. For arrays, the prototype chain is: array → Array.prototype → Object.prototype → null. When you define a method on Array.prototype, all arrays inherit it. Constructor functions: function Person(name) { this.name = name; } Person.prototype.greet = function() { return "Hi, " + this.name; }. ES6 classes are syntactic sugar over prototype-based inheritance.
All three methods control what this refers to inside a function. call(thisArg, arg1, arg2, ...): calls the function immediately with the specified this and arguments passed individually. greet.call(user, "Hello", "!"). apply(thisArg, [arg1, arg2, ...]): calls the function immediately with arguments passed as an array. greet.apply(user, ["Hello", "!"]). Useful when arguments are already in an array: Math.max.apply(null, numbers) (now use spread: Math.max(...numbers)). bind(thisArg, arg1, ...): does NOT call immediately — returns a NEW function with this permanently bound to thisArg and optional arguments pre-filled (partial application). const boundGreet = greet.bind(user). Common use: bind event handlers to preserve this: button.addEventListener("click", this.handler.bind(this)). Memory trick: call counts arguments, apply takes an array, bind binds for later.
A callback function is a function passed as an argument to another function, to be executed at a later time — typically when an asynchronous operation completes or at a specific point in the calling function's execution. setTimeout(() => console.log("done"), 1000) — the arrow function is a callback. arr.forEach(item => console.log(item)) — forEach calls the callback for each element. Callbacks enabled asynchronous JavaScript before Promises. Callback hell (pyramid of doom) occurs when multiple async operations are nested: getData(function(a) { getMoreData(a, function(b) { getEvenMore(b, function(c) { ... }) }) }) — deeply nested, hard to read and error-handle. Promises and async/await solve this. Higher-order functions (map, filter, reduce, forEach, sort) use callbacks as a functional programming pattern — these synchronous callbacks are perfectly clean and preferred.
Array.from() creates a new array from an array-like or iterable object. Array-like objects have a length property and indexed elements but are not true arrays (NodeLists, HTMLCollections, arguments object, strings). Array.from(document.querySelectorAll("p")) converts a NodeList to an array (giving access to map, filter, etc.). Array.from("hello") → ["h","e","l","l","o"]. Array.from({length: 5}, (_, i) => i) → [0,1,2,3,4] — the second argument is a map function applied to each element. Create array with specific values: Array.from({length: 3}, () => []) creates three separate empty arrays (not the same reference issue as new Array(3).fill([])). Array.from(new Set([1,2,2,3])) → [1,2,3] (deduplicate). Also useful for converting Maps: Array.from(map.entries()).
indexOf(value, fromIndex) searches for the first occurrence of a value and returns its index, or -1 if not found. Works on both strings and arrays. For arrays: [1,2,3].indexOf(2) → 1. For strings: "hello".indexOf("l") → 2. Uses strict equality (===). includes(value, fromIndex) returns a boolean — true if the value is found. Cleaner for checking existence: if (arr.includes("admin")) reads better than if (arr.indexOf("admin") !== -1). Key difference: includes() correctly handles NaN — [NaN].includes(NaN) is true, but [NaN].indexOf(NaN) is -1 (because NaN !== NaN). Use indexOf when you need the position; use includes when you just need to know if the value exists. Both perform a linear search O(n) — use a Set for O(1) lookups.
The prototype chain is the mechanism JavaScript uses for property lookup and inheritance. When accessing a property on an object, JavaScript first checks the object's own properties. If not found, it checks [[Prototype]] (the prototype), then the prototype's prototype, and so on until reaching null. Example: const arr = [1, 2, 3] — arr.push is not on the array itself but found on Array.prototype. arr.toString is found further up on Object.prototype. All objects ultimately trace back to Object.prototype which itself has null as its prototype. Object.create(proto) creates an object with a specific prototype. Object.getPrototypeOf(obj) returns the prototype. The prototype chain enables: method sharing (all array instances share Array.prototype.push), inheritance hierarchies, and mixins. Extending built-in prototypes is considered bad practice (monkey-patching).
ES6 classes are syntactic sugar over JavaScript's prototype-based inheritance — they provide a cleaner syntax but the underlying mechanism is still prototypes. Define: class Animal { constructor(name) { this.name = name; } speak() { return `\${this.name} makes a sound`; } static create(name) { return new Animal(name); } }. Inheritance: class Dog extends Animal { speak() { return `\${this.name} barks`; } }. super(name) calls the parent constructor; super.method() calls the parent method. Class fields (ES2022): class Counter { count = 0; #privateField = "secret"; }. Private fields (#) are truly private — inaccessible outside the class. Getters/setters: get fullName() { }; set fullName(value) { }. Classes are NOT hoisted like function declarations. Class expressions: const Animal = class { }.
Both merge objects but have nuances. Object.assign(target, source1, source2) copies own enumerable properties from sources to target, modifying and returning target. Mutates the first argument. Use {} as first arg to avoid mutation: const merged = Object.assign({}, obj1, obj2). Spread operator {...obj1, ...obj2} creates a new object without mutating anything — cleaner and more readable. Both perform shallow copies — nested objects are still references. Both copy only own enumerable properties — Symbol-keyed properties are copied by spread but not by Object.assign with certain patterns. Key difference: Object.assign triggers setters on the target; spread does not. Object.assign also works for patching existing objects directly: Object.assign(this.state, updates). For deep cloning, use structuredClone(obj) (modern), JSON.parse(JSON.stringify(obj)) (serializable data only), or a library like Lodash _.cloneDeep().
Symbol (ES6) is a primitive type that creates unique, immutable identifiers — no two symbols are ever equal, even with the same description. Create: const id = Symbol("description"). The description is for debugging only. Use as object property keys to avoid name collisions with other code: const KEY = Symbol("key"); obj[KEY] = value. Symbols are not enumerated by for...in, Object.keys(), or JSON.stringify() — making them effectively private. Object.getOwnPropertySymbols(obj) retrieves symbol keys. Well-known Symbols customize built-in behavior: Symbol.iterator (makes objects iterable), Symbol.toPrimitive (custom type conversion), Symbol.hasInstance (custom instanceof), Symbol.toStringTag (custom toString tag). Global Symbol registry: Symbol.for("key") creates/retrieves a shared symbol — same key = same symbol globally.
Generator functions (ES6) can pause execution and resume later, yielding multiple values over time. Declared with function*. When called, they return a generator object (iterator) — the function body does not execute yet. Each call to .next() runs until the next yield, returning {value, done}. function* count() { yield 1; yield 2; yield 3; }. const gen = count(); gen.next() // {value:1, done:false}. Infinite sequences: function* ids() { let n = 0; while(true) yield n++; }. Pass values into the generator: const val = yield expression — the value passed to the next .next(val) call becomes the result of yield. Return early: gen.return(value). yield* delegates to another iterable. Generators implement the iterable protocol — use with for...of. Used for: lazy evaluation, infinite sequences, async control flow (async generators), and state machines.
WeakMap is like a Map but with two key differences: keys must be objects (not primitives), and the references to keys are "weak" — if the key object has no other references, it can be garbage collected, and the WeakMap entry is removed automatically. WeakMaps are not iterable (no .size, no .keys()). API: set(obj, value), get(obj), has(obj), delete(obj). Use case: storing private data associated with objects without preventing GC: const privateData = new WeakMap(); class Foo { constructor() { privateData.set(this, {secret: "value"}); } }. WeakSet is like a Set but holds weak references to objects. Membership test only — no iteration, no size. Use case: tracking which objects have been "visited" without keeping them alive. Both avoid memory leaks when keys/values are no longer needed, making them ideal for caching and metadata storage on DOM nodes or other objects with uncertain lifetimes.
Memoization is an optimization technique where function results are cached by their input, so repeated calls with the same arguments return the cached result instead of recomputing. Useful for pure functions with expensive computations. Simple implementation: function memoize(fn) { const cache = new Map(); return function(...args) { const key = JSON.stringify(args); if (cache.has(key)) return cache.get(key); const result = fn.apply(this, args); cache.set(key, result); return result; }; }. Classic example — memoized fibonacci: const fib = memoize(n => n <= 1 ? n : fib(n-1) + fib(n-2)). Reduces time complexity from O(2^n) to O(n). Trade-off: faster execution at the cost of memory. Use when: the function is pure (same inputs always give same output), calls are expensive (computation, API), and the same inputs are likely to repeat. Lodash provides _.memoize(). In React, useMemo and useCallback hooks memoize values and functions.
Currying transforms a function with multiple arguments into a sequence of functions, each taking one argument. Instead of add(1, 2, 3), a curried version: add(1)(2)(3). Implementation: const curry = fn => function curried(...args) { if (args.length >= fn.length) return fn(...args); return (...more) => curried(...args, ...more); };. Benefits: partial application — pre-fill some arguments to create specialized functions: const add5 = curriedAdd(5); add5(3) // 8. Function composition — combine curried functions in pipelines. Point-free programming — compose operations without mentioning data. Example: const multiply = a => b => a * b; const double = multiply(2); const triple = multiply(3); [1,2,3].map(double) // [2,4,6]. Currying enables functional programming patterns and creates highly reusable, composable functions. Lodash's _.curry() supports mixed calling styles.
Event delegation is a pattern where instead of adding event listeners to each child element, you add a single listener to a parent element and use event.target to determine which child was actually clicked. This leverages event bubbling. Example: document.getElementById("list").addEventListener("click", function(event) { if (event.target.matches("li.item")) { handleItemClick(event.target); } }). Benefits: memory efficient (one listener instead of N), works for dynamically added elements (elements added after the listener was attached are also handled — no need to re-attach listeners), and simpler code. Use event.target.closest("selector") to find the nearest matching ancestor of the clicked element. Limitations: not all events bubble (blur, focus, scroll do not bubble — use capture phase or focusin/focusout instead). Event delegation is a best practice for any list, table, or dynamically generated content.
Both store key-value pairs, but they differ significantly. Object keys must be strings or Symbols — other types are converted to strings. Map keys can be any type (objects, functions, primitives — even NaN). Object has a prototype (inherited properties can pollute your data — use Object.create(null) for a clean dictionary). Map has no prototype keys — safer for dynamic key storage. Map maintains insertion order (so does modern Object, but it is not guaranteed by spec for all cases). Map has a built-in .size property; Object requires Object.keys(obj).length. Map is iterable (for...of, .forEach(), .entries(), .keys(), .values()). Map is more performant for frequent additions/removals. Object is better for static data with known keys and JSON serialization (JSON.stringify does not serialize Maps). Use Map when: keys are non-strings, insertion order matters, size checking is needed, or key-value pairs are frequently added/removed.
A Set is a collection of unique values of any type. Duplicate values are automatically removed. Create: const set = new Set([1, 2, 2, 3]) → stores {1, 2, 3}. Methods: add(value), has(value) (O(1) lookup), delete(value), clear(). Properties: size. Sets are iterable in insertion order — for...of, forEach(), spread. Convert array to unique array: const unique = [...new Set(arr)] or Array.from(new Set(arr)). Set operations: union new Set([...a, ...b]), intersection new Set([...a].filter(x => b.has(x))), difference new Set([...a].filter(x => !b.has(x))). Sets do not have index-based access. WeakSet holds objects with weak references. Sets are excellent for: deduplication, tracking visited items, and membership checks where O(1) lookup matters.
A shallow copy creates a new object with the same top-level properties as the original, but nested objects/arrays are still shared references. Changes to nested objects affect both the original and the copy. Methods that create shallow copies: spread operator ({...obj}, [...arr]), Object.assign({}, obj), Array.from(arr), arr.slice(), arr.concat(). A deep copy recursively copies all nested objects and arrays — the copy is completely independent. Methods: structuredClone(obj) (native, ES2022, handles most types including Date, Map, Set, circular references), JSON.parse(JSON.stringify(obj)) (works for JSON-safe data only — loses functions, undefined, Date objects, circular references), Lodash _.cloneDeep(obj). Rule of thumb: for primitive values, copying is always safe. For nested mutable data, use deep copy when you need full independence. React state patterns require immutable updates — shallow copy outer object, replace nested references.
Promise.all(promises) takes an array of Promises and returns a new Promise that resolves when ALL input Promises resolve, with an array of their resolved values. If ANY Promise rejects, Promise.all immediately rejects with that error — other pending Promises are abandoned (though they still run). Use when all operations must succeed: const [user, posts] = await Promise.all([fetchUser(), fetchPosts()]). Promise.allSettled(promises) (ES2020) waits for ALL Promises to settle (fulfill or reject) and always resolves with an array of result objects: {status: "fulfilled", value: ...} or {status: "rejected", reason: ...}. Never rejects. Use when you want results from all operations regardless of individual failures: sending multiple analytics events, bulk operations where partial success is acceptable. Related: Promise.race() settles with the first settled Promise; Promise.any() (ES2021) settles with the first FULFILLED Promise (ignores rejections unless all fail).
The Fetch API is a modern, Promise-based interface for making HTTP requests in the browser, replacing the older XMLHttpRequest. Basic GET: const response = await fetch("https://api.example.com/users"); const data = await response.json();. Fetch returns a Promise that resolves when the response headers are received — you must call .json(), .text(), or .blob() to read the body. Important: Fetch only rejects on network errors, not HTTP errors (404, 500 are resolved responses). Always check: if (!response.ok) throw new Error(`HTTP error \${response.status}`). POST request: fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(data) }). Handle errors: wrap in try/catch. Cancel requests with AbortController: const controller = new AbortController(); fetch(url, { signal: controller.signal }); controller.abort(). For more features (interceptors, timeouts), use axios.
Both techniques limit how often a function executes during rapid repeated invocations — useful for scroll, resize, input, and mousemove events. Debouncing delays execution until after a specified quiet period — the function only fires after the user has stopped triggering the event for N milliseconds. If triggered again before the delay expires, the timer resets. Use for: search input (wait until the user stops typing before making an API call), window resize (only recalculate after resize ends). Implementation: function debounce(fn, delay) { let timer; return (...args) => { clearTimeout(timer); timer = setTimeout(() => fn(...args), delay); }; }. Throttling ensures the function executes at most once per N milliseconds, regardless of how many times it is triggered. Use for: scroll event handlers, rate-limiting button clicks, gaming input. Implementation uses a timestamp or flag to skip calls within the period. Lodash provides battle-tested _.debounce() and _.throttle().
An iterable is an object that implements Symbol.iterator — a method returning an iterator. An iterator is an object with a next() method that returns { value, done }. This protocol powers for...of, spread, destructuring, Array.from(), and Promise.all(). Built-in iterables: arrays, strings, Maps, Sets, generators, arguments. Custom iterable: const range = { from: 1, to: 5, [Symbol.iterator]() { let current = this.from; const last = this.to; return { next() { return current <= last ? { value: current++, done: false } : { value: undefined, done: true }; } }; } }; for (const n of range) console.log(n). An object can be both iterable AND an iterator (returns this from Symbol.iterator) — generators do this. Iterables enable lazy evaluation (process elements one at a time, not loading all into memory) and custom traversal logic.
Async iterators and async generators (ES2018) enable iterating over asynchronous data sources — APIs that deliver data over time (database cursors, streams, paginated APIs). An async iterable implements Symbol.asyncIterator returning an async iterator whose next() returns a Promise of {value, done}. Consume with for await...of: for await (const chunk of asyncIterable) { process(chunk); }. Async generator functions (async function*) combine async and generator: async function* paginate(url) { while (url) { const {data, nextUrl} = await fetch(url).then(r => r.json()); yield* data; url = nextUrl; } }. for await (const item of paginate("/api/items")) { }. This cleanly handles pagination, streaming responses, and any push-based data source. The ReadableStream browser API is also async iterable in modern browsers, enabling for await (const chunk of stream).
The Proxy object (ES6) wraps another object and intercepts fundamental operations on it, allowing custom behavior for property access, assignment, function calls, and more. Create: new Proxy(target, handler). The handler defines traps: get(target, prop) — intercept property reads, set(target, prop, value) — intercept property writes, has(target, prop) — intercept in operator, deleteProperty, apply (function calls), construct (new operator). Example — validation: const validatedUser = new Proxy({}, { set(obj, prop, value) { if (prop === "age" && typeof value !== "number") throw TypeError("Age must be a number"); return Reflect.set(obj, prop, value); } }). Use cases: validation, logging, data binding, React-like reactivity (Vue 3 uses Proxy), virtual properties, API mocking. Proxy enables meta-programming — code that programs code. Always use Reflect methods for the default behavior in traps.
The Reflect object (ES6) provides methods for interceptable JavaScript operations — it mirrors the Proxy traps and is the "complement" of Proxy. Unlike Object methods, Reflect methods have consistent return values (boolean for set/delete instead of throwing). Reflect.get(target, prop, receiver), Reflect.set(target, prop, value), Reflect.has(target, prop) (in equivalent), Reflect.deleteProperty(target, prop), Reflect.ownKeys(target) (all own keys — string + symbol), Reflect.apply(fn, thisArg, args), Reflect.construct(Cls, args), Reflect.defineProperty(), Reflect.getPrototypeOf(). Best practice: use Reflect methods inside Proxy traps to invoke the default behavior: return Reflect.set(target, prop, value). This ensures proper prototype chain handling and return values. Reflect also standardizes some operations that were previously inconsistent (like Object.defineProperty throwing vs Reflect returning false).
JavaScript uses try/catch/finally for synchronous error handling and Promise rejections for asynchronous. try { riskyOperation(); } catch(error) { console.error(error.message, error.stack); } finally { cleanup(); }. The finally block always runs. Custom errors: extend the Error class: class ValidationError extends Error { constructor(message, field) { super(message); this.name = "ValidationError"; this.field = field; } }. Check error type: error instanceof ValidationError. Async error handling: try { await fetchData(); } catch(err) { ... } or promise.catch(err => { ... }). Global error handlers: window.addEventListener("error", handler) for uncaught synchronous errors, window.addEventListener("unhandledrejection", handler) for unhandled Promise rejections. Error types: SyntaxError, ReferenceError, TypeError, RangeError, URIError, EvalError. Always handle Promise rejections — unhandled rejections crash Node.js processes.
Scope determines where variables are accessible. JavaScript has three types: Global scope — variables declared outside any function are accessible everywhere. Function scope — variables declared with var inside a function are accessible only within that function and nested functions. Block scope — let and const are accessible only within the {} block they are declared in (if statements, loops, etc.). The scope chain is how JavaScript resolves variable names. When a variable is accessed, JavaScript first looks in the current scope, then moves up through enclosing scopes (parent function, grandparent function, global) until found or a ReferenceError is thrown. Closures capture the scope chain — inner functions can access outer function variables because they maintain a reference to the outer lexical environment. The scope chain is determined lexically at write-time (where you write the code), not at call-time — this is lexical scoping (or static scoping).
Synchronous errors propagate up the call stack and are caught with try/catch at any point in the stack: try { JSON.parse("invalid"); } catch(e) { }. Uncaught sync errors crash the script and show in the console. Asynchronous errors (in callbacks, Promises, async functions) do NOT propagate through the synchronous call stack — they must be handled differently. For Promise-based code: every Promise chain should end with .catch(), or the entire chain should be awaited inside a try/catch. For async/await: wrap with try/catch — it converts rejected Promises to thrown errors. For traditional callbacks: Node.js uses the error-first convention — callback(err, result). A common mistake: wrapping a setTimeout or .then() callback in try/catch does not catch errors thrown inside the async callback — the try/catch has already completed by then. Always use await-based patterns with try/catch or Promise .catch().
Function composition is the technique of combining multiple functions where the output of one function becomes the input of the next. It creates a pipeline of transformations. Manual composition: const result = format(capitalize(trim(userInput))) — executes right to left. Compose helper: const compose = (...fns) => x => fns.reduceRight((acc, fn) => fn(acc), x). const process = compose(format, capitalize, trim). Pipe is the same but left-to-right (usually more readable): const pipe = (...fns) => x => fns.reduce((acc, fn) => fn(acc), x). Example: const getActiveUserEmails = pipe(filterActive, mapToEmail, sortAlphabetically). Composition works best with: pure functions (no side effects), curried functions (single input, single output), and data transformation pipelines. Libraries: Lodash/fp _.compose(), Ramda R.pipe(). Point-free style: define functions in terms of other functions without mentioning data arguments.
An IIFE (Immediately Invoked Function Expression) is a function that is defined and called immediately. Syntax: (function() { /* code */ })() or (() => { /* code */ })(). The outer parentheses turn the function declaration into an expression; the trailing () immediately invokes it. Primary use: creating a private scope — variables inside an IIFE do not pollute the global scope (pre-ES6 when there were no block-scoped variables). This was the basis of the module pattern: const counter = (function() { let count = 0; return { inc: () => ++count, get: () => count }; })(). IIFEs are also used to avoid variable hoisting issues in loops. With ES6 let/const and modules, IIFEs are less necessary, but still used for: executing top-level async code ((async () => { const data = await fetch(url); })();), avoiding variable naming conflicts in scripts, and wrapping legacy code.
The arguments object is an array-like object available inside regular functions (not arrow functions) containing all passed arguments, regardless of the function's parameter count. function sum() { return Array.from(arguments).reduce((a, b) => a + b, 0); }. It has a length property and numeric indices but lacks array methods (map, filter, etc.) — convert with Array.from(arguments) or [...arguments]. In ES6, rest parameters (...args) are the modern replacement — they are actual arrays: function sum(...args) { return args.reduce((a, b) => a + b, 0); }. Arrow functions do NOT have their own arguments object — they inherit it from the enclosing regular function's scope. In strict mode, arguments does not track changes to named parameters. Always prefer rest parameters over arguments in modern code — they are clearer, are real arrays, and work in arrow functions.
Short-circuit evaluation means logical operators (&&, ||) stop evaluating as soon as the result is determined. AND (&&): if the left side is falsy, returns it immediately (does not evaluate right side). If truthy, returns the right side. false && sideEffect() — sideEffect never called. OR (||): if the left side is truthy, returns it immediately. If falsy, evaluates and returns the right side. true || sideEffect() — sideEffect never called. These are NOT just boolean operators — they return the actual values, not true/false. Practical patterns: user && user.name (safe access), count || 0 (default value), isValid && submit() (conditional execution), config.timeout || 3000 (fallback). Guard clauses: isLoggedIn && renderDashboard(). With ?? (nullish coalescing), short-circuiting only on null/undefined. Short-circuit also applies to ?? and optional chaining ?..
Both iterate over object keys but with an important difference. for...in iterates over ALL enumerable properties of an object, including inherited ones from the prototype chain. This can be surprising: Object.prototype.customProp = "test"; for (const key in {}) console.log(key) would log "customProp" even for empty objects. Always use hasOwnProperty guard: if (obj.hasOwnProperty(key)). Object.keys(obj) returns ONLY the object's own enumerable property names as an array — no inherited properties, no non-enumerable properties. It is safe without a hasOwnProperty check. Related: Object.values(obj) returns own enumerable values; Object.entries(obj) returns [key, value] pairs. Object.getOwnPropertyNames(obj) returns all own properties including non-enumerable ones. Reflect.ownKeys(obj) returns all own keys including Symbols. Best practice: prefer Object.keys()/values()/entries() over for...in for plain object iteration.
Type coercion is the automatic or explicit conversion of values from one type to another. Implicit coercion happens automatically: "5" + 3 → "53" (string concatenation takes priority), "5" - 3 → 2 (subtraction forces numeric). if ("hello") → truthy coercion. !!"hello" → true. Coercion rules: + with a string → string; arithmetic operators (-, *, /) → numbers; comparison <, > between strings → lexicographic; between mixed → numeric. Explicit coercion: Number("42"), String(42), Boolean(0), parseInt("42px"). Famous coercion oddities: [] + [] → "", [] + {} → "[object Object]", {} + [] → 0 (if {} is a block). This is why === is preferred — it skips coercion. Understanding coercion is important for reading legacy code and debugging unexpected values. The abstract equality algorithm (==) defines coercion rules in detail.
Both search an array using a predicate function, but they differ in what they return. find(callback) returns the first element that satisfies the condition — it stops searching as soon as it finds a match (early termination for performance). If no element matches, returns undefined. users.find(u => u.id === 5) returns the user object or undefined. Use when you need a single matching item and know only one (or the first) matters. filter(callback) returns a new array of ALL elements that satisfy the condition — it always processes every element. Returns an empty array if nothing matches. users.filter(u => u.active) returns all active users. Use when you need all matching items. Related: findIndex() returns the index of the first match (or -1); findLast() / findLastIndex() (ES2023) search from the end. Performance tip: find() is more efficient when you only need the first match — filter()[0] processes the entire array unnecessarily.
Every JavaScript function is an object and has a prototype property (distinct from the function's own [[Prototype]]). When a function is used as a constructor with new, the newly created object's [[Prototype]] is set to the constructor's prototype property. So methods added to Function.prototype are available on all function objects. The call stack is a data structure (LIFO) that tracks the execution of function calls. When a function is called, a new frame is pushed onto the stack containing the function's local variables and execution context. When the function returns, its frame is popped. Example: calling a() which calls b() which calls c() creates a stack: global → a → b → c. When c() returns, the stack becomes: global → a → b. A stack overflow (Maximum call stack size exceeded) occurs when recursion goes too deep without a base case — the stack fills up. The stack is visible in DevTools when an error is thrown — the stack trace shows the call chain.
Prototype pollution is a security vulnerability where an attacker modifies Object.prototype, causing all objects in the application to inherit the malicious property. Example: if a library does obj[key] = value without sanitizing key, and an attacker passes key = "__proto__" or key = "constructor", they can add properties to Object.prototype that affect ALL objects. Impact: corrupts data, bypasses security checks, and can lead to remote code execution. Common sources: deep merge/clone functions, JSON parsing with user input as keys, template rendering libraries. Prevention: validate/sanitize input keys — reject __proto__, constructor, prototype. Use Object.create(null) for dictionaries (no prototype). Use Object.freeze(Object.prototype). Use Map instead of plain objects for user-controlled key-value stores. Modern libraries like Lodash have patched prototype pollution vulnerabilities — keep dependencies updated. This is a critical security concern in Node.js applications handling user input.
An execution context is the environment in which JavaScript code is evaluated and executed. There are three types: Global Execution Context (GEC) — created on page load; the default context; creates the global object (window in browser, global in Node) and sets this to it. Function Execution Context (FEC) — created whenever a function is called. Eval Execution Context — rarely used. Each execution context has: a Variable Environment (all var declarations and function declarations, hoisted), a Lexical Environment (current scope + outer scope reference), and a binding for this. Contexts are managed by the call stack — when a function is called, its context is pushed; when it returns, it is popped. The outer environment reference forms the scope chain. Understanding execution contexts explains hoisting (variables/functions are processed before code runs within each context), closures (inner contexts reference outer environments), and this binding.
JavaScript uses automatic garbage collection — the engine periodically reclaims memory from objects that are no longer reachable. The main algorithm is mark-and-sweep: starting from roots (global variables, call stack), the GC marks all reachable objects, then sweeps and frees all unmarked objects. Modern engines (V8) use generational GC: young generation (recently allocated — GC'd frequently, quickly) and old generation (survived several GCs — GC'd less frequently). Memory leaks occur when references to objects are unintentionally retained: global variables (attaching data to window), detached DOM nodes (removed from DOM but still referenced in JS), event listeners not removed, closures holding large data, timers not cleared, and circular references in older engines. Detection: Chrome DevTools Memory tab (heap snapshots, allocation timeline). Prevention: use WeakMaps/WeakRefs for object metadata, clean up event listeners, clear timers, avoid unnecessary global state.
The Temporal Dead Zone (TDZ) is the period between the start of a block scope and the point where a let or const declaration is encountered. During the TDZ, the variable exists in scope (it has been hoisted) but has not been initialized — accessing it throws a ReferenceError. Example: console.log(x); // ReferenceError: Cannot access "x" before initialization\nlet x = 5;. Unlike var which initializes to undefined during hoisting, let and const are hoisted to the top of the block but remain in the TDZ until their declaration is reached. This was an intentional design decision to eliminate "use before declaration" bugs that were common with var. The TDZ also applies to function parameters when using default values: function f(a = b, b = 1) { } — a accessing b before b is initialized throws ReferenceError.
Tail call optimization (TCO) is a specification-level optimization where a function call in the "tail position" (the very last action of a function) does not need to add a new frame to the call stack. In strict mode ES6, if the last thing a function does is call another function and return its result directly, the engine can reuse the current stack frame instead of creating a new one. This allows recursive functions to run without stack overflow risk — O(1) stack space instead of O(n). A tail call: "use strict"; function factorial(n, acc = 1) { if (n <= 1) return acc; return factorial(n - 1, n * acc); // tail call }. Non-tail call: return n * factorial(n - 1) — cannot be optimized because multiplication happens AFTER the recursive call. Caveat: as of 2024, only JavaScriptCore (Safari) implements TCO from the ES6 spec. V8 (Chrome/Node) dropped it due to tooling issues. TCO remains important conceptually and is fully implemented in some environments.
Key JavaScript design patterns: Module pattern — encapsulates private state using closures: const counter = (() => { let n = 0; return { inc: () => ++n, get: () => n }; })(). Singleton — one instance: module-level variable or class with a static instance check. Observer/Pub-Sub — EventEmitter, custom event system, RxJS observables. Factory — creates objects without new: function createUser(type) { return type === "admin" ? new Admin() : new User(); }. Decorator — add behavior without subclassing: wrapping functions or using class decorators. Strategy — swap algorithms at runtime by passing functions. Prototype — clone objects instead of constructing from scratch: Object.create(proto). Command — encapsulate actions as objects (undo/redo). Facade — simple interface to complex subsystem. Mixin — Object.assign(Target.prototype, mixin). Modern JavaScript favors composition over inheritance and functional patterns over class hierarchies.
The JavaScript event loop processes two queues with different priorities. Macrotask queue (Task Queue) — contains callbacks from: setTimeout, setInterval, I/O events, UI rendering, setImmediate (Node.js), requestAnimationFrame (browser). The event loop processes ONE macrotask per loop iteration. Microtask queue — contains callbacks from: resolved/rejected Promises (.then/.catch/.finally), queueMicrotask(), MutationObserver, async/await continuations. After each macrotask (or after synchronous code), the event loop drains the ENTIRE microtask queue before moving to the next macrotask. This means: Promise callbacks always run before the next setTimeout callback. Example: setTimeout(() => console.log("timeout")); Promise.resolve().then(() => console.log("promise")) — outputs: "promise" then "timeout". Practical impact: Promise chains can "starve" macrotasks if they keep adding microtasks; recursive Promise chains can delay rendering.
Module bundlers combine JavaScript modules (and their dependencies) into optimized files for production. Webpack — the most widely used; highly configurable; transforms assets (CSS, images, fonts) via loaders; optimizes via plugins; supports code splitting. Complex configuration but very powerful. Vite — modern build tool using native ES modules for development (no bundling during dev, instant HMR) and Rollup for production builds. Much faster developer experience than Webpack. Default for Vue, and increasingly for React. Rollup — excellent for libraries; produces clean, efficient bundles with tree-shaking; less complexity than Webpack. esbuild — written in Go; extremely fast (100x faster than Webpack); used as a sub-dependency in Vite/Parcel. Parcel — zero-configuration bundler; great for quick projects. Turbopack — Next.js's new Rust-based bundler (successor to Webpack). Key optimizations all bundlers provide: tree-shaking (remove unused code), code splitting (lazy-load chunks), minification, and module federation.
The Event Emitter (Pub/Sub) pattern decouples producers from consumers — emitters broadcast events without knowing who is listening; subscribers listen without knowing who emitted. In Node.js: const EventEmitter = require("events"); class Store extends EventEmitter { setData(data) { this.data = data; this.emit("change", data); } }. In browsers: DOM events follow this pattern. Custom implementation: class EventEmitter { constructor() { this.events = {}; } on(event, cb) { (this.events[event] ??= []).push(cb); return this; } emit(event, ...args) { (this.events[event] || []).forEach(cb => cb(...args)); } off(event, cb) { this.events[event] = (this.events[event] || []).filter(fn => fn !== cb); } once(event, cb) { const wrapper = (...args) => { cb(...args); this.off(event, wrapper); }; this.on(event, wrapper); } }. React's state management libraries (Redux, Zustand), Vue's reactivity, and RxJS all build on this pattern. Always remove listeners when components unmount to prevent memory leaks.
Key JavaScript performance optimization techniques: Reduce DOM manipulation — batch DOM changes, use DocumentFragment, avoid layout thrashing (don't read and write DOM properties alternately). Avoid memory leaks — clean up event listeners, clear timers, use WeakMaps. Use efficient data structures — Set/Map for O(1) lookups instead of array searches. Debounce/throttle event handlers. Lazy loading — dynamic imports (import()) for code splitting, defer non-critical scripts. Web Workers — move CPU-intensive computation off the main thread. Memoization — cache expensive function results. Virtual DOM — frameworks batch updates and diff the tree. requestAnimationFrame for animations instead of setTimeout. Avoid synchronous XHR. Profile first — use Chrome DevTools Performance tab, lighthouse, and the performance API (performance.now(), performance.mark()) to identify actual bottlenecks before optimizing. Premature optimization is the root of all evil — measure, identify the bottleneck, then optimize.
An Observable is a powerful asynchronous primitive that represents a stream of data over time — like a multi-value, lazy, cancellable Promise. Where a Promise resolves once, an Observable can emit multiple values. RxJS (Reactive Extensions for JavaScript) is the most popular Observable library. Create: const obs = new Observable(subscriber => { subscriber.next(1); subscriber.next(2); subscriber.complete(); }). Subscribe: obs.subscribe({ next: v => console.log(v), error: e => console.error(e), complete: () => console.log("done") }). Operators transform streams: obs.pipe(map(x => x * 2), filter(x => x > 2), debounceTime(300), switchMap(q => search(q))). switchMap cancels previous requests when a new one arrives — perfect for type-ahead search. RxJS is built into Angular and used in React with custom hooks. Key operators: map, filter, mergeMap, switchMap, concatMap, combineLatest, zip, takeUntil, retry. Unsubscribe to prevent memory leaks.
Web Workers allow running JavaScript code in a background thread, separate from the main UI thread, preventing heavy computations from blocking the UI. Main thread creates a worker: const worker = new Worker("worker.js"). Communicate via postMessage() and message events — data is copied (not shared) via structured cloning. Main: worker.postMessage(data); worker.onmessage = e => console.log(e.data). Worker file: self.onmessage = e => { const result = heavyComputation(e.data); self.postMessage(result); }. Workers do NOT have access to DOM, window, or document. Workers can use: fetch(), IndexedDB, WebSockets, importScripts(), and other Web APIs. Shared Workers: multiple tabs/windows can access the same worker. Service Workers: act as a proxy between the web app and network — enable offline functionality, push notifications, and background sync (Progressive Web Apps). Terminate: worker.terminate().
IndexedDB is a low-level, browser-based NoSQL database for storing significant amounts of structured data (including files/blobs) on the client side. Unlike localStorage (string key-value, ~5-10MB), IndexedDB can store gigabytes, complex objects, and supports indexes for efficient queries. It is asynchronous (non-blocking) and uses transactions for data integrity. Operations return IDBRequest objects — listen to onsuccess/onerror. Basic flow: open DB (indexedDB.open("name", version)), handle onupgradeneeded (schema setup), then create transactions to read/write. The API is callback-heavy and verbose — use wrapper libraries: Dexie.js (most popular, clean Promise-based API), localForage (falls back to localStorage), or the modern IDB library. Use cases: offline-first web apps (Progressive Web Apps), caching large datasets, client-side search, and storing user-generated content. Service Workers + IndexedDB = powerful offline capabilities.
Two module systems exist in JavaScript. CommonJS (CJS): used in Node.js, require()/module.exports. Synchronous loading — modules are loaded and executed on demand. Exports are cached after first load. Dynamic — can use require in conditionals. The entire module object is exported. ES Modules (ESM): the standard for browsers and modern Node.js, import/export. Static — all imports are resolved at parse time (enables tree-shaking and static analysis). Asynchronous loading (important for browsers). Named and default exports. Live bindings — imported values reflect changes in the exporting module. "type": "module" in package.json or .mjs extension for ESM in Node. Interop issues: ESM can import CJS (sort of), but CJS cannot use ESM without workarounds. In browsers: <script type="module">. The ecosystem is converging on ESM — modern packages publish both CJS and ESM ("dual package" exports in package.json). Tree-shaking only works with ESM.
Strict mode (ES5) is an opt-in mode that makes JavaScript more restrictive, catching common errors and preventing unsafe features. Enable for a file: "use strict"; at the top. Enable for a function: function fn() { "use strict"; }. ES6 modules and classes are automatically in strict mode. Changes in strict mode: undeclared variables throw ReferenceError (instead of creating globals), this in regular functions is undefined (instead of global), duplicate parameter names throw SyntaxError, writing to read-only properties throws TypeError (instead of silently failing), deleting non-configurable properties throws TypeError, with statement is forbidden (ambiguous scoping), eval does not introduce variables into surrounding scope, and arguments.caller/callee are disallowed. Strict mode makes refactoring safer and enables better engine optimizations. Always use it in new code — or better yet, use ES modules which are automatically strict.
Immutability means data cannot be changed after creation — instead of modifying existing data, you create new data with the desired changes. JavaScript primitives are inherently immutable (strings, numbers). Objects and arrays are mutable by default but can be treated immutably. Benefits: predictable state management (no side effects), pure functions (same input → same output), enables efficient change detection (shallow equality check is sufficient if data is immutable — used by React, Redux), easier debugging (no hidden mutations), and safe sharing between components. Techniques: Object.freeze(obj) — shallow freeze (prevents property modification; nested objects still mutable). Spread/destructuring for updates: const newState = {...state, count: state.count + 1}. Immer.js — write mutating code inside a produce() callback and get an immutable result automatically (uses Proxy). Immutable.js — provides persistent immutable data structures with structural sharing (efficient memory). React's useState and Redux rely heavily on immutability for change detection.
Both prevent object modification but to different degrees. Object.freeze(obj) is the most restrictive: cannot add properties, cannot remove properties, cannot change property values, and cannot change property descriptors. Property attributes become writable: false, configurable: false. Returns the same object. Shallow only — nested objects are not frozen. For deep freezing, recursively apply. Check: Object.isFrozen(obj). Object.seal(obj) is less restrictive: cannot add new properties, cannot remove existing properties, but CAN change existing property values (if writable). Properties become configurable: false. Check: Object.isSealed(obj). Object.preventExtensions(obj) is the least restrictive: only prevents adding new properties — existing properties can be changed or deleted. Hierarchy: freeze > seal > preventExtensions. Violations silently fail in sloppy mode but throw TypeError in strict mode. None of these prevent modification of nested objects — use Immer.js or deep freeze for deep immutability.
Tagged template literals allow processing a template literal with a function, giving full control over how the template is assembled. Syntax: tag`template string \${expr}`. The tag function receives: an array of string parts, followed by the interpolated values as separate arguments. function highlight(strings, ...values) { return strings.reduce((result, str, i) => result + str + (values[i] !== undefined ? `<mark>\${values[i]}</mark>` : ""), ""); }. Use: highlight`Hello \${name}, you have \${count} messages`. Real-world uses: SQL queries (automatic parameterization to prevent injection): sql`SELECT * FROM users WHERE id = \${userId}`. styled-components (CSS in JS): styled.div`color: \${props => props.color}`. GraphQL queries: gql`query { users { name } }`. i18n (internationalization with automatic translation). String.raw is a built-in tag that returns the raw string with escape sequences preserved: String.raw`\n` gives the two-character string \n instead of a newline.
Lazy evaluation defers computation until the result is actually needed, enabling efficient processing of large or infinite datasets by generating values on demand instead of upfront. JavaScript achieves lazy evaluation through generators and iterators. An infinite sequence using a generator: function* naturals(start = 0) { while(true) yield start++; } — never ends but is perfectly safe because values are produced only when requested. Take N values: function take(n, iterable) { const result = []; for (const item of iterable) { result.push(item); if (result.length >= n) break; } return result; }. Lazy pipelines: function* map(fn, iterable) { for (const x of iterable) yield fn(x); } function* filter(pred, iterable) { for (const x of iterable) if (pred(x)) yield x; } const first10EvenSquares = take(10, map(x => x**2, filter(x => x%2===0, naturals()))) — this processes elements lazily, only computing what is needed. Libraries like lazy.js and Lodash/fp provide lazy collection operations.
The Abstract Equality Comparison algorithm defines the rules JavaScript uses for the == operator. Key rules: same type → use strict equality. null == undefined → true (only these two are loosely equal to each other, nothing else). If one is a Number and the other is a String → convert String to Number. If one is Boolean → convert it to Number (true→1, false→0) then re-compare. If one is an Object and the other is String/Number/Symbol → call ToPrimitive(object) (tries valueOf() then toString()) and compare. This explains famous quirks: [] == false: false→0, []→0 (via valueOf()→[] then toString()→"" then Number("")→0) → true. "" == 0: Number("")→0 → true. [] == ![]: ![]→false, then →0, []→0 → true. These counterintuitive results are why === should almost always be used. Understanding this algorithm explains every "JavaScript is broken" meme. The == operator should be reserved for null/undefined checks: if (val == null) covers both null and undefined cleanly.
The event loop in depth: JavaScript runtime has the call stack (synchronous execution), heap (memory allocation), and Web APIs. When async operations (fetch, setTimeout) are initiated, they are handed to Web APIs which process them outside the stack. On completion, callbacks are queued. The loop checks: if the call stack is empty, process ALL microtasks (Promise callbacks, queueMicrotask, MutationObserver) — drain the entire microtask queue, including any new microtasks added during processing. Then take ONE macrotask (setTimeout, setInterval, I/O, UI events). Then process all microtasks again. Then render (if needed). Repeat. Key insight: a Promise chain can produce many microtasks synchronously — if you chain thousands of .then() calls, rendering and macrotasks are blocked until all microtasks complete. requestAnimationFrame fires before rendering, after macrotasks. queueMicrotask(fn) queues a microtask directly. In Node.js: process.nextTick() runs before Promise callbacks; setImmediate() runs after I/O callbacks. Understanding this ordering is crucial for debugging race conditions and performance issues.
JavaScript Proxy supports 13 traps intercepting fundamental object operations. Property access: get(target, prop, receiver) — intercept obj.prop; set(target, prop, value, receiver) — intercept obj.prop = value; has(target, prop) — intercept prop in obj; deleteProperty(target, prop). Property definition: defineProperty(target, prop, descriptor); getOwnPropertyDescriptor(target, prop). Prototype: getPrototypeOf(target); setPrototypeOf(target, proto). Extensibility: isExtensible(target); preventExtensions(target). Enumeration: ownKeys(target) — intercept Object.keys(), for...in. Functions: apply(target, thisArg, args) — intercept function calls; construct(target, args) — intercept new. Always use Reflect to forward to default behavior within traps: return Reflect.get(target, prop, receiver). The receiver ensures correct prototype chain behavior with getter properties. Proxy invariants prevent traps from breaking fundamental language contracts (e.g., you cannot make a non-configurable property appear configurable).
The V8 engine (Chrome, Node.js, Edge, Deno) uses a sophisticated generational garbage collector. Heap is divided into: Young generation (small, ~1-8 MB) — most new objects start here. Collected frequently with Scavenger (Cheney algorithm) — objects that survive two GC cycles are promoted to old generation. Old generation (large) — uses Mark-Sweep-Compact: mark reachable objects from roots, sweep unreachable ones, compact to reduce fragmentation. V8 uses incremental marking (spread GC work over multiple small pauses), concurrent marking (mark on background threads while JS runs), and parallel sweeping. This minimizes "stop-the-world" pauses. Hidden classes (Shapes): V8 creates internal "hidden classes" for objects with the same property layout — enables JIT compilation optimizations. Adding properties in different orders creates different hidden classes — avoid this for performance. Inline caches: V8 caches property lookup results for objects of the same hidden class. Deoptimization occurs when assumptions are violated (polymorphic property access). Writing predictable, consistently-shaped objects is key to V8 performance.
JavaScript's type system is dynamic (types are checked at runtime) and weakly typed (implicit coercion between types). The ToPrimitive abstract operation converts objects to primitives by calling [Symbol.toPrimitive](hint) (if defined), then valueOf(), then toString(). Hint is "number", "string", or "default". Custom coercion: class Money { [Symbol.toPrimitive](hint) { if (hint === "string") return `$\${this.amount}`; return this.amount; } }. The ToNumber operation: undefined→NaN, null→0, true→1, false→0, ""→0, "42"→42, "42px"→NaN, []→0, [3]→3, [1,2]→NaN, {}→NaN. ToString operation: null→"null", undefined→"undefined", true→"true", []→"", [1,2]→"1,2", {}→"[object Object]". Understanding these operations explains all JavaScript coercion behavior. TypeScript was created specifically to add static types and eliminate coercion bugs at compile time.
Symbols' deeper applications extend far beyond simple unique property keys. Well-known Symbols are hooks into JavaScript internals: Symbol.iterator — makes objects iterable (for...of); Symbol.asyncIterator — async iteration (for await...of); Symbol.toPrimitive — custom type conversion; Symbol.toStringTag — custom Object.prototype.toString result; Symbol.hasInstance — customize instanceof: class EvenNumber { static [Symbol.hasInstance](n) { return typeof n === "number" && n % 2 === 0; } }; 4 instanceof EvenNumber // true; Symbol.species — specify the constructor for derived objects (used by map, filter, etc.); Symbol.isConcatSpreadable — control Array.prototype.concat behavior; Symbol.match, Symbol.replace, Symbol.search, Symbol.split — customize string methods. Global symbol registry: Symbol.for("key") returns the same symbol for the same key across realms (iframes, workers) — unlike regular symbols which are always unique. Symbol.keyFor(sym) retrieves the key for a registered symbol.
JavaScript supports multiple inheritance patterns, each with trade-offs. Prototype chain (prototypal inheritance): Dog.prototype = Object.create(Animal.prototype) — classic, efficient memory (methods shared), but verbose. ES6 Classes: class Dog extends Animal — cleaner syntax, same prototypal mechanism underneath. Mixin pattern: copy methods from multiple sources onto a prototype — multiple "inheritance": Object.assign(Dog.prototype, CanMixin, SwimMixin). No deep hierarchy, compose capabilities. Factory functions with composition: create objects via functions returning plain objects with shared methods via closure or shared prototype — no new, no this confusion: const createDog = (name) => ({ name, bark: () => "Woof", ...animalMethods }). Delegation: objects delegate behavior to other objects via composition rather than inheritance: const dog = Object.create(animal); dog.bark = () => "Woof". The JavaScript community increasingly favors composition over inheritance — mixins and factory functions over deep class hierarchies. The "diamond problem" of multiple inheritance is avoided by explicit composition.
The module pattern encapsulates private state and exposes a public API. It evolved through several stages. Classic IIFE Module (pre-ES6): const counter = (function() { let count = 0; return { inc() { return ++count; }, reset() { count = 0; } }; })() — private count, public interface. Revealing Module: all logic defined privately, then selectively exposed — cleaner but can be confusing about what is public. CommonJS (Node.js): module.exports = { inc, reset } — synchronous, each file is a module with private scope. AMD (Asynchronous Module Definition): define(["dep"], function(dep) { return { } }) — async loading for browsers (RequireJS). UMD (Universal Module Definition): works in both CommonJS and AMD environments — used for libraries. ES Modules (ES6, current standard): export const inc = () => ++count; export default counter — static, tree-shakeable, async-loadable, native browser support. ES Modules are the definitive standard — all other patterns are legacy. Understanding the evolution helps when reading older codebases that use IIFE or CommonJS patterns.
A pure function has two properties: deterministic (same inputs always produce the same output) and no side effects (does not modify any external state — no mutating arguments, no global variables, no I/O, no DOM manipulation). Example: const add = (a, b) => a + b is pure. function addToCart(item) { cart.push(item); } is impure (mutates external state). Side effects are necessary in real applications — making API calls, updating DOM, writing to databases — but should be isolated to specific parts of the codebase. Benefits of pure functions: testable (no mocking needed — just call with inputs, check output), composable, memoizable, parallelizable (no shared state), and predictable. Functional programming paradigm pushes side effects to the "edges" of the system (at the application boundary) and keeps core logic pure. React components should be pure — given the same props, render the same output. Redux reducers must be pure functions.