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
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.
var vs let vs const
var | let | const | |
|---|---|---|---|
| Scope | Function-scoped | Block-scoped | Block-scoped |
| Reassignable | Yes | Yes | No (but see note below) |
| Redeclarable | Yes | No | No |
| Hoisted as | Hoisted & initialized to undefined | Hoisted but in TDZ (§1.4) — using it early throws | Same as let |
| Attaches to | window/global object (in scripts) | Not attached to global object | Not attached to global object |
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 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.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.)Scope & lexical scoping
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).
Hoisting
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.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;
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.Temporal Dead Zone (TDZ)
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.
{
// TDZ for `score` starts here
console.log(score); // ReferenceError: Cannot access 'score' before initialization
let score = 100; // TDZ ends here
}
Functions & this
Closures are the single most-tested JS concept in interviews — expect at least one "predict the output" question built on it.
Closures
count. As long as any reference to the inner function exists, count can't be garbage collected.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`
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.
Function declarations vs expressions vs arrow functions
| Declaration | Expression | Arrow function | |
|---|---|---|---|
| Syntax | function foo(){} | const foo = function(){} | const foo = () => {} |
| Hoisting | Fully hoisted (callable before defined) | Only the binding hoists, not the value | Only the binding hoists, not the value |
Own this? | Yes — depends on how it's called | Yes — depends on how it's called | No — inherits this lexically from where it's defined |
arguments object | Yes | Yes | No (use rest params: (...args)) |
Can be a constructor (new) | Yes | Yes | No |
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)."this" in JavaScript
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 form | this 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 function | Whatever this was in the enclosing (lexical) scope — never rebound |
fn.call(obj) / .apply(obj) / .bind(obj) | Explicitly set to obj (§2.4) |
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);
}
};
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.call / apply / bind
this a function runs with, instead of relying on the call site.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).
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.
Primitive vs reference types
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
== vs === and type coercion
=== (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.'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()
=== — 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.Shallow copy vs deep copy
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));
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.Destructuring & spread/rest — bonus, shows up constantly in real code
// 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.
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.
map / filter / forEach / reduce
| Method | Returns | Purpose |
|---|---|---|
forEach | undefined | Just run side effects per item — logging, pushing to an outside array |
map | New array, same length | Transform every element into something else |
filter | New array, same or shorter | Keep only elements matching a condition |
reduce | Anything — a number, object, string, array | Fold the whole array down into a single accumulated value |
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
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).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.find / some / every / sort — bonus, rounds out the array-method family
| Method | Returns | Stops early? |
|---|---|---|
find | First matching element (or undefined) | Yes |
findIndex | Index of first match (or -1) | Yes |
some | true if any element matches | Yes |
every | true only if all elements match | Yes (on first failure) |
sort | The array, sorted in place | No |
sort gotcha everyone hits oncesort() 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.Object methods & immutability — bonus
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 only locks the top-level keys. A nested object inside a frozen object is still fully mutable unless you recursively freeze it too.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.
Prototype chain & inheritance
null.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
class & the "new" keyword
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).
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)"
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.
The event loop
- Synchronous code runs first, top to bottom, on the call stack — nothing async can interleave while the stack isn't empty.
- 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. - When that operation finishes, its callback doesn't run immediately — it's placed in a queue (microtask or macrotask, §6.2).
- 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.
Microtask vs macrotask
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.
.then() that never stops) can starve timers and I/O indefinitely — a real, if rare, production bug class.Callbacks & callback hell
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.
Promises
.then() called after settlement still fires correctly.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
.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.async / await
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.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 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.// ❌ 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").Promise.all vs allSettled vs race / any
| Method | Resolves when | Rejects when | Result shape |
|---|---|---|---|
Promise.all | Every promise fulfills | Any single one rejects — immediately, others ignored | Array of values, in order |
Promise.allSettled | Every promise settles (fulfilled or rejected) | Never rejects | Array of {status, value|reason} |
Promise.race | The first promise to settle (win or lose) | If that first one was a rejection | That one value/error |
Promise.any | The first promise to fulfill | Only if all reject | That one fulfilled value |
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);
});
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.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.
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
Debounce & throttle — bonus, a favorite "write it live" question — pure closures + timers
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
function throttle(fn, limit) {
let inCooldown = false;
return function(...args) {
if (inCooldown) return;
fn.apply(this, args);
inCooldown = true;
setTimeout(() => inCooldown = false, limit);
};
}
Modules
Directly relevant given the JD asks for Node.js specifically.
CommonJS vs ES Modules
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.// 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.// 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.
60-second cheat sheet
Skim this right before the call. Every line links back to its full section.
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.
Easy — 10 problems
Two Sum
EasyGiven 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.
- 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.
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
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.
Valid Anagram
EasyGiven two strings s and t, return true if t is an anagram of s (same letters, same frequency, any order).
- Different lengths ⇒ can't be anagrams, bail out immediately.
- Anagram = same character frequency. Count characters in
s, then subtract while walkingt— if any count goes negative (or missing), it's not an anagram.
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
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.
Reverse a String (in-place)
EasyGiven a character array, reverse it in-place (constant extra space).
- "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.
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']
The two-pointer pattern is worth internalizing on its own — it shows up again almost verbatim in Valid Palindrome (E4) below.
Valid Palindrome
EasyGiven 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.
- Same two-pointer idea as E3, but this time comparing instead of swapping, and skipping characters that don't count.
- At each step, advance
leftpast non-alphanumeric characters, advancerightpast non-alphanumeric characters, then compare the two (lower-cased). Mismatch ⇒ not a palindrome.
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;
}
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.
FizzBuzz
EasyPrint numbers 1 to n; for multiples of 3 print "Fizz", multiples of 5 print "Buzz", multiples of both print "FizzBuzz".
- The only trap: check "divisible by both" before checking 3 and 5 individually, or use a single
% 15check — otherwise "FizzBuzz" cases wrongly print just "Fizz".
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;
}
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.
Missing Number
EasyGiven an array containing n distinct numbers from 0 to n, find the one number missing from the range.
- 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.
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
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).
Contains Duplicate
EasyGiven an array, return true if any value appears at least twice.
- 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.
function containsDuplicate(nums) {
const seen = new Set();
for (const n of nums) {
if (seen.has(n)) return true;
seen.add(n);
}
return false;
}
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.
Flatten a Nested Array
EasyGiven an array that may contain arrays nested to any depth, return a single flat array of all the values, in order.
- 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."
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]
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.
Implement debounce()
EasyImplement 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).
- 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.
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
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.
Implement Array.prototype.myReduce
EasyImplement reduce from scratch, matching the native method's signature and behavior — the strongest possible proof you actually understand §4.1.
- 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.
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
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.
Medium — 5 problems
Group Anagrams
MediumGiven an array of strings, group the ones that are anagrams of each other.
- 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.
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']]
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.
Longest Substring Without Repeating Characters
MediumGiven a string, find the length of the longest substring with no repeated characters.
- 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. Expandrighteach step; if you hit a character already in the window, jumpleftto just past its last occurrence instead of resetting to zero.
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"
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.
Merge Intervals
MediumGiven an array of intervals [start, end], merge all overlapping intervals and return the result.
- 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.
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]]
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.
LRU Cache
MediumDesign 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.
- 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.
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);
}
}
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.
Implement Promise.all from scratch
MediumImplement 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.
- The function itself must return a
Promise(§6.4) — everything happens inside anew 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
[].
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
});
});
}
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.