The 65 JavaScript questions interviewers actually ask, with direct answers, runnable code, and what the interviewer is listening for. Grouped for freshers, intermediate, and experienced rounds.
65 questions with answersKey Takeaways
JavaScript is a high-level, dynamically typed programming language created by Brendan Eich in 1995, originally to add interactivity to web pages. It's single-threaded and event-driven, uses prototype-based objects, and treats functions as first-class values, which is why it spans procedural, object-oriented, and functional styles. Node.js took the same runtime to the server, so one language now covers browser interactivity, backend APIs, build tooling, and cross-platform apps. In interviews, JavaScript questions probe the runtime model (the event loop, closures, this binding, prototypes, coercion) rather than memorized trivia, because that's where real bugs live. This page collects the 65 questions that come up most, each with a direct answer and runnable code. If you're still building fundamentals, the MDN JavaScript reference from Mozilla is the canonical path; increasingly the first round is a live AI coding interview, so pair this bank with our AI interview preparation guides for the format side.
Watch: 100+ JavaScript Concepts you Need to Know
Video: 100+ JavaScript Concepts you Need to Know (Fireship, YouTube)
Test yourself and earn a certificate
10 quick questions. Score 70%+ to download your JavaScript certificate.
The fundamentals every entry-level round checks. If any answer here surprises you, that's your study list.
JavaScript is a high-level, dynamically typed, single-threaded language that started as the browser's scripting language and now runs on servers through Node.js, plus in mobile and desktop wrappers. It's event-driven, prototype-based, and treats functions as first-class values.
Its reach is the selling point: the browser runs nothing else natively, so front-end interactivity is JavaScript, and Node.js lets you use the same language and skills on the backend. That single-language full-stack story is why it's everywhere.
Key point: A one-line definition plus the browser-and-Node reach beats a memorized feature list. Interviewers open with this to hear how you organize an answer.
var is function-scoped and hoisted (initialized to undefined before its line runs). let and const are block-scoped and sit in the temporal dead zone until their declaration, so using them early throws. const can't be reassigned; let can.
const prevents reassignment of the binding, not mutation of the value: a const object can still have its properties changed. Modern code defaults to const, reaches for let when reassignment is needed, and avoids var.
function demo() {
console.log(x); // undefined (var is hoisted)
var x = 1;
// console.log(y); // ReferenceError (temporal dead zone)
let y = 2;
const obj = { a: 1 };
obj.a = 5; // fine: mutating, not reassigning
// obj = {}; // TypeError: reassignment
}| var | let | const | |
|---|---|---|---|
| Scope | Function | Block | Block |
| Hoisting | To undefined | Temporal dead zone | Temporal dead zone |
| Reassignable | Yes | Yes | No |
| Typical use | Legacy code | Counters, reassigned values | Default choice |
Key point: The follow-up is 'does const make the object immutable?'. Say no, it freezes the binding, and mention Object.freeze for shallow immutability.
Hoisting is how JavaScript moves declarations to the top of their scope during compilation. var declarations and function declarations are hoisted; var is initialized to undefined, function declarations are fully available, and let and const are hoisted but left uninitialized in the temporal dead zone.
So you can call a function declaration before its line but not a function expression assigned to a variable, because only the variable is hoisted, not the value. Understanding this explains a lot of 'undefined' and 'cannot access before initialization' errors.
greet(); // works: function declaration is hoisted
function greet() { return "hi"; }
// welcome(); // TypeError: welcome is not a function
var welcome = function () { return "hey"; };Key point: The trap is claiming everything is hoisted equally. Distinguishing function declarations from expressions and the TDZ is what this question screens for.
=== is strict equality: it compares value and type with no conversion. == is loose equality: it coerces the operands to a common type first, which produces surprising results like 0 == "" being true and null == undefined being true.
The practical rule teams follow: use === (and !==) everywhere, and reach for == only for the deliberate null == undefined check, if at all. Linters flag == for exactly this reason.
0 == "0"; // true (coercion)
0 === "0"; // false (different types)
null == undefined; // true
null === undefined; // false
"" == false; // true, one of many coercion surprisesKey point: Interviewers love the 0 == "" and [] == false gotchas. Lead with the rule (always use ===), then show you know why == misbehaves.
Seven primitives: string, number, bigint, boolean, undefined, null, and symbol. Everything else, including arrays and functions, is an object. Primitives are immutable and compared by value; objects are compared by reference.
Two quirks worth naming: typeof null returns "object" (a historic bug), and numbers are all 64-bit floats, which is why 0.1 + 0.2 isn't exactly 0.3 and why bigint exists for large integers.
typeof "hi"; // "string"
typeof 42; // "number"
typeof 10n; // "bigint"
typeof true; // "boolean"
typeof undefined; // "undefined"
typeof null; // "object" <- the famous bug
typeof Symbol(); // "symbol"undefined means a variable has been declared but not assigned, or a property or return value is missing; JavaScript sets it automatically. null is an intentional 'no value' that you assign yourself to signal emptiness on purpose.
They're loosely equal (null == undefined is true) but not strictly equal. A clean habit: let the engine produce undefined, and use null when you deliberately want to represent 'nothing here'.
Key point: Expect the follow-up 'why does typeof null return object but typeof undefined return undefined?'. Frame it as a legacy bug that can't be fixed without breaking the web.
It's a bug from JavaScript's first implementation. Values were tagged by a small type flag in their low bits, and null was represented as the null pointer (all zeros), which shared the tag used for objects. So typeof reported "object".
It was never fixed because changing it would break countless existing pages. To check for null reliably, compare directly: value === null. This is a favorite because it rewards knowing the language's history, not just its rules.
typeof null; // "object" (the bug)
function isNull(v) {
return v === null; // the reliable check
}Key point: the fix would break backward compatibility. Lead with the identity check.
NaN (Not-a-Number) is the numeric result of an invalid math operation, like 0/0 or Number("abc"). It's the only value in JavaScript that isn't equal to itself, so NaN === NaN is false.
Because of that, check with Number.isNaN(value), not === NaN. Avoid the older global isNaN, which coerces its argument first, so isNaN("abc") is true for the wrong reason. Number.isNaN doesn't coerce.
NaN === NaN; // false
Number.isNaN(NaN); // true (reliable)
Number.isNaN("abc"); // false (no coercion)
isNaN("abc"); // true (coerces "abc" to NaN first, misleading)Key point: The 'NaN isn't equal to itself' line always lands. Follow it with Number.isNaN over global isNaN to show current-best-practice awareness.
Coercion is JavaScript automatically converting a value from one type to another. It happens implicitly with operators ("5" + 1 is "51", "5" - 1 is 4) and explicitly when you call Number(), String(), or Boolean().
The + operator prefers string concatenation when either side is a string; other arithmetic operators convert to numbers. Truthiness rules drive if statements: 0, "", null, undefined, NaN, and false are falsy, everything else is truthy.
"5" + 1; // "51" (+ concatenates with a string)
"5" - 1; // 4 (- forces numbers)
true + 1; // 2 (true becomes 1)
[] + {}; // "[object Object]"
Boolean(""); // false
Boolean("hi"); // truepush and pop work at the end; unshift and shift work at the front. splice inserts or removes anywhere by index. push, pop, shift, unshift, and splice mutate the array; slice and the spread operator produce a new array without touching the original.
Knowing which methods mutate matters in interviews and in React, where accidental mutation causes stale renders. When in doubt, prefer the non-mutating options.
const a = [1, 2, 3];
a.push(4); // [1, 2, 3, 4]
a.pop(); // [1, 2, 3]
a.unshift(0); // [0, 1, 2, 3]
a.splice(1, 1); // removes index 1 -> [0, 2, 3]
const copy = [...a, 9]; // new array, original untouchedmap transforms each element and returns a new array of the same length. filter keeps elements that pass a test and returns a shorter or equal array. reduce folds the array down to a single value (a sum, an object, another array) using an accumulator.
All three return new values and don't mutate the source, which makes them the backbone of functional-style data transforms. reduce is the general one: you can implement map and filter with it.
const nums = [1, 2, 3, 4];
nums.map(n => n * n); // [1, 4, 9, 16]
nums.filter(n => n % 2 === 0); // [2, 4]
nums.reduce((sum, n) => sum + n, 0); // 10Key point: The follow-up is often 'implement map using reduce'. Being able to do it shows you understand the accumulator, not just the method names.
Watch a deeper explanation
Video: 8 Must Know JavaScript Array Methods (Web Dev Simplified, YouTube)
Arrow functions are a shorter syntax: const add = (a, b) => a + b. The important difference is behavioral, not cosmetic: arrow functions don't have their own this, arguments, or prototype, and can't be used as constructors.
Because they capture this lexically from the surrounding scope, they're ideal for callbacks inside methods where a regular function would rebind this to undefined or the caller. That single trait is why they're everywhere in modern code.
const square = x => x * x;
const counter = {
count: 0,
start() {
setInterval(() => this.count++, 1000); // arrow keeps this = counter
},
};Key point: The bar coverage names the lexical this behavior, not just the shorter syntax. The follow-up is usually 'when would an arrow function be the wrong choice?' (object methods, constructors).
Template literals are strings written with backticks that support embedded expressions via ${...} and span multiple lines without escapes. They replace clumsy string concatenation with readable interpolation.
They also enable tagged templates, where a function processes the string parts and interpolated values, used by libraries for styled components, SQL builders, and safe HTML.
const name = "Asha";
const role = "engineer";
const line = `${name} works as an ${role}.`;
const block = `Hello ${name},
Welcome aboard.`; // real newline, no \n neededDestructuring unpacks values from arrays or properties from objects into distinct variables in one statement. Array destructuring goes by position; object destructuring goes by key name and supports defaults and renaming.
It's everywhere in modern JavaScript: pulling props in React, swapping variables without a temp, and reading function results. Combined with the rest pattern, it separates the pieces you want from the ones you don't.
const [first, second] = [10, 20];
const { name, age = 0 } = { name: "Ben" }; // age defaults to 0
const { id, ...rest } = { id: 1, x: 2, y: 3 }; // rest = { x: 2, y: 3 }
let a = 1, b = 2;
[a, b] = [b, a]; // swap without a tempThey share the ... syntax but do opposite things. Spread expands an iterable into individual elements: copying arrays, merging objects, passing array items as arguments. Rest collects multiple elements into one array or object: gathering extra function arguments or leftover properties.
The rule of thumb: spread on the right side (building a value), rest on the left side or in a parameter list (collecting values).
// spread: expand
const merged = [...[1, 2], ...[3, 4]]; // [1, 2, 3, 4]
const clone = { ...{ a: 1 }, b: 2 }; // { a: 1, b: 2 }
// rest: collect
function sum(...nums) {
return nums.reduce((t, n) => t + n, 0);
}
sum(1, 2, 3); // 6Key point: Interviewers ask 'is spread a deep copy?'. Answer no: it's shallow, so nested objects are still shared. That leads into the deep-clone question.
The ternary condition ? a : b is a compact if/else expression. Nullish coalescing (??) returns the right side only when the left is null or undefined, unlike || which also triggers on any falsy value like 0 or "". Optional chaining (?.) short-circuits to undefined if a reference is null or undefined instead of throwing.
?? and ?. together clean up a lot of defensive code. Reaching for ?? over || is the fix for the classic bug where a valid 0 gets replaced by a default.
const count = 0;
count || 10; // 10 <- bug: 0 is falsy
count ?? 10; // 0 <- correct: 0 isn't null/undefined
const city = user?.address?.city; // undefined instead of a crashKey point: The ?? vs || distinction with 0 and empty string is a common trap. Volunteering it signals you've hit the bug in real code.
Functions are values: you can assign them to variables, pass them as arguments, return them from other functions, and store them in arrays or objects. This is the foundation for callbacks, higher-order functions, and closures.
Higher-order functions (functions that take or return functions) fall straight out of this, which is why map, filter, and event handlers all accept function arguments. If you understand first-class functions, closures and callbacks stop being mysterious.
const shout = t => t.toUpperCase();
function apply(fn, value) {
return fn(value); // functions passed as arguments
}
apply(shout, "hello"); // "HELLO"A callback is a function passed to another function to be called later, either when work finishes (async) or during iteration (sync, like the function you give to map). It's the oldest way JavaScript handles 'do this when that's ready'.
The downside of nesting callbacks for sequential async steps is callback hell: deeply indented, hard-to-read pyramids with scattered error handling. Promises and async/await exist largely to fix that.
function fetchUser(id, callback) {
setTimeout(() => callback(null, { id, name: "Asha" }), 100);
}
fetchUser(1, (err, user) => {
if (err) return console.error(err);
console.log(user.name); // "Asha"
});Scope determines where a variable is accessible. There's global scope, function scope (var and function bodies), and block scope (let and const inside {}). Inner scopes can read outer variables, but not the reverse.
Lookups walk outward through the scope chain until they find the name or hit the global scope. This chain, resolved at definition time (lexical scoping), is exactly what makes closures work.
const outer = "global";
function demo() {
const inner = "local";
console.log(outer); // reachable up the chain
}
demo();
// console.log(inner); // ReferenceError: inner is block/function scopedAn IIFE (Immediately Invoked Function Expression) is a function defined and called at once: (function () { ... })(). Before block scoping, it was the main way to create a private scope, avoid polluting the global namespace, and encapsulate variables (the module pattern).
Today let, const, and ES modules cover most of what IIFEs did, so you'll see them more in legacy code. But knowing the pattern and why it existed still comes up.
const counter = (function () {
let count = 0; // private, not global
return { next: () => ++count };
})();
counter.next(); // 1
counter.next(); // 2Key point: If you say IIFEs are mostly historical now, The replaced them (block scope and modules). That indicates current, not dated, knowledge.
this refers to the object a function is called on, and its value is decided by how the function is called, not where it's defined. A method call sets this to the object before the dot; a plain function call sets it to undefined in strict mode (or the global object otherwise).
Arrow functions are the exception: they have no this of their own and inherit it lexically. Because this is call-site dependent, the same function can see different this values, which is the source of many bugs.
const user = {
name: "Asha",
greet() { return `Hi, ${this.name}`; },
};
user.greet(); // "Hi, Asha" (this = user)
const fn = user.greet;
fn(); // this is undefined -> error or "Hi, undefined"Key point: The classic follow-up: 'what is this when you extract a method and call it bare?'. The answer (it loses its binding) leads straight into call/apply/bind.
Watch a deeper explanation
Video: JavaScript this Keyword (Programming with Mosh, YouTube)
querySelector and querySelectorAll select by CSS selector; getElementById is the fastest for a single id. Once you have an element, you read and write textContent, innerHTML, classList, and attributes, and attach behavior with addEventListener.
Prefer textContent over innerHTML when inserting user data, because innerHTML parses HTML and opens an XSS hole. That safety note is what separates a beginner answer from a job-ready one.
const btn = document.querySelector(".save");
btn.textContent = "Saved";
btn.classList.add("active");
btn.addEventListener("click", () => console.log("clicked"));JSON.stringify serializes a JavaScript value to a JSON string; JSON.parse turns a JSON string back into a value. They're how you send data over the network, store it in localStorage, and log complex objects.
Watch the edges: stringify drops undefined, functions, and symbols, converts Dates to strings, and throws on circular references. parse gives back plain objects, so a Date comes back as a string unless you revive it.
const data = { id: 1, when: new Date(), fn: () => {} };
const json = JSON.stringify(data); // fn dropped, date becomes a string
const back = JSON.parse(json); // { id: 1, when: "2026-..." }Key point: Interviewers use this as a bridge to deep clone: 'can you deep-clone with JSON?'. Yes for plain data, no for dates, functions, or cycles.
Strict mode opts a script or function into a stricter parse and runtime: assigning to an undeclared variable throws instead of creating a global, this is undefined in plain function calls rather than the global object, duplicate parameter names are errors, and several silent failures become loud.
ES modules and class bodies are strict automatically, so you rarely write the directive today, but knowing what it changes (especially the this behavior) explains why modern code catches bugs the old sloppy mode swallowed.
A default parameter value fills in when an argument is missing or explicitly undefined: function greet(name = "there") uses "there" if you call greet() or greet(undefined). Passing null does not trigger the default, because null is a real value.
Defaults are evaluated at call time, once per call, so a default can reference earlier parameters and even call a function. That's the opposite of a language like Python, where a mutable default is shared, and it's a clean detail to mention.
function makeUser(name, role = "member", createdAt = Date.now()) {
return { name, role, createdAt };
}
makeUser("Asha"); // role defaults to "member"
makeUser("Ben", undefined); // still defaults
makeUser("Cara", null); // role is null, default skippedKey point: The 'undefined triggers the default but null doesn't' distinction is the follow-up. It ties back to the undefined-vs-null question.
For candidates with working experience: the runtime model, async control flow, and the questions that separate users from understanders.
A closure is a function bundled together with references to the variables in the scope where it was defined. Even after the outer function returns, the inner function keeps access to those variables, because it closed over them.
Closures power private state (a variable no one outside can touch), function factories, memoization, and event handlers that remember context. The classic gotcha is a loop with var: all closures share the one function-scoped variable, so they see its final value. let fixes it by creating a fresh binding per iteration.
function makeCounter() {
let count = 0; // private, kept alive by the closure
return () => ++count;
}
const next = makeCounter();
next(); // 1
next(); // 2
// the loop gotcha
for (var i = 0; i < 3; i++) setTimeout(() => console.log(i)); // 3, 3, 3
for (let j = 0; j < 3; j++) setTimeout(() => console.log(j)); // 0, 1, 2Key point: Being able to write makeCounter from memory AND explain the var loop bug is the bar. The two together prove you understand closures, not just define them.
Watch a deeper explanation
Video: Closures Explained in 100 Seconds // Tricky JavaScript Interview Prep (Fireship, YouTube)
JavaScript runs on a single thread with a call stack. When async work (a timer, a fetch, an event) finishes, its callback is queued rather than run immediately. The event loop constantly checks: if the call stack is empty, it takes the next callback from a queue and runs it.
The subtlety is two queues. Microtasks (promise callbacks, queueMicrotask) have priority and drain completely before any macrotask (setTimeout, setInterval, I/O). So a promise .then scheduled after a setTimeout still runs first. This ordering is what the next question drills into.
One turn of the event loop
Microtasks always fully drain before the next macrotask, which is why promise callbacks beat setTimeout(0).
Key point: 'Is JavaScript single-threaded?' is the setup. Yes, one call stack, but the runtime offloads timers and I/O, so it feels concurrent. That framing works well.
Macrotasks include setTimeout, setInterval, and I/O callbacks; microtasks include promise .then/.catch/.finally callbacks, queueMicrotask, and async/await continuations. After each macrotask, the event loop empties the entire microtask queue before running the next macrotask.
That's why the output ordering below surprises people: the promise callback runs before the setTimeout callback even though the timer was scheduled first. Getting this ordering right is a favorite senior screen.
console.log("1");
setTimeout(() => console.log("2"), 0); // macrotask
Promise.resolve().then(() => console.log("3")); // microtask
console.log("4");
// Output: 1, 4, 3, 2Task ordering: microtasks drain before the next macrotask
Relative priority for the snippet above. Lower runs sooner. Sync code first, then all microtasks, then one macrotask.
Key point: Interviewers write four log statements and ask for the order. Practice this pattern until the microtask-before-macrotask rule is automatic.
A Promise is an object representing the eventual result of an async operation. It has three states: pending (still running), fulfilled (resolved with a value), and rejected (failed with a reason). Once settled, it can't change state again.
You consume it with .then for success, .catch for errors, and .finally for cleanup, and chain them because each returns a new promise. Promises solved callback hell by flattening nested callbacks into a readable chain with one error path.
const wait = ms => new Promise(resolve => setTimeout(resolve, ms));
wait(100)
.then(() => fetch("/api/user"))
.then(res => res.json())
.then(user => console.log(user))
.catch(err => console.error(err))
.finally(() => console.log("done"));Key point: The follow-up is 'what does a .then callback return, and how does chaining work?'. Say each .then returns a new promise, and returning a value or a promise feeds the next link.
Watch a deeper explanation
Video: JavaScript Promise in 100 Seconds (Fireship, YouTube)
An async function always returns a promise. await pauses that function until the awaited promise settles, then resumes with the resolved value, so asynchronous code reads top-to-bottom like synchronous code. Under the hood it's promises plus the microtask queue, not new threads.
Errors are handled with ordinary try/catch instead of .catch, which is cleaner. The trap is awaiting in a loop when the operations are independent: that serializes them. Use Promise.all to run independent awaits concurrently.
async function loadUser(id) {
try {
const res = await fetch(`/api/users/${id}`);
return await res.json();
} catch (err) {
console.error("failed", err);
}
}
// concurrent, not serial
const [a, b] = await Promise.all([loadUser(1), loadUser(2)]);Key point: The strong coverage names the serial-await-in-a-loop trap and fixes it with Promise.all. That distinction is exactly what 'intermediate' screens for.
Watch a deeper explanation
Video: JavaScript Async Await (Web Dev Simplified, YouTube)
Promise.all resolves with an array of all results, but rejects as soon as any input rejects. Promise.allSettled always resolves, giving you a status/value or status/reason for every promise. Promise.race settles with the first promise to settle, success or failure. Promise.any resolves with the first fulfillment and only rejects if all reject.
Pick by intent: all when you need every result and want to fail fast, allSettled when partial success is fine, race for timeouts, any for 'first success wins'.
| Method | Resolves when | Rejects when |
|---|---|---|
| Promise.all | All fulfill | Any rejects (first rejection) |
| Promise.allSettled | All settle | Never (each result reports its own status) |
| Promise.race | First settles (fulfill) | First settles (reject) |
| Promise.any | First fulfills | All reject (AggregateError) |
Key point: The design follow-up is often 'implement Promise.all yourself'. That question lives in the experienced tier below; know both the behavior table and the implementation.
All three set this explicitly. call invokes the function immediately with this and arguments listed individually. apply is the same but takes arguments as an array. bind returns a new function with this (and optionally leading arguments) permanently fixed, to call later.
Mnemonic: call for Comma-separated args, apply for an Array, bind to Build a bound copy. bind is the one you reach for when passing a method as a callback that must keep its this.
function greet(greeting) { return `${greeting}, ${this.name}`; }
const user = { name: "Asha" };
greet.call(user, "Hi"); // "Hi, Asha"
greet.apply(user, ["Hello"]); // "Hello, Asha"
const bound = greet.bind(user);
bound("Hey"); // "Hey, Asha"Key point: The practical follow-up is 'why use bind in a class or event handler?'. Answer: to lock this before the method is passed somewhere that would rebind it.
Every object has an internal link ([[Prototype]], reachable via Object.getPrototypeOf) to another object, its prototype. When you read a property, JavaScript checks the object, then its prototype, then that prototype's prototype, up the chain until it finds the key or reaches null.
This is how inheritance works without classes: methods live on a shared prototype so instances don't each carry a copy. Array methods like map live on Array.prototype; your arrays borrow them through the chain.
const animal = { speak() { return "..."; } };
const dog = Object.create(animal); // dog's prototype is animal
dog.speak(); // "..." found up the chain
Object.getPrototypeOf(dog) === animal; // trueKey point: Expect 'how do classes relate to prototypes?'. Say class is syntax sugar over prototypes: methods still land on the prototype, not each instance.
Classical inheritance (Java, C++) copies structure from classes into instances at construction. Prototypal inheritance links objects to other objects: an instance delegates to its prototype at lookup time, so behavior is shared by reference, not copied.
The class keyword added familiar syntax, but it's still delegation underneath: extends sets up the prototype chain, and super calls walk it. Saying 'JavaScript classes are sugar over prototypes' is the answer the key point is.
class Animal {
constructor(name) { this.name = name; }
speak() { return `${this.name} makes a sound`; }
}
class Dog extends Animal {
speak() { return `${this.name} barks`; } // overrides via the chain
}
new Dog("Rex").speak(); // "Rex barks"Event delegation attaches one listener to a parent element instead of many listeners on children, relying on event bubbling: an event fired on a child propagates up to ancestors. In the handler you inspect event.target to see which child triggered it.
It's efficient (one listener, not hundreds) and it handles elements added later automatically, which plain per-element listeners can't. It's the standard pattern for lists, tables, and dynamic UIs.
document.querySelector("#list").addEventListener("click", (e) => {
const item = e.target.closest("li");
if (item) console.log("clicked", item.dataset.id);
});
// works even for <li>s added after this listener was attachedKey point: The follow-up is 'bubbling vs capturing'. Bubbling (child to parent) is the default; pass { capture: true } for top-down. Delegation relies on bubbling.
Both limit how often a function runs during rapid events. Debounce waits until activity stops: it runs the function only after a quiet gap, so a search box fires once the user pauses typing. Throttle runs at most once per interval no matter how many events arrive, so a scroll handler fires every 100ms rather than on every pixel.
Rule of thumb: debounce for 'do it when they're done' (search, resize-finished), throttle for 'do it at a steady rate' (scroll, drag, mousemove). Both are closures that hold a timer between calls.
function debounce(fn, delay) {
let timer;
return (...args) => {
clearTimeout(timer);
timer = setTimeout(() => fn(...args), delay);
};
}
function throttle(fn, limit) {
let waiting = false;
return (...args) => {
if (waiting) return;
fn(...args);
waiting = true;
setTimeout(() => (waiting = false), limit);
};
}Key point: Being able to write debounce from memory is a common live-coding ask. Note that it's a closure over timer, which ties back to the closures question.
Watch a deeper explanation
Video: Learn Debounce And Throttle In 16 Minutes (Web Dev Simplified, YouTube)
CommonJS (require, module.exports) is Node's original synchronous module system: modules load at runtime and exports are a live object you mutate. ES modules (import, export) are the standard: statically analyzable, so bundlers can tree-shake, and imports are read-only live bindings resolved before execution.
ESM works in browsers and Node; CommonJS is Node-only. The friction points are that ESM is async and top-level await capable, imports are hoisted, and mixing the two needs care (dynamic import() to load CommonJS from ESM).
| CommonJS | ES Modules | |
|---|---|---|
| Syntax | require / module.exports | import / export |
| Loading | Synchronous, at runtime | Static, resolved before run |
| Bindings | Copied value snapshot | Live read-only bindings |
| Tree-shaking | Hard | Supported |
| Where | Node only | Browsers and Node |
Key point: The follow-up is 'why can bundlers tree-shake ESM but not CommonJS?'. Because ESM imports are static: the tooling knows what's used before running anything.
Currying transforms a function that takes multiple arguments into a chain of functions that each take one argument. add(a, b, c) becomes add(a)(b)(c). It's built on closures: each returned function remembers the arguments captured so far.
The practical payoff is partial application: fix some arguments now, supply the rest later, which makes small reusable configured functions. Libraries use it for point-free, composable pipelines.
const multiply = a => b => c => a * b * c;
multiply(2)(3)(4); // 24
const double = multiply(2); // partial application
const doubleTriple = double(3);
doubleTriple(5); // 30Key point: Interviewers sometimes ask you to write a generic curry() that works for any arity. That's the harder version; start by explaining the closure that captures earlier args.
Memoization caches a function's results keyed by its inputs, so repeated calls with the same arguments return the stored answer instead of recomputing. It trades memory for speed and only works for pure functions (same input, same output, no side effects).
It's a closure holding a cache (often a Map). The classic demo is recursive Fibonacci, which goes from exponential to linear once results are cached. React's useMemo and useCallback are the same idea applied to renders.
function memoize(fn) {
const cache = new Map();
return (...args) => {
const key = JSON.stringify(args);
if (cache.has(key)) return cache.get(key);
const result = fn(...args);
cache.set(key, result);
return result;
};
}Key point: The constraint out loud: memoization is only safe for pure functions. That caveat is what turns a rote answer into an understanding one.
A shallow copy duplicates only the top level: spread ({...obj}) and Object.assign copy references to nested objects, so mutating a nested value shows up in both copies. A deep copy recursively duplicates everything, producing fully independent structures.
For deep cloning, structuredClone is the modern built-in (handles dates, maps, cycles). JSON.parse(JSON.stringify(x)) is a common quick hack but drops functions, undefined, and dates and throws on cycles. This gap is a favorite bug source.
const original = { user: { name: "Asha" } };
const shallow = { ...original };
shallow.user.name = "Ben";
original.user.name; // "Ben" <- shared nested object
const deep = structuredClone(original); // independentKey point: The follow-up 'implement your own deep clone' shows up in the experienced tier. Here, knowing structuredClone exists is the current-best-practice signal.
A Set stores unique values with O(1) add, delete, and has, so it's the clean way to dedupe or test membership. A Map stores key/value pairs where keys can be any type (including objects), preserves insertion order, and has a real size, unlike plain objects whose keys are strings or symbols.
Reach for Map over an object when keys aren't strings, when you need frequent adds and deletes, or when you want a reliable size and iteration order without prototype-key surprises.
const unique = [...new Set([1, 1, 2, 3, 3])]; // [1, 2, 3]
const seen = new Map();
const keyObj = {};
seen.set(keyObj, "value"); // object as a key, impossible with plain objects
seen.get(keyObj); // "value"A generator function (function*) can pause and resume. yield produces a value and suspends execution; calling next() resumes right after the last yield, keeping all local state. It returns an iterator, so you can loop it or pull values one at a time.
Generators are lazy, which makes them good for infinite sequences, custom iteration, and streaming large data without holding it all in memory. They also underpin how some async patterns and libraries model cooperative pausing.
function* idMaker() {
let id = 1;
while (true) yield id++; // infinite, but lazy
}
const gen = idMaker();
gen.next().value; // 1
gen.next().value; // 2Key point: The link interviewers probe is generators to iterators: a generator is the easiest way to implement the iterator protocol, which is the next question.
An iterable is any object with a Symbol.iterator method that returns an iterator; an iterator is an object with a next() method returning { value, done }. Arrays, strings, Sets, and Maps are iterable, which is why for...of and spread work on them.
To make your own object iterable, add a Symbol.iterator method. The easiest correct implementation is a generator, because it produces { value, done } and handles done for you.
const range = {
from: 1, to: 3,
*[Symbol.iterator]() {
for (let n = this.from; n <= this.to; n++) yield n;
},
};
[...range]; // [1, 2, 3]
for (const n of range) console.log(n); // 1 2 3for...in iterates enumerable keys, including inherited ones, so it's for objects (and risky on arrays because it yields index strings and can pick up added properties). for...of iterates values of any iterable and supports break, continue, and await. forEach runs a callback per array element but can't break and skips holes.
Rule of thumb: for...of for arrays and other iterables when you might break, forEach for a quick side-effecting loop, and for...in only for plain object keys (guarded with hasOwnProperty).
const arr = ["a", "b"];
arr.forEach((v, i) => console.log(i, v)); // 0 a, 1 b
for (const v of arr) console.log(v); // a, b (break-able)
for (const k in { x: 1, y: 2 }) console.log(k); // x, y (keys)Key point: The trap is using for...in on arrays. If you say 'for...in is for object keys, for...of is for values', you've already sidestepped the mistake they're probing.
When an event fires, it travels in three phases: capturing (top-down from the document to the target), the target itself, then bubbling (bottom-up from the target back to the document). Listeners run in the bubbling phase by default; pass { capture: true } to run them on the way down.
You control propagation with stopPropagation (stop the event traveling further) and preventDefault (cancel the browser's default action, like a form submit). Event delegation relies on bubbling, which is why one parent listener can handle clicks on any child.
parent.addEventListener("click", () => console.log("parent"));
child.addEventListener("click", (e) => {
console.log("child");
// e.stopPropagation(); // would stop "parent" from logging
});
// clicking child logs: child, then parent (bubbling)Key point: The follow-up pairs stopPropagation with preventDefault. Keep them straight: one stops travel through the DOM, the other cancels the browser's default behavior.
A Symbol is a unique, immutable primitive created with Symbol("description"). Every symbol is distinct even with the same description, so symbols make collision-proof object keys: a symbol property won't clash with any string key or any other symbol, and it stays out of normal enumeration and JSON.
Their main real-world role is well-known symbols that hook into language behavior: Symbol.iterator makes an object iterable, Symbol.asyncIterator for async iteration, Symbol.toPrimitive for coercion. Libraries also use symbols to attach hidden metadata that won't interfere with user keys.
const id = Symbol("id");
const user = { name: "Asha", [id]: 123 };
user[id]; // 123
Object.keys(user); // ["name"] <- symbol key hidden
Symbol("id") === Symbol("id"); // false, always uniqueBoth turn an iterable into an array, and spread ([...set]) is shorter for that case. Array.from does more: it also works on array-like objects that have a length but aren't iterable (like the old arguments object or a NodeList in older environments), and it takes a mapping function as a second argument, so you can transform while converting.
Array.from({ length: n }, (_, i) => i) is the idiomatic way to build a range, something spread can't do alone. Use spread for iterables you just want to copy, Array.from when you need the map step or array-like support.
Array.from("abc"); // ["a", "b", "c"]
Array.from({ length: 3 }, (_, i) => i); // [0, 1, 2]
[...new Set([1, 1, 2])]; // [1, 2] (spread, dedupe)
Array.from(new Set([1, 1, 2])); // [1, 2] (same result)Key point: The distinguishing detail is the mapping second argument and array-like support. If you only ever use spread, this question probes whether you know when it falls short.
advanced rounds probe internals, design implementations, and production scars. Expect every answer here to draw a follow-up.
Return a new promise. Track how many inputs have resolved and collect results in an array at the correct index (order must match the input, not completion order). Resolve the outer promise when the count reaches the input length; reject immediately on the first rejection.
The details interviewers watch for: preserving order by index rather than pushing, handling an empty input array (resolve with []), and wrapping each input in Promise.resolve so non-promise values work too.
function promiseAll(promises) {
return new Promise((resolve, reject) => {
const results = [];
let remaining = promises.length;
if (remaining === 0) return resolve(results);
promises.forEach((p, i) => {
Promise.resolve(p).then(
(value) => {
results[i] = value; // index preserves order
if (--remaining === 0) resolve(results);
},
reject // first rejection wins
);
});
});
}Key point: The order-by-index detail is the whole point. Candidates who push results in completion order fail the follow-up test with out-of-order timers.
Recurse through the structure: primitives return as-is, arrays and plain objects get rebuilt with each value cloned, and you must handle a WeakMap of already-seen objects to survive circular references. Special types (Date, Map, Set, RegExp) need their own branches.
The production-ready coverage names structuredClone as the built-in you'd actually use, then explains why the hand-rolled version needs the cycle guard and type handling that JSON.parse(JSON.stringify(x)) skips.
function deepClone(value, seen = new WeakMap()) {
if (value === null || typeof value !== "object") return value;
if (seen.has(value)) return seen.get(value); // circular guard
if (value instanceof Date) return new Date(value);
const copy = Array.isArray(value) ? [] : {};
seen.set(value, copy);
for (const key of Reflect.ownKeys(value)) {
copy[key] = deepClone(value[key], seen);
}
return copy;
}Key point: Mentioning the WeakMap cycle guard in practice is the strongest single signal here. It's the detail naive implementations forget, and it stack-overflows without it.
Keep a map from event name to an array (or Set) of listener callbacks. on adds a listener, off removes it, and emit calls every listener for that event with the passed arguments. once wraps a listener so it removes itself after firing.
The edges worth handling: return an unsubscribe function from on for easy cleanup, copy the listener list before emitting so a listener that unsubscribes mid-emit doesn't corrupt iteration, and isolate listener errors so one throw doesn't stop the rest.
class EventEmitter {
constructor() { this.events = new Map(); }
on(name, fn) {
if (!this.events.has(name)) this.events.set(name, new Set());
this.events.get(name).add(fn);
return () => this.off(name, fn); // unsubscribe handle
}
off(name, fn) { this.events.get(name)?.delete(fn); }
emit(name, ...args) {
[...(this.events.get(name) ?? [])].forEach(fn => fn(...args));
}
}Key point: The 'copy listeners before emitting' detail matters: without it, a once listener that removes itself while you iterate can skip the next listener. The it.
JavaScript engines use tracing garbage collection: starting from roots (the global object, the current call stack), the collector marks everything reachable and sweeps the rest. Objects are freed when nothing reachable references them, so you never free manually.
Leaks come from unintended references that keep things reachable: forgotten setInterval timers, event listeners never removed, closures that capture large objects, detached DOM nodes still referenced in JavaScript, and unbounded caches or global arrays that only grow. The fix is removing the reference (clearInterval, removeEventListener) or using WeakMap/WeakRef so entries don't pin their keys alive.
Key point: the question needs the leak categories (listeners, timers, closures, detached nodes) and the diagnosis path (heap snapshots in DevTools, diff over time), not a GC algorithm lecture.
WeakMap keys and WeakSet members are held weakly: if nothing else references a key, the garbage collector can reclaim it and the entry disappears. They only accept objects as keys/members, aren't iterable, and have no size, precisely because their contents can vanish at any time.
Use them to attach private data or metadata to objects without preventing those objects from being collected: caching computed results keyed by an object, tracking DOM nodes, or the seen-map in a deep clone. When the object dies, its associated data is cleaned up automatically, so you can't leak through them.
const privateData = new WeakMap();
class Account {
constructor(balance) { privateData.set(this, { balance }); }
getBalance() { return privateData.get(this).balance; }
}
// when an Account instance is unreachable, its private entry is GC'd tooKey point: The tie-in interviewers like: WeakMap can't leak the way a plain Map can, because holding an object as a key in a Map keeps it alive forever. That's the whole reason it exists.
Five rules, in priority order. new binding: calling with new sets this to the fresh object. Explicit binding: call, apply, and bind set this directly. Implicit binding: obj.method() sets this to obj. Default binding: a plain call sets this to undefined in strict mode (or the global object otherwise). Arrow functions ignore all of these and take this lexically from where they're defined.
The bugs come from losing implicit binding: extract a method into a variable or pass it as a callback and the dot is gone, so it falls back to default binding. The fixes are bind, an arrow wrapper, or a class field arrow.
const obj = {
val: 42,
regular() { return this.val; },
arrow: () => this.val, // this is NOT obj (lexical, likely undefined)
};
obj.regular(); // 42
const loose = obj.regular;
loose(); // undefined (lost implicit binding)
obj.regular.call({ val: 9 }); // 9 (explicit)Key point: The precedence order (new > explicit > implicit > default, with arrows outside the whole system). Reciting it in order is what separates a memorized answer from a mental model.
With async/await, wrap awaited calls in try/catch, and remember that an error in one awaited call skips the rest of the try block. With raw promises, attach .catch at the end of the chain, because one .catch handles rejections from every prior link. A rejection with no handler becomes an unhandledrejection event.
The senior details: Promise.all fails fast on the first rejection, so use allSettled when you need every outcome; always attach a rejection handler to fire-and-forget promises or they throw unhandled; and in Node, an unhandled rejection can crash the process depending on config.
async function load() {
try {
const [a, b] = await Promise.all([fetchA(), fetchB()]);
return process(a, b);
} catch (err) {
logger.error(err); // catches either fetch or process throwing
throw err; // rethrow so callers can react
}
}Tree-shaking is dead-code elimination that bundlers do by statically analyzing ES module imports and exports. Because import/export are static (resolved before code runs), the bundler can see which exports are actually used and drop the rest from the output.
It breaks on side effects: a module that mutates globals on import can't be safely removed, so mark packages sideEffects: false in package.json when they're pure. CommonJS resists tree-shaking because require is dynamic, and re-exporting an entire namespace or importing for side effects also defeats it.
Key point: The follow-up 'why can't CommonJS tree-shake?' has a one-line answer: require runs at runtime, so the tooling can't know statically what's used. Say that and you've nailed it.
The base debounce is a closure over a timer that resets on each call. To support leading-edge (fire immediately, then ignore until quiet) add a flag that tracks whether you're in a fresh burst. To support cancel, expose a method that clears the pending timer so a queued call never fires.
Getting the trailing versus leading distinction right, and exposing cancel for cleanup (in React effects, for instance), is what turns the textbook version into the production version.
function debounce(fn, delay, { leading = false } = {}) {
let timer = null;
function debounced(...args) {
const callNow = leading && !timer;
clearTimeout(timer);
timer = setTimeout(() => {
timer = null;
if (!leading) fn(...args);
}, delay);
if (callNow) fn(...args);
}
debounced.cancel = () => { clearTimeout(timer); timer = null; };
return debounced;
}Key point: Exposing debounced.cancel in practice signals real-world use. Anyone who has cleaned up a debounced handler in a React effect has needed exactly that.
var is function-scoped, so every iteration shares one binding. The callbacks all close over that single variable, and by the time they run (after the loop) it holds the final value. So three setTimeout logs print 3, 3, 3, not 0, 1, 2.
let creates a fresh block-scoped binding per iteration, so each closure captures its own copy. Before let, the fix was an IIFE that passed the current value in as an argument, creating a new scope each pass.
for (var i = 0; i < 3; i++) setTimeout(() => console.log(i)); // 3 3 3
for (let i = 0; i < 3; i++) setTimeout(() => console.log(i)); // 0 1 2
// pre-let fix: IIFE captures a fresh binding
for (var i = 0; i < 3; i++) {
(function (j) { setTimeout(() => console.log(j)); })(i);
}Key point: This is the single most-asked closure question. Explaining why (one shared var binding) beats reciting the let fix, because the why is what they're testing.
let and const are hoisted to the top of their block, so the name exists throughout the block, but they're not initialized until execution reaches the declaration. The span from the block's start to that line is the temporal dead zone, and accessing the variable there throws a ReferenceError.
This is deliberate: it turns silent bugs (reading a var as undefined before assignment) into loud errors, and it's why typeof on a TDZ variable also throws, unlike typeof on a truly undeclared name, which returns "undefined".
{
// console.log(x); // ReferenceError: Cannot access 'x' before initialization
// typeof x; // also throws inside the TDZ
let x = 5;
console.log(x); // 5
}Synchronous code runs first, to completion. Then the microtask queue drains fully: promise .then callbacks and the code after each await. Only then does one macrotask (a setTimeout callback) run. So awaits and .then always beat setTimeout(0), and the code right after an await is itself a microtask.
Reasoning through this out loud, labeling each statement as sync, microtask, or macrotask, is the skill being tested. Rote memorization of one example doesn't transfer; the model does.
console.log("A");
setTimeout(() => console.log("B"), 0);
(async () => {
console.log("C");
await null;
console.log("D"); // microtask after await
})();
Promise.resolve().then(() => console.log("E"));
console.log("F");
// Output: A, C, F, D, E, BKey point: Practice narrating: which line is sync, which schedules a microtask, which schedules a macrotask. Interviewers care that you can derive the order, not recall it.
Modern JavaScript has hard private fields with a # prefix (#balance): they're accessible only inside the class body, invisible to Reflect and JSON, and throw if accessed from outside. This is real encapsulation enforced by the language, unlike the old underscore convention that was privacy by politeness.
Before # fields, the patterns were closures (private variables in the constructor) and WeakMap keyed by instance. Those still work and are worth knowing, but # is the current answer, plus static private members and private methods.
class Account {
#balance = 0; // truly private
deposit(n) { this.#balance += n; return this; }
get balance() { return this.#balance; }
}
const a = new Account();
a.deposit(100).balance; // 100
// a.#balance; // SyntaxError from outside the classA Proxy wraps an object and intercepts fundamental operations (get, set, has, deleteProperty) through trap functions, letting you customize behavior: validation on assignment, logging, reactive updates, negative array indices, default values for missing keys. Reflect provides the default implementations of those operations, so a trap can do its custom work and then forward to Reflect.get or Reflect.set for the normal behavior.
Proxies power reactive frameworks (Vue 3's reactivity is built on them) and mocking libraries. The caveat: they add overhead and can't intercept everything, so reach for them when you genuinely need to trap operations, not for ordinary getters and setters.
const validated = new Proxy({}, {
set(target, key, value) {
if (key === "age" && value < 0) throw new RangeError("age >= 0");
return Reflect.set(target, key, value); // default behavior
},
});
validated.age = 30; // ok
// validated.age = -1; // RangeErrorcompose(f, g, h) returns a function that applies right to left: compose(f, g)(x) is f(g(x)). pipe is the same but left to right, which reads more naturally as a data pipeline. Both are one-line reduces over the function list.
They lean on first-class functions and are the backbone of point-free, functional-style code. the check is that you're comfortable treating functions as data and reasoning about evaluation order.
const compose = (...fns) => x => fns.reduceRight((acc, fn) => fn(acc), x);
const pipe = (...fns) => x => fns.reduce((acc, fn) => fn(acc), x);
const inc = n => n + 1;
const double = n => n * 2;
compose(inc, double)(5); // inc(double(5)) = 11
pipe(inc, double)(5); // double(inc(5)) = 12Key point: The reduce vs reduceRight direction is the detail to get right. compose is right-to-left (math convention), pipe is left-to-right (reading convention).
Object.freeze prevents adding, removing, or changing an object's own properties, but it's shallow, so nested objects stay mutable unless you freeze recursively. In practice teams get immutability through discipline and non-mutating operations: spread and destructuring to build new objects, map/filter over splice, and libraries like Immer that produce new state from a mutation-style draft.
It matters most in state management: React and Redux rely on reference equality to detect changes cheaply, so mutating state in place causes missed re-renders and subtle bugs. Immutable updates keep change detection correct and time-travel debugging possible.
const state = Object.freeze({ user: { name: "Asha" }, count: 1 });
// state.count = 2; // silently ignored (throws in strict mode)
state.user.name = "Ben"; // still works: freeze is shallow
const next = { ...state, count: state.count + 1 }; // immutable updateKey point: The React angle is the payoff: mutating state in place breaks reference-equality change detection. Connecting immutability to why re-renders fire is the production signal.
Observe first: reproduce with real data, then use DevTools (Performance panel for long tasks and layout thrash, Memory panel with heap-snapshot diffs for leaks) or server-side profiling for Node. Check the usual suspects: unbounded re-renders, listeners and timers never cleaned up, synchronous work blocking the main thread, N+1 network calls, and large bundles inflating load.
Then fix at the right layer with a measurement to confirm, not a guess: memoize the hot computation, debounce the noisy handler, virtualize the long list, code-split the heavy route. The structure, observe, hypothesize, verify, fix, confirm, matters more than any one tool.
Key point: The methodology is; naming the exact DevTools panel is supporting evidence, not the answer.
bind returns a new function whose this is fixed to a given object, with optional leading arguments pre-filled. So your polyfill captures the original function (this inside the polyfill), stores the bound context and partial arguments, and returns a wrapper that calls the original with the fixed context plus the partials and any later arguments.
The detail that separates a correct answer: a bound function used with new should ignore the bound this and construct normally, so a full implementation checks whether it was called as a constructor. For most interviews the apply-based version below is enough, but naming the new-target edge case earns the senior nod.
Function.prototype.myBind = function (context, ...boundArgs) {
const fn = this; // the original function
return function (...callArgs) {
return fn.apply(context, [...boundArgs, ...callArgs]);
};
};
function greet(greeting) { return `${greeting}, ${this.name}`; }
const bound = greet.myBind({ name: "Asha" }, "Hi");
bound(); // "Hi, Asha"Key point: The follow-up 'what if someone calls the bound function with new?' tests whether you know bind's constructor behavior. Even naming the edge case, without coding it, indicates depth.
JavaScript wins when you need one language everywhere the web reaches: the browser runs nothing else natively, and Node.js extends it to servers and tooling. It trades compile-time safety for reach and speed of iteration, so teams ship features fast and hire from a huge pool. Where it bites is the same dynamism: silent type coercion, undefined creeping through, and async control flow that surprises people. TypeScript answers the safety gap without leaving the ecosystem, which is why most serious teams now write TypeScript that compiles to JavaScript. Knowing these trade-offs out loud is itself an interview signal: it shows you pick tools on merits, not habit.
| Language | Typing | Best at | Watch out for |
|---|---|---|---|
| JavaScript | Dynamic | Browser, full-stack web, fast iteration | Coercion quirks, undefined bugs, async surprises |
| TypeScript | Static (compiles to JS) | Large JS codebases that need safety | Build step, type gymnastics on edge cases |
| Python | Dynamic | Data, ML, scripting, backend | Not native in browsers, GIL for CPU parallelism |
| Java | Static | Large enterprise systems, Android | Verbosity, slower to prototype |
Prepare in layers, and practice out loud. Most JavaScript rounds move from concept questions to live coding to a design or debugging discussion, so rehearse each stage rather than only reading answers.
The typical JavaScript interview flow
Earlier rounds increasingly run as AI coding interviews. The concepts on this page are what gets probed at every stage.
10 questions, about 6 minutes. Score 70% or higher to earn a shareable certificate.
Hyring builds the AI Coding Interviewer that runs live technical rounds in 20+ languages, JavaScript included. These questions reflect what actually gets asked and evaluated inside the interviews we host for 5,000+ HR teams.
See how the AI Coding Interviewer works