JS Field Manual
ONE-DAY CRAM · JAVASCRIPT FOR THE IONIC WEALTH BACKEND ROLE

JavaScript fundamentals, field-manual style

Everything on your list — scope, closures, this, types, array methods, async — plus the concepts that always ride along with them in an interview, each with a definition, the "why", a runnable example, and a diagram where it actually helps. DSA-in-JS practice set at the very end.

Suggested run order for today

First · Parts I–IIIScope, hoisting, closures, types & copying — the mental model everything else sits on. Don't skip these to get to async faster.
Then · Parts IV–VArray methods (you'll write these by hand in the DSA section) and a light pass on prototypes/classes.
Then · Part VIAsync JS — this is the highest-yield section for a Node.js role. Read it slowly, run the code-order example yourself in a console.
Last · Part IX15 DSA problems in JS. Read the "thinking approach" before the code — cover the code and try to derive it yourself first.
PART I

Variables & scope

The mechanics underneath every "why does this print undefined" question. Get this section rock-solid — closures and the event loop both build directly on it.

§1.1

var vs let vs const

Definition: Three ways to declare a variable, differing in scope, whether they can be reassigned, and whether they're hoisted "usable" or hoisted "locked."
varletconst
ScopeFunction-scopedBlock-scopedBlock-scoped
ReassignableYesYesNo (but see note below)
RedeclarableYesNoNo
Hoisted asHoisted & initialized to undefinedHoisted but in TDZ (§1.4) — using it early throwsSame as let
Attaches towindow/global object (in scripts)Not attached to global objectNot attached to global object
Example — the block-scope difference, live
if (true) {
  var a = 1;
  let b = 2;
}
console.log(a); // 1 — var leaked out of the block
console.log(b); // ReferenceError — b only exists inside the { }
"const means immutable" is a trap
const only freezes the binding — you can't reassign the variable to point at a new value — but if that value is an object or array, its contents are still fully mutable.
const user = { name: 'Yash' };
user.name = 'Yashwardhan';  // ✅ fine — mutating the object, not reassigning
user = {};                 // ❌ TypeError — reassigning the binding
True deep immutability needs Object.freeze() — see §4.3.
🎯 Interview answer, one sentence
"Default to const, use let when you need to reassign, and avoid var — it's function-scoped instead of block-scoped, which causes the classic loop-closure bug." (Full example in §2.1.)
§1.2

Scope & lexical scoping

Definition: Scope is where a variable is accessible. JavaScript uses lexical scoping — a function's access to outer variables is determined by where it's written in the source code, not by who calls it.
GLOBAL SCOPE let appName = 'Ionic' FUNCTION SCOPE — outer() let count = 0 FUNCTION SCOPE — inner() console.log(count, appName) ✓ can read count & appName — they're visible from here
Each scope can see its own variables plus everything in the scopes around it — outward, never inward. This nested lookup path is the scope chain.

Three practical scope levels: global (top-level), function (created by every function call), and block (created by { }if, for, a bare block — for let/const only, not var).

"Lexical" is the key word interviewers listen for — it means the scope is fixed by where you wrote the function, not by the call stack at runtime. This is also exactly what makes closures possible (§2.1).

§1.3

Hoisting

Definition: Before executing a scope's code, the JS engine scans it and registers all var/let/const/function declarations in memory — so they technically exist from the top of the scope, even though the code reads top-to-bottom.
WHAT YOU WRITE console.log(x); var x = 5; (reads top → bottom) WHAT THE ENGINE DOES var x; // ① hoisted, = undefined console.log(x); // ② prints undefined x = 5; // ③ assignment happens here
Only the declaration is hoisted, never the assignment. That's the whole trick.
Example — var vs function vs let
console.log(a);   // undefined — declaration hoisted, value isn't
var a = 10;

sayHi();          // "hi" — function DECLARATIONS hoist fully, body included
function sayHi() { console.log('hi'); }

console.log(b);   // ReferenceError — let is hoisted but stuck in the TDZ
let b = 20;
🎯 Nuance that separates a good answer from a great one
Function declarations (function foo(){}) hoist completely — you can call them before they appear in the file. Function expressions (const foo = function(){} or arrow functions) don't — only the variable binding hoists (per var/let/const rules above), the function value doesn't exist until that line runs.
§1.4

Temporal Dead Zone (TDZ)

Definition: The span between entering a scope (where let/const are already hoisted) and the line where they're actually declared. Accessing the variable anywhere in that span throws a ReferenceError instead of returning undefined.

This is why let/const feel like they "aren't hoisted" even though they technically are — they're hoisted into an unusable, locked state on purpose, specifically to catch the exact bug that silent undefined values from var used to hide.

Example
{
  // TDZ for `score` starts here
  console.log(score); // ReferenceError: Cannot access 'score' before initialization
  let score = 100;  // TDZ ends here
}

PART II

Functions & this

Closures are the single most-tested JS concept in interviews — expect at least one "predict the output" question built on it.

§2.1

Closures

Definition: A closure is a function bundled together with references to its surrounding (lexical) scope — so the function keeps access to those outer variables even after the outer function has finished running.
makeCounter() — call finished, popped off the stack let count = 0 ↳ normally garbage collected... ↳ but a closure keeps it alive ↓ returned function () => { count++; return count; } holds a live link to "count" — not a copy
The inner function "closes over" count. As long as any reference to the inner function exists, count can't be garbage collected.
Example — the classic counter
function makeCounter() {
  let count = 0;
  return function() {
    count++;
    return count;
  };
}
const counter = makeCounter();
counter(); // 1
counter(); // 2 — same `count`, remembered between calls

const counter2 = makeCounter();
counter2(); // 1 — a fresh, independent closure over its own `count`
🎯 The #1 closure interview question — the loop bug
for (var i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 0);
}
// prints 3, 3, 3 — NOT 0, 1, 2
var is function-scoped, so all three callbacks close over the exact same i — and by the time setTimeout actually runs them (§6.1), the loop has already finished with i === 3. Swapping in let fixes it: let creates a new binding per iteration, so each closure captures its own snapshot: prints 0, 1, 2. This single example is why "prefer let over var" is more than a style preference.

Practical uses: private variables (a module pattern — data only accessible through returned functions), memoization/caching, and — directly relevant below — debounce/throttle implementations (§6.8), which are really just closures holding onto a timer ID between calls.

§2.2

Function declarations vs expressions vs arrow functions

DeclarationExpressionArrow function
Syntaxfunction foo(){}const foo = function(){}const foo = () => {}
HoistingFully hoisted (callable before defined)Only the binding hoists, not the valueOnly the binding hoists, not the value
Own this?Yes — depends on how it's calledYes — depends on how it's calledNo — inherits this lexically from where it's defined
arguments objectYesYesNo (use rest params: (...args))
Can be a constructor (new)YesYesNo
🎯 When to reach for which
Arrow functions for callbacks where you want to preserve the outer this (array methods inside a class method, event handlers) — that's their whole reason for existing beyond brevity. Regular functions/methods when you need your own dynamic this (object methods, constructors).
§2.3

"this" in JavaScript

Definition: this is not fixed by where a function is defined — for regular functions, it's determined by how the function is called (its "call site"). Arrow functions are the one exception: they take this from their enclosing scope, permanently.
Call formthis is
obj.method()The object before the dot — obj
fn() (plain call)undefined in strict mode / module scope (was globalThis in old sloppy mode)
new Fn()The newly created object (§5.2)
Arrow functionWhatever this was in the enclosing (lexical) scope — never rebound
fn.call(obj) / .apply(obj) / .bind(obj)Explicitly set to obj (§2.4)
Example — the classic "lost this" bug, and the fix
const user = {
  name: 'Yash',
  greetLater() {
    setTimeout(function() {
      console.log(`Hi, ${this.name}`); // "Hi, undefined" — this is NOT `user` here!
    }, 100);                              // plain function call inside setTimeout loses `this`
  },
  greetLaterFixed() {
    setTimeout(() => {
      console.log(`Hi, ${this.name}`);   // "Hi, Yash" — arrow inherits `this` from greetLaterFixed
    }, 100);
  }
};
🎯 One-sentence answer
"this is determined by the call site, not the definition site — except for arrow functions, which lock in whatever this was around them when they were written." Then walk through the table above if asked to elaborate.
§2.4

call / apply / bind

Definition: Three ways to explicitly control what this a function runs with, instead of relying on the call site.
Example
function introduce(greeting, punctuation) {
  console.log(`${greeting}, I'm ${this.name}${punctuation}`);
}
const person = { name: 'Yash' };

introduce.call(person, 'Hi', '!');     // args passed one by one, runs immediately
introduce.apply(person, ['Hi', '!']);  // same, but args as an array — runs immediately

const boundIntroduce = introduce.bind(person, 'Hi');
boundIntroduce('!');                       // bind returns a NEW function, called later, `this` locked forever

Mnemonic: call — comma-separated args. apply — array of args. bind — doesn't call it, returns a new permanently-bound function for later (classic use: binding event handler methods in a class constructor before arrow-function class fields made it unnecessary).


PART III

Types & copying

Where "why did changing a copy also change the original" bugs come from — and the mental model (stack vs heap) that makes them stop being surprising.

§3.1

Primitive vs reference types

Definition: Primitives (string, number, boolean, null, undefined, symbol, bigint) are copied by value — each variable gets its own independent copy. Reference types (object, array, function) are copied by reference — variables hold a pointer to the same underlying value in memory.
PRIMITIVE — independent copies a = 5 b = 5 b = a copies the value. b = 10 never touches a. REFERENCE — shared pointer obj1 obj2 {name:'Y'} obj2 = obj1 copies the pointer. obj2.name = 'X' changes what obj1 sees too.
Example
let a = 5;
let b = a;
b = 10;
console.log(a); // 5 — untouched

const obj1 = { name: 'Yash' };
const obj2 = obj1;      // copies the REFERENCE, not the object
obj2.name = 'Yashwardhan';
console.log(obj1.name); // "Yashwardhan" — same object in memory
🎯 Why this matters beyond trivia
This is the entire reason function parameters behave the way they do: passing an object into a function and mutating a property affects the caller's object too (still "pass by value" — the value being copied is the reference itself), and it's the root cause behind §3.3's shallow-vs-deep-copy question.
§3.2

== vs === and type coercion

Definition: === (strict equality) compares value and type with no conversion. == (loose equality) converts one or both sides to a common type first, then compares — which produces some famously surprising results.
Example
'5' == 5       // true — string coerced to number
'5' === 5      // false — different types, no coercion
null == undefined   // true — special-cased to equal each other, and ONLY each other
null === undefined  // false
[] == false       // true — [] → "" → 0, false → 0. Classic gotcha.
typeof null    // "object" — a 25+ year old language bug, kept for backward compatibility
NaN === NaN     // false — NaN is never equal to anything, including itself; use Number.isNaN()
🎯 One-sentence answer
"Always use === — it avoids the coercion rules entirely, so behavior stays predictable." Mention typeof null === 'object' as your one piece of trivia if pushed for a gotcha.
§3.3

Shallow copy vs deep copy

Definition: A shallow copy duplicates the top-level object/array, but nested objects inside it are still shared references with the original. A deep copy recursively duplicates everything, all the way down — the result shares nothing with the original.
SHALLOW COPY original { addr: →, id:1 } shallow copy { addr: →, id:1 } addr {city} both still point to the SAME nested "addr" DEEP COPY original addr {city} (own) deep copy addr {city} (own copy) fully independent, all the way down
Example
const original = { id: 1, addr: { city: 'Prayagraj' } };

// shallow copy — spread / Object.assign / Array.slice
const shallow = { ...original };
shallow.addr.city = 'Bengaluru';
console.log(original.addr.city); // "Bengaluru" — leaked! addr was shared, not copied

// deep copy — structuredClone (modern, handles most cases well)
const deep = structuredClone(original);
deep.addr.city = 'Mumbai';
console.log(original.addr.city); // "Bengaluru" — untouched, fully independent

// older deep-copy trick — JSON round-trip (has real limitations)
const deep2 = JSON.parse(JSON.stringify(original));
JSON.parse(JSON.stringify(x)) — know its limits
Silently drops functions and undefined values, converts Date objects to strings, can't handle circular references (throws), and breaks on Map/Set. structuredClone() (built into modern Node & browsers) handles Dates, Maps, Sets and circular refs correctly — prefer it. For deep merges/clones with fine control, libraries like lodash's cloneDeep are still common in real codebases.
§3.4

Destructuring & spread/rest — bonus, shows up constantly in real code

Example
// destructuring — pull values out by name/position
const { name, addr: { city } } = user;
const [first, second, ...rest] = [10, 20, 30, 40]; // rest = [30, 40]

// spread — expand an iterable/object into individual elements
const merged = { ...defaults, ...userConfig };  // userConfig's keys win on conflict
const combined = [...arr1, ...arr2];

// rest params — collect remaining args into an array (arrow-function-safe alternative to `arguments`)
const sum = (...nums) => nums.reduce((a,b) => a+b, 0);

... means two different things depending on position: on the right of = or inside a call, it spreads (expands). On the left in a destructuring pattern, it rests (collects). Same symbol, opposite direction — worth saying explicitly if asked.


PART IV

Arrays & objects in practice

You'll re-derive several of these from scratch in the DSA section — read the signatures carefully now so that's easy later.

§4.1

map / filter / forEach / reduce

Definition: Four core higher-order functions (functions that take a function as an argument) for working with arrays without hand-written loops — each has a distinct return shape and purpose.
MethodReturnsPurpose
forEachundefinedJust run side effects per item — logging, pushing to an outside array
mapNew array, same lengthTransform every element into something else
filterNew array, same or shorterKeep only elements matching a condition
reduceAnything — a number, object, string, arrayFold the whole array down into a single accumulated value
Example — same data, all four
const orders = [
  { id: 1, amount: 500, status: 'filled' },
  { id: 2, amount: 1200, status: 'pending' },
  { id: 3, amount: 300, status: 'filled' }
];

orders.forEach(o => console.log(o.id));           // side effect only, returns undefined

const amounts = orders.map(o => o.amount);           // [500, 1200, 300]

const filled = orders.filter(o => o.status === 'filled'); // 2 matching order objects

const total = orders.reduce((sum, o) => sum + o.amount, 0); // 2000
const byStatus = orders.reduce((acc, o) => {
  (acc[o.status] ??= []).push(o);
  return acc;
}, {});   // { filled: [...], pending: [...] } — reduce can build ANY shape
🎯 The line that shows real understanding
"map, filter, and even forEach are really just reduce with a specific accumulator shape — reduce is the general-purpose primitive underneath all of them." You'll prove this yourself by implementing reduce from scratch in the DSA section (§9, Easy 10).
Common mistake
map always returns an array the same length as the input — using it purely for side effects (like forEach) and ignoring the return value works, but wastes a new array and confuses readers. Reach for forEach when there's nothing to collect, map only when you want the transformed array back.
§4.2

find / some / every / sort — bonus, rounds out the array-method family

MethodReturnsStops early?
findFirst matching element (or undefined)Yes
findIndexIndex of first match (or -1)Yes
sometrue if any element matchesYes
everytrue only if all elements matchYes (on first failure)
sortThe array, sorted in placeNo
The sort gotcha everyone hits once
Without a comparator, sort() converts everything to strings first — so [10, 2, 1].sort() gives [1, 10, 2], not [1, 2, 10]. Always pass a comparator for numbers: arr.sort((a,b) => a - b). Also remember sort (and reverse, splice) mutate the original array — map/filter don't.
§4.3

Object methods & immutability — bonus

Example
const user = { id: 1, name: 'Yash' };
Object.keys(user);     // ['id', 'name']
Object.values(user);   // [1, 'Yash']
Object.entries(user);  // [['id',1], ['name','Yash']] — pairs well with for...of and reduce

const frozen = Object.freeze({ role: 'admin' });
frozen.role = 'viewer';   // silently ignored (throws in strict mode)
console.log(frozen.role); // "admin" — still locked
Object.freeze is also shallow
Same trap as §3.3's shallow copy — Object.freeze only locks the top-level keys. A nested object inside a frozen object is still fully mutable unless you recursively freeze it too.

PART V

Prototypes & OOP

Not in your original list, but "how does inheritance work in JS" is a very common follow-up once this comes up — kept brief on purpose.

§5.1

Prototype chain & inheritance

Definition: Every JS object has an internal link to another object — its prototype — and when you access a property that isn't found on the object itself, JS keeps looking up that chain until it finds it or hits null.
yash = {name:'Yash'} __proto__ User.prototype Object.prototype yash.name → found on yash itself yash.greet() → not on yash, found one level up, on User.prototype yash.toString() → from Object.prototype
This lookup chain is the entire mechanism behind JS "inheritance" — methods don't get copied onto every instance, they're found by walking up the chain.
Example — the pre-class way (what `class` compiles down to)
function User(name) { this.name = name; }
User.prototype.greet = function() { return `Hi, ${this.name}`; };

const yash = new User('Yash');
yash.greet();  // "Hi, Yash" — found via the prototype chain, not copied onto yash
§5.2

class & the "new" keyword

Definition: class is syntax sugar over the prototype pattern above — cleaner to write, same mechanism underneath. new is what actually wires an instance up to its prototype.

What new Fn() does, step by step: (1) creates a brand-new empty object, (2) links its prototype to Fn.prototype, (3) calls Fn with this bound to that new object, (4) returns the object (unless Fn explicitly returns its own object).

Example
class User {
  constructor(name) { this.name = name; }
  greet() { return `Hi, ${this.name}`; }     // lands on User.prototype, same as before
}
class Admin extends User {
  greet() { return super.greet() + ' (admin)'; }
}
new Admin('Yash').greet(); // "Hi, Yash (admin)"

PART VI

Asynchronous JavaScript

The highest-yield section for a Node.js role. If you only deeply revise one part today besides closures, make it this one.

§6.1

The event loop

Definition: The mechanism that lets JavaScript — single-threaded — run asynchronous code without blocking, by handing slow work (timers, network, I/O) off to the environment (browser or Node) and running queued callbacks only when the call stack is empty.
CALL STACK main() fetchData() runs sync code, one frame at a time WEB APIs / NODE APIs setTimeout, fetch/http, fs, DB drivers — run OUTSIDE the stack MICROTASK QUEUE Promise .then/.catch, queueMicrotask MACROTASK (CALLBACK) QUEUE setTimeout, setInterval, DOM/network events result ready → queued event loop: whenever the call stack is empty → drain ALL microtasks → then run ONE macrotask → repeat
This is the browser/spec-level model. Node.js implements the same idea via libuv with more granular phases (covered separately if you've studied Node internals) — the core rule is identical either way: microtasks always drain first.
Narrate it end to end
  1. Synchronous code runs first, top to bottom, on the call stack — nothing async can interleave while the stack isn't empty.
  2. An async operation (setTimeout, a network request, a file read) is handed off to the browser/Node's own APIs — it runs independently, off the JS thread.
  3. When that operation finishes, its callback doesn't run immediately — it's placed in a queue (microtask or macrotask, §6.2).
  4. The event loop's only job: once the call stack is completely empty, pull the next callback from a queue and run it, then repeat forever.
🎯 One-sentence answer
"JS runs on one thread, but never blocks on I/O — it delegates the waiting to the environment and only re-enters your code, via the event loop, once a result is ready and the stack is clear."
§6.2

Microtask vs macrotask

Definition: Two priority tiers of queued callbacks. Microtasks (Promise callbacks) always run to completion — all of them, including new ones they schedule — before the event loop touches the macrotask queue (timers, I/O callbacks) again.
Example — the question that catches almost everyone once
console.log('1');
setTimeout(() => console.log('2 (macrotask)'), 0);
Promise.resolve().then(() => console.log('3 (microtask)'));
console.log('4');

// Output: 1, 4, 3 (microtask), 2 (macrotask)
// All synchronous code finishes FIRST.
// Then ALL pending microtasks drain — even a 0ms setTimeout waits behind them.

Why it exists: microtasks are meant for finishing up work that's logically still "part of" the current operation (resolving a promise chain) before moving on to the next unrelated event — it keeps promise chains predictable and gives them priority over timer-based/IO-based callbacks.

Starvation risk
Because microtasks fully drain before a single macrotask runs, a promise chain that keeps scheduling more microtasks (e.g. a recursive .then() that never stops) can starve timers and I/O indefinitely — a real, if rare, production bug class.
§6.3

Callbacks & callback hell

Definition: A callback is a function passed into another function to be run later (usually on completion of async work). Callback hell is the deeply nested, hard-to-read pyramid that results from chaining several dependent async callbacks the old way.
Example — the pyramid of doom
getUser(id, (err, user) => {
  if (err) return handleError(err);
  getOrders(user.id, (err, orders) => {
    if (err) return handleError(err);
    getInvoice(orders[0].id, (err, invoice) => {
      if (err) return handleError(err);
      console.log(invoice);         // 3 levels deep and growing — error handling repeated every level
    });
  });
});

Real problems, not just aesthetics: error handling repeated at every level (and easy to forget one), hard to run steps in parallel, hard to reason about execution order, and painful to refactor. Promises (§6.4) were designed specifically to flatten this; async/await (§6.5) flattens it even further.

§6.4

Promises

Definition: An object representing the eventual result of an async operation — one of three states: pending, fulfilled (succeeded, has a value), or rejected (failed, has a reason). Once settled (fulfilled or rejected), a promise's outcome never changes again.
pending fulfilled ✓ rejected ✗ resolve(value) reject(error)
Once it moves to fulfilled or rejected, it's "settled" — permanently. This is why .then() called after settlement still fires correctly.
Example — creating and consuming one
function fetchUser(id) {
  return new Promise((resolve, reject) => {
    db.query('SELECT * FROM users WHERE id=$1', [id], (err, row) => {
      if (err) reject(err);
      else resolve(row);
    });
  });
}

fetchUser(42)
  .then(user => getOrders(user.id))   // flattened, no nesting — each .then returns a new promise
  .then(orders => console.log(orders))
  .catch(err => console.error(err))   // one .catch handles errors from ANY step above it
  .finally(() => console.log('done')); // always runs, success or failure
🎯 Why chaining works
Each .then() returns a brand-new promise — if the callback returns a plain value, the next .then() receives it directly; if it returns another promise, the chain automatically waits for it to settle first. That auto-unwrapping is what lets you flatten arbitrarily deep async steps into one flat chain.
§6.5

async / await

Definition: Syntax sugar over promises — async makes a function always return a promise, and await pauses that function (without blocking the thread) until the awaited promise settles, letting you write async code that reads top-to-bottom like sync code.
Example — the same fetchUser chain, rewritten
async function loadUserOrders(id) {
  try {
    const user = await fetchUser(id);     // pauses here, rest of the app keeps running
    const orders = await getOrders(user.id);
    return orders;
  } catch (err) {
    console.error(err);               // catches a rejection from EITHER await above
    throw err;
  } finally {
    console.log('done');
  }
}
"await pauses the thread" — a common misstatement
await only pauses that function's execution (it's resumed later as a microtask, §6.2) — it does not block the JS thread. Every other request/callback in your Node process keeps running normally while one function is awaiting.
Sequential-await trap
// ❌ slow — each await waits before the next one even starts, fully sequential
const a = await fetchA(); const b = await fetchB();

// ✅ fast — start both immediately, wait for both together (§6.6)
const [a, b] = await Promise.all([fetchA(), fetchB()]);
If two async calls don't depend on each other, awaiting them one after another wastes time for no reason — this is a real, common performance bug, and directly ties back to §5.6 of the backend manual ("how to improve a slow API").
§6.6

Promise.all vs allSettled vs race / any

Definition: Four ways to run multiple promises concurrently and combine their outcomes, differing in how they handle rejection and how many results they wait for.
MethodResolves whenRejects whenResult shape
Promise.allEvery promise fulfillsAny single one rejects — immediately, others ignoredArray of values, in order
Promise.allSettledEvery promise settles (fulfilled or rejected)Never rejectsArray of {status, value|reason}
Promise.raceThe first promise to settle (win or lose)If that first one was a rejectionThat one value/error
Promise.anyThe first promise to fulfillOnly if all rejectThat one fulfilled value
Example — the difference that actually matters
const requests = [fetchPrices(), fetchNews(), fetchWatchlist()];

// all: fine when EVERY result is required — one failure kills the whole batch
const [prices, news, watchlist] = await Promise.all(requests);

// allSettled: better for a dashboard — show whatever succeeded, don't let
// one flaky widget's failure blank out the other two
const results = await Promise.allSettled(requests);
results.forEach(r => {
  if (r.status === 'fulfilled') render(r.value);
  else renderError(r.reason);
});
🎯 When to reach for which
all — you need every result and any failure should fail the whole operation (e.g. "place these 3 dependent trades atomically"). allSettled — independent widgets/calls where partial success is fine (a dashboard). race — implementing a timeout (Promise.race([fetchData(), timeout(3000)])). any — you have several redundant sources for the same thing and just want the fastest one that works.
§6.7

Error handling in async code — bonus

A rejected promise with no .catch() (or no surrounding try/catch around an await) becomes an unhandled rejection — in Node.js, this now crashes the process by default (as of Node 15+), which is exactly why every promise chain and every await needs an error path.

Example — a safe Express handler
app.get('/orders/:id', async (req, res) => {
  try {
    const order = await getOrder(req.params.id);
    res.json(order);
  } catch (err) {
    res.status(500).json({ error: 'internal_error' }); // never let it bubble unhandled
  }
});
// in real apps this try/catch is usually a wrapper/middleware so you don't repeat it everywhere
§6.8

Debounce & throttle — bonus, a favorite "write it live" question — pure closures + timers

Definition: Two techniques for limiting how often a fast-firing function actually runs. Debounce waits for a pause in activity before firing once. Throttle fires at most once per fixed interval, no matter how often it's called.
calls: debounce fires once, only after calls stop for the wait period throttle fires on a fixed cadence regardless of call bursts
Same underlying tool (a closure holding a timer ID), two different guarantees.
Example — debounce (implemented fully in the DSA section too)
function debounce(fn, wait) {
  let timeoutId;
  return function(...args) {
    clearTimeout(timeoutId);                    // cancel the pending call
    timeoutId = setTimeout(() => fn.apply(this, args), wait);
  };
}
const search = debounce(query => api.search(query), 300);
// typing fast: only fires 300ms after the LAST keystroke
Example — throttle
function throttle(fn, limit) {
  let inCooldown = false;
  return function(...args) {
    if (inCooldown) return;
    fn.apply(this, args);
    inCooldown = true;
    setTimeout(() => inCooldown = false, limit);
  };
}
🎯 When to use which
Debounce for "wait until they've stopped" — search-as-you-type, save-on-idle, resize-end. Throttle for "cap the rate but keep responding regularly" — scroll position tracking, rate-limiting a button, live position updates. On the backend, this same idea is exactly what §1.8's rate limiting formalizes at the API level.

PART VII

Modules

Directly relevant given the JD asks for Node.js specifically.

§7.1

CommonJS vs ES Modules

Definition: Two module systems for splitting JS into files. CommonJS (require/module.exports) is Node's original system, synchronous. ES Modules (import/export) is the language-standard system, supports static analysis and works in both browsers and modern Node.
CommonJS
// math.js
module.exports = { add: (a,b)=>a+b };

// app.js
const { add } = require('./math');
Loaded synchronously, resolved at runtime — you can even require() conditionally inside an if.
ES Modules
// math.js
export const add = (a,b)=>a+b;

// app.js
import { add } from './math.js';
Imports are resolved statically at parse time (before any code runs) — this is what allows bundlers to "tree-shake" (strip unused exports).

In Node.js, ESM is opted into via "type": "module" in package.json or a .mjs extension. A practical difference worth naming: CommonJS exports are a live-ish snapshot copied at require-time in older Node semantics, while ESM exports are live bindings — if the exporting module later updates a value, an ESM importer sees the update; a destructured CommonJS import may not.


§8

60-second cheat sheet

Skim this right before the call. Every line links back to its full section.

var/let/constvar: function-scoped, hoisted as undefined. let/const: block-scoped, TDZ.
HoistingDeclarations move to the top of scope; assignments don't.
ClosuresA function + its captured outer variables, alive as long as the function is.
thisDetermined by the call site — except arrow functions, which inherit it lexically.
Primitive vs referencePrimitives copy by value. Objects/arrays copy the reference, not the contents.
== vs ====== never coerces types. Use it always.
Shallow vs deep copyShallow: nested objects still shared. Deep: fully independent, use structuredClone.
map/filter/reducemap transforms, filter selects, reduce folds to any shape. forEach: side effects only.
Prototype chainProperty lookup walks up __proto__ links; class is sugar over this.
Event loopSingle thread, non-blocking. Runs a callback only when the stack is empty.
Microtask vs macrotaskAll microtasks (Promises) drain before the next macrotask (setTimeout) runs.
Promisepending → fulfilled/rejected, settles once, permanently.
async/awaitSugar over promises. Pauses the function, never blocks the thread.
all vs allSettledall fails fast on one rejection. allSettled always resolves with every outcome.
Callback hellDeep nesting from chained async callbacks — fixed by promises/async-await.
Debounce vs throttleDebounce: fire once after a pause. Throttle: fire on a fixed cadence.
CommonJS vs ESMrequire = sync, runtime-resolved. import = static, live bindings.

PART IX

DSA practice in JavaScript

15 problems — 10 easy, 5 medium — each with the thinking approach first, then the implementation, then why it works. Several deliberately reuse concepts from Parts I–VIII (closures, reduce, Map, Promise.all) so the fundamentals above stop being abstract.

How to actually use this section in one day
Cover the code with your hand. Read the problem + thinking approach, try to write the function yourself (even just on paper/mentally), then check against the implementation. Recognizing a solution is a completely different skill from producing one under pressure — don't skip the attempt.

Easy — 10 problems

E1

Two Sum

Easy

Given an array of integers nums and an integer target, return the indices of the two numbers that add up to target. Exactly one valid answer exists.

Thinking approach
  • Brute force: check every pair — O(n²). Works, but there's a faster way.
  • For each number, what you actually need is its complement (target - num) — and whether you've already seen it.
  • A hashmap lets you check "have I seen this complement?" in O(1), so one pass is enough: for each number, look up its complement before adding the current number to the map.
Implementation (JavaScript)
function twoSum(nums, target) {
  const seen = new Map(); // value -> index
  for (let i = 0; i < nums.length; i++) {
    const complement = target - nums[i];
    if (seen.has(complement)) return [seen.get(complement), i];
    seen.set(nums[i], i);
  }
  return [];
}
twoSum([2,7,11,15], 9); // [0, 1] — nums[0]+nums[1] = 9
Explanation

The map is built as you go, so by the time you check for a complement, the map only contains numbers seen before the current index — this naturally prevents using the same element twice.

Time: O(n)Space: O(n)
E2

Valid Anagram

Easy

Given two strings s and t, return true if t is an anagram of s (same letters, same frequency, any order).

Thinking approach
  • Different lengths ⇒ can't be anagrams, bail out immediately.
  • Anagram = same character frequency. Count characters in s, then subtract while walking t — if any count goes negative (or missing), it's not an anagram.
Implementation (JavaScript)
function isAnagram(s, t) {
  if (s.length !== t.length) return false;
  const count = {};
  for (const ch of s) count[ch] = (count[ch] || 0) + 1;
  for (const ch of t) {
    if (!count[ch]) return false;  // missing, or already used up
    count[ch]--;
  }
  return true;
}
isAnagram('listen', 'silent'); // true
Explanation

Building then draining one frequency map avoids the O(n log n) cost of sorting both strings. !count[ch] catches both "never seen" (undefined) and "count hit zero" in one check.

Time: O(n)Space: O(1) — bounded by alphabet size
E3

Reverse a String (in-place)

Easy

Given a character array, reverse it in-place (constant extra space).

Thinking approach
  • "In-place" is the constraint that rules out building a new reversed array — think two pointers instead.
  • One pointer starts at the front, one at the back. Swap them, then step inward. Stop when they meet or cross.
Implementation (JavaScript)
function reverseString(arr) {
  let left = 0, right = arr.length - 1;
  while (left < right) {
    [arr[left], arr[right]] = [arr[right], arr[left]]; // swap via destructuring
    left++;
    right--;
  }
  return arr;
}
reverseString(['h','e','l','l','o']); // ['o','l','l','e','h']
Explanation

The two-pointer pattern is worth internalizing on its own — it shows up again almost verbatim in Valid Palindrome (E4) below.

Time: O(n)Space: O(1)
E4

Valid Palindrome

Easy

Given a string, determine if it's a palindrome after ignoring non-alphanumeric characters and case — e.g. "A man, a plan, a canal: Panama" → true.

Thinking approach
  • Same two-pointer idea as E3, but this time comparing instead of swapping, and skipping characters that don't count.
  • At each step, advance left past non-alphanumeric characters, advance right past non-alphanumeric characters, then compare the two (lower-cased). Mismatch ⇒ not a palindrome.
Implementation (JavaScript)
function isPalindrome(s) {
  const isAlnum = c => /[a-z0-9]/i.test(c);
  let left = 0, right = s.length - 1;
  while (left < right) {
    while (left < right && !isAlnum(s[left])) left++;
    while (left < right && !isAlnum(s[right])) right--;
    if (s[left].toLowerCase() !== s[right].toLowerCase()) return false;
    left++;
    right--;
  }
  return true;
}
Explanation

Doing the skip-and-compare in one pass avoids the naive approach of first building a cleaned string (an extra O(n) allocation) before checking it.

Time: O(n)Space: O(1)
E5

FizzBuzz

Easy

Print numbers 1 to n; for multiples of 3 print "Fizz", multiples of 5 print "Buzz", multiples of both print "FizzBuzz".

Thinking approach
  • The only trap: check "divisible by both" before checking 3 and 5 individually, or use a single % 15 check — otherwise "FizzBuzz" cases wrongly print just "Fizz".
Implementation (JavaScript)
function fizzBuzz(n) {
  const result = [];
  for (let i = 1; i <= n; i++) {
    if (i % 15 === 0) result.push('FizzBuzz');
    else if (i % 3 === 0) result.push('Fizz');
    else if (i % 5 === 0) result.push('Buzz');
    else result.push(String(i));
  }
  return result;
}
Explanation

Trivial algorithmically — its real purpose in an interview is to see whether you write clean, correct code under mild pressure without overthinking a simple problem.

Time: O(n)Space: O(n) — for the output
E6

Missing Number

Easy

Given an array containing n distinct numbers from 0 to n, find the one number missing from the range.

Thinking approach
  • What should be there: numbers 0..n sum to a known value via Gauss's formula, n*(n+1)/2.
  • What is there: sum the actual array.
  • The missing number is simply the difference — no sorting, no extra data structure needed.
Implementation (JavaScript)
function missingNumber(nums) {
  const n = nums.length;
  const expectedSum = (n * (n + 1)) / 2;
  const actualSum = nums.reduce((sum, x) => sum + x, 0);
  return expectedSum - actualSum;
}
missingNumber([3,0,1]); // 2
Explanation

An alternative worth mentioning out loud: XOR every index and every value together — every number that appears in both cancels to 0 (x ^ x = 0), leaving only the missing number. Same O(n)/O(1), avoids any risk of numeric overflow in languages with fixed-size integers (less of a concern in JS, but interviewers like hearing you know it).

Time: O(n)Space: O(1)
E7

Contains Duplicate

Easy

Given an array, return true if any value appears at least twice.

Thinking approach
  • Track what you've already seen in a Set (O(1) lookup). The moment you see something already in the set, you can stop immediately.
Implementation (JavaScript)
function containsDuplicate(nums) {
  const seen = new Set();
  for (const n of nums) {
    if (seen.has(n)) return true;
    seen.add(n);
  }
  return false;
}
Explanation

A one-liner like new Set(nums).size !== nums.length also works, but it always scans the whole array first to build the set — the loop version above can exit early on the very first duplicate, which matters on large inputs.

Time: O(n)Space: O(n)
E8

Flatten a Nested Array

Easy

Given an array that may contain arrays nested to any depth, return a single flat array of all the values, in order.

Thinking approach
  • This is naturally recursive: for each item, if it's an array, flatten it and splice the results in; if it's a plain value, keep it as-is.
  • reduce (§4.1) is a clean way to express "build up one flat array from many items."
Implementation (JavaScript)
function flatten(arr) {
  return arr.reduce((flat, item) =>
    flat.concat(Array.isArray(item) ? flatten(item) : item), []);
}
flatten([1, [2, [3, [4]], 5]]); // [1, 2, 3, 4, 5]

// the built-in equivalent, worth knowing exists:
[1, [2, [3]]].flat(Infinity); // [1, 2, 3]
Explanation

The recursion depth matches the nesting depth, not the element count — a very deeply nested (thousands of levels) array could theoretically hit a stack limit, worth mentioning as an edge case if asked.

Time: O(n) total elementsSpace: O(d) call stack, d = nesting depth
E9

Implement debounce()

Easy

Implement a debounce(fn, wait) that returns a new function which only invokes fn after wait ms have passed without another call — a favorite "write it live" question, and pure application of §2.1 (closures) and §6.1 (the event loop).

Thinking approach
  • You need to remember one thing between calls — the current pending timer's ID. A closure is exactly the tool for "state that persists between calls to a returned function."
  • Every new call should cancel whatever timer is currently pending and start a fresh one — that's the entire behavior.
Implementation (JavaScript)
function debounce(fn, wait) {
  let timeoutId;
  return function(...args) {
    clearTimeout(timeoutId);
    timeoutId = setTimeout(() => fn.apply(this, args), wait);
  };
}

// usage
const logSearch = debounce(q => console.log('searching:', q), 300);
logSearch('a'); logSearch('ap'); logSearch('app');
// only "searching: app" ever logs — the first two calls got cancelled
Explanation

timeoutId lives in the closure created by debounce's single invocation, so every call to the returned function shares and updates the same variable — that's what lets each new call cancel the previous pending one via clearTimeout.

Time: O(1) per callSpace: O(1)
E10

Implement Array.prototype.myReduce

Easy

Implement reduce from scratch, matching the native method's signature and behavior — the strongest possible proof you actually understand §4.1.

Thinking approach
  • Core loop: keep an accumulator, call the callback with (accumulator, currentValue, index, array), update the accumulator with the return value, repeat.
  • The one real subtlety: an optional initial value. If it's not given, the native method uses the array's first element as the starting accumulator and begins iterating from index 1 instead of 0.
Implementation (JavaScript)
Array.prototype.myReduce = function(callback, initialValue) {
  let acc = initialValue;
  let startIndex = 0;
  if (acc === undefined) {
    acc = this[0];   // no initial value → seed with first element
    startIndex = 1;    // ...and start the loop one element later
  }
  for (let i = startIndex; i < this.length; i++) {
    acc = callback(acc, this[i], i, this);
  }
  return acc;
};

[1,2,3,4].myReduce((sum, x) => sum + x, 0); // 10
Explanation

Attaching it to Array.prototype (§5.1) is what makes this inside the function refer to the array it's called on — the same prototype mechanism that makes every built-in array method work.

Time: O(n)Space: O(1) beyond the accumulator

Medium — 5 problems

M1

Group Anagrams

Medium

Given an array of strings, group the ones that are anagrams of each other.

Thinking approach
  • Direct extension of E2: two strings are anagrams exactly when some canonical form of them is identical.
  • Sorting a string's characters gives a canonical form: "eat" and "tea" both sort to "aet".
  • Use that sorted string as a hashmap key, and push each original string into the bucket for its key.
Implementation (JavaScript)
function groupAnagrams(strs) {
  const groups = new Map();
  for (const s of strs) {
    const key = s.split('').sort().join('');
    if (!groups.has(key)) groups.set(key, []);
    groups.get(key).push(s);
  }
  return Array.from(groups.values());
}
groupAnagrams(['eat','tea','tan','ate','nat','bat']);
// [['eat','tea','ate'], ['tan','nat'], ['bat']]
Explanation

Sorting each string costs O(k log k) for a string of length k. For large alphabets-only inputs, a 26-length character-count array joined into a key (rather than sorting) drops this to O(k) per string — worth mentioning as the optimization if asked to go faster.

Time: O(n·k log k)Space: O(n·k)
M2

Longest Substring Without Repeating Characters

Medium

Given a string, find the length of the longest substring with no repeated characters.

Thinking approach
  • Brute force checks every substring — O(n²) or worse. The key insight: as you scan left to right, you only ever need to know the most recent position of each character.
  • This is a sliding window: keep a window [left, right] with no repeats. Expand right each step; if you hit a character already in the window, jump left to just past its last occurrence instead of resetting to zero.
Implementation (JavaScript)
function lengthOfLongestSubstring(s) {
  const lastSeen = new Map(); // char -> most recent index
  let left = 0, maxLen = 0;
  for (let right = 0; right < s.length; right++) {
    const ch = s[right];
    if (lastSeen.has(ch) && lastSeen.get(ch) >= left) {
      left = lastSeen.get(ch) + 1; // jump window start past the earlier duplicate
    }
    lastSeen.set(ch, right);
    maxLen = Math.max(maxLen, right - left + 1);
  }
  return maxLen;
}
lengthOfLongestSubstring('abcabcbb'); // 3 — "abc"
Explanation

Because left only ever moves forward (never resets to 0), each character is visited by right once and by left at most once — giving linear time despite looking like a nested-loop problem at first glance.

Time: O(n)Space: O(min(n, alphabet size))
M3

Merge Intervals

Medium

Given an array of intervals [start, end], merge all overlapping intervals and return the result.

Thinking approach
  • Overlaps are only obvious once intervals are in order — so sort by start time first. This turns an all-pairs comparison problem into a single linear scan.
  • Walk through sorted intervals keeping a "current merged interval." If the next interval's start is ≤ the current one's end, they overlap — extend the end. Otherwise, close out the current merged interval and start a new one.
Implementation (JavaScript)
function merge(intervals) {
  if (intervals.length <= 1) return intervals;
  intervals.sort((a, b) => a[0] - b[0]);   // sort by start

  const result = [intervals[0]];
  for (let i = 1; i < intervals.length; i++) {
    const last = result[result.length - 1];
    const curr = intervals[i];
    if (curr[0] <= last[1]) {
      last[1] = Math.max(last[1], curr[1]);  // overlap — extend in place
    } else {
      result.push(curr);                        // no overlap — new group
    }
  }
  return result;
}
merge([[1,3],[2,6],[8,10],[15,18]]);
// [[1,6],[8,10],[15,18]]
Explanation

Math.max(last[1], curr[1]) matters — a later interval can be fully contained inside the current merged one (e.g. [1,10] then [2,4]), and blindly overwriting the end would incorrectly shrink the merged interval.

Time: O(n log n) — the sort dominatesSpace: O(n) for the output
M4

LRU Cache

Medium

Design a Least-Recently-Used cache with a fixed capacity, supporting get(key) and put(key, value) in O(1), evicting the least-recently-used entry when full.

Thinking approach
  • The classic textbook answer is a hashmap + a manually-built doubly linked list (hashmap for O(1) lookup, linked list to track recency order in O(1)).
  • JavaScript shortcut worth knowing: a Map's keys iterate in insertion order, by spec — so re-inserting a key (delete then set) moves it to the "most recent" end for free, and the first key in iteration order is always the least recently used one.
Implementation (JavaScript)
class LRUCache {
  constructor(capacity) {
    this.capacity = capacity;
    this.map = new Map();
  }
  get(key) {
    if (!this.map.has(key)) return -1;
    const value = this.map.get(key);
    this.map.delete(key);
    this.map.set(key, value);   // re-insert → now the most-recently-used
    return value;
  }
  put(key, value) {
    if (this.map.has(key)) {
      this.map.delete(key);
    } else if (this.map.size >= this.capacity) {
      const lruKey = this.map.keys().next().value; // first = least recently used
      this.map.delete(lruKey);
    }
    this.map.set(key, value);
  }
}
Explanation

Every operation here — Map.has, .get, .set, .delete, and reading the first key — is O(1), so the whole cache stays O(1) per operation without hand-rolling a linked list. In a language without an insertion-ordered map, you'd need the manual doubly-linked-list approach instead — good to say out loud so it's clear this is a JS-specific shortcut, not a universal trick.

Time: O(1) for get and putSpace: O(capacity)
M5

Implement Promise.all from scratch

Medium

Implement myPromiseAll(promises) that mirrors Promise.all: resolves with an array of all results (in the original order) once every promise fulfills, or rejects immediately with the reason of the first one that rejects.

Thinking approach
  • The function itself must return a Promise (§6.4) — everything happens inside a new Promise((resolve, reject) => ...) executor.
  • Promises can settle in any order, but the result array must preserve the original order — so store each result at its own index i, not by push order.
  • Keep a counter of how many have completed. Resolve the outer promise only when that counter reaches promises.length.
  • Any single rejection should reject the whole thing immediately — don't wait for the others.
  • Edge case: an empty input array should resolve immediately with [].
Implementation (JavaScript)
function myPromiseAll(promises) {
  return new Promise((resolve, reject) => {
    const results = new Array(promises.length);
    let completed = 0;

    if (promises.length === 0) return resolve(results);

    promises.forEach((p, i) => {
      Promise.resolve(p)               // handles plain (non-promise) values too
        .then(value => {
          results[i] = value;          // store at ORIGINAL index, not arrival order
          completed++;
          if (completed === promises.length) resolve(results);
        })
        .catch(reject);                // first rejection wins, immediately
    });
  });
}
Explanation

The whole problem is really a state-machine over one shared completed counter and one shared results array, both captured in the executor's closure (§2.1) — every one of the promises.length parallel .then() callbacks writes into the same array and reads/increments the same counter. This is also a genuinely great way to cement §6.6: reimplementing all makes the difference from allSettled completely concrete — notice this version has no equivalent of "wait for every one to settle even on failure," which is exactly what allSettled would add.

Time: O(n) promises tracked, dominated by the slowest oneSpace: O(n)