02-javascript-advanced.md

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

  1. Callbacks — Where It All Started
  2. Promises — The Modern Way
  3. Async/Await — Syntactic Sugar Over Promises
  4. The Event Loop — How JavaScript Actually Works
  5. Generators and Iterators
  6. Modules — CommonJS vs ES Modules
  7. Proxy and Reflect
  8. Error Handling in Async Code
  9. Advanced Patterns — Debounce, Throttle, Memoize
  10. Design Patterns in JavaScript
  11. Memory Management & Garbage Collection
  12. Practice Problems
  13. 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 logic

2. 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 rejects

Real-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 imports

4. 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 stop

6. 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 module

ES 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'; // Rename

Key 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 modulemodule.exports │ undefined Dynamic importrequire() 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); // 2

Reflect — 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 scroll

Memoize 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 instance

Observer 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 handler

Strategy 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'); // 50

Factory 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 1ms

11. 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 collected

Common 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 order

Problem 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 value

13. 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 code

Next Module: 03 - Node.js Deep Dive — The runtime that makes server-side JavaScript possible.