JavaScript remains the backbone of modern web development, powering everything from simple interactive buttons to complex single-page applications and server-side environments like Node.js. Whether you are a fresh graduate stepping into the tech industry or a seasoned developer aiming for a senior role, mastering the core concepts of the language is non-negotiable. Technical interviews for JavaScript roles rarely focus solely on syntax; they probe your understanding of the engine under the hood—how the event loop handles asynchronous code, how closures capture scope, and how the prototype chain enables inheritance. This guide walks through the most critical interview questions categorized by concept, providing clear answers and the "why" behind them so you can articulate your knowledge with confidence.
The Foundations: Scope, Hoisting, and Closures
Interviewers almost always start with the fundamentals because they dictate how every other piece of code behaves. A shaky grasp here leads to bugs that are notoriously difficult to debug in production.
What is the difference between var, let, and const?
It's the quintessential warm-up question. The answer revolves around scope, hoisting, and reassignment.
var: Function-scoped or globally scoped. It is hoisted to the top of its scope and initialized withundefined. This means you can reference avarvariable before its declaration (though it will beundefined). It can be re-declared and updated.let: Block-scoped (bound by{}curly braces). It is hoisted but stays in the Temporal Dead Zone (TDZ) until the declaration line is reached. Accessing it before declaration throws aReferenceError. It can be updated but not re-declared in the same scope.const: Also block-scoped and subject to TDZ. It must be initialized at declaration. It cannot be updated or re-declared. Crucial nuance: For objects and arrays declared withconst, the binding is immutable, not the value. You can still mutate properties (const obj = {}; obj.prop = 1;works).
Interview Tip: Always mention the Temporal Dead Zone when discussing let and const. It signals you understand the specification, not just the behavior No workaround needed..
Explain Hoisting in JavaScript.
Hoisting is JavaScript's default behavior of moving declarations to the top of the current scope (script or function) before code execution.
- Variable Hoisting:
vardeclarations are hoisted and initialized withundefined.letandconstare hoisted but not initialized (TDZ). Function declarations are hoisted completely (name and body), allowing you to call a function before it appears in the code. - Function Expressions / Arrow Functions: These are not hoisted fully. Only the variable declaration (if
var) is hoisted. The assignment (the function body) stays in place. Calling them early results inTypeError: myFunc is not a function(forvar) orReferenceError(forlet/const).
What is a Closure? Give a practical example.
A closure is the combination of a function bundled together (enclosed) with references to its surrounding state (the lexical environment). In simpler terms, a closure gives you access to an outer function’s scope from an inner function, even after the outer function has returned Still holds up..
Practical Use Case: Data Encapsulation / Module Pattern.
function createCounter() {
let count = 0; // Private variable
return {
increment: () => ++count,
decrement: () => --count,
getCount: () => count
};
}
const counter = createCounter();
console.log(counter.Worth adding: increment()); // 1
console. log(counter.
Here, `increment`, `decrement`, and `getCount` form closures over the `count` variable. The variable persists in memory because the inner functions hold a reference to it, effectively creating *private state* in a language that historically lacked private class fields.
## Asynchronous JavaScript: The Event Loop and Promises
This is where junior and mid-level developers often separate. Understanding the **Event Loop** is mandatory for writing performant, non-blocking applications.
### Explain the Event Loop: Call Stack, Web APIs, Callback Queue, Microtask Queue.
JavaScript is single-threaded. The Event Loop is the mechanism that allows it to handle non-blocking operations (like `setTimeout`, `fetch`, DOM events) by offloading them to the browser (Web APIs) and scheduling their callbacks.
1. **Call Stack**: Where synchronous code executes (LIFO).
2. **Web APIs**: Browser-provided APIs (`setTimeout`, `fetch`, `DOM`). When an async task completes, its callback is pushed to a **Task Queue** (Macrotask Queue) or **Microtask Queue**.
3. **Microtask Queue**: Holds Promise callbacks (`.then`, `.catch`, `.finally`), `queueMicrotask`, `MutationObserver`. **High Priority.**
4. **Macrotask Queue (Callback Queue)**: Holds `setTimeout`, `setInterval`, `setImmediate`, I/O, UI Rendering. **Lower Priority.**
5. **The Loop**: The Event Loop checks: *Is the Call Stack empty?* If yes, it checks the **Microtask Queue** first. It drains *all* microtasks (executing them one by one, potentially adding more microtasks) before touching a single macrotask. Then it takes one macrotask, executes it, renders UI if needed, and repeats.
**Classic Interview Question:** *What is the output?*
```javascript
console.log('1. Script Start');
setTimeout(() => console.log('2. setTimeout'), 0);
Promise.resolve().then(() => console.log('3. Promise 1'));
console.log('4. Script End');
Output: 1 -> 4 -> 3 -> 2.
Why? Synchronous code runs first. setTimeout goes to Macrotask Queue. Promise.then goes to Microtask Queue. After script ends (stack empty), Microtask queue drains (3), then Macrotask runs (2).
What is the difference between Promise.all, Promise.allSettled, Promise.race, and Promise.any?
Handling multiple concurrent promises is a daily task.
Promise.all(iterable): Fail-fast. Resolves when all promises resolve. Rejects immediately if any promise rejects. Returns array of results in order. Use when you need all data to proceed (e.g., fetching user profile + permissions simultaneously).Promise.allSettled(iterable): Wait-for-all. Never rejects (unless the iterable itself is invalid). Resolves when all promises settle (either fulfill or reject). Returns array of objects{status: 'fulfilled', value}or{status: 'rejected', reason}. Use for independent tasks where you want results regardless of individual failures (e.g., analytics tracking, multiple non-critical API calls).Promise.race(iterable): First-settled. Settles (resolves or rejects) as soon as any promise settles. Mirrors the outcome of the fastest promise. Use for timeouts (race a fetch against asetTimeoutpromise that rejects).Promise.any(iterable): First-fulfilled. Resolves as soon as any promise fulfills. Ignores rejections. Only rejects (withAggregateError) if all promises reject. Use when you have multiple sources for the same data (e.g., CDN mirrors) and want the first successful response.
Async/Await vs. Promises: Error Handling Nuances.
async/await is syntactic sugar over Promises, making asynchronous code look synchronous
but it introduces distinct error handling behaviors that frequently trip up developers Simple, but easy to overlook..
The try/catch Imperative
With raw Promises, you attach a .catch() handler. With async/await, you must wrap await calls in try/catch blocks to prevent unhandled promise rejections from crashing your Node process or breaking the React component tree Worth keeping that in mind..
// ❌ Dangerous: Unhandled rejection if fetchUser fails
async function getUserData() {
const user = await fetchUser(id); // If this rejects, it bubbles up unhandled
return user;
}
// ✅ Safe: Explicit error boundary
async function getUserData() {
try {
const user = await fetchUser(id);
return user;
} catch (error) {
// Handle specific error types (network, 404, validation)
logger.error('User fetch failed', { id, error });
throw new AppError('USER_NOT_FOUND', { cause: error });
}
}
Sequential vs. Parallel Execution: The Performance Trap
The synchronous look of await encourages sequential code, which is often an accidental performance bottleneck That's the whole idea..
// 🐢 SLOW: Sequential (Waits for user, THEN waits for posts)
async function slowDashboard(userId) {
const user = await fetchUser(userId); // 300ms
const posts = await fetchPosts(userId); // 200ms
// Total: ~500ms
return { user, posts };
}
// 🚀 FAST: Parallel (Both start immediately)
async function fastDashboard(userId) {
// Initiate both promises *before* awaiting
const [user, posts] = await Promise.But all` (or `Promise. In real terms, all([
fetchUser(userId),
fetchPosts(userId)
]);
// Total: ~300ms (max of the two)
return { user, posts };
}
Rule of thumb: If operations are independent, Promise. allSettled) with a single await is almost always the correct pattern.
The "Floating Promise" Anti-Pattern
Forgetting await inside an async function returns a Promise instead of the resolved value, leading to subtle bugs where code executes out of order or errors are swallowed.
async function notifyUser(userId) {
// ❌ Missing await: sendEmail returns a Promise, function returns immediately
sendEmail(userId, 'Welcome!');
logActivity('Email sent'); // Runs BEFORE email actually sends
// ✅ Correct
await sendEmail(userId, 'Welcome!');
logActivity('Email sent'); // Runs AFTER confirmation
}
ESLint rule require-await (or TypeScript's no-floating-promises) catches this automatically—enable it And that's really what it comes down to..
Error Stack Traces & Context
async/await generally preserves cleaner, more readable stack traces compared to chained .then().catch(). That said, re-throwing errors (throw error) inside catch blocks can sometimes strip context if not careful. Use throw new CustomError('Context', { cause: error }) (ES2022 Error cause option) to maintain the full causal chain for debugging Worth keeping that in mind..
Summary Cheatsheet
| Scenario | Tool / Pattern | Key Reason |
|---|---|---|
| All must succeed | `Promise. | |
| Independent parallel | await Promise.allSettled |
Inspect individual outcomes, never throws. |
| First success only | `Promise. | |
| Sequential dependency | await step-by-step |
Step B needs data from Step A. Now, |
| All must finish | `Promise. | |
| First winner (any outcome) | `Promise.Consider this: | |
| Fire-and-forget (bg tasks) | void promise. Consider this: all |
Fail-fast, ordered results. all([...Practically speaking, any` |
The official docs gloss over this. That's a mistake.
Conclusion
Mastering JavaScript concurrency isn't about memorizing queue names—it's about intentional architecture. The Event Loop dictates when code runs; the Promise static methods dictate how multiple asynchronous operations relate to one another; and async/await dictates how readable and maintainable that logic remains.
The senior developer doesn't just ask "Does it work?Worth adding: " They ask: "Does this run in parallel or sequence? Because of that, what happens if this specific promise rejects? Still, is the error actionable? " By internalizing the Microtask/Macrotask priority, choosing the correct Promise.* combinator, and respecting the try/catch discipline of async/await, you transform asynchronous chaos into predictable, resilient, and performant systems.