Interview Handbook
JavaScript Core — Interview Handbook
Deep-dive into JavaScript fundamentals that appear in every frontend interview: types, closures, prototypes, the event loop, promises, ES6+, and performance.
Types, Values & Coercion
Understanding types, values, and coercion is crucial for JavaScript interviews because these concepts are the foundation of how JavaScript operates and are frequently tested. Misunderstandings about primitive vs reference types, coercion rules, and special values like NaN and null can lead to subtle bugs. Mastering these topics demonstrates a deep grasp of the language's core mechanics.
Primitive vs Reference Types
Primitive types (string, number, boolean, null, undefined, symbol, bigint) are immutable and stored by value. Reference types (object, array, function) are mutable and stored by reference. When you assign or compare, primitives copy the value, while references copy the memory address.
let a = 10;
let b = a;
b = 20;
console.log(a); // 10 (primitive, independent)
let obj1 = { value: 10 };
let obj2 = obj1;
obj2.value = 20;
console.log(obj1.value); // 20 (reference, shared)Type Coercion and ==
JavaScript's == operator performs type coercion before comparing, converting values to a common type. This can lead to unexpected results. Always prefer === (strict equality) to avoid coercion surprises, unless you intentionally need coercion.
console.log(5 == '5'); // true (coercion: string to number) console.log(5 === '5'); // false (no coercion) console.log(false == 0); // true (coercion: boolean to number) console.log(null == undefined); // true (special rule)
Memorize the coercion table for ==: null and undefined are equal to each other but not to anything else. For other types, JavaScript converts to numbers or strings. In interviews, always explain that === is safer and preferred.
typeof and instanceof
typeof returns a string indicating the type of a value. It's useful for primitives but has quirks (e.g., typeof null returns 'object'). instanceof checks if an object is an instance of a constructor in its prototype chain, working only with objects.
console.log(typeof 42); // 'number'
console.log(typeof 'hello'); // 'string'
console.log(typeof null); // 'object' (historical bug)
console.log(typeof undefined); // 'undefined'
console.log([] instanceof Array); // true
console.log({} instanceof Object); // true
console.log(5 instanceof Number); // false (5 is primitive)null vs undefined
undefined is the default value for uninitialized variables or missing properties. null is an intentional absence of any object value. They are loosely equal (null == undefined is true) but strictly different (null === undefined is false).
let x; console.log(x); // undefined let y = null; console.log(y); // null console.log(null == undefined); // true console.log(null === undefined); // false
NaN
NaN (Not-a-Number) is a special number value resulting from invalid math operations. It is the only value in JavaScript that is not equal to itself (NaN !== NaN). Use Number.isNaN() to reliably check for NaN.
console.log(0 / 0); // NaN
console.log(NaN === NaN); // false
console.log(Number.isNaN(NaN)); // true
console.log(isNaN('hello')); // true (coerces to number)
console.log(Number.isNaN('hello')); // false (no coercion)BigInt and Symbol
BigInt allows representation of integers larger than Number.MAX_SAFE_INTEGER (2^53 - 1). Symbol creates unique, immutable values often used as object property keys to avoid name collisions.
const big = 9007199254740991n + 1n;
console.log(big); // 9007199254740992n
const sym1 = Symbol('id');
const sym2 = Symbol('id');
console.log(sym1 === sym2); // false (unique)
const obj = { [sym1]: 'value' };
console.log(obj[sym1]); // 'value'What does the following code output? console.log(typeof NaN);
Scope, Closures & Hoisting
Scope, closures, and hoisting are foundational JavaScript concepts that appear in nearly every technical interview. Understanding how variables are accessed, how functions remember their lexical environment, and how declarations are processed before execution will help you debug tricky code and write more predictable programs.
var vs let vs const
| Feature | var | let | const |
|---|---|---|---|
| Scope | Function scope | Block scope | Block scope |
| Hoisting | Hoisted (initialized as undefined) | Hoisted (TDZ) | Hoisted (TDZ) |
| Reassignment | Allowed | Allowed | Not allowed |
| Redeclaration | Allowed | Not allowed | Not allowed |
function example() {
console.log(a); // undefined (hoisted)
var a = 5;
console.log(b); // ReferenceError: Cannot access 'b' before initialization
let b = 10;
const c = 15;
c = 20; // TypeError: Assignment to constant variable
}Function vs Block Scope
Variables declared with var are scoped to the nearest function, while let and const are scoped to the nearest block (e.g., if, for, while). This distinction is critical for avoiding unintended variable leakage.
if (true) {
var x = 10;
let y = 20;
}
console.log(x); // 10 (function scope, leaks out)
console.log(y); // ReferenceError: y is not defined (block scope)Hoisting Mechanics
Hoisting moves variable and function declarations to the top of their scope during compilation. var declarations are hoisted and initialized with undefined, while let and const are hoisted but remain in the temporal dead zone (TDZ) until their actual declaration line.
console.log(foo); // undefined (var hoisted)
var foo = 'bar';
console.log(baz); // ReferenceError: Cannot access 'baz' before initialization
let baz = 'qux';
sayHello(); // 'Hello!' (function declaration hoisted)
function sayHello() {
console.log('Hello!');
}
sayHi(); // TypeError: sayHi is not a function (var hoisted, not initialized)
var sayHi = function() {
console.log('Hi!');
};Temporal Dead Zone (TDZ)
The TDZ is the period between entering a scope and the actual declaration of a let or const variable. Accessing the variable during this time throws a ReferenceError. This prevents the common bugs associated with var hoisting.
{
// TDZ for 'name' starts here
console.log(name); // ReferenceError
let name = 'Alice';
// TDZ ends here
}When asked about hoisting, always mention the temporal dead zone for let and const. Interviewers love to see that you understand the difference between hoisting (declaration moved) and initialization (value assignment). A common trick question: 'What does console.log(a) output before let a = 5?' The answer is a ReferenceError, not undefined.
Closures: Definition and Use Cases
A closure is a function that retains access to its lexical scope even when executed outside that scope. Closures are used for data privacy, creating function factories, and maintaining state in asynchronous code.
function createCounter() {
let count = 0;
return function() {
count++;
return count;
};
}
const counter = createCounter();
console.log(counter()); // 1
console.log(counter()); // 2
// 'count' is private and persists across callsIIFE Pattern (Immediately Invoked Function Expression)
An IIFE is a function that runs as soon as it is defined. It creates a new scope to avoid polluting the global namespace, often used for data privacy and module patterns before ES6 modules.
(function() {
var privateVar = 'secret';
console.log(privateVar); // 'secret'
})();
console.log(typeof privateVar); // 'undefined' (private)
// Modern alternative using block scope:
{
let privateVar = 'secret';
console.log(privateVar);
}What will be the output of the following code? for (var i = 0; i < 3; i++) { setTimeout(() => console.log(i), 100); }
Functions, this & Execution
Functions are the building blocks of JavaScript, and mastering how they work—especially the this keyword—is critical for acing interviews. This section covers function declarations vs expressions, arrow vs regular functions, this binding rules, call/apply/bind, the arguments object vs rest parameters, and currying. Understanding these concepts will help you write cleaner, more predictable code and answer common interview questions with confidence.
Function Declarations vs Expressions
A function declaration is hoisted to the top of its scope, meaning you can call it before its definition. A function expression is not hoisted—it's assigned to a variable and can only be used after the assignment. Use declarations for named functions you want to hoist; use expressions for anonymous functions or when you need to assign them conditionally.
// Function declaration (hoisted)
console.log(add(2, 3)); // 5
function add(a, b) {
return a + b;
}
// Function expression (not hoisted)
console.log(subtract(5, 2)); // ReferenceError
const subtract = function(a, b) {
return a - b;
};Arrow vs Regular Functions
Arrow functions are syntactically shorter and do not have their own this, arguments, or super—they inherit this from the enclosing lexical scope. Regular functions have their own this based on how they are called. Use arrow functions for callbacks or when you want to preserve the outer this; use regular functions when you need dynamic this binding or the arguments object.
const obj = {
name: 'Alice',
regularFunc: function() {
console.log(this.name); // 'Alice' (own this)
},
arrowFunc: () => {
console.log(this.name); // undefined (inherits from outer scope)
}
};
obj.regularFunc();
obj.arrowFunc();this Binding Rules
The value of this is determined by how a function is called, not where it's defined. There are four main rules: default binding (global object or undefined in strict mode), implicit binding (object method call), explicit binding (call/apply/bind), and new binding (constructor call). Arrow functions ignore these rules and use lexical scoping.
function showThis() {
console.log(this);
}
// Default binding (non-strict mode)
showThis(); // Window (or global)
// Implicit binding
const obj = { name: 'Bob', show: showThis };
obj.show(); // { name: 'Bob', show: f }
// Explicit binding
showThis.call({ name: 'Charlie' }); // { name: 'Charlie' }
// New binding
new showThis(); // showThis {}In DOM event handlers, this refers to the element that fired the event when using a regular function. With an arrow function, this comes from the surrounding lexical context (e.g., the class instance). Interviewers often ask about this difference—be ready to explain it with an example.
call, apply, and bind
call and apply invoke a function immediately with a specified this value. call takes arguments individually; apply takes an array of arguments. bind returns a new function with a permanently bound this (and optional partial arguments) that can be called later. These are essential for borrowing methods and setting context.
const person = {
name: 'Dave',
greet: function(greeting, punctuation) {
console.log(greeting + ', ' + this.name + punctuation);
}
};
const otherPerson = { name: 'Eve' };
person.greet.call(otherPerson, 'Hello', '!'); // Hello, Eve!
person.greet.apply(otherPerson, ['Hi', '?']); // Hi, Eve?
const boundGreet = person.greet.bind(otherPerson, 'Hey');
boundGreet('.'); // Hey, Eve.arguments Object vs Rest Parameters
The arguments object is an array-like object available inside regular functions (not arrow functions) that contains all passed arguments. Rest parameters (...args) are a modern alternative that provides a real array. Use rest parameters for cleaner code and array methods; avoid arguments unless you need backward compatibility.
// arguments object (regular function only)
function sumArgs() {
let total = 0;
for (let i = 0; i < arguments.length; i++) {
total += arguments[i];
}
return total;
}
console.log(sumArgs(1, 2, 3)); // 6
// Rest parameters (works in arrow functions too)
const sumRest = (...numbers) => numbers.reduce((acc, n) => acc + n, 0);
console.log(sumRest(1, 2, 3)); // 6Currying
Currying transforms a function that takes multiple arguments into a sequence of functions each taking a single argument. It's useful for partial application and creating reusable, composable functions. In JavaScript, currying is often implemented manually or with libraries like Lodash.
// Manual currying
function multiply(a) {
return function(b) {
return a * b;
};
}
const double = multiply(2);
console.log(double(5)); // 10
// Arrow function currying
const add = a => b => a + b;
const add5 = add(5);
console.log(add5(3)); // 8What will the following code log?
Prototypes & Inheritance
Prototypes and inheritance are foundational to how JavaScript objects work. Interviewers frequently test your understanding of the prototype chain, Object.create, and how modern class syntax relates to prototypal inheritance. Mastering these concepts shows you grasp JavaScript's unique object model, not just syntactic sugar.
The Prototype Chain
Every JavaScript object has an internal link to another object called its prototype. When you access a property on an object, JavaScript first checks the object itself, then walks up the prototype chain until it finds the property or reaches null. This is how inheritance works in JavaScript.
const animal = { eats: true };
const dog = { barks: true };
Object.setPrototypeOf(dog, animal);
console.log(dog.eats); // true (inherited from animal)
console.log(dog.barks); // true (own property)
console.log(dog.hasOwnProperty('eats')); // falseObject.create
Object.create(proto) creates a new object with the specified prototype. It's a clean way to set up inheritance without constructor functions. You can also pass a properties descriptor object as the second argument.
const vehicle = {
start() { return 'Engine started'; }
};
const car = Object.create(vehicle);
car.drive = () => 'Driving';
console.log(car.start()); // 'Engine started'
console.log(Object.getPrototypeOf(car) === vehicle); // trueClass Syntax Under the Hood
ES6 class is syntactic sugar over the existing prototype-based inheritance. A class's methods are stored on ClassName.prototype, and instances have a __proto__ link to that prototype. The constructor method is called when you use new.
class Animal {
constructor(name) {
this.name = name;
}
speak() {
return `${this.name} makes a sound`;
}
}
class Dog extends Animal {
speak() {
return `${this.name} barks`;
}
}
const d = new Dog('Rex');
console.log(d.speak()); // 'Rex barks'
console.log(Object.getPrototypeOf(d) === Dog.prototype); // true
console.log(Dog.prototype.__proto__ === Animal.prototype); // trueMixin Patterns
Since JavaScript only supports single inheritance through the prototype chain, mixins allow you to compose behavior from multiple sources. A mixin is an object with methods that you copy into another object's prototype.
const canFly = {
fly() { return `${this.name} is flying`; }
};
const canSwim = {
swim() { return `${this.name} is swimming`; }
};
class Bird {
constructor(name) { this.name = name; }
}
// Apply mixins
Object.assign(Bird.prototype, canFly, canSwim);
const duck = new Bird('Duck');
console.log(duck.fly()); // 'Duck is flying'
console.log(duck.swim()); // 'Duck is swimming'instanceof and Prototype Checks
The instanceof operator checks if the prototype property of a constructor appears anywhere in an object's prototype chain. For more precise checks, use Object.getPrototypeOf or isPrototypeOf.
function Car() {}
const myCar = new Car();
console.log(myCar instanceof Car); // true
console.log(myCar instanceof Object); // true
// Manual check
console.log(Car.prototype.isPrototypeOf(myCar)); // true
console.log(Object.getPrototypeOf(myCar) === Car.prototype); // trueObject.getPrototypeOf
Object.getPrototypeOf(obj) returns the prototype of a given object. It's the standard way to get the prototype (preferred over the deprecated __proto__). Use it to inspect or traverse the prototype chain.
const base = { x: 1 };
const derived = Object.create(base);
console.log(Object.getPrototypeOf(derived)); // { x: 1 }
console.log(Object.getPrototypeOf(derived) === base); // true
// Traverse chain
let proto = Object.getPrototypeOf(derived);
while (proto) {
console.log(proto);
proto = Object.getPrototypeOf(proto);
}Interviewers often ask how to tell if a property is on the object itself or inherited. Use hasOwnProperty (or Object.hasOwn in modern JS) to check. For example: obj.hasOwnProperty('toString') returns false because toString is inherited from Object.prototype.
What does the following code log?
Event Loop & Asynchrony
The event loop is the core mechanism that enables JavaScript's non-blocking concurrency despite being single-threaded. Interviewers frequently probe this topic to assess your understanding of asynchronous execution order, which is critical for debugging race conditions and optimizing UI performance. Mastering the event loop distinguishes junior from senior developers.
Call Stack
The call stack is a LIFO (Last In, First Out) data structure that tracks function execution. When a function is called, it's pushed onto the stack; when it returns, it's popped off. The event loop can only process tasks when the call stack is empty.
function foo() {
console.log('foo');
bar();
}
function bar() {
console.log('bar');
}
foo();
// Stack: foo -> bar (then pop bar, pop foo)
// Output: foo, barTask Queue vs Microtask Queue
The task queue (macrotask queue) holds callbacks from setTimeout, setInterval, and I/O events. The microtask queue holds promises, queueMicrotask, and MutationObserver callbacks. After each macrotask, the event loop empties the entire microtask queue before processing the next macrotask.
console.log('1');
setTimeout(() => console.log('2'), 0);
Promise.resolve().then(() => console.log('3'));
console.log('4');
// Output: 1, 4, 3, 2
// Explanation: 1 and 4 are sync, then microtask (3) runs before macrotask (2)If you recursively enqueue microtasks (e.g., a promise chain that never resolves), the microtask queue never empties, blocking macrotasks like rendering or setTimeout. This is a common performance pitfall—always ensure microtasks eventually yield.
setTimeout / setInterval Ordering
setTimeout(fn, 0) does not execute immediately; it schedules the callback as a macrotask with a minimum delay of 0ms (browsers clamp to 4ms after nesting). setInterval similarly queues callbacks, but if execution takes longer than the interval, callbacks can stack.
let count = 0;
const id = setInterval(() => {
console.log(count++);
if (count === 3) clearInterval(id);
}, 100);
// Output: 0, 1, 2 (each ~100ms apart)requestAnimationFrame
requestAnimationFrame schedules a callback before the next browser repaint, making it ideal for animations. It runs after microtasks but before the next macrotask, and is tied to the display refresh rate (typically 60fps).
let start = null;
function animate(timestamp) {
if (!start) start = timestamp;
const progress = timestamp - start;
element.style.transform = `translateX(${Math.min(progress / 10, 200)}px)`;
if (progress < 2000) requestAnimationFrame(animate);
}
requestAnimationFrame(animate);queueMicrotask
queueMicrotask explicitly adds a function to the microtask queue. It's useful for deferring work until after the current synchronous execution but before any macrotasks, such as batching DOM updates.
console.log('sync');
queueMicrotask(() => console.log('microtask'));
console.log('sync end');
// Output: sync, sync end, microtaskEvent Loop in Node.js vs Browser
Both environments use the same event loop concept, but Node.js has additional phases: timers, I/O callbacks, idle/prepare, poll, check (setImmediate), and close callbacks. The browser's event loop prioritizes rendering and user interactions, while Node.js focuses on I/O and timers.
| Feature | Browser | Node.js |
|---|---|---|
| Microtask execution | After each macrotask | After each phase |
| requestAnimationFrame | Yes, before repaint | Not available |
| setImmediate | Not available | Yes, in check phase |
| I/O handling | Event-driven (e.g., fetch) | libuv thread pool |
What is the output of the following code? console.log('A'); setTimeout(() => console.log('B'), 0); Promise.resolve().then(() => console.log('C')); console.log('D');
Promises & Async/Await
Promises and async/await are fundamental to modern JavaScript, especially for handling asynchronous operations like API calls and file I/O. Interviewers frequently test your understanding of promise states, chaining, and error handling to gauge your ability to write robust, non-blocking code. Mastery of these concepts is essential for senior-level roles and is a common topic in technical screens.
Promise States
A Promise has three states: pending, fulfilled, and rejected. Once settled (fulfilled or rejected), it cannot change state. This immutability is key for reliable async code.
const promise = new Promise((resolve, reject) => {
// pending
setTimeout(() => resolve('done'), 1000);
});
console.log(promise); // Promise { <pending> }
promise.then(value => console.log(value)); // 'done' after 1sPromise Chaining
Chaining allows sequential async operations. Each .then() returns a new promise, enabling clean composition. Always return a value or promise from a .then() to continue the chain.
fetch('/api/user')
.then(res => res.json())
.then(user => fetch(`/api/posts?userId=${user.id}`))
.then(res => res.json())
.then(posts => console.log(posts))
.catch(err => console.error(err));Promise.all / race / allSettled / any
These static methods handle multiple promises: Promise.all rejects fast on any error; Promise.race settles on first settled promise; Promise.allSettled waits for all to settle (never rejects); Promise.any resolves on first fulfillment, rejects only if all reject.
const p1 = Promise.resolve(1);
const p2 = Promise.reject('err');
const p3 = new Promise(resolve => setTimeout(() => resolve(3), 100));
Promise.allSettled([p1, p2, p3]).then(results => {
console.log(results);
// [{status:'fulfilled', value:1}, {status:'rejected', reason:'err'}, {status:'fulfilled', value:3}]
});Remember that Promise.all fails fast—if any promise rejects, the entire promise rejects immediately. Use Promise.allSettled when you need results from all promises regardless of failures, such as batch API calls where partial success is acceptable.
Async/Await Internals
An async function always returns a promise. await pauses execution until the awaited promise settles, then resumes the function. Under the hood, it's syntactic sugar over generators and promises, managed by the event loop.
async function fetchData() {
const response = await fetch('/api/data');
const data = await response.json();
return data;
}
// Equivalent to:
// function fetchData() { return fetch('/api/data').then(res => res.json()); }Error Handling with try/catch
Use try/catch blocks to handle errors in async functions. Uncaught rejections in async functions result in unhandled promise rejections. Always wrap await calls in try/catch or attach a .catch() to the returned promise.
async function getUser(id) {
try {
const user = await fetch(`/api/users/${id}`);
if (!user.ok) throw new Error('User not found');
return await user.json();
} catch (error) {
console.error('Failed to fetch user:', error);
throw error; // re-throw if needed
}
}Common Async Pitfalls
- Forgetting to
awaitinside an async function (returns a promise, not the value). - Using
forEachwith async callbacks (doesn't wait for promises); usefor...oforPromise.allinstead. - Not handling errors in promise chains (missing
.catch()or try/catch). - Mixing callbacks and promises (leads to callback hell or unhandled rejections).
- Assuming
Promise.allruns promises in sequence (it runs them concurrently).
What does the following code log?
ES6+ & Modern Syntax
Modern JavaScript (ES6+) features are essential for writing cleaner, more efficient code and are frequently tested in interviews. Mastering destructuring, spread/rest, template literals, optional chaining, nullish coalescing, generators, iterators, and weak collections demonstrates a deep understanding of the language's evolution. This section covers the most commonly asked topics with practical examples.
Destructuring
Destructuring allows you to unpack values from arrays or properties from objects into distinct variables. It simplifies code and reduces repetition, especially when working with function returns or API responses.
// Array destructuring
const [first, second] = [10, 20];
console.log(first); // 10
// Object destructuring with renaming
const user = { name: 'Alice', age: 30 };
const { name: userName, age } = user;
console.log(userName); // 'Alice'Spread and Rest Operators
The spread operator (...) expands an iterable into individual elements, while the rest operator collects multiple elements into an array. They are used for copying arrays/objects, merging, and handling variable arguments.
// Spread: copy and merge arrays
const arr1 = [1, 2];
const arr2 = [...arr1, 3, 4]; // [1, 2, 3, 4]
// Rest: collect function arguments
function sum(...numbers) {
return numbers.reduce((acc, n) => acc + n, 0);
}
console.log(sum(1, 2, 3)); // 6Template Literals
Template literals use backticks (`) and support embedded expressions via ${}. They make string interpolation and multi-line strings much more readable.
const name = 'Bob';
const greeting = `Hello, ${name}!`;
console.log(greeting); // 'Hello, Bob!'
const multiLine = `This is
a multi-line
string.`;Optional Chaining and Nullish Coalescing
Optional chaining (?.) safely accesses nested properties without throwing an error if a reference is null or undefined. Nullish coalescing (??) returns the right-hand operand only when the left is null or undefined (not for other falsy values).
const user = { profile: { name: 'Alice' } };
console.log(user?.profile?.name); // 'Alice'
console.log(user?.address?.city); // undefined
const value = 0;
const result = value ?? 'default';
console.log(result); // 0 (not 'default')When asked about optional chaining, emphasize that it short-circuits—if a property is null/undefined, the entire chain returns undefined without evaluating further. This prevents runtime errors in deeply nested data.
Generators and Iterators
Generators are functions that can be paused and resumed using function* and yield. They return an iterator object. Iterators implement the next() method and are used with for...of loops.
function* idGenerator() {
let id = 1;
while (true) {
yield id++;
}
}
const gen = idGenerator();
console.log(gen.next().value); // 1
console.log(gen.next().value); // 2
// Custom iterator
const range = {
from: 1, to: 3,
[Symbol.iterator]() {
let current = this.from;
const end = this.to;
return {
next() {
return current <= end
? { value: current++, done: false }
: { done: true };
}
};
}
};
for (const num of range) console.log(num); // 1, 2, 3WeakMap, WeakSet, and WeakRef
WeakMap and WeakSet hold weak references to objects, meaning they don't prevent garbage collection. They are useful for private data or caching without memory leaks. WeakRef (ES2021) allows holding a weak reference to an object without preventing its collection.
// WeakMap: keys must be objects
const wm = new WeakMap();
let obj = {};
wm.set(obj, 'private data');
obj = null; // obj can be garbage collected
// WeakSet: similar, stores objects weakly
const ws = new WeakSet();
let obj2 = {};
ws.add(obj2);
obj2 = null; // obj2 can be garbage collected
// WeakRef (ES2021)
let ref = new WeakRef({});
console.log(ref.deref()); // object or undefined if collectedWhat does the nullish coalescing operator (??) return for the expression: `null ?? 'default'`?
Modules & the Build Pipeline
This section covers the module system and build pipeline—critical knowledge for any JavaScript interview. You'll need to understand the differences between ES modules and CommonJS, how bundlers like Webpack and Rollup work, and modern features like tree shaking and top-level await. Mastering these topics shows you can write maintainable, performant code in real-world projects.
ES Modules vs CommonJS
ES modules (ESM) are the official standard for JavaScript modules, using import/export syntax. CommonJS (CJS) is Node.js's original module system, using require()/module.exports. Key differences: ESM is static (imports are hoisted and analyzed at parse time), while CJS is dynamic (require can be called conditionally). ESM supports tree shaking; CJS does not.
// ES module (ESM)
// math.mjs
export const add = (a, b) => a + b;
export default function multiply(a, b) { return a * b; }
// app.mjs
import multiply, { add } from './math.mjs';
console.log(add(2, 3)); // 5
console.log(multiply(2, 3)); // 6// CommonJS (CJS)
// math.js
const add = (a, b) => a + b;
function multiply(a, b) { return a * b; }
module.exports = { add, multiply };
// app.js
const { add, multiply } = require('./math.js');
console.log(add(2, 3)); // 5
console.log(multiply(2, 3)); // 6Static vs Dynamic Import
Static imports (import ... from 'module') are evaluated at parse time, enabling optimizations like tree shaking. Dynamic imports (import('module')) return a promise and allow lazy loading at runtime. Use dynamic imports for code splitting or conditionally loading modules.
// Static import (compile-time)
import { format } from 'date-fns';
// Dynamic import (runtime)
const loadChart = async () => {
const { Chart } = await import('chart.js');
new Chart(ctx, config);
};
// Conditional dynamic import
if (user.isAdmin) {
const adminModule = await import('./admin.js');
adminModule.init();
}Tree Shaking
Tree shaking is a dead-code elimination technique performed by bundlers (like Webpack, Rollup) that removes unused exports. It relies on static analysis of ES module imports. Only works with ESM, not CJS. To maximize tree shaking, avoid side effects in modules and use named exports.
// utils.js
export const used = () => 'I am used';
export const unused = () => 'I am dead code'; // will be removed
// app.js
import { used } from './utils.js';
console.log(used()); // Only 'used' survives bundlingWhen asked about tree shaking, mention that bundlers mark modules with sideEffects: false in package.json to safely remove unused code. Also note that dynamic imports can break tree shaking because the module is loaded at runtime.
Circular Dependency Pitfalls
Circular dependencies occur when two modules import each other. In CJS, this can lead to undefined values because module.exports is not fully populated at the time of require. ESM handles this better with live bindings, but still requires careful design. Best practice: refactor to avoid cycles.
// CJS circular dependency example
// a.js
const b = require('./b.js');
console.log(b); // undefined (if b.js requires a.js first)
module.exports = { value: 'A' };
// b.js
const a = require('./a.js');
console.log(a); // { value: 'A' }
module.exports = { value: 'B' };Bundler Basics
Bundlers like Webpack, Rollup, and Parcel take multiple JavaScript files and combine them into optimized bundles for the browser. They handle module resolution, code splitting, minification, and asset processing. Key concepts: entry point, output, loaders (for non-JS files), and plugins (for advanced transformations).
// Simple Webpack config (webpack.config.js)
const path = require('path');
module.exports = {
entry: './src/index.js',
output: {
filename: 'bundle.js',
path: path.resolve(__dirname, 'dist'),
},
module: {
rules: [
{ test: /\.css$/, use: ['style-loader', 'css-loader'] },
],
},
mode: 'production',
};Top-Level Await
Top-level await allows using await outside of async functions in ES modules. It blocks the module's execution until the promise resolves, making it easier to initialize modules with async dependencies. Supported in modern browsers and Node.js (ESM only).
// db.mjs
import { connect } from 'db-driver';
export const db = await connect('mongodb://localhost:27017');
// app.mjs
import { db } from './db.mjs';
// db is ready to use immediately
const data = await db.find('users');Which of the following is true about tree shaking?
Patterns & Functional Techniques
Patterns and functional techniques are core to writing maintainable, scalable JavaScript. Interviewers frequently test your understanding of these patterns to gauge your ability to structure code, manage state, and optimize performance. Mastering these topics demonstrates both theoretical knowledge and practical problem-solving skills.
Module Pattern
The module pattern encapsulates private state and exposes only a public API, preventing global scope pollution. It leverages closures to create private variables and methods.
const CounterModule = (function() {
let count = 0; // private
return {
increment() { count++; },
decrement() { count--; },
getCount() { return count; }
};
})();
CounterModule.increment();
console.log(CounterModule.getCount()); // 1
console.log(CounterModule.count); // undefined (private)Observer / Pub-Sub Pattern
The Observer pattern allows objects (subscribers) to listen for events on another object (subject). Pub-Sub (Publish-Subscribe) decouples this further with a message broker. Both are essential for event-driven architectures.
class EventBus {
constructor() {
this.listeners = {};
}
subscribe(event, callback) {
if (!this.listeners[event]) this.listeners[event] = [];
this.listeners[event].push(callback);
return () => this.unsubscribe(event, callback);
}
publish(event, data) {
(this.listeners[event] || []).forEach(cb => cb(data));
}
unsubscribe(event, callback) {
this.listeners[event] = (this.listeners[event] || []).filter(cb => cb !== callback);
}
}
const bus = new EventBus();
const unsub = bus.subscribe('userLogin', (user) => console.log(`Welcome ${user}`));
bus.publish('userLogin', 'Alice'); // Welcome Alice
unsub();
bus.publish('userLogin', 'Bob'); // no outputDebounce vs Throttle
Debounce delays execution until after a pause in calls, while throttle ensures execution at most once per interval. Use debounce for search inputs, throttle for scroll/resize events.
function debounce(fn, delay) {
let timer;
return function(...args) {
clearTimeout(timer);
timer = setTimeout(() => fn.apply(this, args), delay);
};
}
function throttle(fn, limit) {
let inThrottle = false;
return function(...args) {
if (!inThrottle) {
fn.apply(this, args);
inThrottle = true;
setTimeout(() => inThrottle = false, limit);
}
};
}
// Usage
const log = () => console.log('called');
const debouncedLog = debounce(log, 300);
const throttledLog = throttle(log, 300);When implementing debounce or throttle, always handle the this context correctly using fn.apply(this, args). Interviewers often check for this subtlety.
Memoization
Memoization caches function results based on arguments, avoiding redundant computations for pure functions. It's a classic optimization technique.
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;
};
}
const factorial = memoize(function(n) {
if (n <= 1) return 1;
return n * factorial(n - 1);
});
console.log(factorial(5)); // 120 (computed)
console.log(factorial(5)); // 120 (cached)Factory vs Constructor
Constructors (with new) create instances linked to a prototype, while factories return any object without new. Factories offer more flexibility (closures, conditionals) but lack prototype inheritance.
// Constructor
function Car(make, model) {
this.make = make;
this.model = model;
}
Car.prototype.drive = function() { console.log('Driving'); };
const car1 = new Car('Toyota', 'Camry');
// Factory
function createCar(make, model) {
return {
make,
model,
drive() { console.log('Driving'); }
};
}
const car2 = createCar('Honda', 'Civic');
console.log(car1 instanceof Car); // true
console.log(car2 instanceof Car); // falseImmutability Patterns
Immutability prevents unintended side effects by creating new objects/arrays instead of mutating existing ones. Use Object.assign, spread operator, or libraries like Immer.
const state = { user: { name: 'Alice', age: 30 }, items: [1, 2, 3] };
// Mutating (bad)
state.user.age = 31;
// Immutable update
const newState = {
...state,
user: { ...state.user, age: 31 },
items: [...state.items, 4]
};
console.log(state.user.age); // 30 (unchanged)
console.log(newState.user.age); // 31Which pattern is best for decoupling components in an event-driven system?
Memory Management & Performance
Memory management and performance are critical topics in JavaScript interviews, as they test your understanding of how the engine handles resources under the hood. Interviewers often probe these areas to gauge your ability to write efficient, leak-free code in production. This section covers garbage collection, common memory leaks, and profiling techniques to help you stand out.
Garbage Collection: Mark-and-Sweep
JavaScript uses automatic garbage collection, primarily via the mark-and-sweep algorithm. It starts from root objects (e.g., global object, local variables) and marks all reachable objects, then sweeps away unmarked ones. This is why unreferenced objects are eventually freed.
// Object becomes unreachable after function returns
function createUser() {
let user = { name: 'Alice' };
return user;
}
let userRef = createUser();
userRef = null; // Now eligible for GCCommon Causes of Memory Leaks
Memory leaks occur when objects are no longer needed but remain referenced, preventing garbage collection. Common causes include: global variables, forgotten timers, detached DOM nodes, and closures that retain large data.
- Global variables: Accidental globals (e.g., undeclared variable) persist forever.
- Timers: setInterval/setTimeout with references to DOM or large objects.
- Detached DOM: JavaScript holding references to removed DOM elements.
- Closures: Functions that capture outer scope variables, preventing their release.
Closure-Based Leaks
Closures can cause memory leaks when they capture large objects or DOM references that outlive their intended use. For example, an event listener inside a closure may keep a reference to a whole scope.
function setupButton() {
let largeData = new Array(1000000).fill('leak');
document.getElementById('btn').addEventListener('click', function() {
console.log('clicked'); // closure retains largeData
});
}
// largeData persists as long as the listener existsWhen asked about closure leaks, mention that modern engines optimize by not retaining unused variables, but eval or with can break this. Always nullify references when removing listeners.
WeakMap for Private Data
WeakMap holds weak references to keys, meaning it doesn't prevent garbage collection if no other references exist. This is ideal for storing private data without causing memory leaks.
const privateData = new WeakMap();
class User {
constructor(name) {
privateData.set(this, { name });
}
getName() {
return privateData.get(this).name;
}
}
let user = new User('Bob');
user = null; // privateData entry is GC'd automaticallyPerformance.now and Profiling
Use performance.now() for high-resolution timing (microseconds) to measure code execution. For deeper profiling, browser DevTools (Performance tab) can record memory allocations and identify bottlenecks.
const start = performance.now();
for (let i = 0; i < 1000000; i++) {
Math.sqrt(i);
}
const end = performance.now();
console.log(`Loop took ${end - start} ms`);V8 Hidden Classes
V8 optimizes object property access using hidden classes (also called maps). When you add properties in a consistent order, objects share the same hidden class, enabling faster property lookups. Changing property order or deleting properties can cause deoptimization.
// Efficient: same property order
function Point(x, y) {
this.x = x;
this.y = y;
}
let p1 = new Point(1, 2);
let p2 = new Point(3, 4); // shares hidden class
// Inefficient: different order
function BadPoint(x, y) {
this.y = y;
this.x = x; // different order
}Which of the following is NOT a common cause of memory leaks in JavaScript?