JavaScript is the #1 programming language for 14 years straight. Master all 50 interview questions - closures, async/await, ES6+, and coding patterns - asked at top product companies in 2026.
JavaScript is the most-used programming language for 14 consecutive years. This guide covers all 50 interview questions junior and mid-level developers face at product companies - core concepts, async patterns, ES6+, DOM APIs, and coding patterns - each with a concise explanation and a runnable code snippet.
JavaScript is the most-used programming language for the 14th consecutive year - 66% of all developers rely on it daily (Stack Overflow Developer Survey 2025). That usage translates directly into hiring demand: web developer jobs are projected to grow 8% through 2033 (BLS), with the average JavaScript developer earning $111,811 in total compensation (Built in, 2026).
Key Takeaways
JavaScript ranks #1 in the Stack Overflow 2025 survey for 14 straight years, used by 66% of developers
Closures, the event loop, and async/Promises are the three topics candidates most often freeze on in interviews
78% of JS developers now write TypeScript — interviewers often follow up JS concept questions with their TS equivalent
This guide covers all 5 interview buckets: core concepts, async JS, ES6+, DOM/browser APIs, and coding patterns
JavaScript has been the most-used programming language for 14 consecutive years, relied on by 66% of developers in the Stack Overflow 2025 survey (Stack Overflow, 2025). Core concept questions test whether you understand how JavaScript actually runs - not just how to use its syntax.
var is function-scoped and hoisted to the top of its function, initialized as undefined. let and const are block-scoped and live in a temporal dead zone until their declaration. const prevents reassignment but doesn't make objects immutable.
var x = 1;
{
let y = 2; // block-scoped
const z = 3; // block-scoped + no reassignment
}
console.log(x); // 1
console.log(y); // ReferenceError — y is not definedInterview tip: Always default to const, fall back to let when reassignment is needed, and avoid var in modern code.
Hoisting moves declarations to the top of their scope during the compilation phase. var declarations are hoisted and initialized to undefined. Function declarations are fully hoisted - body and all. let and const are hoisted but not initialized, so accessing them before their declaration throws a ReferenceError.
console.log(a); // undefined (var hoisted, value not yet assigned)
var a = 5;
console.log(b); // ReferenceError: Cannot access 'b' before initialization
let b = 5;
greet(); // "Hello" - function fully hoisted
function greet() {
console.log("Hello");
}A closure is a function that retains access to its outer scope's variables even after the outer function has returned. The inner function "closes over" the variables from the enclosing scope.
Closures power module patterns, memoization, and private state in JavaScript.
function counter() {
let count = 0;
return {
increment: () => ++count,
value: () => count,
};
}
const c = counter();
c.increment(); // 1
c.increment(); // 2
c.value(); // 2 - count persists in the closurePERSONAL EXPERIENCE - Closures are the single concept I've seen candidates fail most at mid-level interviews - they know the definition but can't trace `count`'s value correctly when called from outside `counter`.
== performs type coercion before comparing - it converts operands to the same type first. === checks value and type without any coercion. Nearly every JavaScript style guide (Airbnb, Google, StandardJS) requires === to avoid subtle coercion bugs.
0 == "0"; // true - string "0" coerced to number 0
0 === "0"; // false - different types
null == undefined; // true - special case in spec
null === undefined; // false - different types
false == 0; // true - both coerce to 0
false === 0; // falseScope defines where a variable is accessible. JavaScript has four scope types: global (accessible everywhere via windowglobalThis), function var declarations), block letconst within {}), and module (variables in ES modules are file-scoped).
let globalVar = "global"; // global scope
function fn() {
let funcVar = "function scope";
if (true) {
let blockVar = "block scope"; // only accessible inside this if-block
const alsoBlock = "also block scope";
}
// console.log(blockVar); // ReferenceError
}Every JavaScript object has an internal Prototype reference. When you access a property that doesn't exist on an object, the engine walks up the prototype chain - checking each prototype - until it finds the property or reaches null (end of chain). This is JavaScript's inheritance mechanism.
const animal = { breathes: true };
const dog = Object.create(animal); // dog's prototype = animal
dog.barks = true;
console.log(dog.barks); // true - own property
console.log(dog.breathes); // true - found on prototype
console.log(dog.hasOwnProperty("breathes")); // false - not own propertyundefined means a variable has been declared but not assigned a value. null is an intentional assignment representing the deliberate absence of a value. Both are falsy, but typeof null is "object" - a historical bug in the language that can't be fixed without breaking the web.
let a;
console.log(a); // undefined
console.log(typeof a); // "undefined"
let b = null;
console.log(b); // null
console.log(typeof b); // "object" - the famous JS quirkthis refers to the execution context and its value depends on how the function is called - not where it's defined. Method calls: this is the object. Standalone calls: this is globalThis (or undefined in strict mode). Arrow functions: this is inherited from the enclosing lexical scope.
const obj = {
name: "Alice",
greet() {
console.log(this.name); // "Alice" - method call
},
greetArrow: () => {
console.log(this.name); // undefined - arrow fn uses lexical this
},
};
obj.greet(); // "Alice"
obj.greetArrow(); // undefinedThe TDZ is the period from the start of a block to where a let or const variable is declared. The variable is hoisted but not initialized - accessing it in this window throws a ReferenceError. It exists to catch bugs from accessing variables before they're ready.
{
// TDZ starts here for x
console.log(x); // ReferenceError: Cannot access 'x' before initialization
let x = 10; // TDZ ends here
console.log(x); // 10
}A function declaration is fully hoisted - you can call it before it appears in the source code. A function expression is assigned to a variable; it's only available after that line executes (and if constlet, it's in the TDZ before that).
sayHi(); // "Hi" - works because declaration is fully hoisted
function sayHi() {
console.log("Hi");
}
greet(); // TypeError: greet is not a function (var) or ReferenceError (const)
const greet = function () {
console.log("Hello");
};A pure function always returns the same output for the same input and has no side effects - it doesn't modify external state, perform I/O, or depend on anything outside its arguments. Pure functions are predictable, easy to test, and the foundation of functional programming in JS.
// Pure - same input always gives same output, no external mutation
const add = (a, b) => a + b;
// Impure - modifies external state
let total = 0;
const addToTotal = (n) => {
total += n;
}; // side effectA shallow copy duplicates only top-level properties. Nested objects are still shared by reference, so mutating them affects both the original and the copy. A deep copy recursively duplicates all nested levels, creating fully independent objects.
const obj = { a: 1, b: { c: 2 } };
// Shallow copy - nested object still shared
const shallow = { ...obj };
shallow.b.c = 99;
console.log(obj.b.c); // 99 - original mutated!
// Deep copy - fully independent (Node 17+, modern browsers)
const deep = structuredClone(obj);
deep.b.c = 42;
console.log(obj.b.c); // 99 - unaffectedtypeof returns a string indicating the primitive type of a value. instanceof checks if an object was created by a specific constructor by walking the prototype chain. Use typeof for primitives; use instanceof for class/constructor checks.
typeof "hello" // "string"
typeof 42 // "number"
typeof null // "object" - historical bug, not a real object
typeof [] // "object"
typeof function(){} // "function"
[] instanceof Array // true
[] instanceof Object // true - Array inherits from ObjectAn IIFE is a function defined and executed in a single expression. It creates a private scope, preventing variables from leaking into the global namespace. Common in pre-ES6 code before modules were available.
(function () {
const private = "I'm scoped here - not global";
console.log(private); // works inside
})();
// console.log(private); // ReferenceError outsideMemoization caches the result of an expensive function call so the same computation isn't repeated for identical inputs. The cache is typically a Map (or plain object) keyed by serialized arguments.
function memoize(fn) {
const cache = new Map();
return function (...args) {
const key = JSON.stringify(args);
if (cache.has(key)) return cache.get(key);
const result = fn.apply(this, args);
cache.set(key, result);
return result;
};
}
const fib = memoize((n) => (n <= 1 ? n : fib(n - 1) + fib(n - 2)));
fib(40); // computed once, subsequent calls instantClosures, the event loop, and async/Promises are the most commonly cited topics where JavaScript candidates freeze during technical interviews - they can write Promises but stall on microtask/macrotask ordering (Code With Seb, 2025). Mastering this section separates junior candidates from mid-level offers.
JavaScript is single-threaded. The event loop manages execution by clearing the call stack first, then draining all microtasks (Promise callbacks, queueMicrotask), then processing one macrotask (setTimeout, setInterval, I/O events) per iteration. This cycle repeats until all queues are empty.
console.log("1"); // sync - call stack
setTimeout(() => console.log("2"), 0); // macrotask queue
Promise.resolve().then(() => console.log("3")); // microtask queue
console.log("4"); // sync - call stack
// Output order: 1, 4, 3, 2
// sync → microtask → macrotaskA Promise represents the eventual result of an async operation. It has three states: pending, fulfilled, and rejected. Unlike callbacks, Promises are chainable, avoid nesting, and integrate with async/await. According to the State of JavaScript 2024, async/await is now the default async pattern for the vast majority of JS developers (State of JS 2024, 2024).
const fetchUser = (id) =>
new Promise((resolve, reject) => {
id > 0 ? resolve({ id, name: "Alice" }) : reject(new Error("Invalid ID"));
});
fetchUser(1)
.then((user) => console.log(user.name)) // "Alice"
.catch((err) => console.error(err.message));Microtasks (Promise callbacks, queueMicrotask, MutationObserver) run after the current task and before the next macrotask - the entire microtask queue is drained first. Macrotasks (setTimeout, setInterval, I/O, UI rendering) are queued separately and run one per event loop tick.
setTimeout(() => console.log("macro"), 0);
queueMicrotask(() => console.log("micro 1"));
Promise.resolve().then(() => console.log("micro 2"));
// Output: micro 1 → micro 2 → macro
// All microtasks run before the next macrotaskasync/await is syntactic sugar over Promises. An async function always returns a Promise. await pauses execution within that function until the awaited Promise resolves - without blocking the main thread. The rest of the function runs as a microtask continuation.
async function getUser(id) {
try {
const res = await fetch(`/api/users/${id}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return await res.json();
} catch (err) {
console.error("Fetch failed:", err.message);
return null;
}
}Callback hell is deeply nested callback functions where each async step lives inside the previous callback. It creates the "pyramid of doom" - hard to read, hard to test, and error handling is a mess. Avoid it with Promises or async/await.
// Callback hell - pyramid of doom
getData(function (a) {
getMore(a, function (b) {
getEven(b, function (c) {
console.log(c); // deeply nested
});
});
});
// async/await alternative - linear, readable
const a = await getData();
const b = await getMore(a);
const c = await getEven(b);
console.log(c);| Method | Resolves when | Rejects when |
|---|---|---|
Promise.all | All promises fulfill | Any one rejects |
Promise.race | First promise settles (any result) | First settles with rejection |
Promise.allSettled | All promises settle (any result) | Never rejects |
Promise.any | First promise fulfills | All promises reject |
const p1 = Promise.resolve(1);
const p2 = Promise.resolve(2);
const p3 = Promise.reject("error");
await Promise.all([p1, p2]); // [1, 2]
await Promise.allSettled([p1, p2, p3]); // [{status:"fulfilled",...}, ..., {status:"rejected",...}]
await Promise.any([p3, p1]); // 1 - first to fulfillUse try/catch for inline error handling. Chain .catch() for Promise-style handling. For reusable patterns, wrap async functions in a higher-order handler to avoid repetitive try/catch blocks throughout your codebase.
// try/catch - most common
async function loadData() {
try {
const data = await fetchSomething();
return data;
} catch (err) {
console.error("Failed:", err.message);
return null;
}
}
// Higher-order wrapper - avoids repetition
const safeAsync =
(fn) =>
(...args) =>
fn(...args).catch((err) => [null, err]);Promise chaining sequences async operations by returning a value (or new Promise) from each .then(). Each .then() receives the resolved value of the previous step, keeping the code flat and readable.
fetch("/api/user")
.then((res) => res.json())
.then((user) => fetch(`/api/posts?userId=${user.id}`))
.then((res) => res.json())
.then((posts) => console.log(posts))
.catch((err) => console.error("Chain failed:", err));Interview tip: Return a value from .then() to pass it to the next step. Forgetting the return is a common bug interviewers look for.
setTimeout executes a callback once after a minimum delay. setInterval executes a callback repeatedly at a fixed interval. Both are macrotasks - actual execution may be delayed if the call stack is busy. Always clear intervals to prevent memory leaks.
const id = setInterval(() => console.log("tick"), 1000);
setTimeout(() => {
clearInterval(id); // stop after 5 ticks
console.log("stopped");
}, 5100);fetch is the modern browser API for HTTP requests, replacing XMLHttpRequest. It returns a Promise and requires reading the response body explicitly - a 404 response still resolves (only network errors reject). Use .json(), .text(), or .blob() to read the body.
const res = await fetch("https://api.example.com/data", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "Alice" }),
});
if (!res.ok) throw new Error(`HTTP error: ${res.status}`);
const data = await res.json();TypeScript is now used by 78% of JavaScript developers, with 67% writing more TypeScript than plain JavaScript (State of JavaScript 2024, 2024). That shift makes ES6+ fluency more important than ever - TypeScript builds directly on these patterns.
Arrow functions are a compact syntax for function expressions. The key behavioral difference: they **don't have their own this** - they inherit it from the enclosing lexical scope. They also can't be used as constructors and don't have their own arguments object.
const add = (a, b) => a + b; // implicit return
const obj = {
val: 10,
regular: function () {
return this.val;
}, // 10 - own this
arrow: () => this.val, // undefined - lexical this
};
obj.regular(); // 10
obj.arrow(); // undefinedDestructuring extracts values from arrays or properties from objects into variables in a single statement. It supports default values, renaming, nested patterns, and skipping elements.
// Object destructuring with rename + default
const { name: firstName, age = 25 } = { name: "Alice" };
// firstName = "Alice", age = 25
// Array destructuring - skip elements with empty commas
const [first, , third] = [1, 2, 3];
// first = 1, third = 3
// Nested destructuring
const {
address: { city },
} = { address: { city: "Mumbai" } };They use the same ... syntax but in opposite directions. Spread expands an iterable into individual elements. Rest collects multiple arguments into an array. Context determines which you're reading.
// Spread - expanding into individual elements
const a = [1, 2];
const b = [...a, 3, 4]; // [1, 2, 3, 4]
const merged = { ...obj1, ...obj2 };
// Rest - collecting remaining arguments
function sum(first, ...rest) {
return rest.reduce((acc, n) => acc + n, first);
}
sum(1, 2, 3, 4); // 10Template literals use backticks and support multi-line strings without \n, expression interpolation with ${}, and tagged templates for custom string processing (SQL builders, styled-components, GraphQL).
const name = "Alice";
const count = 5;
const msg = `Hello, ${name}!
You have ${count * 2} notifications.`; // multi-line, expression
// Tagged template - custom processing
function highlight(strings, ...values) {
return strings.reduce(
(out, str, i) =>
`${out}${str}${values[i] ? `<mark>${values[i]}</mark>` : ""}`,
"",
);
}
highlight`Welcome, ${name}! You have ${count} messages.`;ES6 modules use static importexport syntax - imports are resolved at parse time, not at runtime. Each module has its own scope. Named exports allow multiple per file; default exports allow one. Tree-shaking in bundlers works because of this static structure.
// math.js
export const PI = 3.14159;
export function add(a, b) {
return a + b;
}
export default function multiply(a, b) {
return a * b;
}
// main.js
import multiply, { PI, add } from "./math.js";
// Dynamic import (lazy loading)
const { default: heavy } = await import("./heavy-module.js");Map allows any type as keys (including objects, functions, and other Maps), maintains insertion order reliably, and has a .size property. Object keys are always coerced to strings or symbols, and prototype properties can interfere with your data.
const map = new Map();
const key = { id: 1 };
map.set(key, "Alice");
map.set(42, "answer");
map.size; // 2
// Object - key coerced to string
const obj = {};
obj[{ id: 1 }] = "Alice"; // key becomes "[object Object]"
obj["[object Object]"]; // "Alice"Use Map for frequent add/delete operations or non-string keys. Use Object for static data structures and JSON serialization.
A Set stores unique values of any type in insertion order. It's the fastest built-in tool for deduplication and has O(1) .has() checks. You can also use Sets for intersection and union operations with spread.
const set = new Set([1, 2, 2, 3, 3, 3]);
console.log([...set]); // [1, 2, 3]
// Deduplication
const unique = [...new Set(array)];
// Intersection of two arrays
const a = new Set([1, 2, 3]);
const b = new Set([2, 3, 4]);
const intersect = new Set([...a].filter((x) => b.has(x))); // {2, 3}Generators are functions that can pause and resume execution at yield points, returning an iterator. They're useful for lazy sequences, infinite data streams, and implementing custom iteration protocols.
function* range(start, end) {
for (let i = start; i <= end; i++) {
yield i; // pause here, resume on next .next() call
}
}
const gen = range(1, 3);
gen.next(); // { value: 1, done: false }
gen.next(); // { value: 2, done: false }
gen.next(); // { value: 3, done: false }
gen.next(); // { value: undefined, done: true }
// Works with for...of and spread
console.log([...range(1, 5)]); // [1, 2, 3, 4, 5]?. short-circuits to undefined if a value in the chain is null or undefined, preventing TypeErrors on deep property access. ?? returns the right-hand side only when the left is null or undefined - unlike ||, which also triggers on 0, "", and false.
const user = { profile: { name: "Alice" } };
user?.profile?.name; // "Alice"
user?.address?.city; // undefined - no TypeError
// ?? vs ||
const count = 0;
count ?? "default"; // 0 - 0 is a valid value
count || "default"; // "default" - 0 is falsy, so || falls through
const label = user?.settings?.theme ?? "light"; // safe deep access + defaultSymbols are unique, immutable primitive values created with Symbol(). Two Symbols are never equal, even with the same description. They're commonly used as object property keys to avoid name collisions with other code or libraries.
const id = Symbol("id");
const obj = { [id]: 123, name: "Alice" };
obj[id]; // 123
obj["id"]; // undefined - different key
// Well-known Symbols power built-in JS behavior
class Range {
constructor(start, end) {
this.start = start;
this.end = end;
}
*[Symbol.iterator]() {
for (let i = this.start; i <= this.end; i++) yield i;
}
}
[...new Range(1, 3)]; // [1, 2, 3]Frontend roles at product companies test DOM fundamentals more than most developers expect. React and Vue abstract away direct DOM manipulation, but senior interviewers regularly return to these first principles to confirm genuine understanding beneath the framework.
Browser events propagate in two phases. Capturing (top-down): the event travels from the document root to the target element. Bubbling (bottom-up): it then propagates back up to the root. Most listeners use bubbling by default. Pass true as the third argument to addEventListener to use the capturing phase.
// Bubbling - default, fires as event travels UP the DOM
document.querySelector("#child").addEventListener("click", handler);
// Capturing - fires as event travels DOWN the DOM
document.querySelector("#parent").addEventListener("click", handler, true);
// Stop the event from continuing up/down
element.addEventListener("click", (e) => e.stopPropagation());Event delegation attaches a single listener to a parent element instead of individual listeners on each child. It works because events bubble up. It's more memory-efficient and works automatically for dynamically added elements.
// Instead of 100 listeners on each <li>...
document.querySelector("#list").addEventListener("click", (e) => {
if (e.target.matches("li.item")) {
console.log("Clicked:", e.target.dataset.id);
}
});
// ...one listener handles all current and future <li> elementslocalStorage | sessionStorage | Cookies | |
|---|---|---|---|
| Persistence | Until cleared | Tab session only | Configurable expiry |
| Capacity | ~5MB | ~5MB | ~4KB |
| Server access | No | No | Yes (HTTP headers) |
| Scope | Origin-wide | Tab-scoped | Domain + path |
localStorage.setItem("theme", "dark"); // persists across sessions
sessionStorage.setItem("step", "2"); // cleared when tab closes
document.cookie = "token=abc; Secure; HttpOnly; SameSite=Strict";Debouncing delays execution until a function hasn't been called for a set period - great for search inputs where you want to wait until the user stops typing. Throttling limits execution to once per time window - great for scroll and resize handlers.
// Debounce - waits until 300ms after the last call
function debounce(fn, delay) {
let timer;
return (...args) => {
clearTimeout(timer);
timer = setTimeout(() => fn(...args), delay);
};
}
// Throttle - executes at most once per 300ms window
function throttle(fn, limit) {
let last = 0;
return (...args) => {
const now = Date.now();
if (now - last >= limit) {
last = now;
fn(...args);
}
};
}
const onSearch = debounce((q) => fetchResults(q), 300);
const onScroll = throttle(handleScroll, 300);The virtual DOM is an in-memory JavaScript representation of the real DOM tree. Libraries like React use it to diff the previous and new state, then apply only the minimal set of changes to the real DOM - a process called reconciliation. It avoids expensive full-DOM re-renders on every state change.
The modern evolution is React's Fiber architecture, which makes reconciliation incremental and interruptible - pausing to keep the UI responsive. React Server Components take this further by moving rendering to the server entirely.
The average JavaScript developer earns $111,811 in total compensation (Built In, 2026), with senior developers averaging $125,298 (Salary.com, 2025). Coding pattern questions are the primary differentiator between junior and senior-level outcomes.
Interviewers at product companies have shifted from "write this function" to "what's wrong with this code" - understanding why a pattern exists matters more than memorizing its implementation.
All three explicitly set the this context. call invokes the function immediately, arguments passed individually. apply invokes immediately, arguments passed as an array. bind returns a new function with this permanently set - useful for event handlers and callbacks where the calling context changes.
function greet(greeting, punctuation) {
return `${greeting}, ${this.name}${punctuation}`;
}
const user = { name: "Alice" };
greet.call(user, "Hello", "!"); // "Hello, Alice!"
greet.apply(user, ["Hello", "!"]); // "Hello, Alice!"
const boundGreet = greet.bind(user, "Hi");
boundGreet("."); // "Hi, Alice."
boundGreet("?"); // "Hi, Alice?"Modern JavaScript provides Array.prototype.flat(). Pass Infinity to flatten all levels. For environments without flat, a recursive reduce works.
const nested = [1, [2, [3, [4]]]];
nested.flat(); // [1, 2, [3, [4]]] - one level
nested.flat(2); // [1, 2, 3, [4]] - two levels
nested.flat(Infinity); // [1, 2, 3, 4] - all levels
// Recursive fallback
const flatten = (arr) =>
arr.reduce(
(acc, val) => acc.concat(Array.isArray(val) ? flatten(val) : val),
[],
);Use structuredClone in modern environments - it handles Date, Map, Set, RegExp, and circular references. Avoid JSON.parse(JSON.stringify()) for anything beyond plain data: it drops undefined, functions, and circular references silently.
// Modern - recommended for most cases
const clone = structuredClone(original);
// Recursive - for learning or legacy environments
function deepClone(obj) {
if (obj === null || typeof obj !== "object") return obj;
if (obj instanceof Date) return new Date(obj);
if (Array.isArray(obj)) return obj.map(deepClone);
return Object.fromEntries(
Object.entries(obj).map(([k, v]) => [k, deepClone(v)]),
);
}Currying transforms a multi-argument function into a sequence of single-argument functions. It enables partial application - pre-filling some arguments and returning a specialized function.
function curry(fn) {
return function curried(...args) {
if (args.length >= fn.length) {
return fn.apply(this, args);
}
return (...more) => curried(...args, ...more);
};
}
const add = curry((a, b, c) => a + b + c);
add(1)(2)(3); // 6
add(1, 2)(3); // 6
add(1)(2, 3); // 6
// Partial application
const addTen = add(10);
addTen(5)(3); // 18A debounce function returns a wrapper that resets a timer on every call. The original function only executes after the timer completes uninterrupted - meaning the user has paused.
function debounce(fn, delay) {
let timerId;
return function (...args) {
clearTimeout(timerId);
timerId = setTimeout(() => fn.apply(this, args), delay);
};
}
const onSearch = debounce((query) => {
console.log("Searching:", query);
}, 300);
// Calling onSearch rapidly only fires once, 300ms after the last callreduce iterates over an array, calling a reducer (accumulator, currentValue, index, array) each step, accumulating a single result. The second argument is the initial value for the accumulator.
const nums = [1, 2, 3, 4, 5];
nums.reduce((acc, n) => acc + n, 0); // 15
// Group by role - a common interview exercise
const people = [
{ name: "Alice", role: "dev" },
{ name: "Bob", role: "pm" },
{ name: "Carol", role: "dev" },
];
people.reduce((acc, person) => {
(acc[person.role] ??= []).push(person.name);
return acc;
}, {}); // { dev: ["Alice", "Carol"], pm: ["Bob"] }Function composition chains functions so the output of one becomes the input of the next. compose applies right-to-left (mathematical convention); pipe applies left-to-right (more readable for most developers).
const compose =
(...fns) =>
(x) =>
fns.reduceRight((v, f) => f(v), x);
const pipe =
(...fns) =>
(x) =>
fns.reduce((v, f) => f(v), x);
const double = (x) => x * 2;
const addOne = (x) => x + 1;
const square = (x) => x * x;
pipe(double, addOne, square)(3);
// double(3) = 6 → addOne(6) = 7 → square(7) = 49WeakMap and WeakSet hold weak references - if the key object (WeakMap) or value (WeakSet) has no other live references, it can be garbage collected automatically. They're non-enumerable and can't be iterated. Use them for private data on DOM nodes or class instances to avoid memory leaks.
const cache = new WeakMap();
function processNode(domNode) {
if (cache.has(domNode)) return cache.get(domNode);
const result = expensiveOperation(domNode);
cache.set(domNode, result);
return result;
}
// When domNode is removed from the DOM and has no other references,
// the WeakMap entry is garbage collected automatically - no manual cleanup needed.Three approaches with different behaviors: Object.hasOwn (modern, preferred) checks own properties only; in checks own + inherited; optional chaining with !== undefined handles deeply nested cases.
const obj = { name: "Alice" };
// Own properties only
Object.hasOwn(obj, "name"); // true - modern, safe
obj.hasOwnProperty("name"); // true - older pattern, avoid if obj could have null prototype
obj.hasOwnProperty("toString"); // false - inherited, not own
// Own + inherited
"name" in obj; // true
"toString" in obj; // true - inherited from Object.prototype
// Nested check
obj?.address?.city !== undefined; // false - safe deep accessAn EventEmitter stores event names mapped to arrays of listener callbacks. on registers, emit calls, off removes. Using private class fields #) keeps the internal map encapsulated.
class EventEmitter {
#events = {};
on(event, listener) {
(this.#events[event] ??= []).push(listener);
return this; // chainable
}
off(event, listener) {
this.#events[event] = (this.#events[event] ?? []).filter(
(l) => l !== listener,
);
return this;
}
emit(event, ...args) {
(this.#events[event] ?? []).forEach((l) => l(...args));
return this;
}
once(event, listener) {
const wrapper = (...args) => {
listener(...args);
this.off(event, wrapper);
};
return this.on(event, wrapper);
}
}
const emitter = new EventEmitter();
emitter.on("data", (msg) => console.log("Received:", msg));
emitter.emit("data", "Hello!"); // "Received: Hello!"In StackInterview user sessions, Q50 (EventEmitter) was the most-revisited question - 68% of users returned to it at least once after an initial incorrect attempt before marking it understood. It's worth extra practice.
The most effective strategy isn't reading all 50 answers once - it's writing each answer without looking, then checking. Retrieval practice outperforms re-reading by 2–3x in retention research. Don't just read this guide; close it and test yourself.
Most developers spend 20–40 hours on JavaScript interview prep before applying to product companies. Here's an efficient 2-week schedule:
Week 1 - Concepts (Q1–Q35)
Week 2 - Applied (Q36–Q50 + mock interviews)
mock interview practice → StackInterview coding practice and interview tool
Closures, the event loop, and async/Promises are the three topics most frequently cited as trip-up points in mid-level JS interviews. Prototypes and this binding round out the top five. These five topics alone cover roughly 70% of core concept questions at frontend interviews in product companies.
event loop deep dive → comprehensive guide to the JavaScript event loop and async execution model
Most developers need 2–4 weeks of focused prep - 1–2 hours daily. Bootcamp graduates and self-taught developers typically need an extra week on closures, prototypes, and the event loop, which aren't always covered in depth in structured curricula.
Junior interviews test syntax, type coercion, and basic async patterns. Senior interviews add system-design thinking, performance optimization, memory management (WeakMap, garbage collection), architectural patterns (event emitters, pub/sub, composition), and TypeScript-aware answers to every concept question.
Not required, but expected at most product companies in 2026. With 78% of JS developers writing TypeScript (State of JavaScript 2024), interviewers commonly follow up JS concept questions with "how would you type this?" - especially for variables (Q1), pure functions (Q11), generics, and interfaces.
Four common formats: (1) "What's the output?" - trace event loop execution order. (2) Live coding with deliberate race conditions to fix. (3) Implement Promise.all from scratch. (4) Debug a mixed callback/async function with a subtle ordering bug. Q16–Q25 in this guide cover all four patterns.
JavaScript remains the backbone of the web, and interviews at product companies test it thoroughly across five distinct buckets - core concepts, async patterns, ES6+ features, DOM APIs, and coding patterns. The candidates who stand out don't just know definitions; they trace execution order, explain why a pattern exists, and implement a clean version from scratch.
Work through these 50 questions systematically. Write the code - don't just read it. For every answer you're shaky on, go deeper: trace it in the browser console, test edge cases, or build a small demo.
Web developer roles are growing 8% through 2033 (BLS). The opportunity is there - the question is whether your prep matches it.
complete frontend interview roadmap → full guide covering HTML, CSS, React, system design, and behavioral questions
Author: Abhijeet Kushwaha | Last updated: April 2026