JavaScript Interview Questions

Real JavaScript interview questions and answers covering closures, this, the event loop and hoisting.

A curated set of JavaScript interview questions, ordered roughly from fundamentals to more advanced — the kind you'll actually be asked in real screens and on-sites.

Language fundamentals

Q: What's the difference between == and ===? === (strict equality) compares both value and type with no conversion — "5" === 5 is false. == (loose equality) first coerces the operands to a common type before comparing — "5" == 5 is true. The coercion rules for == are notoriously confusing ("" == 0 is true, null == undefined is true but null == 0 is false), so idiomatic JavaScript uses ===/!== everywhere except for the rare, deliberate == null check that catches both null and undefined at once.

Q: What is hoisting? Before executing a scope's code, the JavaScript engine "hoists" variable and function declarations to the top of that scope. function declarations are hoisted with their entire body, so they can be called before their line in the file. var declarations are hoisted but only initialized to undefined — accessing them before their line gives undefined, not an error. let/const are hoisted too but remain uninitialized in a "temporal dead zone," so accessing them before their declaration throws a ReferenceError — a much safer failure mode.

Closures and scope

Q: What is a closure, and why does it matter? A closure is formed when an inner function retains access to variables from its enclosing (outer) function's scope, even after the outer function has finished running. It's the mechanism behind private state in JavaScript (a counter that keeps its own count without exposing a mutable global), memoization caches, and factory functions that produce customized functions (like retry(3) producing a function that retries three times).

Q: What's the classic closure-in-a-loop bug, and how was it fixed? Historically, using var in a loop meant every closure created inside that loop shared the same single var binding, so by the time any of the deferred callbacks ran (e.g., inside a setTimeout), the loop had already finished and every callback saw the final value:

Javascript
for (var i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 0);
}
// logs: 3, 3, 3 — not 0, 1, 2

Switching var to let fixes it, because let creates a new binding of i for every iteration:

Javascript
for (let i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 0);
}
// logs: 0, 1, 2

this and functions

Q: How is this determined inside a regular function versus an arrow function? A regular function's this depends entirely on how it's calledobj.method() binds this to obj, but a detached reference to the same function loses that binding. An arrow function has no this binding of its own; it inherits this lexically from the scope where it was written, which is why arrow functions are the standard fix for a callback that needs to reference the enclosing class instance's this.

Asynchronous JavaScript

Q: Explain the difference between the microtask queue and the macrotask (task) queue. Promise callbacks (.then, catch, async/await continuations) go into the microtask queue, which is fully drained after every synchronous block finishes, before the event loop does anything else. setTimeout, setInterval, and I/O callbacks go into the macrotask queue, which processes just one task per event loop tick — after the microtask queue is empty. That's why Promise.resolve().then(cb) always fires before setTimeout(cb, 0).

Q: What happens if you don't handle a Promise rejection? In the browser, it fires an unhandledrejection event on window and logs a warning to the console but doesn't crash the page. In Node.js, an unhandled rejection prints a warning and — depending on the Node version — can terminate the process entirely, since newer Node defaults treat unhandled rejections as fatal errors. Always attach a .catch() or wrap await calls in try...catch.

Modules

Q: What's the practical difference between a named export and a default export? A module can have any number of named exports (export const x, imported with import { x } from "...", name must match unless aliased with as), but at most one default export (export default x, imported with import x from "..." under whatever name the importer picks). Many style guides prefer named exports even for single-purpose modules, since the imported name then stays consistent everywhere it's used, whereas a default export's freedom to rename can lead to the same thing being called different names across a codebase.

Q: What does dynamic import() return, and when would you reach for it over a static import? It returns a Promise that resolves to the target module's exports, and — unlike a static import (which must be a top-level statement, resolved before any of the module's code runs) — it can be called from anywhere at runtime, including conditionally inside a function or event handler. It's the mechanism behind code splitting: a bundler can defer downloading a rarely-needed module until the exact moment import() for it actually executes, instead of bundling it into what every user downloads up front.

Error handling

Q: Why should you always throw an Error object rather than a plain string or number? An Error (or one of its subclasses, or a custom class extending it) automatically captures a stack trace at the moment it's created, showing exactly where the failure originated — critical for debugging. A thrown string or number carries none of that, and catching code can't reliably assume it has a .message or .name property the way it can with a real Error.

Q: How do you build a custom error type in JavaScript, and why bother? By extending the built-in Error class, calling super(message) before touching this, and typically setting this.name to the subclass's own name so it's identifiable in logs and stack traces. It's worth doing because instanceof then lets calling code distinguish a specific, expected failure (like InsufficientFundsError) from an arbitrary, unexpected one, and the custom class can carry structured extra data (like the account's balance) that calling code can act on directly instead of parsing it back out of a message string.

Testing

Q: In Jest, why does asserting a thrown error require wrapping the call in a function — expect(() => fn()).toThrow() instead of expect(fn()).toThrow()? toThrow needs to invoke the function itself inside Jest's own try/catch so it can observe what gets thrown. Calling the function directly inside expect(...) throws immediately, before Jest's matcher machinery ever runs — the test fails, but with a confusing, unrelated error rather than a clear assertion failure.

Q: What's the point of mocking a dependency in a test, and what's the risk of over-mocking? A mock replaces a real dependency — a network call, a database, the system clock — with a fully controlled fake, so a test exercises only the logic actually under test rather than everything that dependency does too (which would make the test slow, flaky, or dependent on external state). The risk of over-mocking is mocking so much of the surrounding code that the test stops verifying real behavior at all — it's important to mock the external boundary (an HTTP client, a database call) while still exercising the actual function or class the test claims to be testing.