Module 02: JavaScript Advanced
Goal: Master async programming, the event loop, and advanced patterns that separate junior from senior developers. Time: 3-4 days of focused study Prerequisites: Module 01 (JavaScript Fundamentals)
Table of Contents
- Callbacks — Where It All Started
- Promises — The Modern Way
- Async/Await — Syntactic Sugar Over Promises
- The Event Loop — How JavaScript Actually Works
- Generators and Iterators
- Modules — CommonJS vs ES Modules
- Proxy and Reflect
- Error Handling in Async Code
- Advanced Patterns — Debounce, Throttle, Memoize
- Design Patterns in JavaScript
- Memory Management & Garbage Collection
- Practice Problems
- Interview Questions
1. Callbacks — Where It All Started
A callback is a function passed as an argument to another function, to be called later (when something finishes).
Why Callbacks Exist
JavaScript is single-threaded — it can only do one thing at a time. But many operations are slow (reading files, making HTTP requests, querying databases). If JavaScript waited for each one, your program would freeze.
Solution: Start the operation, give it a callback function, and move on. When the operation finishes, the callback is called with the result.
// Synchronous — BLOCKING (freezes while reading)
const fs = require('fs');
const data = fs.readFileSync('/path/to/file.txt', 'utf8'); // Blocks here!
console.log(data);
console.log("This runs AFTER the file is read");
// Asynchronous with callback — NON-BLOCKING
fs.readFile('/path/to/file.txt', 'utf8', (error, data) => {
if (error) {
console.error("Failed:", error);
return;
}
console.log(data); // Runs when file is ready
});
console.log("This runs IMMEDIATELY — doesn't wait for file!");The Node.js Callback Convention (Error-First Callbacks)
// Node.js uses "error-first" callbacks:
// callback(error, result)
// - If error is null/undefined, the operation succeeded
// - If error is an Error object, the operation failed
function fetchUser(id, callback) {
// Simulate async database query
setTimeout(() => {
if (id <= 0) {
callback(new Error("Invalid ID"), null);
return;
}
callback(null, { id, name: "Alice", email: "alice@example.com" });
}, 100);
}
// Usage:
fetchUser(1, (error, user) => {
if (error) {
console.error("Error:", error.message);
return;
}
console.log("User:", user);
});Callback Hell — The Problem
// When you need to do async operations in sequence, callbacks get nested:
getUser(userId, (err, user) => {
if (err) return handleError(err);
getOrders(user.id, (err, orders) => {
if (err) return handleError(err);
getOrderDetails(orders[0].id, (err, details) => {
if (err) return handleError(err);
getShippingStatus(details.trackingId, (err, status) => {
if (err) return handleError(err);
updateUI(status); // Finally!
// This is "Callback Hell" or the "Pyramid of Doom"
});
});
});
});
// Problems:
// 1. Hard to read (nested pyramid shape)
// 2. Hard to debug (which callback failed?)
// 3. Error handling is repetitive
// 4. Hard to add branching logic2. Promises — The Modern Way
A Promise is an object representing the eventual completion (or failure) of an async operation. Think of it as a "placeholder for a future value."
Promise States
┌─────────┐
│ PENDING │ ← Initial state
└────┬─────┘
│
┌────────┴────────┐
│ │
┌─────▼─────┐ ┌──────▼─────┐
│ FULFILLED │ │ REJECTED │
│ (resolved)│ │ (failed) │
└───────────┘ └────────────┘A promise is:
- Pending — initial state, neither fulfilled nor rejected
- Fulfilled — operation completed successfully
- Rejected — operation failed
Once fulfilled or rejected, a promise is settled and its state cannot change.
Creating Promises
// The Promise constructor takes a function with resolve and reject parameters
const myPromise = new Promise((resolve, reject) => {
// Do some async work...
const success = true;
if (success) {
resolve("Operation successful!"); // Fulfill the promise
} else {
reject(new Error("Operation failed!")); // Reject the promise
}
});
// Real-world example: wrapping setTimeout in a promise
function delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
// Usage: await delay(1000); // Wait 1 second
// Real-world example: wrapping a callback-based function
function readFilePromise(path) {
return new Promise((resolve, reject) => {
const fs = require('fs');
fs.readFile(path, 'utf8', (error, data) => {
if (error) reject(error);
else resolve(data);
});
});
}Consuming Promises — .then(), .catch(), .finally()
fetchUser(1)
.then(user => {
console.log("User:", user); // Runs if promise fulfills
return fetchOrders(user.id); // Return another promise to chain
})
.then(orders => {
console.log("Orders:", orders);
return fetchOrderDetails(orders[0].id);
})
.then(details => {
console.log("Details:", details);
})
.catch(error => {
// Catches ANY error in the chain above
console.error("Something failed:", error.message);
})
.finally(() => {
// Runs regardless of success or failure
console.log("Cleanup complete");
});
// Compare this to callback hell — MUCH flatter and readable!Promise Chaining — How .then() Works
// .then() ALWAYS returns a new promise
// Whatever you return from .then() becomes the resolved value of that new promise
Promise.resolve(1)
.then(val => {
console.log(val); // 1
return val + 1; // Return a value → next .then() gets 2
})
.then(val => {
console.log(val); // 2
return new Promise(resolve => {
setTimeout(() => resolve(val + 1), 100); // Return a promise
});
})
.then(val => {
console.log(val); // 3 (after 100ms)
// If you don't return anything, next .then() gets undefined
})
.then(val => {
console.log(val); // undefined
});
// If you throw inside .then(), the promise is rejected:
Promise.resolve("start")
.then(val => {
throw new Error("Something broke!");
})
.then(val => {
console.log("This never runs");
})
.catch(err => {
console.log(err.message); // "Something broke!"
return "recovered"; // Catch can also return values!
})
.then(val => {
console.log(val); // "recovered" — chain continues after catch
});Promise Static Methods — Running Multiple Promises
const promise1 = fetch('/api/users');
const promise2 = fetch('/api/orders');
const promise3 = fetch('/api/products');
// Promise.all — wait for ALL to succeed (fails fast on first rejection)
Promise.all([promise1, promise2, promise3])
.then(([users, orders, products]) => {
console.log("All done!");
})
.catch(error => {
console.log("At least one failed:", error);
// If ANY promise rejects, .catch runs immediately
});
// Promise.allSettled — wait for ALL to settle (never rejects)
Promise.allSettled([promise1, promise2, promise3])
.then(results => {
results.forEach(result => {
if (result.status === "fulfilled") {
console.log("Success:", result.value);
} else {
console.log("Failed:", result.reason);
}
});
});
// Promise.race — resolves/rejects with the FIRST settled promise
Promise.race([
fetch('/api/server1'),
fetch('/api/server2'),
new Promise((_, reject) => setTimeout(() => reject(new Error("Timeout")), 5000))
])
.then(result => console.log("Fastest:", result))
.catch(error => console.log("Timeout or error:", error));
// Promise.any — resolves with the FIRST fulfilled promise (ignores rejections)
Promise.any([
fetch('/api/mirror1'),
fetch('/api/mirror2'),
fetch('/api/mirror3')
])
.then(result => console.log("First success:", result))
.catch(error => console.log("ALL failed:", error)); // AggregateError
// Promise.resolve / Promise.reject — create already-settled promises
const resolved = Promise.resolve(42); // Promise that immediately resolves with 42
const rejected = Promise.reject(new Error("Nope")); // Promise that immediately rejectsReal-World Promise Patterns
// 1. Retry pattern — retry a failed operation up to N times
function retry(fn, maxRetries = 3, delay = 1000) {
return new Promise((resolve, reject) => {
let attempts = 0;
function attempt() {
fn()
.then(resolve)
.catch(error => {
attempts++;
if (attempts >= maxRetries) {
reject(new Error(`Failed after ${maxRetries} attempts: ${error.message}`));
} else {
console.log(`Attempt ${attempts} failed, retrying in ${delay}ms...`);
setTimeout(attempt, delay);
}
});
}
attempt();
});
}
// Usage:
retry(() => fetch('https://flaky-api.com/data'), 3, 2000)
.then(data => console.log("Got data:", data))
.catch(err => console.error("Gave up:", err.message));
// 2. Timeout wrapper — add timeout to any promise
function withTimeout(promise, ms) {
const timeout = new Promise((_, reject) => {
setTimeout(() => reject(new Error(`Timed out after ${ms}ms`)), ms);
});
return Promise.race([promise, timeout]);
}
// Usage:
withTimeout(fetch('/api/slow-endpoint'), 5000)
.then(response => console.log("Got response"))
.catch(err => console.error(err.message)); // "Timed out after 5000ms"
// 3. Sequential execution — run promises one after another
async function sequential(tasks) {
const results = [];
for (const task of tasks) {
results.push(await task());
}
return results;
}
// 4. Concurrent with limit — run N promises at a time
async function concurrentLimit(tasks, limit) {
const results = [];
const executing = [];
for (const task of tasks) {
const promise = task().then(result => {
executing.splice(executing.indexOf(promise), 1);
return result;
});
results.push(promise);
executing.push(promise);
if (executing.length >= limit) {
await Promise.race(executing);
}
}
return Promise.all(results);
}
// Usage: Process 100 URLs, but only 5 at a time
const urls = Array.from({ length: 100 }, (_, i) => `https://api.com/page/${i}`);
const tasks = urls.map(url => () => fetch(url));
const results = await concurrentLimit(tasks, 5);3. Async/Await — Syntactic Sugar Over Promises
async/await makes asynchronous code look and behave like synchronous code. It's built on top of Promises.
Basic Syntax
// async function ALWAYS returns a promise
async function fetchUser(id) {
// await pauses execution until the promise resolves
const response = await fetch(`/api/users/${id}`);
const user = await response.json();
return user; // This becomes the resolved value of the promise
}
// Calling an async function
fetchUser(1)
.then(user => console.log(user))
.catch(err => console.error(err));
// Or from another async function:
async function main() {
const user = await fetchUser(1);
console.log(user);
}Error Handling with Async/Await
// Method 1: try/catch (most common)
async function getUser(id) {
try {
const response = await fetch(`/api/users/${id}`);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const user = await response.json();
return user;
} catch (error) {
console.error("Failed to fetch user:", error.message);
throw error; // Re-throw to propagate
}
}
// Method 2: .catch() on the promise
const user = await getUser(1).catch(err => {
console.error(err);
return null; // Default value on error
});
// Method 3: Wrapper function (Go-style error handling)
async function to(promise) {
try {
const result = await promise;
return [null, result];
} catch (error) {
return [error, null];
}
}
// Usage:
const [err, user] = await to(getUser(1));
if (err) {
console.error("Error:", err.message);
} else {
console.log("User:", user);
}Parallel vs Sequential Execution
// ❌ SEQUENTIAL — each await blocks the next (SLOW!)
async function slow() {
const users = await fetchUsers(); // Wait...
const products = await fetchProducts(); // Wait...
const orders = await fetchOrders(); // Wait...
// Total time = users + products + orders
return { users, products, orders };
}
// ✅ PARALLEL — start all at once, await together (FAST!)
async function fast() {
const [users, products, orders] = await Promise.all([
fetchUsers(),
fetchProducts(),
fetchOrders()
]);
// Total time = max(users, products, orders)
return { users, products, orders };
}
// ⚠️ Common mistake — this is STILL sequential!
async function stillSlow() {
const userPromise = fetchUsers();
const productPromise = fetchProducts();
// These are sequential because we await one after another
// BUT they were already started in parallel above!
const users = await userPromise; // If this takes 2s, both started at t=0
const products = await productPromise; // This resolves immediately if it finished during users' await
// Actually, this IS parallel! The key is that you start the promises
// before awaiting them. Promise.all is cleaner though.
}Async Iteration — for await...of
// For consuming async iterables (like reading a stream line by line)
async function* generateIds() {
let id = 1;
while (true) {
await new Promise(resolve => setTimeout(resolve, 100));
yield id++;
}
}
async function processIds() {
for await (const id of generateIds()) {
console.log("Processing ID:", id);
if (id >= 5) break;
}
}
// Real-world: Reading a file line by line
const readline = require('readline');
const fs = require('fs');
async function processFile(filePath) {
const fileStream = fs.createReadStream(filePath);
const rl = readline.createInterface({ input: fileStream });
for await (const line of rl) {
// Process each line without loading entire file into memory
console.log("Line:", line);
}
}Top-Level Await
// In ES Modules (not CommonJS), you can use await at the top level
// config.mjs
const response = await fetch('https://api.com/config');
const config = await response.json();
export default config;
// This is useful for:
// - Loading configuration before the module is used
// - Database connection setup
// - Conditional imports4. The Event Loop — How JavaScript Actually Works
This is the #1 most asked interview topic for JavaScript backend positions. Understanding it deeply separates you from other candidates.
The Big Picture
JavaScript is single-threaded, but it handles concurrency through an event loop. Here's the architecture:
┌─────────────────────────────────────────────────────────────┐
│ JavaScript Runtime │
│ │
│ ┌──────────────┐ ┌────────────────────────────────┐ │
│ │ │ │ Web APIs / │ │
│ │ Call Stack │ │ Node.js APIs │ │
│ │ │ │ (setTimeout, fs, http, etc.) │ │
│ │ ┌────────┐ │ │ │ │
│ │ │ func() │ │ │ These run OUTSIDE the main │ │
│ │ ├────────┤ │ │ thread (in C++/OS threads) │ │
│ │ │ main() │ │ │ │ │
│ │ └────────┘ │ └──────────┬─────────────────────┘ │
│ └──────────────┘ │ │
│ │ When done, callbacks │
│ │ are placed in queues │
│ ▼ │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ Task Queues │ │
│ │ │ │
│ │ Microtask Queue (higher priority): │ │
│ │ [Promise.then] [queueMicrotask] [MutationObserver] │ │
│ │ │ │
│ │ Macrotask Queue (lower priority): │ │
│ │ [setTimeout] [setInterval] [setImmediate] [I/O] │ │
│ └────────────────────────────────────────────────────────┘ │
│ │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ EVENT LOOP │ │
│ │ │ │
│ │ 1. Execute all code in the Call Stack │ │
│ │ 2. When stack is empty, process ALL microtasks │ │
│ │ 3. Process ONE macrotask │ │
│ │ 4. Go to step 2 │ │
│ └────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘Step-by-Step Execution
console.log("1. Script start");
setTimeout(() => {
console.log("2. setTimeout callback");
}, 0);
Promise.resolve()
.then(() => console.log("3. Promise 1"))
.then(() => console.log("4. Promise 2"));
console.log("5. Script end");
// OUTPUT:
// 1. Script start ← Synchronous, runs immediately
// 5. Script end ← Synchronous, runs immediately
// 3. Promise 1 ← Microtask (higher priority than macrotask)
// 4. Promise 2 ← Microtask (chained .then)
// 2. setTimeout callback ← Macrotask (runs after ALL microtasks)Why This Order?
Step 1: Execute synchronous code (call stack)
→ "1. Script start"
→ setTimeout callback is registered, put in macrotask queue
→ Promise callback is registered, put in microtask queue
→ "5. Script end"
→ Call stack is now empty
Step 2: Process ALL microtasks
→ "3. Promise 1" (this creates another microtask: Promise 2)
→ "4. Promise 2" (process this microtask too — drain the queue!)
Step 3: Process ONE macrotask
→ "2. setTimeout callback"
Step 4: Process microtasks again (none in queue)
Step 5: Process next macrotask (none in queue)
→ Done!Complex Example — Predict the Output
console.log("start");
setTimeout(() => console.log("timeout 1"), 0);
setTimeout(() => console.log("timeout 2"), 0);
Promise.resolve()
.then(() => {
console.log("promise 1");
setTimeout(() => console.log("timeout 3"), 0);
})
.then(() => console.log("promise 2"));
Promise.resolve().then(() => console.log("promise 3"));
console.log("end");
// OUTPUT:
// start
// end
// promise 1
// promise 3
// promise 2
// timeout 1
// timeout 2
// timeout 3
// Explanation:
// 1. Sync: "start", "end"
// 2. Microtasks: "promise 1", "promise 3" (both were queued from sync code)
// - "promise 1" runs and queues timeout 3
// - "promise 3" was also queued during sync
// - After "promise 1", "promise 2" is queued as a new microtask
// - "promise 2" runs (drain all microtasks before moving to macrotasks)
// 3. Macrotasks (one at a time): "timeout 1", then "timeout 2", then "timeout 3"Microtasks vs Macrotasks — Complete List
MICROTASKS (processed ALL at once, higher priority):
- Promise.then/catch/finally callbacks
- queueMicrotask()
- process.nextTick() (Node.js only — even higher priority than promises!)
- MutationObserver (browser only)
MACROTASKS (processed ONE at a time):
- setTimeout / setInterval
- setImmediate (Node.js only)
- I/O callbacks (file read, network, etc.)
- UI rendering (browser only)Node.js Event Loop Phases (More Detailed)
┌───────────────────────────────────────────┐
┌──► timers (setTimeout, setInterval) │
│ └──────────────┬────────────────────────────┘
│ ┌──────────────▼────────────────────────────┐
│ │ pending callbacks (I/O) │
│ └──────────────┬────────────────────────────┘
│ ┌──────────────▼────────────────────────────┐
│ │ idle, prepare (internal) │
│ └──────────────┬────────────────────────────┘
│ ┌──────────────▼────────────────────────────┐
│ │ poll (I/O events) │ ← Most time is spent here
│ └──────────────┬────────────────────────────┘
│ ┌──────────────▼────────────────────────────┐
│ │ check (setImmediate) │
│ └──────────────┬────────────────────────────┘
│ ┌──────────────▼────────────────────────────┐
│ │ close callbacks (socket.close) │
│ └──────────────┬────────────────────────────┘
└─────────────────┘
Between EVERY phase: drain the microtask queue
(process.nextTick first, then Promise callbacks)process.nextTick vs setImmediate vs setTimeout(0)
// Node.js specific ordering:
setImmediate(() => console.log("setImmediate"));
setTimeout(() => console.log("setTimeout"), 0);
process.nextTick(() => console.log("nextTick"));
Promise.resolve().then(() => console.log("Promise"));
// Guaranteed output:
// nextTick ← process.nextTick is ALWAYS first (before any I/O or timers)
// Promise ← Microtask (after nextTick)
// setTimeout ← Timer phase (order with setImmediate can vary outside I/O)
// setImmediate ← Check phase
// Inside an I/O callback, setImmediate ALWAYS fires before setTimeout:
const fs = require('fs');
fs.readFile(__filename, () => {
setImmediate(() => console.log("setImmediate")); // Always first
setTimeout(() => console.log("setTimeout"), 0); // Always second
});⚠️ Common Event Loop Pitfalls
// 1. Blocking the event loop with CPU-intensive work
// BAD — blocks the entire server
app.get('/compute', (req, res) => {
let sum = 0;
for (let i = 0; i < 1e9; i++) { // Billion iterations — blocks for seconds!
sum += i;
}
res.json({ sum });
});
// During this computation, NO other requests can be processed!
// Solutions: Worker threads, child processes, or offload to a queue (Module 07)
// 2. Microtask starvation — infinite microtask loop blocks macrotasks
function infiniteMicrotasks() {
Promise.resolve().then(() => infiniteMicrotasks());
}
// This creates microtasks forever — setTimeout callbacks will NEVER run!
// The event loop never gets to the macrotask queue.
// 3. Forgetting that setTimeout(0) is NOT instant
console.log("before");
setTimeout(() => console.log("timeout"), 0);
console.log("after");
// Output: "before", "after", "timeout"
// setTimeout(0) doesn't mean "run immediately" — it means
// "run in the next macrotask, after all sync code and microtasks"5. Generators and Iterators
Iterators — The Protocol
An iterator is an object with a next() method that returns { value, done }.
// Creating a custom iterator
function createRangeIterator(start, end) {
let current = start;
return {
next() {
if (current <= end) {
return { value: current++, done: false };
}
return { value: undefined, done: true };
}
};
}
const iter = createRangeIterator(1, 3);
iter.next(); // { value: 1, done: false }
iter.next(); // { value: 2, done: false }
iter.next(); // { value: 3, done: false }
iter.next(); // { value: undefined, done: true }
// Making an object iterable (for...of support)
class Range {
constructor(start, end) {
this.start = start;
this.end = end;
}
[Symbol.iterator]() {
let current = this.start;
const end = this.end;
return {
next() {
if (current <= end) {
return { value: current++, done: false };
}
return { done: true };
}
};
}
}
for (const num of new Range(1, 5)) {
console.log(num); // 1, 2, 3, 4, 5
}
// Spread also works with iterables
const numbers = [...new Range(1, 5)]; // [1, 2, 3, 4, 5]Generators — Functions That Can Pause
// function* — generator function
// yield — pause and return a value
function* countUp() {
yield 1;
yield 2;
yield 3;
}
const gen = countUp();
gen.next(); // { value: 1, done: false } — runs until first yield
gen.next(); // { value: 2, done: false } — resumes, runs until second yield
gen.next(); // { value: 3, done: false } — resumes, runs until third yield
gen.next(); // { value: undefined, done: true } — no more yields
// Generators are iterable!
for (const num of countUp()) {
console.log(num); // 1, 2, 3
}Practical Generator Use Cases
// 1. Infinite sequences (lazy evaluation)
function* fibonacci() {
let a = 0, b = 1;
while (true) {
yield a;
[a, b] = [b, a + b];
}
}
// Get first 10 Fibonacci numbers
const fib = fibonacci();
const first10 = Array.from({ length: 10 }, () => fib.next().value);
// [0, 1, 1, 2, 3, 5, 8, 13, 21, 34]
// 2. ID Generator
function* idGenerator(prefix = "id") {
let id = 1;
while (true) {
yield `${prefix}_${id++}`;
}
}
const userIds = idGenerator("user");
userIds.next().value; // "user_1"
userIds.next().value; // "user_2"
userIds.next().value; // "user_3"
// 3. Paginated API consumption
async function* fetchPages(baseUrl) {
let page = 1;
let hasMore = true;
while (hasMore) {
const response = await fetch(`${baseUrl}?page=${page}`);
const data = await response.json();
yield data.items;
hasMore = data.hasNextPage;
page++;
}
}
// Usage: process all pages without loading everything into memory
async function processAllUsers() {
for await (const users of fetchPages('/api/users')) {
for (const user of users) {
await processUser(user);
}
}
}
// 4. Two-way communication (sending values INTO a generator)
function* calculator() {
let result = 0;
while (true) {
const input = yield result; // yield sends result out, receives input
if (input === null) break;
result += input;
}
return result;
}
const calc = calculator();
calc.next(); // { value: 0, done: false } — start the generator
calc.next(5); // { value: 5, done: false } — send 5, get back 5
calc.next(3); // { value: 8, done: false } — send 3, get back 8
calc.next(null); // { value: 8, done: true } — send null to stop6. Modules — CommonJS vs ES Modules
CommonJS (CJS) — Node.js Original
// math.js — exporting
function add(a, b) { return a + b; }
function subtract(a, b) { return a - b; }
// Export single thing
module.exports = add;
// Or export multiple things
module.exports = { add, subtract };
// Or add to exports object
exports.add = add;
exports.subtract = subtract;
// ⚠️ Don't mix: exports = { add } — this breaks the reference!
// app.js — importing
const add = require('./math'); // Single export
const { add, subtract } = require('./math'); // Destructured
const math = require('./math'); // Whole moduleES Modules (ESM) — The Standard
// math.mjs (or .js with "type": "module" in package.json)
export function add(a, b) { return a + b; }
export function subtract(a, b) { return a - b; }
// Default export (one per module)
export default function multiply(a, b) { return a * b; }
// Named + default together
export default class Calculator { /* ... */ }
export const PI = 3.14159;
// app.mjs — importing
import multiply from './math.mjs'; // Default import
import { add, subtract } from './math.mjs'; // Named imports
import multiply, { add, subtract } from './math.mjs'; // Both
import * as math from './math.mjs'; // Everything as namespace
import { add as addition } from './math.mjs'; // RenameKey Differences
Feature │ CommonJS (CJS) │ ES Modules (ESM)
────────────────┼───────────────────────┼─────────────────────
Syntax │ require / module.exports │ import / export
Loading │ Synchronous │ Asynchronous
Parsing │ Runtime │ Static (at parse time)
Top-level await │ ❌ Not supported │ ✅ Supported
Tree shaking │ ❌ Not possible │ ✅ Possible (bundlers)
File extension │ .js (default) │ .mjs or "type": "module"
this in module │ module.exports │ undefined
Dynamic import │ require() anywhere │ import() returns promise
Circular deps │ Partial support │ Better support// Dynamic import (works in both CJS and ESM)
const moduleName = './math.mjs';
const math = await import(moduleName);
math.add(1, 2); // 3
// Useful for conditional imports
if (process.env.NODE_ENV === 'development') {
const devTools = await import('./dev-tools.mjs');
devTools.setup();
}7. Proxy and Reflect
Proxy — Intercepting Object Operations
A Proxy wraps an object and intercepts operations on it (get, set, delete, etc.).
const user = { name: "Alice", age: 25 };
const proxy = new Proxy(user, {
get(target, property) {
console.log(`Getting ${property}`);
return target[property];
},
set(target, property, value) {
console.log(`Setting ${property} = ${value}`);
if (property === 'age' && (typeof value !== 'number' || value < 0)) {
throw new Error("Age must be a positive number");
}
target[property] = value;
return true;
},
deleteProperty(target, property) {
console.log(`Deleting ${property}`);
delete target[property];
return true;
}
});
proxy.name; // Logs "Getting name", returns "Alice"
proxy.age = 30; // Logs "Setting age = 30"
proxy.age = -1; // Throws Error: "Age must be a positive number"
delete proxy.name; // Logs "Deleting name"Real-World Proxy Use Cases
// 1. Validation Layer
function createValidatedObject(schema) {
return new Proxy({}, {
set(target, prop, value) {
if (schema[prop]) {
const { type, required, min, max } = schema[prop];
if (type && typeof value !== type) {
throw new TypeError(`${prop} must be of type ${type}`);
}
if (min !== undefined && value < min) {
throw new RangeError(`${prop} must be >= ${min}`);
}
if (max !== undefined && value > max) {
throw new RangeError(`${prop} must be <= ${max}`);
}
}
target[prop] = value;
return true;
}
});
}
const userSchema = {
name: { type: 'string' },
age: { type: 'number', min: 0, max: 150 },
email: { type: 'string' }
};
const user = createValidatedObject(userSchema);
user.name = "Alice"; // ✅
user.age = 25; // ✅
user.age = -5; // ❌ RangeError
user.age = "twenty"; // ❌ TypeError
// 2. Observable objects (trigger actions on change)
function makeObservable(target, onChange) {
return new Proxy(target, {
set(obj, prop, value) {
const oldValue = obj[prop];
obj[prop] = value;
onChange(prop, value, oldValue);
return true;
}
});
}
const config = makeObservable({ theme: "dark", lang: "en" }, (prop, newVal, oldVal) => {
console.log(`Config changed: ${prop}: ${oldVal} → ${newVal}`);
// Could trigger re-render, save to DB, notify subscribers, etc.
});
config.theme = "light"; // "Config changed: theme: dark → light"
// 3. Auto-populating defaults (like defaultdict in Python)
function defaultDict(defaultFactory) {
return new Proxy({}, {
get(target, prop) {
if (!(prop in target)) {
target[prop] = defaultFactory();
}
return target[prop];
}
});
}
const wordCount = defaultDict(() => 0);
"hello world hello hello world".split(" ").forEach(word => {
wordCount[word]++;
});
console.log(wordCount.hello); // 3
console.log(wordCount.world); // 2Reflect — Clean Object Operations
// Reflect provides methods for interceptable JavaScript operations
// It's the "proper" way to do object operations
Reflect.get(obj, 'name'); // Same as obj.name
Reflect.set(obj, 'name', 'Alice'); // Same as obj.name = 'Alice'
Reflect.has(obj, 'name'); // Same as 'name' in obj
Reflect.deleteProperty(obj, 'name'); // Same as delete obj.name
Reflect.ownKeys(obj); // Object.keys + Symbol keys
// Commonly used inside Proxy handlers:
const proxy = new Proxy(target, {
get(target, prop, receiver) {
console.log(`Accessing ${prop}`);
return Reflect.get(target, prop, receiver); // Proper delegation
}
});8. Error Handling in Async Code
The Unhandled Promise Rejection Problem
// BAD — unhandled promise rejection (Node.js will crash in future versions!)
async function riskyOperation() {
throw new Error("Something broke!");
}
riskyOperation(); // No .catch() and no try/catch!
// Node.js warning: "UnhandledPromiseRejectionWarning"
// Future Node.js versions will CRASH on this.
// GOOD — always handle rejections
riskyOperation().catch(err => console.error(err));
// Or with try/catch in an async context
try {
await riskyOperation();
} catch (err) {
console.error(err);
}
// Global handler for unhandled rejections (safety net)
process.on('unhandledRejection', (reason, promise) => {
console.error('Unhandled rejection:', reason);
// Log, alert, or gracefully shut down
});
process.on('uncaughtException', (error) => {
console.error('Uncaught exception:', error);
// MUST exit after this — state is unreliable
process.exit(1);
});Error Handling Patterns in Production Code
// 1. Wrapper for route handlers (Express)
function asyncHandler(fn) {
return (req, res, next) => {
Promise.resolve(fn(req, res, next)).catch(next);
};
}
// Without wrapper — you must try/catch every route:
app.get('/users', async (req, res) => {
try {
const users = await User.find();
res.json(users);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// With wrapper — errors automatically go to error middleware:
app.get('/users', asyncHandler(async (req, res) => {
const users = await User.find(); // If this throws, asyncHandler catches it
res.json(users);
}));
// 2. Retry with exponential backoff
async function retryWithBackoff(fn, maxRetries = 3, baseDelay = 1000) {
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
return await fn();
} catch (error) {
if (attempt === maxRetries - 1) throw error;
const delay = baseDelay * Math.pow(2, attempt); // 1s, 2s, 4s
const jitter = Math.random() * delay * 0.1; // Add randomness
console.log(`Attempt ${attempt + 1} failed, retrying in ${delay}ms...`);
await new Promise(resolve => setTimeout(resolve, delay + jitter));
}
}
}
// Usage:
const data = await retryWithBackoff(() => fetch('https://api.example.com/data'));
// 3. Circuit breaker pattern
class CircuitBreaker {
constructor(fn, { threshold = 5, timeout = 30000 } = {}) {
this.fn = fn;
this.threshold = threshold;
this.timeout = timeout;
this.failures = 0;
this.state = 'CLOSED'; // CLOSED = normal, OPEN = failing, HALF_OPEN = testing
this.nextAttempt = Date.now();
}
async call(...args) {
if (this.state === 'OPEN') {
if (Date.now() < this.nextAttempt) {
throw new Error('Circuit breaker is OPEN — request blocked');
}
this.state = 'HALF_OPEN';
}
try {
const result = await this.fn(...args);
this.onSuccess();
return result;
} catch (error) {
this.onFailure();
throw error;
}
}
onSuccess() {
this.failures = 0;
this.state = 'CLOSED';
}
onFailure() {
this.failures++;
if (this.failures >= this.threshold) {
this.state = 'OPEN';
this.nextAttempt = Date.now() + this.timeout;
console.log(`Circuit OPEN — will retry after ${this.timeout}ms`);
}
}
}
const apiBreaker = new CircuitBreaker(
(url) => fetch(url).then(r => r.json()),
{ threshold: 3, timeout: 10000 }
);9. Advanced Patterns
Debounce
// Debounce: Wait until the user STOPS doing something for X ms
// Use case: Search autocomplete, form validation, window resize
function debounce(fn, delay, { leading = false } = {}) {
let timerId;
let isLeadingInvoked = false;
function debounced(...args) {
// Leading edge: fire immediately on first call
if (leading && !isLeadingInvoked) {
fn.apply(this, args);
isLeadingInvoked = true;
}
clearTimeout(timerId);
timerId = setTimeout(() => {
// Trailing edge: fire after delay
if (!leading || isLeadingInvoked) {
fn.apply(this, args);
}
isLeadingInvoked = false;
}, delay);
}
debounced.cancel = () => {
clearTimeout(timerId);
isLeadingInvoked = false;
};
return debounced;
}Throttle
// Throttle: Execute at most once every X ms
// Use case: Scroll handlers, API rate limiting, mouse move
function throttle(fn, interval) {
let lastTime = 0;
let timerId;
function throttled(...args) {
const now = Date.now();
if (now - lastTime >= interval) {
lastTime = now;
fn.apply(this, args);
} else {
// Schedule trailing call
clearTimeout(timerId);
timerId = setTimeout(() => {
lastTime = Date.now();
fn.apply(this, args);
}, interval - (now - lastTime));
}
}
throttled.cancel = () => clearTimeout(timerId);
return throttled;
}
// Difference:
// Debounce: User types "hello" → fires ONCE after they stop
// Throttle: User scrolls for 5 seconds → fires every 200ms during scrollMemoize with Cache Expiry
function memoize(fn, { maxAge = Infinity, maxSize = 1000 } = {}) {
const cache = new Map();
function memoized(...args) {
const key = JSON.stringify(args);
if (cache.has(key)) {
const entry = cache.get(key);
if (Date.now() - entry.timestamp < maxAge) {
return entry.value;
}
cache.delete(key); // Expired
}
const result = fn.apply(this, args);
// Evict oldest entry if cache is full
if (cache.size >= maxSize) {
const firstKey = cache.keys().next().value;
cache.delete(firstKey);
}
cache.set(key, { value: result, timestamp: Date.now() });
return result;
}
memoized.clear = () => cache.clear();
memoized.size = () => cache.size;
return memoized;
}
// Usage:
const expensiveCompute = memoize(
(n) => {
// Simulate heavy computation
let result = 0;
for (let i = 0; i < n; i++) result += Math.sqrt(i);
return result;
},
{ maxAge: 60000, maxSize: 100 } // Cache for 1 minute, max 100 entries
);10. Design Patterns in JavaScript
Singleton Pattern
// Ensures only one instance of a class exists
class Database {
static #instance = null;
constructor(connectionString) {
if (Database.#instance) {
return Database.#instance;
}
this.connectionString = connectionString;
this.connected = false;
Database.#instance = this;
}
async connect() {
if (this.connected) return;
console.log(`Connecting to ${this.connectionString}...`);
this.connected = true;
}
static getInstance() {
return Database.#instance;
}
}
const db1 = new Database("mongodb://localhost");
const db2 = new Database("postgres://localhost"); // Returns same instance!
console.log(db1 === db2); // true
// In Node.js, modules are singletons by default (cached after first require)
// So you can also do:
// database.js
// const db = new Database(process.env.DB_URL);
// module.exports = db;
// Now every file that requires('./database') gets the SAME instanceObserver Pattern (Pub/Sub)
class EventBus {
#handlers = {};
subscribe(event, handler) {
if (!this.#handlers[event]) {
this.#handlers[event] = [];
}
this.#handlers[event].push(handler);
// Return unsubscribe function
return () => {
this.#handlers[event] = this.#handlers[event].filter(h => h !== handler);
};
}
publish(event, data) {
if (!this.#handlers[event]) return;
this.#handlers[event].forEach(handler => handler(data));
}
}
// Usage:
const bus = new EventBus();
const unsubscribe = bus.subscribe('user:login', (user) => {
console.log(`${user.name} logged in`);
});
bus.subscribe('user:login', (user) => {
// Send analytics
analytics.track('login', { userId: user.id });
});
bus.publish('user:login', { id: 1, name: 'Alice' });
// "Alice logged in"
// Analytics tracked
unsubscribe(); // Remove first handlerStrategy Pattern
// Define a family of algorithms and make them interchangeable
const pricingStrategies = {
regular: (price) => price,
premium: (price) => price * 0.9, // 10% discount
vip: (price) => price * 0.8, // 20% discount
employee: (price) => price * 0.5, // 50% discount
};
function calculatePrice(basePrice, customerType) {
const strategy = pricingStrategies[customerType];
if (!strategy) throw new Error(`Unknown customer type: ${customerType}`);
return strategy(basePrice);
}
calculatePrice(100, 'regular'); // 100
calculatePrice(100, 'premium'); // 90
calculatePrice(100, 'vip'); // 80
calculatePrice(100, 'employee'); // 50Factory Pattern
class Notification {
send(message) { throw new Error('send() must be implemented'); }
}
class EmailNotification extends Notification {
constructor(email) { super(); this.email = email; }
send(message) { console.log(`Email to ${this.email}: ${message}`); }
}
class SMSNotification extends Notification {
constructor(phone) { super(); this.phone = phone; }
send(message) { console.log(`SMS to ${this.phone}: ${message}`); }
}
class PushNotification extends Notification {
constructor(deviceId) { super(); this.deviceId = deviceId; }
send(message) { console.log(`Push to ${this.deviceId}: ${message}`); }
}
// Factory
function createNotification(type, destination) {
switch (type) {
case 'email': return new EmailNotification(destination);
case 'sms': return new SMSNotification(destination);
case 'push': return new PushNotification(destination);
default: throw new Error(`Unknown notification type: ${type}`);
}
}
// Usage:
const notification = createNotification('email', 'alice@example.com');
notification.send('Hello!'); // "Email to alice@example.com: Hello!"Middleware Pattern (Express-style)
// This is how Express.js works internally
class Pipeline {
#middlewares = [];
use(fn) {
this.#middlewares.push(fn);
return this;
}
async execute(context) {
let index = 0;
const next = async () => {
if (index >= this.#middlewares.length) return;
const middleware = this.#middlewares[index++];
await middleware(context, next);
};
await next();
return context;
}
}
// Usage:
const pipeline = new Pipeline();
pipeline
.use(async (ctx, next) => {
ctx.startTime = Date.now();
console.log("Start");
await next();
console.log(`Done in ${Date.now() - ctx.startTime}ms`);
})
.use(async (ctx, next) => {
console.log("Auth check");
ctx.user = { id: 1, name: "Alice" };
await next();
})
.use(async (ctx, next) => {
console.log(`Hello, ${ctx.user.name}!`);
ctx.result = "Success";
await next();
});
pipeline.execute({});
// Start
// Auth check
// Hello, Alice!
// Done in 1ms11. Memory Management & Garbage Collection
How JavaScript Memory Works
// JavaScript uses automatic garbage collection (you don't free memory manually)
// The main algorithm: "Mark-and-Sweep"
// 1. Mark: Start from "roots" (global variables, call stack) and mark all reachable objects
// 2. Sweep: Delete all objects that are NOT marked (unreachable)
// Objects become unreachable when there are no references to them:
let user = { name: "Alice" }; // Object is reachable via `user`
user = null; // Object is now unreachable — will be garbage collectedCommon Memory Leaks
// 1. Accidental globals
function doSomething() {
leakedVar = "I'm global!"; // Missing let/const — creates global variable!
}
// Fix: Always use let/const. Enable 'use strict' to catch this.
// 2. Forgotten timers
const id = setInterval(() => {
const data = getHugeData();
processData(data);
}, 1000);
// If you never call clearInterval(id), this runs forever
// and `data` is allocated every second
// 3. Closures holding references
function createHandler() {
const hugeData = new Array(1000000).fill('*'); // Large allocation
return function handler() {
// Even if handler doesn't USE hugeData, the closure still holds a reference
console.log("handling...");
};
}
const handler = createHandler(); // hugeData stays in memory as long as handler exists
// 4. Detached DOM nodes (browser)
const btn = document.getElementById('myBtn');
document.body.removeChild(btn);
// btn variable still references the removed DOM node — it can't be GC'd
// 5. Growing data structures
const cache = {};
function addToCache(key, value) {
cache[key] = value; // Cache grows forever!
}
// Fix: Use WeakMap, or implement cache eviction (LRU cache)WeakRef and FinalizationRegistry (ES2021)
// WeakRef — hold a weak reference to an object (doesn't prevent GC)
let obj = { data: "important" };
const weakRef = new WeakRef(obj);
weakRef.deref(); // { data: "important" } — the object
obj = null; // Object can now be garbage collected
// Later: weakRef.deref() might return undefined
// FinalizationRegistry — callback when object is GC'd
const registry = new FinalizationRegistry((heldValue) => {
console.log(`Object with id ${heldValue} was garbage collected`);
});
let resource = { id: 1, data: new ArrayBuffer(1024) };
registry.register(resource, resource.id);
resource = null; // Eventually: "Object with id 1 was garbage collected"12. Practice Problems
Problem 1: Implement Promise.all from Scratch
// TRY IT YOURSELF FIRST!
function promiseAll(promises) {
return new Promise((resolve, reject) => {
const results = new Array(promises.length);
let completed = 0;
if (promises.length === 0) {
resolve(results);
return;
}
promises.forEach((promise, index) => {
Promise.resolve(promise) // Handle non-promise values
.then(value => {
results[index] = value;
completed++;
if (completed === promises.length) {
resolve(results);
}
})
.catch(reject); // First rejection rejects the whole thing
});
});
}
// Test:
const p1 = Promise.resolve(1);
const p2 = new Promise(resolve => setTimeout(() => resolve(2), 100));
const p3 = Promise.resolve(3);
promiseAll([p1, p2, p3]).then(console.log); // [1, 2, 3]Problem 2: Implement a Promise-based Queue
// Process tasks one at a time, in order
class AsyncQueue {
#queue = [];
#processing = false;
enqueue(task) {
return new Promise((resolve, reject) => {
this.#queue.push({ task, resolve, reject });
this.#process();
});
}
async #process() {
if (this.#processing) return;
this.#processing = true;
while (this.#queue.length > 0) {
const { task, resolve, reject } = this.#queue.shift();
try {
const result = await task();
resolve(result);
} catch (error) {
reject(error);
}
}
this.#processing = false;
}
get size() {
return this.#queue.length;
}
}
// Usage:
const queue = new AsyncQueue();
queue.enqueue(() => fetch('/api/1')).then(r => console.log('First done'));
queue.enqueue(() => fetch('/api/2')).then(r => console.log('Second done'));
queue.enqueue(() => fetch('/api/3')).then(r => console.log('Third done'));
// Processes one at a time, in orderProblem 3: Predict the Output
async function foo() {
console.log('A');
const result = await Promise.resolve('B');
console.log(result);
return 'C';
}
console.log('D');
foo().then(val => console.log(val));
console.log('E');
// Answer: D, A, E, B, C
// D — sync code
// A — foo() starts executing synchronously until first await
// E — sync code (foo is paused at await)
// B — microtask: the await resolves, foo resumes
// C — microtask: the .then callback runs with return value13. Interview Questions
Q1: Explain the event loop in your own words.
Answer: JavaScript has a single call stack for executing code. When an async operation (setTimeout, HTTP request, file read) is initiated, it's handed off to the runtime's APIs (libuv in Node.js). When the operation completes, its callback is placed in a task queue. The event loop continuously checks: if the call stack is empty, it first processes all microtasks (Promises, process.nextTick), then processes one macrotask (setTimeout, I/O), then checks microtasks again, and repeats.
Q2: What's the difference between microtasks and macrotasks?
Answer: Microtasks (Promise callbacks, queueMicrotask, process.nextTick) are processed ALL at once before the event loop moves to the next phase. Macrotasks (setTimeout, setInterval, I/O) are processed one at a time. Microtasks have higher priority — they always run before the next macrotask.
Q3: What happens if you don't catch a rejected promise?
Answer: In older Node.js, it logs an "UnhandledPromiseRejectionWarning." In Node.js 15+, it terminates the process with an unhandled rejection. You should always catch promise rejections with .catch() or try/catch with await. As a safety net, listen for process.on('unhandledRejection').
Q4: CommonJS vs ES Modules — when would you use each?
Answer: CommonJS is Node.js's original module system (require/module.exports), it's synchronous and loaded at runtime. ES Modules (import/export) are the JavaScript standard, they're asynchronous, statically analyzed (enabling tree-shaking), and support top-level await. Use ESM for new projects. Use CJS when working with older Node.js packages that don't support ESM.
Q5: Implement Promise.race from scratch.
function promiseRace(promises) {
return new Promise((resolve, reject) => {
for (const promise of promises) {
Promise.resolve(promise).then(resolve).catch(reject);
}
});
}Q6: What is the output and why?
const promise = new Promise((resolve) => {
console.log(1);
resolve();
console.log(2);
});
promise.then(() => console.log(3));
console.log(4);
// Answer: 1, 2, 4, 3
// The Promise executor runs synchronously (1, then resolve(), then 2)
// resolve() doesn't stop execution — it just changes the promise state
// 4 is sync code
// 3 is a microtask (.then callback) — runs after all sync codeNext Module: 03 - Node.js Deep Dive — The runtime that makes server-side JavaScript possible.