JavaScript interview questions: closures, hoisting, prototypes, this, promises, async/await and ES6.
29 questions
Prefer const by default, let when you must reassign, and never var.
A closure is a function that remembers variables from the scope where it was created, even after that scope has exited.
function counter() {
let count = 0;
return function () {
count += 1;
return count;
};
}
const c = counter();
c(); // 1
c(); // 2Here the inner function closes over count, keeping it alive. Closures power module patterns, callbacks and private state.
JavaScript runs on a single thread. Long tasks block the thread, so async work (timers, network, I/O) is delegated: the callback is queued and the event loop picks it up when the call stack is empty.
Promises schedule their reactions as microtasks, which run after the current task and before the next macrotask (setTimeout). So Promise.then callbacks fire before pending timer callbacks. async/await is just syntactic sugar over promises.
== compares after type coercion (e.g. 1 == "1" is true), which hides bugs.
=== compares value and type without coercion (1 === "1" is false). Always prefer strict equality; use null checks via x == null only when you deliberately accept both null and undefined.
Arrow functions (const f = (x) => x * 2) have a shorter syntax and - most importantly - no own this: they inherit this lexically from the enclosing scope, and they cannot be used as constructors.
Regular functions have their own this (dynamic based on invocation) and can be called with new. Choose the right tool: methods and constructors use regular functions; callbacks and short transformers fit arrows.
The DOM (Document Object Model) is the tree representation of the page that JavaScript can read and modify: elements, attributes and text are nodes.
document.querySelector(selector) returns the first element matching a CSS selector (".btn", "#id", "div > p"); querySelectorAll returns a static NodeList. Then you can change classes, styles, text or attach event listeners.
undefined means a variable has been declared but has no value yet - the engine assigns it as the default. null is an intentional assignment representing "no value". Checks: typeof null === "object" (a historical quirk), typeof undefined === "undefined". Use x == null to catch both, or ?? for defaults (nullish coalescing).
Primitives: string, number, boolean, null, undefined, symbol (ES6) and bigint. Non-primitive: object (which includes arrays and functions). Primitives are immutable and compared by value; objects are compared by reference.
Hoisting moves var declarations and function declarations to the top of their scope at compile time. So a function declared with function keyword can be called before its text; a var is defined as undefined before assignment. let/const are hoisted but stay in the temporal dead zone until the declaration line runs, so using them early throws ReferenceError.
var is function-scoped, hoisted as undefined and re-declarable - it also leaks into the global scope. let is block-scoped, cannot be redeclared in the same scope and is not usable before its declaration. const is like let but the binding is read-only - the value cannot be reassigned (though objects/arrays it points to can still be mutated). Prefer const, then let; avoid var.
A closure is a function that remembers the variables from the scope in which it was created, even after that scope exits:
function counter() {
let n = 0;
return () => ++n;
}
const c = counter();
c(); // 1
c(); // 2Uses: data privacy, factory functions, event handlers and partial application / currying.
Each loop iteration with let creates a fresh binding for the variable, so closures created inside the loop capture the correct per-iteration value. With var the same binding is shared, so all closures see the final value. The classic fix for the var bug was an IIFE; now let solves it directly, and for-of also creates per-iteration bindings.
Arrow functions: no own this (they inherit lexically from the surrounding scope), no arguments object, cannot be used as constructors with new, no prototype, and concise syntax. Regular functions have their own dynamic this (based on how they are called). Use arrows for callbacks and small transforms; use functions/methods when you need this or arguments.
fn.call(ctx, a, b) - invoke fn immediately with a given this and arguments listed.fn.apply(ctx, [a, b]) - same, but arguments in an array.fn.bind(ctx, a) - returns a new function with this permanently bound (and optional partial args); invoke it later.bind is great for event handlers where this must stay fixed.
Every object has a hidden __proto__ (the prototype) that is another object. Property lookup walks up this chain until a match or null - that is why almost everything can reach Object.prototype methods. Classes (ES6) are syntactic sugar over prototypes:
class Dog extends Animal {}
// Dog.prototype inherits from Animal.prototypeThis is prototype-based inheritance under the hood.
new F() creates an object, sets its prototype to F.prototype and runs F with this set to it. Object.create(p) creates an object whose prototype is exactly p - clean, explicit inheritance and prototype reuse. ES6 class/extends is a clearer syntax for the same constructor-plus-prototype mechanism, with super/static support.
A Promise represents a value that will be available later (async). States: pending (initial), fulfilled (resolved with a value), rejected (failed with a reason), and settled = fulfilled or rejected (not pending). Chain with .then(), .catch() and .finally():
fetch(url).then(r => r.json()).catch(e => console.error(e));async/await is syntactic sugar over promises that reads like synchronous code. An async function always returns a promise; await pauses it until the promise settles. Errors are handled with try/catch instead of .catch(). It makes complex chains readable but still is promises underneath - you can await any promise.
JavaScript runs on a single thread but uses an event loop for async work. Long tasks (timers, I/O, promises) are delegated; when the call stack empties, the loop pushes their callbacks in. So: microtasks (Promise callbacks, queueMicrotask) run before the next macrotask (setTimeout, setInterval, I/O). Blocking code stalls the entire loop - that is why heavy loops freeze the page.
setTimeout(fn, ms) runs fn once after a delay; setInterval(fn, ms) runs it repeatedly. Both are macrotasks and their timing is not exact - they run when the loop is free. requestAnimationFrame(fn) schedules fn before the next browser paint - ideal for animations because it syncs with the display refresh rate and pauses in background tabs.
Microtasks (promise .then/.catch callbacks, queueMicrotask) are executed as soon as the current stack finishes, before the next macrotask - higher priority, and a microtask flood can starve rendering. Macrotasks (setTimeout, setInterval, I/O, message events) are queued in the task queue and run one at a time between microtask drains. This ordering is why promises resolve before timers.
Shallow copy: Object.assign({}, obj), the spread operator {...obj}, or Array.from/slice for arrays - nested objects are still shared references. Deep copy: structuredClone(obj) (native), JSON.parse(JSON.stringify(obj)) (loses functions, dates, undefined, symbols), or a library like lodash cloneDeep. Choose shallow for flat data, deep for nested state you must not alias.
JSON is a text format for data interchange based on JavaScript literal syntax but stricter (double quotes, no trailing commas, no functions). JSON.stringify(obj) serializes to a string; JSON.parse(str) parses back to an object. Use them to send data over HTTP or persist it.
NaN (Not-a-Number) results from invalid math like 0/0 and is the only value not equal to itself, so test with Number.isNaN(x). Infinity/-Infinity come from overflow, like 1/0 and -1/0, and are proper numbers. Be careful converting strings: +"abc" is NaN, while Number("10") is 10.
Map holds key-value pairs with strong references to keys - keys are never garbage collected, so Maps help when keys must stay alive. WeakMap keys must be objects and are held weakly, so entries disappear when the key object is GC'd - ideal for per-object private metadata/caches. Similarly, Set holds strong references while WeakSet holds objects weakly (no iteration).
localStorage persists until explicitly removed, ~5-10MB, synchronous, per origin, never sent automatically to the server. sessionStorage is the same but cleared when the tab closes. Cookies are small (~4KB), sent with every matching request (so they handle auth), set with an expiry, and have httpOnly/secure/samesite flags. Choose by need: storage for data, cookies for session/identification.
When an event fires on an element it travels in two phases: capturing (window/document → down to the target) and then bubbling (target → back up to document). By default listeners run during bubbling. Use addEventListener(evt, fn, {capture: true}) for the capturing phase. Knowing this enables event delegation - one listener on a parent handling many children.
Event delegation attaches one listener to a common ancestor and uses event.target to react to events on any descendant. It is useful because it needs a single listener (less memory), automatically handles dynamically added elements, and centralizes logic - for example a list where any row click is handled in one place instead of per row.
Common modern features: arrow functions, template literals (`hi ${name}`), destructuring and default params, spread/rest, let/const, class syntax, modules (import/export), promises and async/await, Map/Set, Symbol, optional chaining (a?.b), nullish coalescing (a ?? b), Array.of/find/includes/flat, and the nullish assignment. Together they make code shorter and less bug-prone.