JavaScript Interview Questions and How to Answer Them (50+ Questions)
You have a JavaScript interview coming up. Maybe it's for a frontend role, a full-stack position, or a senior engineer spot at a company you actually want to work at. Whatever the case, you need more than a list of buzzwords — you need to understand the concepts deeply enough to explain them under pressure, handle follow-up questions, and write code on a whiteboard without freezing.
This guide covers 50+ real JavaScript interview questions, from the fundamentals interviewers use to filter out junior candidates, to the tricky behavioral traps used in senior rounds, to the deep-dive questions that separate good engineers from great ones.
Each question includes:
- The answer you should give (not a textbook definition — a conversational, confident answer)
- Real working code examples
- What the interviewer is actually testing
- Common follow-up questions to expect
No fluff. No padding. Let's go.
How to Use This Guide
Read it end to end once. Then come back and drill the sections where you feel weakest. The questions are grouped by topic so you can focus your prep time.
Topics covered:
- 1Core Concepts and Types
- 2Functions and Scope
- 3Closures and the Module Pattern
- 4The
thisKeyword - 5Prototypes and Inheritance
- 6Async JavaScript
- 7ES6+ Features
- 8The Event Loop
- 9DOM and Browser APIs
- 10Performance and Best Practices
- 11Advanced / Senior-Level Questions
Part 1: Core Concepts and Types
Question 1: What are the primitive types in JavaScript?
The answer:
JavaScript has 7 primitive types: string, number, bigint, boolean, undefined, null, and symbol. Everything else — arrays, functions, objects — is an object type.
The key distinction is that primitives are immutable and stored by value. Objects are stored by reference.
// Primitives — stored by value
let a = 5;
let b = a;
b = 10;
console.log(a); // 5 — a is unchanged
// Objects — stored by reference
let obj1 = { count: 5 };
let obj2 = obj1;
obj2.count = 10;
console.log(obj1.count); // 10 — same referenceWhat the interviewer is testing: Whether you understand the value vs. reference distinction, which causes real bugs in codebases (e.g., accidental mutation of shared state).
Common follow-up: "What's typeof null?"
Answer: "object" — this is a historical bug in JavaScript that was never fixed for backward compatibility. null is a primitive, not an object, but typeof null === "object".
Question 2: What is the difference between `==` and `===`?
The answer:
=== (strict equality) checks both value and type — no coercion happens. == (loose equality) performs type coercion before comparing.
Use === almost always. == produces surprising results:
0 == false // true — false coerces to 0
"" == false // true
null == undefined // true
null == 0 // false (this one surprises people)
NaN == NaN // false — NaN is never equal to itself
// Strict equality — no surprises
0 === false // false
null === undefined // falseWhat the interviewer is testing: Your awareness of implicit type coercion and whether you write defensive, predictable code.
Pro tip: Mention Object.is() for edge cases — it handles NaN and -0 correctly:
Object.is(NaN, NaN) // true
Object.is(-0, 0) // false
NaN === NaN // false
-0 === 0 // trueQuestion 3: What is `NaN` and how do you check for it?
The answer:
NaN stands for "Not a Number" and represents an invalid numeric computation. The weird part: typeof NaN === "number" is true. And NaN !== NaN — it's the only value in JavaScript not equal to itself.
console.log(typeof NaN); // "number"
console.log(NaN === NaN); // false
// Wrong way to check
if (someValue === NaN) { ... } // never works
// Right way
Number.isNaN(42); // false
Number.isNaN(NaN); // true
Number.isNaN("hello"); // false — strict, doesn't coerce
// Global isNaN — coerces first, more permissive
isNaN("hello"); // true — coerces "hello" to NaN firstAlways prefer Number.isNaN() over the global isNaN().
Question 4: What is the difference between `null` and `undefined`?
The answer:
undefined means a variable has been declared but not assigned a value. null is an explicit assignment — it means "intentionally no value."
let a;
console.log(a); // undefined — declared but not assigned
let b = null;
console.log(b); // null — explicitly set to nothing
function getUser(id) {
if (id) return { name: "Ana" };
return null; // explicit: no user found
}
function greet(name = "Guest") {
console.log(`Hello, ${name}`);
}
greet(undefined); // "Hello, Guest" — default kicks in
greet(null); // "Hello, null" — null is a real value, default doesn't applyCommon follow-up: "How do you check for both null and undefined in one go?"
// Nullish check
if (value == null) { ... } // catches both null and undefined (one of the few good uses of ==)
// Or explicit
if (value === null || value === undefined) { ... }
// Nullish coalescing
const result = value ?? "default"; // only falls back if null or undefinedQuestion 5: How does type coercion work in JavaScript?
The answer:
JavaScript automatically converts types in certain contexts. It's implicit, which is why it causes bugs when you don't expect it.
There are two kinds: explicit coercion (you do it intentionally) and implicit coercion (JavaScript does it automatically).
// Implicit coercion
"5" + 3 // "53" — number coerced to string (+ prefers strings)
"5" - 3 // 2 — string coerced to number (- only works on numbers)
"5" * "3" // 15
true + 1 // 2 — true coerces to 1
false + 1 // 1
[] + [] // "" — both convert to empty strings
[] + {} // "[object Object]"
{} + [] // 0 — {} treated as empty block, +[] converts to 0
// Explicit coercion
Number("42") // 42
String(42) // "42"
Boolean(0) // false
Boolean("") // false
Boolean("0") // true — non-empty string, even "0", is truthy
parseInt("42px") // 42What the interviewer is testing: Do you understand why JavaScript sometimes behaves unexpectedly? Can you predict behavior in edge cases?
Part 2: Functions and Scope
Question 6: What is the difference between function declarations and function expressions?
The answer:
Function declarations are hoisted — they're fully available before their position in the code. Function expressions (including arrow functions assigned to variables) are not.
// Function declaration — hoisted, can be called before its definition
sayHello(); // Works
function sayHello() {
console.log("Hello");
}
// Function expression — NOT hoisted
greet(); // TypeError: greet is not a function
const greet = function() {
console.log("Hello");
};
// Arrow function expression — same hoisting rules as const/let
add(1, 2); // ReferenceError
const add = (a, b) => a + b;The named function expression also creates a name visible inside the function itself, useful for recursion:
const factorial = function fact(n) {
return n <= 1 ? 1 : n * fact(n - 1); // fact is accessible here
};Question 7: What is hoisting?
The answer:
Hoisting is JavaScript's behavior of moving declarations to the top of their scope during the compilation phase — before execution begins. Only the declaration is hoisted, not the initialization.
// What you write:
console.log(x); // undefined, not ReferenceError
var x = 5;
console.log(x); // 5
// How JS sees it:
var x; // declaration hoisted
console.log(x); // undefined
x = 5; // initialization stays here
console.log(x); // 5let and const are also hoisted, but they land in the "Temporal Dead Zone" (TDZ) — accessing them before declaration throws a ReferenceError:
console.log(y); // ReferenceError: Cannot access 'y' before initialization
let y = 10;Function declarations are hoisted fully (both declaration and definition). That's why you can call a function declared with function before its position in the file.
Question 8: What is the difference between `var`, `let`, and `const`?
The answer:
| Feature | var | let | const |
|---|---|---|---|
| Scope | Function | Block | Block |
| Hoisting | Yes (initialized as undefined) | Yes (TDZ) | Yes (TDZ) |
| Re-declaration | Yes | No | No |
| Re-assignment | Yes | Yes | No |
// var — function-scoped, causes bugs in loops
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 100);
}
// Prints: 3, 3, 3 — all share the same i
// let — block-scoped, each iteration has its own i
for (let i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 100);
}
// Prints: 0, 1, 2 — correct
// const — block-scoped, can't be reassigned
const user = { name: "Ana" };
user.name = "Carlos"; // Fine — mutation is allowed
user = {}; // TypeError — reassignment is not allowedRule of thumb: Use const by default. Use let when you need to reassign. Avoid var.
Question 9: What is lexical scope?
The answer:
Lexical scope means a function's scope is determined by where it's written in the source code, not where it's called. Inner functions have access to variables in their outer scope.
function outer() {
const language = "JavaScript";
function inner() {
console.log(language); // Has access — lexically scoped
}
inner();
}
outer(); // "JavaScript"
// The scope chain goes outward, never inward
function parent() {
function child() {
const secret = "hidden";
}
console.log(secret); // ReferenceError — parent can't see into child
}This is the foundation for understanding closures. The word "lexical" just means "as written" — where it appears in the code.
Question 10: What is the difference between `call`, `apply`, and `bind`?
The answer:
All three let you explicitly set the this context of a function. The difference is how they handle arguments and when they execute.
function greet(greeting, punctuation) {
console.log(`${greeting}, ${this.name}${punctuation}`);
}
const user = { name: "Ana" };
// call — invokes immediately, arguments passed individually
greet.call(user, "Hello", "!"); // "Hello, Ana!"
// apply — invokes immediately, arguments passed as array
greet.apply(user, ["Hello", "!"]); // "Hello, Ana!"
// bind — returns a new function with 'this' bound, doesn't invoke
const boundGreet = greet.bind(user, "Hello");
boundGreet("!"); // "Hello, Ana!"
boundGreet("?"); // "Hello, Ana?"
// Practical use: borrowing methods
const arrayLike = { 0: "a", 1: "b", length: 2 };
const arr = Array.prototype.slice.call(arrayLike); // ["a", "b"]Memory trick: call = comma-separated, apply = array.
Part 3: Closures and the Module Pattern
Question 11: What is a closure?
The answer:
A closure is a function that remembers the variables from its outer scope even after the outer function has finished executing. The inner function "closes over" its lexical environment.
function makeCounter() {
let count = 0; // This variable persists
return function() {
count++;
return count;
};
}
const counter = makeCounter();
console.log(counter()); // 1
console.log(counter()); // 2
console.log(counter()); // 3
// makeCounter() is done, but count lives on inside the returned functionPractical uses:
// 1. Data encapsulation / private state
function createBankAccount(initialBalance) {
let balance = initialBalance; // private — can't be accessed directly
return {
deposit(amount) { balance += amount; },
withdraw(amount) {
if (amount > balance) throw new Error("Insufficient funds");
balance -= amount;
},
getBalance() { return balance; }
};
}
const account = createBankAccount(100);
account.deposit(50);
console.log(account.getBalance()); // 150
console.log(account.balance); // undefined — private
// 2. Function factories
function multiply(factor) {
return (number) => number * factor;
}
const double = multiply(2);
const triple = multiply(3);
console.log(double(5)); // 10
console.log(triple(5)); // 15
// 3. Partial application
function add(a, b) { return a + b; }
const add5 = add.bind(null, 5);
console.log(add5(3)); // 8What the interviewer is testing: This is one of the most important JavaScript concepts. They want to know you can explain it clearly, recognize where it appears, and use it intentionally.
Question 12: What is a common closure bug and how do you fix it?
The answer:
The classic var in a loop bug. The loop variable is shared across all closure instances.
// The bug
const functions = [];
for (var i = 0; i < 3; i++) {
functions.push(function() {
console.log(i);
});
}
functions[0](); // 3, not 0
functions[1](); // 3, not 1
functions[2](); // 3, not 2
// Fix 1: Use let (creates a new binding per iteration)
for (let i = 0; i < 3; i++) {
functions.push(function() {
console.log(i);
});
}
functions[0](); // 0 ✓
// Fix 2: IIFE (classic pre-ES6 fix)
for (var i = 0; i < 3; i++) {
functions.push((function(j) {
return function() { console.log(j); };
})(i));
}Question 13: What is the Module Pattern?
The answer:
The Module Pattern uses closures to create private state and expose a public API. Before ES modules, this was the primary way to organize JavaScript code.
// IIFE Module Pattern
const calculator = (function() {
// Private
let history = [];
function log(operation) {
history.push(operation);
}
// Public API
return {
add(a, b) {
const result = a + b;
log(`${a} + ${b} = ${result}`);
return result;
},
subtract(a, b) {
const result = a - b;
log(`${a} - ${b} = ${result}`);
return result;
},
getHistory() {
return [...history]; // returns a copy
}
};
})();
calculator.add(5, 3); // 8
calculator.getHistory(); // ["5 + 3 = 8"]
calculator.history; // undefined — privateToday you'd use ES modules (import/export) instead, but understanding the pattern helps you read older codebases and understand why modules work the way they do.
Part 4: The `this` Keyword
Question 14: How does `this` work in JavaScript?
The answer:
this refers to the execution context — who is calling the function. It's determined at call time (not at write time), except for arrow functions.
// 1. Global context
console.log(this); // window (browser) or {} (Node.js strict mode)
// 2. Method call — this = the object
const user = {
name: "Ana",
greet() {
console.log(this.name); // "Ana"
}
};
user.greet();
// 3. Regular function — this = undefined (strict mode) or global
function showThis() {
console.log(this); // undefined in strict mode
}
showThis();
// 4. Constructor — this = the new object
function Person(name) {
this.name = name;
}
const p = new Person("Carlos"); // this = the new Person object
// 5. Arrow function — inherits this from surrounding lexical scope
const timer = {
name: "Timer",
start() {
setTimeout(() => {
console.log(this.name); // "Timer" — arrow inherits this from start()
}, 100);
}
};
// vs regular function
const timer2 = {
name: "Timer",
start() {
setTimeout(function() {
console.log(this.name); // undefined — regular function, new context
}, 100);
}
};Question 15: What is the difference between arrow functions and regular functions regarding `this`?
The answer:
Arrow functions do not have their own this. They inherit this from the enclosing lexical scope. This makes them great for callbacks but wrong for methods or constructors.
// Arrow function as a method — common bug
const user = {
name: "Ana",
greet: () => {
console.log(this.name); // undefined — arrow captures outer this (global/module)
}
};
user.greet(); // undefined
// Regular function as a method — correct
const user2 = {
name: "Ana",
greet() {
console.log(this.name); // "Ana"
}
};
user2.greet(); // "Ana"
// Arrow shines in callbacks
class Timer {
constructor() {
this.seconds = 0;
}
start() {
setInterval(() => {
this.seconds++; // 'this' is the Timer instance
console.log(this.seconds);
}, 1000);
}
}
// Arrow functions also can't be used as constructors
const Foo = () => {};
new Foo(); // TypeError: Foo is not a constructor
// And they have no 'arguments' object
const fn = () => {
console.log(arguments); // ReferenceError
};Part 5: Prototypes and Inheritance
Question 16: How does prototypal inheritance work?
The answer:
In JavaScript, objects inherit from other objects via the prototype chain. Every object has an internal [[Prototype]] link. When you access a property, JS looks at the object first, then walks up the chain until it finds it or reaches null.
// The prototype chain
const animal = {
breathe() {
console.log("breathing...");
}
};
const dog = Object.create(animal); // dog's prototype = animal
dog.bark = function() {
console.log("woof");
};
dog.bark(); // "woof" — found on dog
dog.breathe(); // "breathing..." — found on animal via prototype chain
// Checking the chain
Object.getPrototypeOf(dog) === animal; // true
dog.hasOwnProperty("bark"); // true
dog.hasOwnProperty("breathe"); // false — it's inherited// Constructor functions and prototype
function Vehicle(make, model) {
this.make = make;
this.model = model;
}
Vehicle.prototype.describe = function() {
return `${this.make} ${this.model}`;
};
const car = new Vehicle("Toyota", "Corolla");
car.describe(); // "Toyota Corolla"
// class syntax is syntactic sugar over this
class Vehicle2 {
constructor(make, model) {
this.make = make;
this.model = model;
}
describe() {
return `${this.make} ${this.model}`;
}
}Question 17: What is the difference between `Object.create()`, `new`, and class syntax?
The answer:
All three create objects with inheritance, but they operate at different levels of abstraction.
// Object.create — pure prototypal inheritance
const proto = {
greet() { return `Hello, I'm ${this.name}`; }
};
const obj = Object.create(proto);
obj.name = "Ana";
obj.greet(); // "Hello, I'm Ana"
// new with constructor function
function Person(name) {
this.name = name;
}
Person.prototype.greet = function() {
return `Hello, I'm ${this.name}`;
};
const p = new Person("Ana"); // creates object, sets prototype, returns it
// class — syntactic sugar, same prototype mechanics
class PersonClass {
constructor(name) {
this.name = name;
}
greet() {
return `Hello, I'm ${this.name}`;
}
}
const p2 = new PersonClass("Ana");
// Inheritance with class
class Employee extends PersonClass {
constructor(name, role) {
super(name); // calls PersonClass constructor
this.role = role;
}
describe() {
return `${this.greet()}, I'm a ${this.role}`;
}
}What the interviewer is testing: Do you understand that class is sugar over prototypes? Can you explain the prototype chain? Senior candidates should know both the old way and the modern syntax.
Question 18: What happens when you use `new`?
The answer:
Four things happen when you call new on a function:
- 1A new empty object is created
- 2That object's prototype is set to the constructor's
prototypeproperty - 3The constructor runs with
this= the new object - 4The new object is returned (unless the constructor explicitly returns a different object)
function Person(name) {
// Step 1: empty object created {}
// Step 2: {}.__proto__ = Person.prototype
this.name = name; // Step 3: constructor runs
// Step 4: the new object is returned
}
// Simulating new manually
function myNew(Constructor, ...args) {
const obj = Object.create(Constructor.prototype); // steps 1 + 2
const result = Constructor.apply(obj, args); // step 3
return result instanceof Object ? result : obj; // step 4
}
const p = myNew(Person, "Ana");
p.name; // "Ana"Part 6: Async JavaScript
Question 19: What is the difference between callbacks, promises, and async/await?
The answer:
They all handle asynchronous operations, but each generation solved the problems of the previous one.
// Callbacks — original async pattern, leads to "callback hell"
getUserById(1, function(err, user) {
if (err) return handleError(err);
getPostsByUser(user.id, function(err, posts) {
if (err) return handleError(err);
getCommentsByPost(posts[0].id, function(err, comments) {
// Callback hell: deeply nested, hard to read, hard to handle errors
});
});
});
// Promises — chainable, better error handling
getUserById(1)
.then(user => getPostsByUser(user.id))
.then(posts => getCommentsByPost(posts[0].id))
.then(comments => console.log(comments))
.catch(err => handleError(err)); // one catch for all
// async/await — reads like synchronous code
async function loadData() {
try {
const user = await getUserById(1);
const posts = await getPostsByUser(user.id);
const comments = await getCommentsByPost(posts[0].id);
console.log(comments);
} catch (err) {
handleError(err);
}
}Important: async/await is built on top of Promises. An async function always returns a Promise. await pauses execution inside the async function, not in the event loop.
Question 20: What is a Promise and what are its states?
The answer:
A Promise is an object representing an eventual completion or failure of an async operation. It has three states:
- Pending — initial state, operation is in progress
- Fulfilled — completed successfully, has a value
- Rejected — failed, has a reason (error)
Once settled (fulfilled or rejected), a promise cannot change state.
// Creating a promise
const fetchUser = (id) => new Promise((resolve, reject) => {
if (!id) {
reject(new Error("ID required")); // rejected
return;
}
setTimeout(() => {
resolve({ id, name: "Ana" }); // fulfilled
}, 1000);
});
// Consuming
fetchUser(1)
.then(user => console.log(user)) // fulfilled handler
.catch(err => console.error(err)) // rejection handler
.finally(() => console.log("done")); // always runs
// Promise combinators
// Promise.all — resolves when ALL resolve, rejects on first rejection
Promise.all([fetchUser(1), fetchUser(2)])
.then(([user1, user2]) => console.log(user1, user2));
// Promise.allSettled — waits for all, regardless of outcome
Promise.allSettled([fetchUser(1), fetchUser(99)])
.then(results => results.forEach(r => console.log(r.status)));
// Promise.race — resolves/rejects with the first settled promise
// Promise.any — resolves with first fulfilled (ignores rejections)Question 21: How do you handle errors with async/await?
The answer:
Use try/catch blocks. Each async function should handle its own errors or let them propagate to the caller.
// Basic error handling
async function fetchData(url) {
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP error: ${response.status}`);
}
return await response.json();
} catch (err) {
console.error("Fetch failed:", err);
throw err; // re-throw if caller needs to handle it
}
}
// A useful pattern: wrapping to avoid try/catch everywhere
async function safeAsync(promise) {
try {
const data = await promise;
return [null, data];
} catch (err) {
return [err, null];
}
}
// Usage
const [err, user] = await safeAsync(fetchUser(1));
if (err) {
handleError(err);
} else {
console.log(user);
}
// Don't forget: unhandled promise rejections
// Always attach .catch() or use try/catch
fetchData("/api/users").catch(console.error);Question 22: What is `Promise.all` vs `Promise.allSettled`?
The answer:
Promise.all fails fast — if any promise rejects, the entire thing rejects immediately. Promise.allSettled always waits for all promises and gives you the result of each, whether they succeeded or failed.
const p1 = Promise.resolve("user data");
const p2 = Promise.reject(new Error("network error"));
const p3 = Promise.resolve("post data");
// Promise.all — fails because p2 rejects
Promise.all([p1, p2, p3])
.then(console.log)
.catch(err => console.log("Failed:", err.message));
// Output: "Failed: network error"
// Promise.allSettled — gives you everything
Promise.allSettled([p1, p2, p3])
.then(results => {
results.forEach(result => {
if (result.status === "fulfilled") {
console.log("Success:", result.value);
} else {
console.log("Failed:", result.reason.message);
}
});
});
// Success: user data
// Failed: network error
// Success: post dataUse Promise.all when all requests must succeed (e.g., loading critical data). Use Promise.allSettled when you want partial results (e.g., multiple independent API calls).
Part 7: ES6+ Features
Question 23: What is destructuring and what are its common uses?
The answer:
Destructuring extracts values from arrays or objects into distinct variables with concise syntax.
// Object destructuring
const user = { name: "Ana", age: 30, city: "Buenos Aires" };
const { name, age } = user;
// With renaming
const { name: userName, age: userAge } = user;
// With defaults
const { name: n, role = "viewer" } = user;
console.log(role); // "viewer"
// Nested destructuring
const { address: { city, country = "AR" } = {} } = user;
// Array destructuring
const [first, second, ...rest] = [1, 2, 3, 4, 5];
console.log(first); // 1
console.log(rest); // [3, 4, 5]
// Swap variables
let a = 1, b = 2;
[a, b] = [b, a];
// Function parameters
function display({ name, age, role = "user" }) {
console.log(`${name}, ${age}, ${role}`);
}
display(user);
// Skipping elements
const [,, third] = [1, 2, 3];
console.log(third); // 3Question 24: What is the spread operator and rest parameters?
The answer:
Both use ... syntax but do opposite things. Spread expands an iterable into individual elements. Rest collects individual elements into an array.
// Spread — expands
const arr1 = [1, 2, 3];
const arr2 = [4, 5, 6];
const combined = [...arr1, ...arr2]; // [1, 2, 3, 4, 5, 6]
// Copy an array (shallow)
const copy = [...arr1];
// Spread into function arguments
Math.max(...arr1); // same as Math.max(1, 2, 3)
// Spread with objects
const base = { a: 1, b: 2 };
const extended = { ...base, c: 3 }; // { a: 1, b: 2, c: 3 }
const overridden = { ...base, b: 99 }; // { a: 1, b: 99 } — last wins
// Rest parameters — collects
function sum(...numbers) {
return numbers.reduce((acc, n) => acc + n, 0);
}
sum(1, 2, 3, 4); // 10
// Rest in destructuring
const { a, ...remaining } = { a: 1, b: 2, c: 3 };
remaining; // { b: 2, c: 3 }Important: Spread creates shallow copies. Nested objects are still references.
Question 25: What are template literals and tagged templates?
The answer:
Template literals use backticks and support interpolation and multiline strings. Tagged templates let you parse template literals with a function.
// Basic template literals
const name = "Ana";
const greeting = `Hello, ${name}!`;
// Multiline — no \n needed
const html = `
<div>
<p>${greeting}</p>
</div>
`;
// Expressions inside
const total = `${2 + 2} items` // "4 items"
const tax = `${(price * 1.21).toFixed(2)}`
// Tagged templates
function highlight(strings, ...values) {
return strings.reduce((result, str, i) => {
const value = values[i - 1];
return result + (value ? `<mark>${value}</mark>` : "") + str;
});
}
const item = "JavaScript";
const price = 49.99;
const message = highlight`Learn ${item} for only $${price}`;
// "Learn <mark>JavaScript</mark> for only $<mark>49.99</mark>"
// Real-world example: SQL query builder
function sql(strings, ...values) {
// Escape values to prevent SQL injection
const escaped = values.map(v => escapeSQL(v));
return strings.reduce((q, s, i) => q + (escaped[i - 1] || "") + s);
}Question 26: What are generators and when would you use them?
The answer:
Generators are functions that can pause and resume execution. They produce values on demand (lazily). Defined with function*, they use yield to pause and return a value.
function* count() {
yield 1;
yield 2;
yield 3;
}
const counter = count();
counter.next(); // { value: 1, done: false }
counter.next(); // { value: 2, done: false }
counter.next(); // { value: 3, done: false }
counter.next(); // { value: undefined, done: true }
// Infinite sequence
function* integers(start = 0) {
while (true) {
yield start++;
}
}
const gen = integers(1);
gen.next().value; // 1
gen.next().value; // 2
// Practical: paginate through API results
function* paginate(fetchPage, pageSize = 10) {
let page = 0;
while (true) {
const results = yield fetchPage(page, pageSize);
if (!results || results.length < pageSize) return;
page++;
}
}
// Generators power Symbol.iterator
class Range {
constructor(start, end) {
this.start = start;
this.end = end;
}
[Symbol.iterator]() {
let current = this.start;
const end = this.end;
return {
next() {
return current <= end
? { value: current++, done: false }
: { done: true };
}
};
}
}
const range = new Range(1, 5);
[...range]; // [1, 2, 3, 4, 5]
for (const n of range) console.log(n);Question 27: What are WeakMap and WeakSet?
The answer:
WeakMap and WeakSet hold weak references to their keys/values, meaning the garbage collector can reclaim them if no other references exist. They're not iterable.
// WeakMap — keys must be objects
const cache = new WeakMap();
function processUser(user) {
if (cache.has(user)) {
return cache.get(user);
}
const result = expensiveComputation(user);
cache.set(user, result);
return result;
}
// When user object is garbage collected, cache entry is too
// Regular Map would prevent GC — memory leak
// WeakSet — storing objects without preventing GC
const processed = new WeakSet();
function processOnce(obj) {
if (processed.has(obj)) return;
// ... do work
processed.add(obj);
}
// Use case: private data without memory leaks
const _private = new WeakMap();
class Person {
constructor(name, ssn) {
_private.set(this, { ssn });
this.name = name;
}
getSSN() {
return _private.get(this).ssn;
}
}Part 8: The Event Loop
Question 28: How does the JavaScript event loop work?
The answer:
JavaScript is single-threaded — it runs one thing at a time. The event loop is the mechanism that lets it handle async operations without blocking.
There are three key queues:
- 1Call Stack — where synchronous code runs
- 2Microtask Queue — where resolved Promises and
queueMicrotaskgo (higher priority) - 3Task Queue (Macrotask) — where
setTimeout,setInterval, I/O callbacks go
The event loop does this in order:
- 1Run everything in the call stack
- 2Drain the entire microtask queue (including any new microtasks added during processing)
- 3Take one task from the task queue
- 4Repeat
console.log("1");
setTimeout(() => console.log("2"), 0); // macrotask
Promise.resolve().then(() => console.log("3")); // microtask
queueMicrotask(() => console.log("4")); // microtask
console.log("5");
// Output: 1, 5, 3, 4, 2
// Sync first: 1, 5
// Then microtasks: 3, 4 (both microtasks drain before any macrotask)
// Then macrotask: 2// Microtasks drain completely before next macrotask
Promise.resolve()
.then(() => {
console.log("microtask 1");
Promise.resolve().then(() => console.log("microtask 1.1")); // added during processing
})
.then(() => console.log("microtask 2"));
setTimeout(() => console.log("macrotask"), 0);
// Output: microtask 1, microtask 1.1, microtask 2, macrotaskQuestion 29: What is `setTimeout(fn, 0)` actually doing?
The answer:
It's scheduling fn to run as soon as the current call stack and all microtasks are cleared. The 0 means "minimum delay" — not "run immediately." It effectively defers code to after the current synchronous execution and pending microtasks.
// Useful for deferring DOM work until after the current render
button.addEventListener("click", () => {
// DOM update happens now
element.style.display = "block";
setTimeout(() => {
// This runs after the browser has painted
startAnimation();
}, 0);
});
// Also used to break up long synchronous tasks
function processItems(items) {
let index = 0;
function processNext() {
if (index >= items.length) return;
process(items[index++]);
setTimeout(processNext, 0); // yield to event loop
}
processNext();
}Question 30: What is the difference between `setTimeout`, `setInterval`, and `requestAnimationFrame`?
The answer:
setTimeout(fn, delay)— runs once after at leastdelaymillisecondssetInterval(fn, delay)— runs repeatedly everydelaymilliseconds (or as soon as possible after)requestAnimationFrame(fn)— runs before the next browser repaint, synchronized to the display refresh rate
// setTimeout — single delayed execution
const timerId = setTimeout(() => {
console.log("runs once after 1 second");
}, 1000);
clearTimeout(timerId); // cancel it
// setInterval — repeated execution
const intervalId = setInterval(() => {
console.log("runs every second");
}, 1000);
clearInterval(intervalId); // cancel it
// Problem with setInterval: can stack if callback takes longer than interval
// Better: recursive setTimeout
function reliableInterval(fn, delay) {
function tick() {
fn();
setTimeout(tick, delay); // schedule next run AFTER current completes
}
setTimeout(tick, delay);
}
// requestAnimationFrame — animations
function animate() {
// update animation state
element.style.transform = `translateX(${position}px)`;
position++;
if (position < 300) {
requestAnimationFrame(animate); // schedule next frame
}
}
requestAnimationFrame(animate); // starts when browser is ready to paintPart 9: DOM and Browser APIs
Question 31: What is event delegation?
The answer:
Event delegation attaches a single event listener to a parent element instead of individual listeners on each child. It works because events bubble up the DOM tree.
// Without delegation — a listener on every button
document.querySelectorAll(".btn").forEach(btn => {
btn.addEventListener("click", handleClick);
});
// Problem: doesn't work for dynamically added buttons, and 1000 buttons = 1000 listeners
// With delegation — one listener on the parent
document.getElementById("button-container").addEventListener("click", (e) => {
if (e.target.classList.contains("btn")) {
handleClick(e.target);
}
});
// Works for any .btn added later, and only one listener
// Practical example: todo list
const list = document.getElementById("todo-list");
list.addEventListener("click", (e) => {
const item = e.target.closest("li"); // handles clicks on child elements
if (!item) return;
if (e.target.classList.contains("delete-btn")) {
item.remove();
} else if (e.target.classList.contains("complete-btn")) {
item.classList.toggle("done");
}
});Question 32: What is the difference between `stopPropagation` and `preventDefault`?
The answer:
Two very different things that people often confuse:
stopPropagation()— stops the event from bubbling up (or down in capture phase) the DOM tree. Other handlers on the same element still fire.preventDefault()— prevents the browser's default action for that event (e.g., form submission, link navigation). The event still bubbles.
// preventDefault — stops default browser behavior
form.addEventListener("submit", (e) => {
e.preventDefault(); // stops page reload
validateAndSubmit(e.target);
});
link.addEventListener("click", (e) => {
e.preventDefault(); // stops navigation
showPreview(e.target.href);
});
// stopPropagation — stops event bubbling
document.addEventListener("click", () => {
closeMenu(); // closes menu on any click
});
menuButton.addEventListener("click", (e) => {
e.stopPropagation(); // don't close menu when clicking the toggle
toggleMenu();
});
// stopImmediatePropagation — stops other handlers on same element AND bubbling
// Use rarelyQuestion 33: What is the difference between `innerHTML`, `textContent`, and `innerText`?
The answer:
innerHTML— sets/gets content as HTML, parses and renders tagstextContent— sets/gets raw text, no HTML parsing, includes hidden elementsinnerText— like textContent but respects CSS (visibility), causes reflow
const el = document.getElementById("container");
// innerHTML — renders HTML
el.innerHTML = "<strong>Hello</strong>"; // renders bold text
const content = el.innerHTML; // "<strong>Hello</strong>"
// XSS risk — NEVER use innerHTML with user input
el.innerHTML = userInput; // DANGEROUS if userInput = "<script>alert('xss')</script>"
// Safe alternative
el.textContent = userInput; // escaped, no parsing
// textContent
el.textContent = "<strong>Hello</strong>"; // renders as literal text with < > shown
// innerText
const hidden = document.querySelector("[style='display:none']");
hidden.textContent; // returns the text
hidden.innerText; // returns "" — respects visibility
// For DOM manipulation, prefer creating elements
const div = document.createElement("div");
div.textContent = userInput; // safe
parent.appendChild(div);Part 10: Performance and Best Practices
Question 34: What is debouncing and throttling?
The answer:
Both techniques limit how often a function runs in response to rapid events. They solve different problems:
- Debounce — waits until the event stops firing for a specified time before calling the function. Use for search inputs, resize handlers.
- Throttle — ensures the function fires at most once per interval, no matter how many events fire. Use for scroll handlers, button clicks.
// Debounce — fires once after activity stops
function debounce(fn, delay) {
let timeoutId;
return function(...args) {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => {
fn.apply(this, args);
}, delay);
};
}
const searchHandler = debounce((query) => {
fetchResults(query); // only fires after user stops typing for 300ms
}, 300);
searchInput.addEventListener("input", (e) => searchHandler(e.target.value));
// Throttle — fires at most once per interval
function throttle(fn, interval) {
let lastTime = 0;
return function(...args) {
const now = Date.now();
if (now - lastTime >= interval) {
lastTime = now;
fn.apply(this, args);
}
};
}
const scrollHandler = throttle(() => {
updateScrollIndicator(); // fires at most every 100ms
}, 100);
window.addEventListener("scroll", scrollHandler);Question 35: What is memoization?
The answer:
Memoization caches the results of expensive function calls and returns the cached result for the same inputs, instead of computing again.
// Basic memoization
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;
};
}
// Expensive function
function fibonacci(n) {
if (n <= 1) return n;
return fibonacci(n - 1) + fibonacci(n - 2);
}
// fibonacci(40) — very slow
// Memoized version:
const memoFib = memoize(function fib(n) {
if (n <= 1) return n;
return memoFib(n - 1) + memoFib(n - 2);
});
memoFib(40); // fast — each value computed once
// React.useMemo and React.useCallback are in-framework memoization
// for components (prevent unnecessary re-renders)Question 36: What is the difference between shallow and deep copying?
The answer:
A shallow copy copies the top-level properties but nested objects are still shared references. A deep copy creates an entirely independent clone.
// Shallow copy
const original = {
name: "Ana",
address: { city: "Buenos Aires" }
};
const shallow1 = { ...original };
const shallow2 = Object.assign({}, original);
shallow1.name = "Carlos"; // original.name unchanged
shallow1.address.city = "Rosario"; // original.address.city ALSO changed — same reference
// Deep copy options
// 1. JSON.parse/JSON.stringify — simple but limited
// Loses: functions, undefined, Date, RegExp, circular refs
const deep1 = JSON.parse(JSON.stringify(original));
deep1.address.city = "Córdoba"; // original.address.city unchanged
// 2. structuredClone — built-in, better
// Handles: Date, RegExp, Map, Set, Blob, ArrayBuffer
// Doesn't handle: functions, DOM nodes, classes
const deep2 = structuredClone(original);
// 3. Recursive deep clone
function deepClone(value, seen = new WeakMap()) {
if (value === null || typeof value !== "object") return value;
if (seen.has(value)) return seen.get(value); // handle circular refs
const clone = Array.isArray(value) ? [] : Object.create(Object.getPrototypeOf(value));
seen.set(value, clone);
for (const key of Reflect.ownKeys(value)) {
clone[key] = deepClone(value[key], seen);
}
return clone;
}Part 11: Advanced / Senior-Level Questions
Question 37: What is the difference between `Symbol.iterator` and generators?
The answer:
Symbol.iterator is the protocol that makes an object iterable (usable in for...of, spread, destructuring). A generator function is one way to implement it. They're related but distinct.
// Any object with Symbol.iterator is 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 }
: { done: true, value: undefined };
}
};
}
};
[...range]; // [1, 2, 3, 4, 5]
for (const n of range) console.log(n);
// Generator is shorthand for the same thing
const rangeGen = {
from: 1,
to: 5,
*[Symbol.iterator]() {
for (let i = this.from; i <= this.to; i++) {
yield i;
}
}
};
[...rangeGen]; // [1, 2, 3, 4, 5]Question 38: What is a Proxy and what can you do with it?
The answer:
Proxy wraps an object and intercepts fundamental operations on it — reads, writes, function calls, etc. — letting you add custom behavior.
const handler = {
get(target, prop) {
console.log(`Getting ${prop}`);
return Reflect.get(target, prop); // default behavior
},
set(target, prop, value) {
if (typeof value !== "number") {
throw new TypeError(`${prop} must be a number`);
}
return Reflect.set(target, prop, value);
},
deleteProperty(target, prop) {
if (prop === "id") throw new Error("Cannot delete id");
return Reflect.deleteProperty(target, prop);
}
};
const user = new Proxy({ id: 1, name: "Ana", age: 30 }, handler);
user.name; // logs "Getting name", returns "Ana"
user.age = "thirty"; // TypeError: age must be a number
delete user.id; // Error: Cannot delete id
// Practical uses:
// 1. Validation
// 2. Observability (reactivity systems like Vue 3 use Proxy)
// 3. Default values
const withDefaults = new Proxy({}, {
get(target, prop) {
return prop in target ? target[prop] : "N/A";
}
});
withDefaults.anything; // "N/A"
// 4. Readonly objects
function readonly(obj) {
return new Proxy(obj, {
set() { throw new Error("Object is readonly"); },
deleteProperty() { throw new Error("Object is readonly"); }
});
}Question 39: What are Web Workers and when would you use them?
The answer:
Web Workers run JavaScript in a background thread, separate from the main thread. They can't access the DOM but can do heavy computation without blocking the UI.
// main.js
const worker = new Worker("worker.js");
worker.postMessage({ numbers: largeArray }); // send data
worker.onmessage = (event) => {
console.log("Result:", event.data.result); // receive result
};
worker.onerror = (error) => {
console.error("Worker error:", error);
};
// To stop: worker.terminate();
// worker.js
self.onmessage = (event) => {
const { numbers } = event.data;
const result = numbers.reduce((sum, n) => sum + n, 0); // heavy computation
self.postMessage({ result }); // send back
};
// Use cases:
// - Sorting/filtering large datasets
// - Image/video processing
// - Crypto operations
// - Running WebAssembly
// - Real-time data processingQuestion 40: What is `Object.freeze` vs `Object.seal`?
The answer:
Object.freeze— makes an object immutable. No adding, removing, or modifying properties.Object.seal— prevents adding or removing properties, but allows modifying existing ones.
// Object.freeze
const config = Object.freeze({
apiUrl: "https://api.example.com",
timeout: 5000
});
config.apiUrl = "https://evil.com"; // silently fails (throws in strict mode)
config.newProp = "test"; // silently fails
console.log(config.apiUrl); // "https://api.example.com" — unchanged
// Object.seal
const user = Object.seal({ name: "Ana", age: 30 });
user.name = "Carlos"; // allowed — modifying existing
user.email = "c@c.com"; // fails — can't add new
delete user.age; // fails — can't delete
// Important: freeze is SHALLOW
const nested = Object.freeze({
outer: "immutable",
inner: { value: 42 }
});
nested.inner.value = 99; // Works — inner is not frozen
nested.inner; // { value: 99 }
// Deep freeze
function deepFreeze(obj) {
Object.getOwnPropertyNames(obj).forEach(name => {
const value = obj[name];
if (value && typeof value === "object") {
deepFreeze(value);
}
});
return Object.freeze(obj);
}Question 41: What is the difference between `for...in` and `for...of`?
The answer:
for...in— iterates over enumerable property keys of an object (including inherited ones). Avoid on arrays.for...of— iterates over values of any iterable (arrays, strings, maps, sets, generators).
// for...in — keys
const obj = { a: 1, b: 2, c: 3 };
for (const key in obj) {
console.log(key); // "a", "b", "c"
}
// Danger: includes inherited enumerable properties
function Base() {}
Base.prototype.inherited = "oh no";
const child = new Base();
child.own = "mine";
for (const key in child) {
console.log(key); // "own", "inherited"
}
// Fix: hasOwnProperty check
for (const key in child) {
if (Object.prototype.hasOwnProperty.call(child, key)) {
console.log(key); // only "own"
}
}
// for...of — values
const arr = [10, 20, 30];
for (const value of arr) {
console.log(value); // 10, 20, 30
}
// Works on strings
for (const char of "hello") {
console.log(char); // h, e, l, l, o
}
// Works on Map and Set
const map = new Map([["a", 1], ["b", 2]]);
for (const [key, value] of map) {
console.log(key, value);
}
// Does NOT work on plain objects (not iterable)
for (const val of obj) { } // TypeError
// Use: Object.keys(obj), Object.values(obj), Object.entries(obj)Question 42: What is currying and how do you implement it?
The answer:
Currying transforms a function that takes multiple arguments into a sequence of functions each taking one argument. It enables partial application — fixing some arguments and returning a new function.
// Manual currying
function add(a) {
return function(b) {
return a + b;
};
}
const add5 = add(5);
add5(3); // 8
add5(10); // 15
// Arrow function version
const multiply = a => b => a * b;
const double = multiply(2);
double(4); // 8
// Generic curry function
function curry(fn) {
return function curried(...args) {
if (args.length >= fn.length) {
return fn.apply(this, args);
}
return function(...moreArgs) {
return curried.apply(this, args.concat(moreArgs));
};
};
}
function volume(l, w, h) {
return l * w * h;
}
const curriedVolume = curry(volume);
curriedVolume(2)(3)(4); // 24
curriedVolume(2, 3)(4); // 24
curriedVolume(2)(3, 4); // 24
curriedVolume(2, 3, 4); // 24
// Practical use
const formatPrice = currency => amount => `${currency}${amount.toFixed(2)}`;
const inDollars = formatPrice("$");
const inEuros = formatPrice("€");
inDollars(42.5); // "$42.50"
inEuros(42.5); // "€42.50"Question 43: What is tail call optimization?
The answer:
Tail call optimization (TCO) allows the JavaScript engine to reuse the current stack frame when the last action of a function is to call another function (a "tail call"). This prevents stack overflow in deeply recursive functions.
// Regular recursion — builds up the stack
function factorial(n) {
if (n <= 1) return 1;
return n * factorial(n - 1); // not a tail call — multiply happens after
}
// factorial(100000) → Stack overflow
// Tail recursive version
function factorial(n, accumulator = 1) {
if (n <= 1) return accumulator;
return factorial(n - 1, n * accumulator); // tail call — last action is the call
}
// In strict mode with TCO support, this doesn't overflow
// In practice: TCO is only in Safari as of now (Chrome/V8 dropped it)
// Use iteration for large recursion
function factorialIterative(n) {
let result = 1;
for (let i = 2; i <= n; i++) {
result *= i;
}
return result;
}
// Trampoline pattern — simulates TCO without engine support
function trampoline(fn) {
return function(...args) {
let result = fn(...args);
while (typeof result === "function") {
result = result();
}
return result;
};
}
function factTrampolined(n, acc = 1) {
if (n <= 1) return acc;
return () => factTrampolined(n - 1, n * acc); // returns a thunk
}
const fact = trampoline(factTrampolined);
fact(100000); // works without stack overflowQuestion 44: What are Symbols and when would you use them?
The answer:
Symbols are unique, immutable primitive values. Every Symbol() call creates a distinct value — even if you use the same description. They're primarily used as non-colliding property keys.
const id1 = Symbol("id");
const id2 = Symbol("id");
id1 === id2; // false — always unique
// Use case 1: non-colliding property keys (avoid naming conflicts)
const USER_ID = Symbol("userId");
const AUTH_TOKEN = Symbol("userId"); // same description, different symbol
const user = {
name: "Ana",
[USER_ID]: "abc-123",
[AUTH_TOKEN]: "token-xyz"
};
user[USER_ID]; // "abc-123"
user[AUTH_TOKEN]; // "token-xyz"
// No collision despite same string description
// Symbols are not enumerable by default
Object.keys(user); // ["name"]
Object.getOwnPropertySymbols(user); // [Symbol(userId), Symbol(userId)]
// Use case 2: well-known symbols (customize built-in behavior)
class Stack {
#items = [];
push(item) { this.#items.push(item); }
pop() { return this.#items.pop(); }
[Symbol.iterator]() {
let index = this.#items.length - 1;
const items = this.#items;
return {
next() {
return index >= 0
? { value: items[index--], done: false }
: { done: true };
}
};
}
}
// Use case 3: Symbol.for — global registry (shared symbols)
const shared1 = Symbol.for("app.userId");
const shared2 = Symbol.for("app.userId");
shared1 === shared2; // true — same symbol from registryQuestion 45: What are private class fields and how do they work?
The answer:
Private class fields use # prefix. They are truly private — not accessible outside the class, not even on subclasses. They're enforced by the language, not by convention.
class BankAccount {
#balance; // private field
#transactionLog = []; // private with default
constructor(initialBalance) {
this.#balance = initialBalance;
}
deposit(amount) {
this.#validateAmount(amount);
this.#balance += amount;
this.#transactionLog.push({ type: "deposit", amount });
}
withdraw(amount) {
this.#validateAmount(amount);
if (amount > this.#balance) throw new Error("Insufficient funds");
this.#balance -= amount;
this.#transactionLog.push({ type: "withdrawal", amount });
}
get balance() {
return this.#balance; // read-only public access
}
#validateAmount(amount) { // private method
if (amount <= 0) throw new Error("Amount must be positive");
}
}
const account = new BankAccount(100);
account.deposit(50);
console.log(account.balance); // 150
console.log(account.#balance); // SyntaxError — truly private
console.log(account["#balance"]); // undefined — bracket notation doesn't work either
// Check if an object has a private field
class Foo {
#value;
static isFoo(obj) {
return #value in obj;
}
}Question 46: What is the Temporal Dead Zone (TDZ)?
The answer:
The TDZ is the period between when a let or const variable enters scope (hoisted) and when it's initialized. Accessing the variable during the TDZ throws a ReferenceError.
// TDZ in action
{
// TDZ for 'x' starts here
console.log(x); // ReferenceError: Cannot access 'x' before initialization
let x = 10; // TDZ ends here
console.log(x); // 10
}
// var doesn't have TDZ
{
console.log(y); // undefined — hoisted and initialized as undefined
var y = 10;
console.log(y); // 10
}
// TDZ with function parameters
function bad(a = b, b = 2) { // b is in TDZ when evaluated as default for a
return a + b;
}
bad(); // ReferenceError
function good(a = 1, b = a * 2) { // fine — a is initialized before b's default
return a + b;
}
good(); // 3
// Class bodies are in strict mode and have TDZ
class Foo {
bar = this.baz; // Works — class fields are initialized in order
baz = 42;
}
const f = new Foo(); // f.bar is undefined (baz not yet set when bar evaluated), f.baz is 42Question 47: How does garbage collection work in JavaScript?
The answer:
JavaScript uses automatic garbage collection. The most common algorithm is mark-and-sweep: the GC marks all objects reachable from "roots" (global scope, stack), then sweeps away anything unmarked.
// An object becomes eligible for GC when no references point to it
let user = { name: "Ana" };
user = null; // original object is now unreachable — GC can collect it
// Circular references — modern GC handles these
let obj1 = {};
let obj2 = {};
obj1.ref = obj2;
obj2.ref = obj1;
obj1 = null;
obj2 = null;
// Both are unreachable — GC collects them
// Memory leaks — when references are unintentionally kept alive
// 1. Forgotten timers
const data = largeObject;
const timer = setInterval(() => {
process(data); // data stays alive as long as timer runs
}, 1000);
// Fix: clearInterval(timer) when done
// 2. Detached DOM nodes
const container = document.getElementById("container");
const refs = [];
for (let i = 0; i < 100; i++) {
const el = document.createElement("div");
container.appendChild(el);
refs.push(el); // keeping references
}
container.innerHTML = ""; // DOM nodes removed, but refs still holds them
// Fix: refs.length = 0; or refs = [];
// 3. Closures capturing large objects
function outer() {
const bigData = new Array(1000000).fill("data");
return function inner() {
return bigData.length; // bigData can't be GC'd
};
}
// 4. Global variables
function leak() {
leaked = "this is global now"; // missing var/let/const
}
// WeakMap/WeakSet — hold weak references (GC-friendly)
const cache = new WeakMap();
// When the key object is GC'd, the cache entry goes tooQuestion 48: What is structural sharing and how does it relate to immutability?
The answer:
Structural sharing is the technique of reusing unchanged parts of a data structure when creating modified versions, rather than deep-cloning everything. It makes immutable updates efficient.
// Naive immutable update — expensive deep clone
const state = { user: { name: "Ana", age: 30 }, settings: { theme: "dark" } };
const newState = JSON.parse(JSON.stringify(state)); // clone everything
newState.user.age = 31;
// Structural sharing — only create new objects for what changed
const newState2 = {
...state, // reuse reference to settings
user: { ...state.user, age: 31 } // new user object, reuse unchanged fields
};
state.settings === newState2.settings; // true — same reference, shared
state.user === newState2.user; // false — new object (was mutated)
// This is how React state updates work
function reducer(state, action) {
switch (action.type) {
case "INCREMENT":
return {
...state, // shallow copy
count: state.count + 1 // only this changes
};
default:
return state; // exact same reference — no re-render
}
}
// Libraries like Immer let you write "mutable" code
// that produces structurally-shared immutable updates
import produce from "immer";
const nextState = produce(state, draft => {
draft.user.age = 31; // looks mutable but produces immutable result
});Question 49: What is function composition and how do you implement it?
The answer:
Function composition combines multiple functions into one, where the output of each function becomes the input of the next.
// Basic composition: right to left
const compose = (...fns) => x => fns.reduceRight((acc, fn) => fn(acc), x);
// Pipe: left to right (more intuitive for most)
const pipe = (...fns) => x => fns.reduce((acc, fn) => fn(acc), x);
// Example functions
const trim = str => str.trim();
const toLowerCase = str => str.toLowerCase();
const removeSpaces = str => str.replace(/\s+/g, "-");
const slugify = pipe(trim, toLowerCase, removeSpaces);
slugify(" Hello World "); // "hello-world"
// More complex example
const processUsers = pipe(
users => users.filter(u => u.active),
users => users.map(u => ({ ...u, name: u.name.trim() })),
users => users.sort((a, b) => a.name.localeCompare(b.name))
);
processUsers(rawUsers); // clean, sorted, active users
// Composition with async functions
const asyncPipe = (...fns) => x => fns.reduce(
(promise, fn) => promise.then(fn),
Promise.resolve(x)
);
const fetchAndProcess = asyncPipe(
fetchUser,
enrichWithPermissions,
formatForDisplay
);
await fetchAndProcess(userId);Question 50: What is the difference between imperative and declarative code in JavaScript?
The answer:
Imperative code describes how to do something, step by step. Declarative code describes what you want, leaving the how to the underlying system.
const numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
// Imperative — describes how
const evenSquares = [];
for (let i = 0; i < numbers.length; i++) {
if (numbers[i] % 2 === 0) {
evenSquares.push(numbers[i] ** 2);
}
}
// Declarative — describes what
const evenSquares2 = numbers
.filter(n => n % 2 === 0)
.map(n => n ** 2);
// DOM manipulation
// Imperative
const list = document.createElement("ul");
items.forEach(item => {
const li = document.createElement("li");
li.textContent = item.name;
list.appendChild(li);
});
document.body.appendChild(list);
// Declarative (React/JSX)
const List = ({ items }) => (
<ul>
{items.map(item => <li key={item.id}>{item.name}</li>)}
</ul>
);
// SQL is declarative
SELECT * FROM users WHERE active = true ORDER BY name;
// You say what you want, not how to find itNeither is universally better — declarative code is often more readable and maintainable; imperative code sometimes gives you finer control and better performance.
Bonus: 5 Questions Interviewers Ask to Catch Senior Candidates Off Guard
Question 51: "What happens when you access a property on `undefined`?"
const user = undefined;
user.name; // TypeError: Cannot read properties of undefined (reading 'name')
// Defensive patterns
const name = user?.name; // Optional chaining — undefined if user is nullish
const name2 = user && user.name; // Short-circuit — legacy pattern
const name3 = (user || {}).name; // Fallback to empty object
// Why this matters: the TypeError message now tells you the property name
// In older JS engines you only got "Cannot read property of undefined"Question 52: "What is the output of this code?"
console.log(typeof undefined); // "undefined"
console.log(typeof null); // "object" (historical bug)
console.log(typeof NaN); // "number"
console.log(typeof function(){}); // "function"
console.log(typeof []); // "object" — not "array"
console.log(typeof class {}); // "function" — classes are functions
console.log(typeof Symbol()); // "symbol"
// Reliable type checking
Array.isArray([]); // true
null === null; // true
Number.isNaN(NaN); // true
Object.prototype.toString.call([]); // "[object Array]"
Object.prototype.toString.call(null); // "[object Null]"Question 53: "Why does `0.1 + 0.2 !== 0.3`?"
0.1 + 0.2; // 0.30000000000000004
// Because JavaScript uses IEEE 754 floating-point arithmetic (64-bit doubles)
// 0.1 and 0.2 can't be represented exactly in binary, so small errors accumulate
// Fix: use epsilon comparison
Math.abs(0.1 + 0.2 - 0.3) < Number.EPSILON; // true
// Or: work with integers (cents instead of dollars)
// 10 cents + 20 cents = 30 cents (integer math, exact)
// Or: round for display
(0.1 + 0.2).toFixed(1); // "0.3"Question 54: "How do you prevent the default behavior of a form, but still allow the browser back button to work?"
This tests practical DOM knowledge. preventDefault() on the submit event stops submission but doesn't affect navigation. The History API handles navigation:
form.addEventListener("submit", async (e) => {
e.preventDefault();
const formData = new FormData(e.target);
await submitForm(formData);
// Push to history so back button works
history.pushState({ page: "success" }, "", "/success");
showSuccessPage();
});
window.addEventListener("popstate", (e) => {
if (e.state?.page === "success") {
showSuccessPage();
} else {
showForm();
}
});Question 55: "When would you use `Object.defineProperty`?"
// For fine-grained control over property behavior
const config = {};
Object.defineProperty(config, "apiKey", {
value: "secret-key-123",
writable: false, // can't reassign
enumerable: false, // won't show in for...in or Object.keys()
configurable: false // can't redefine or delete
});
config.apiKey; // "secret-key-123"
config.apiKey = "new"; // silently fails (throws in strict mode)
Object.keys(config); // [] — not enumerable
// Computed/lazy properties
class UserStore {
#data = {};
get(key) { return this.#data[key]; }
defineComputed(name, getter) {
Object.defineProperty(this, name, {
get: getter,
enumerable: true,
configurable: true
});
}
}
// Vue 2 used Object.defineProperty for reactivity
// (Vue 3 switched to Proxy for better coverage)How to Handle Questions You Don't Know
Real talk: you will get questions you can't fully answer. Here's how to handle it:
Say what you know: "I haven't used this directly, but based on how X works, I'd expect it to..."
Show your reasoning process: Interviewers often care more about how you think than whether you have the exact answer memorized.
Admit the gap cleanly: "I'm not certain on the exact behavior there — I'd want to verify that. But what I do know is..."
Don't fake it: Interviewers know when you're guessing with confidence. A candid "I'd need to look that up" is far better than a confident wrong answer.
What to Focus on by Level
Junior: Questions 1–15, 23–25. Get the fundamentals rock-solid. Closures, this, callbacks, and ES6 syntax.
Mid-level: Add questions 16–22, 26–30. Async patterns, prototypes, the event loop.
Senior: All 55 questions. Plus: architecture, performance tradeoffs, when NOT to use a pattern, and explaining things to non-engineers.
The One Thing That Separates Good Answers From Great Ones
Every example in this guide shows the pattern, then a real-world use case. That's what interviewers want to see: you understand a concept well enough to know *when to reach for it*.
Don't just answer "what is debouncing." Say: "Debouncing delays execution until activity stops — I use it on search inputs so we don't fire a request on every keystroke. For scroll handlers, I'd reach for throttling instead because I want consistent updates, not just the final state."
That's the difference between memorizing answers and actually knowing JavaScript.