JavaScript interviews concentrate on the parts of the language that behave unexpectedly. Expect closures, hoisting and the temporal dead zone, how the event loop orders microtasks and macrotasks, promises and async/await, prototypal inheritance, and how this is determined by the call site. Interviewers also test practical judgement around shallow versus deep copies, debouncing and throttling, and where to store an authentication token. The questions below cover the language fundamentals and the browser APIs that accompany them.
Behavioural Questions
1. Tell me about a difficult bug you tracked down in JavaScript. How did you approach it?
Note: The interviewer is testing your debugging method, not your luck. Pick a bug that needed reasoning rather than one you fixed by chance.
Tell it as a narrowing process:
- How you reproduced it. The hardest part of most JavaScript bugs is making them happen on demand — a race that only appears on slow connections, a state bug that only appears after a specific navigation order.
- How you bisected. Commenting out halves,
git bisect, or a breakpoint moved progressively earlier until the state was still correct. - The tools. Conditional breakpoints, the Network tab for a response that was not the shape you assumed, the Performance panel for a rendering problem, or simply logging the value and its
typeof. - The root cause. Good candidates: a stale closure capturing an old value, an
awaitinside a loop that silently serialised requests, mutation of an object someone else held a reference to, orthislosing its binding in a callback.
Finish with the test you wrote so the bug could not return.
2. How do you decide whether to use a library or write something yourself?
Frame it as a cost decision rather than a matter of taste. A dependency is not free — it carries bundle size, a security surface, and a maintenance obligation.
Reach for a library when: the problem is genuinely hard and well-specified, and getting it subtly wrong is dangerous. Date and timezone handling, cryptography, rich text editing, and virtualised lists all qualify.
Write it yourself when: you need a small fraction of what the library does, the whole thing is thirty lines, or the library's API forces your architecture in a direction you do not want.
Questions worth asking out loud in the interview:
- How large is it after tree-shaking, and does it ship to the browser or stay on the server?
- Is it actively maintained, and how many transitive dependencies come with it?
- How hard would it be to remove in a year?
Note: A strong closing line is that you would rather copy thirty lines into the codebase than take a dependency for them — a point made memorably by the left-pad incident.
3. Describe a time you disagreed with a teammate about a technical approach. How did it end?
Note: Choose a disagreement you lost, or one that ended in a third option. Stories where you were simply right and everyone came round make interviewers uneasy.
Give it structure:
- The substance. State both positions fairly — for example, whether to adopt a state management library or to lift state and use context, or whether to rewrite a component or refactor it in place.
- How you moved it out of opinion. This is the part that matters. Building a small prototype, measuring the bundle impact, or writing down the two options with their trade-offs turns an argument into a decision.
- How it was settled. Who decided, and on what evidence.
- How you behaved afterwards. If the decision went against you, say that you committed to it fully rather than relitigating it in code review.
Interviewers are screening for whether you can be overruled without becoming difficult to work with.
4. How do you handle code review, both giving and receiving it?
Split the answer, because the two skills are different.
Giving:
- Separate the blocking from the optional. Say plainly which comments must be addressed and which are preferences — a reviewer who leaves twenty equally-weighted comments is unusable.
- Ask rather than assert when you might be missing context: "what happens if this is called twice?" is more productive than "this is wrong."
- Review the design first and the style second. Style belongs to the linter and formatter, not to a human.
Receiving:
- Treat every comment as a signal that something was unclear, even if you disagree with the fix. If a reviewer misread it, the next reader will too.
- Answer in the code where you can, and in a comment where the reasoning cannot be expressed in code.
Note: If you have introduced a review convention to a team — small pull requests, a template, an agreed turnaround time — mention it. It shows you improve the process rather than just working inside it.
5. The JavaScript ecosystem changes very fast. How do you keep up without chasing every new framework?
Answer with a filter, not a reading list. Anyone can name newsletters; the interesting part is how you decide what to ignore.
What to keep up with: the language and the platform. New syntax that reaches Stage 4, changes to the event loop and module semantics, and browser APIs that remove a dependency you were carrying. These are durable — they will still matter in ten years.
What to watch but not adopt: frameworks and build tools. Learn what problem each new one claims to solve, and wait until it has survived a couple of major versions and a real production story before putting it in front of users.
How to actually learn: build something small and deliberately awkward with it. Reading a tutorial teaches you the happy path; the value is in the second day, when you hit the thing the tutorial skipped.
Note: Being able to say "I evaluated X and chose not to use it, because Y" is the strongest possible answer here.
6. Tell me about a time you migrated a legacy JavaScript codebase, for example from callbacks to async/await or from JavaScript to TypeScript. How did you do it safely?
The interviewer wants to see that you can change a large, live codebase without a big-bang rewrite. Structure your answer with STAR, but spend most of the time on the Action: how you made the migration incremental and reversible.
- Situation: give scale and pain. For example: “a 60,000-line Express and jQuery codebase with nested callbacks, where most production bugs were swallowed errors inside callbacks.”
- Task: what “done” meant. Perhaps: all new code in async/await, the ten most-changed modules converted, no regression in error rates.
- Action: the strategy. Good points to mention:
- You wrapped callback APIs with
util.promisifyor small adapters, so old and new code could call each other. - You converted module by module, starting where bugs clustered, not alphabetically.
- For TypeScript, you enabled
allowJsandcheckJs, then tightenedstrictflags one at a time. - You added characterisation tests before touching risky modules, and added a lint rule so new callback-style code could not be introduced.
- You wrapped callback APIs with
- Result: numbers where possible: fewer unhandled errors, faster onboarding, a percentage of the codebase converted in a quarter while features kept shipping.
Close with what you would do differently, such as agreeing conventions for error handling earlier, or tracking migration progress on a dashboard so the team could see momentum.
Note: Avoid saying you paused feature work for months to rewrite everything. Interviewers read that as risky. The strongest stories show migration running alongside delivery.
7. How would you explain a tricky JavaScript concept, such as closures or the event loop, to a junior developer on your team?
This question tests mentoring and communication, not just knowledge. A strong reply shows that you adapt the explanation to the person, check understanding, and leave them able to work it out themselves next time.
- Start from what they already know. Ask what they think happens first. Misconceptions tell you where to begin.
- Use a concrete, runnable example. For the event loop, a five-line snippet mixing
console.log,setTimeoutandPromise.resolve().thenis better than any diagram. Ask them to predict the output before running it. - Offer one analogy, then drop it. For closures, “the function carries a backpack of the variables it could see when it was created” works, but move quickly back to real code so the analogy does not become the mental model.
- Connect it to a bug they have seen. A stale value in a React hook or a loop that logs the same number three times makes the concept stick.
- Check understanding by reversal. Ask them to explain it back or change the example to produce a different output.
If you have a real story, use it: “A junior colleague kept getting stale state in a useEffect. We paired for thirty minutes, rebuilt the bug in a small file, and afterwards she wrote a short internal note on stale closures that the team still links to.”
Note: Mention follow-up: pointing them to a good resource and reviewing their next related pull request shows you care about lasting learning, not just a one-off explanation.
8. How do you decide what to test in a JavaScript codebase? Tell me about a time tests saved you, or a time the lack of them hurt.
Here the interviewer is looking for judgement: you do not test everything equally, and you can explain why. Give your principles first, then a short story.
Principles worth stating:
- Test behaviour, not implementation. Tests should survive a refactor. Asserting on internal function calls makes them brittle.
- Weight by risk. Money, authentication, data transformation and anything with tricky edge cases (dates, time zones, floating point, empty arrays) get the most coverage.
- Follow a sensible mix. Many fast unit tests for pure logic, fewer integration tests around modules and APIs, and a handful of end-to-end tests for critical user journeys.
- Every bug gets a regression test. The fix is not finished until a test fails without it.
Story structure: describe the situation (for example, refactoring a pricing function used across checkout), what the tests caught (a rounding difference when discounts stacked), and the result (caught in CI instead of by customers). If you use a failure story, own it: “We shipped a date parser without tests for non-UTC time zones, and Indian users saw orders dated a day early. I added table-driven tests covering several offsets and made them part of CI.”
Note: Avoid quoting a coverage percentage as the goal. Say coverage is a signal for finding untested areas, not a target, and that you care more about which paths are covered.
9. Tell me about a time a third-party script or npm package broke your JavaScript application. How did you respond?
This question checks how you behave under pressure and whether you improve the system afterwards. Use STAR, and make sure the “afterwards” part is as strong as the firefighting.
- Situation: be specific. For example: “A minor version of a date library we depended on through a caret range changed its parsing of ambiguous strings, and our booking form started rejecting valid dates.”
- Immediate response: how you contained the damage. Rolling back the deploy, pinning the previous version, or disabling the feature behind a flag. Mention that you communicated status to support and product early.
- Diagnosis: how you proved the cause, for example by comparing lockfiles between the last good and first bad build, or bisecting with
git bisect. - Lasting fixes: this is where you stand out.
- Committing the lockfile and installing with
npm ciin CI so builds are reproducible. - Using Renovate or Dependabot so upgrades arrive as small, reviewed pull requests with tests.
- Wrapping the library behind your own small module so a future swap touches one file.
- For third-party browser scripts, loading them asynchronously and guarding calls so their failure cannot break core flows.
- Committing the lockfile and installing with
Finish with the outcome: time to recovery, and that the same class of issue has not recurred.
Note: Do not blame the library authors. Interviewers want to hear that you took ownership of your dependency choices and your upgrade process.
10. How do you estimate a JavaScript feature, and what do you do when you realise halfway through that your estimate was wrong?
The interviewer wants to know whether you are predictable and honest. Split your answer into how you estimate and how you handle being wrong.
How you estimate:
- Break the feature into small tasks such as UI state, API integration, validation, error states, tests and review. Unknowns usually hide in error handling and edge cases, so list them explicitly.
- Spike the riskiest part first. If you have never used a browser API or a library, spend a timeboxed hour on a prototype before committing to a number.
- Give a range with assumptions, for example “three to five days, assuming the API contract is final.”
When the estimate slips:
- Raise it early, as soon as you know, not on the due date.
- Explain the cause in one sentence, such as “the payment SDK does not support our flow and needs a wrapper.”
- Offer options: cut scope, ship behind a flag, or move the date. Let the stakeholder choose with full information.
A short example helps: “I estimated two days for a file upload feature, but discovered on day one that large files needed chunking. I told my lead that afternoon, proposed shipping a 10 MB limit first and chunking in the next sprint, and we delivered on time with reduced scope.”
Note: Mention that you compare estimates with actuals over time. Showing you calibrate yourself is more convincing than claiming you are always accurate.
Technical Questions
11. Explain the difference between var, let and const, including hoisting and the temporal dead zone.
They differ in scope, in re-assignability, and in what happens if you touch them before the declaration.
- var is function-scoped. It is hoisted to the top of its function and initialised to
undefined, so reading it before the declaration gives youundefinedrather than an error. It can be redeclared and reassigned. - let is block-scoped, can be reassigned, and cannot be redeclared in the same scope.
- const is block-scoped and cannot be reassigned. It does not make the value immutable — the properties of a
constobject can still be changed; only the binding is fixed.
The temporal dead zone is the region from the start of the block until the let or const declaration is evaluated. The binding is hoisted but uninitialised, so accessing it there throws a ReferenceError instead of quietly returning undefined. This is a deliberate improvement: it turns a class of silent bugs into loud ones.
Note: The classic demonstration is a for loop with a setTimeout inside. With var, all callbacks log the final value because they share one binding; with let, each iteration gets a fresh binding and they log 0, 1, 2.
12. What is a closure, and where would you actually use one?
A closure is a function together with the scope it was created in. When a function is defined inside another function, it keeps a live reference to the outer function's variables — even after the outer function has returned.
function counter() {
let count = 0;
return function () { return ++count; };
}
const next = counter();
next(); // 1
next(); // 2count survives because the returned function still refers to it.
Where you genuinely use them:
- Private state. Anything the caller should not be able to reach directly — a cache, a counter, a configuration captured once.
- Function factories.
debounceandthrottleboth work by closing over a timer id. - Partial application. Capturing the first argument and returning a function that takes the rest.
- Every React hook.
useStateanduseEffectrely on closures, which is exactly why stale-closure bugs are the most common hooks mistake.
Note: Be ready for the follow-up on memory. A closure keeps its captured scope alive, so holding one in a long-lived structure keeps everything it captured from being collected.
13. Explain the event loop, the call stack, and the difference between microtasks and macrotasks.
JavaScript runs on a single thread with one call stack. Anything asynchronous is handed to the host — the browser or Node — and comes back as a queued callback.
The event loop repeats one rule: when the call stack is empty, drain the microtask queue completely, then take one task from the macrotask queue.
- Microtasks — promise callbacks (
.then, the code after anawait),queueMicrotask, andMutationObserver. The entire microtask queue is emptied before the next macrotask, and microtasks queued during that drain are also run. - Macrotasks —
setTimeout,setInterval, I/O callbacks, and UI events. Exactly one runs per turn of the loop.
So this logs 1, 4, 3, 2:
console.log(1);
setTimeout(() => console.log(2), 0);
Promise.resolve().then(() => console.log(3));
console.log(4);Note: The practical consequence is that an infinite chain of microtasks starves the macrotask queue and freezes the page, whereas setTimeout always yields.
14. How does the this keyword work in JavaScript, and how do arrow functions change it?
this is decided by how a function is called, not where it is written. There are five rules, checked in order:
- new binding — called with
new,thisis the newly created object. - Explicit binding —
call,applyorbindset it directly. - Method binding — called as
obj.method(),thisisobj. Note that this is about the call site, so pulling the method out into a variable loses it. - Default binding — a plain call gets the global object, or
undefinedin strict mode and in modules. - Arrow functions break the pattern entirely. They have no
thisof their own and inherit it lexically from the enclosing scope at definition time.call,applyandbindcannot change it.
That is exactly why arrows are the standard fix for callbacks:
// loses this
setTimeout(function () { this.tick(); }, 100);
// keeps this
setTimeout(() => this.tick(), 100);Note: The corollary is that an arrow function should never be used as an object method or a constructor, because there is no useful this to inherit.
15. What is prototypal inheritance, and how does the prototype chain resolve a property?
Every JavaScript object has a hidden link to another object called its prototype. When you read a property, the engine looks on the object itself; if it is not there, it follows the link to the prototype, then to that object's prototype, and so on until it reaches null. That path is the prototype chain, and a missing property simply returns undefined at the end of it.
The pieces that confuse people:
Object.getPrototypeOf(obj)gives you an object's prototype. The legacy__proto__accessor does the same thing.- A function's
.prototypeproperty is not that function's prototype. It is the object that will become the prototype of instances created withnew. classsyntax is sugar over exactly this mechanism.extendssets up the chain; there is no separate class system underneath.
Why it matters practically: methods live on the prototype, so a thousand instances share one copy of each method rather than each carrying its own. It is also why for...in walks inherited enumerable properties and Object.keys does not.
16. Explain Promises, async/await, and how you handle errors and run work in parallel.
A Promise represents a value that is not ready yet. It is pending, then settles once as either fulfilled or rejected, and never changes again.
async/await is syntax over the same objects. An async function always returns a promise, and await pauses that function until the promise settles without blocking the thread.
Error handling: wrap the await in try/catch. The trap is that an async function's rejection does not propagate to the caller unless the caller awaits it too — a forgotten await produces an unhandled rejection that is easy to miss.
Running work in parallel is the point most candidates get wrong. This is sequential and takes as long as both requests combined:
const a = await getA();
const b = await getB();This starts both immediately:
const [a, b] = await Promise.all([getA(), getB()]);The combinators: Promise.all rejects as soon as any input rejects; Promise.allSettled always waits for all of them and reports each outcome; Promise.race settles with the first to settle, useful for timeouts; Promise.any resolves with the first to succeed.
17. What is the difference between == and ===, and what are the falsy values in JavaScript?
=== is strict equality: if the two operands are of different types the result is false, with no conversion. == is loose equality: it coerces the operands to a common type first, following a set of rules that produce genuinely surprising results.
0 == '0'is true,0 === '0'is false.null == undefinedis true, butnull == 0is false —nullis loosely equal only toundefinedand itself.NaNis not equal to anything, including itself. UseNumber.isNaNto test for it.
The eight falsy values — everything else is truthy:
false,0,-0,0n,'',null,undefined,NaN
Note: An empty array and an empty object are both truthy, which catches people out constantly. The one place == is genuinely useful is x == null, which tests for null or undefined in a single check — and the modern alternatives are the ?? and ?. operators.
18. What is event delegation, and how do event bubbling and capturing work?
A DOM event travels in three phases: it captures down from the window to the target, fires at the target, then bubbles back up to the window. addEventListener attaches to the bubbling phase by default; passing true or { capture: true } attaches to the capturing phase instead.
Event delegation uses bubbling deliberately. Instead of attaching a listener to every item, you attach one to a common ancestor and inspect event.target:
list.addEventListener('click', (e) => {
const item = e.target.closest('li');
if (!item || !list.contains(item)) return;
handle(item.dataset.id);
});Why it is worth doing:
- One listener instead of hundreds — less memory and a faster initial render.
- It works for elements added later, with no need to rebind after every update.
Note: Know the difference between stopPropagation, which halts the journey up the tree, and preventDefault, which cancels the browser's default action such as following a link. They are unrelated, and reaching for stopPropagation is usually a sign that something else is wrong.
19. What is the difference between a shallow copy and a deep copy, and how do you make each one?
Objects and arrays are held by reference, so assigning one variable to another gives you two names for the same thing. Copying is how you break that.
A shallow copy duplicates the top level only. Nested objects are still shared:
const copy = { ...original };
const copy2 = Object.assign({}, original);
const arr2 = [...arr]; // or arr.slice()Change copy.user.name and the original changes too, because user is the same object in both.
A deep copy duplicates every level:
structuredClone(original)is the modern built-in answer. It handles Dates, Maps, Sets, TypedArrays, and circular references.JSON.parse(JSON.stringify(original))is the old trick, and it is lossy — it silently drops functions andundefined, turns Dates into strings, and throws on circular references.- A library such as lodash's
cloneDeepwhen you need fine control.
Note: The reason this is asked so often is React and Redux state. Mutating nested state in place means the reference does not change, so the component does not re-render.
20. Explain debouncing and throttling, and when you would use each.
Both limit how often a function runs, but they answer different questions.
Debounce — wait until the activity stops, then run once. Every new call resets the timer.
function debounce(fn, delay) {
let t;
return (...args) => {
clearTimeout(t);
t = setTimeout(() => fn(...args), delay);
};
}Throttle — run at most once per interval, no matter how many calls arrive.
function throttle(fn, limit) {
let waiting = false;
return (...args) => {
if (waiting) return;
fn(...args);
waiting = true;
setTimeout(() => { waiting = false; }, limit);
};
}Which to use:
- Debounce when only the final state matters — a search-as-you-type box, validating a field after the user stops typing, saving a draft.
- Throttle when you need regular updates during the activity — scroll position, mouse move, an infinite-scroll trigger, window resize.
Note: Debouncing a scroll handler is a classic mistake: nothing happens at all until the user stops scrolling.
21. What is lexical scope, and how does JavaScript resolve a variable through the scope chain?
Lexical scope means a variable's visibility is decided by where the code is written, not by where a function is called from. When the engine meets an identifier, it looks in the current scope, then the enclosing scope, and so on outwards until it reaches the global scope. That series of nested scopes is the scope chain.
const level = 'global';
function outer() {
const level = 'outer';
function inner() {
console.log(level); // 'outer'
}
return inner;
}
const fn = outer();
fn(); // still 'outer', even though it is called from global codeKey points to cover:
- Kinds of scope: global, module, function, and block scope (created by
{}forlet,constandclass). - Shadowing: an inner declaration with the same name hides the outer one within that block.
- Resolution failure: if the name is not found anywhere, reading it throws a
ReferenceError. In sloppy mode, assigning to it silently creates a global, which strict mode forbids. - Lookup is decided at author time, which is what makes closures possible. A function returned from
outerkeeps access toouter's scope. - Exceptions are rare:
evalandwithcan alter scope at runtime, which is one reason both are discouraged.
Contrast this with this, which is dynamic: it depends on how a function is called. Candidates often mix the two up, so stating the difference clearly is a strong signal.
Note: Top-level let and const in a script create globals that are not properties of window, while top-level var and function declarations do become properties of the global object.
22. How does implicit type coercion work in JavaScript, and how are objects converted to primitives?
Coercion happens when an operator receives a type it does not expect. JavaScript converts values using three abstract operations: ToPrimitive, ToNumber and ToString.
- Binary
+: both sides are converted to primitives first. If either is a string, it concatenates; otherwise it adds numbers. So1 + '2'is'12', but1 - '2'is-1because-always converts to numbers. - Comparisons and arithmetic use ToNumber:
Number('')is 0,Number(' 42 ')is 42,Number(null)is 0,Number(undefined)isNaN. - Boolean contexts use ToBoolean, where only the falsy values become
false. An empty array or empty object is truthy.
Objects to primitives. ToPrimitive checks for Symbol.toPrimitive first. If absent, it calls valueOf() then toString() for a “number” hint, or the reverse for a “string” hint, using the first that returns a primitive.
const price = {
[Symbol.toPrimitive](hint) {
return hint === 'string' ? 'Rs 499' : 499;
}
};
price * 2; // 998
`${price}`; // 'Rs 499'
[] + {}; // '[object Object]'Arrays convert through toString, which joins elements with commas. That is why [1,2] + [3] gives '1,23'. Dates are the one built-in that prefers the string hint with +.
Note: In production code, convert explicitly with Number(), String() or Boolean() and compare with ===. Knowing the rules is for debugging and interviews, not for relying on them.
23. What is the difference between call, apply and bind, and when would you use each one?
All three let you set the value of this for a function. They differ in whether the function runs immediately and how arguments are passed.
| Method | Runs now? | Arguments |
|---|---|---|
fn.call(ctx, a, b) | Yes | Listed individually |
fn.apply(ctx, [a, b]) | Yes | As an array or array-like |
fn.bind(ctx, a) | No, returns a new function | Optionally pre-filled |
function greet(greeting, punct) {
return greeting + ', ' + this.name + punct;
}
const user = { name: 'Asha' };
greet.call(user, 'Hello', '!'); // 'Hello, Asha!'
greet.apply(user, ['Hi', '.']); // 'Hi, Asha.'
const hey = greet.bind(user, 'Hey');
hey('?'); // 'Hey, Asha?'Typical uses:
- call: borrowing a method, for example
Array.prototype.slice.call(arguments)in older code, or calling a parent constructor in pre-class inheritance. - apply: historically for spreading an array into arguments, like
Math.max.apply(null, nums). Today spread syntaxMath.max(...nums)replaces most of these uses. - bind: fixing
thisfor a callback that will be called later, such as an event handler orsetTimeout, and partial application.
Two subtleties interviewers like: a bound function cannot be re-bound, since later bind or call calls cannot change its this; and arrow functions ignore the context argument entirely because they take this from their enclosing scope.
Note: If a bound function is called with new, the bound this is ignored and a new object is created, although the pre-filled arguments are still applied.
24. What does the new keyword actually do when you call a function with it?
Calling a function with new runs it as a constructor. The engine performs four steps:
- Creates a new empty object.
- Links its prototype: the object's internal
[[Prototype]]is set to the constructor'sprototypeproperty. - Runs the function with
thisbound to the new object, so assignments likethis.name = namepopulate it. - Returns the object, unless the function explicitly returns a different object. Returning a primitive is ignored.
You can demonstrate understanding by writing a simplified version:
function myNew(Ctor, ...args) {
const obj = Object.create(Ctor.prototype);
const result = Ctor.apply(obj, args);
return result !== null && typeof result === 'object' ? result : obj;
}
function User(name) { this.name = name; }
User.prototype.hi = function () { return 'Hi ' + this.name; };
myNew(User, 'Ravi').hi(); // 'Hi Ravi'Points that separate a strong answer:
- Forgetting
newon a plain constructor function runs it withthisas the global object (orundefinedin strict mode), which is a classic bug. ES6 classes throw aTypeErrorinstead. - Arrow functions and methods defined with shorthand syntax cannot be used with
newbecause they have no[[Construct]]behaviour. - Inside a constructor,
new.targettells you whether it was called withnew, and which class was instantiated in a subclass chain.
Note: The simplified myNew above ignores functions in the result check. A real constructor that returns a function also replaces the new object, so a complete version checks typeof result === 'function' as well.
25. How do ES6 classes relate to prototypes, and what do extends, super, static members and private fields do?
Classes are mostly a cleaner syntax over the prototype system. Methods declared in a class body are placed on ClassName.prototype, and instances link to it exactly as they would with a constructor function.
class Account {
#balance = 0; // private field
static count = 0; // on the class itself
constructor(owner) { this.owner = owner; Account.count++; }
deposit(n) { this.#balance += n; return this; }
get balance() { return this.#balance; }
}
class Savings extends Account {
constructor(owner, rate) {
super(owner); // must run before using this
this.rate = rate;
}
deposit(n) { return super.deposit(n * 1.01); }
}- extends sets up two prototype links:
Savings.prototypeinherits fromAccount.prototype(instance methods), andSavingsitself inherits fromAccount(static members). - super(...) calls the parent constructor. In a derived class,
thisdoes not exist untilsuperreturns, so touching it earlier throws aReferenceError.super.method()calls the parent's version. - static members live on the constructor, not on instances, which suits factories and counters.
- #private fields are truly private: they are not properties at all, cannot be reached with bracket notation, and accessing them on the wrong object throws.
Classes do differ from constructor functions in real ways: class bodies always run in strict mode, classes are in the temporal dead zone until declared (so they are not usable before the line that defines them), calling one without new throws, and methods are non-enumerable.
Note: A good follow-up to prepare: class fields defined with arrow functions, such as handle = () => {}, create a new function per instance rather than sharing one on the prototype. That fixes this for callbacks but costs memory.
26. What is the difference between Object.freeze, Object.seal and Object.preventExtensions, and how do you get real immutability?
These three methods lock down an object to increasing degrees:
| Method | Add props | Delete props | Change values |
|---|---|---|---|
preventExtensions | No | Yes | Yes |
seal | No | No | Yes |
freeze | No | No | No |
In sloppy mode, violations fail silently. In strict mode, and so in modules and classes, they throw a TypeError. You can check the state with Object.isFrozen, Object.isSealed and Object.isExtensible.
The catch: all three are shallow. Nested objects stay mutable.
const config = Object.freeze({ db: { host: 'localhost' } });
config.db.host = 'prod'; // allowed: db itself is not frozen
function deepFreeze(obj) {
for (const value of Object.values(obj)) {
if (value && typeof value === 'object') deepFreeze(value);
}
return Object.freeze(obj);
}Getting practical immutability:
- Remember that
constonly fixes the binding, not the value. - Update by copying: spread syntax
{ ...state, count: state.count + 1 }and non-mutating array methods such asmap,filterand the ES2023toSorted. - Libraries like Immer let you write mutating-style code that produces new immutable objects, which is how Redux Toolkit works.
- Deep freezing in development builds is a cheap way to catch accidental mutation.
Note: Freezing does not protect internal slots. A frozen Map or Date can still be changed through set() or setFullYear(), because their data is not stored in ordinary properties.
27. How do ES modules work in JavaScript, including static imports, live bindings and dynamic import()?
ES modules (ESM) are JavaScript's built-in module system. Each file has its own scope, runs in strict mode, and shares values only through export and import.
- Static structure.
importandexportmust appear at the top level and use string literals. Because the dependency graph is known without running code, bundlers can do tree shaking and remove unused exports. - Live bindings. An import is a read-only view of the exporter's variable, not a copy. If the exporting module changes the value, importers see the new value. CommonJS, by contrast, copies the value at
requiretime. - Hoisted, evaluated once. Imports are resolved before the module body runs, and each module is evaluated only once no matter how many files import it.
- Asynchronous loading. In browsers,
<script type="module">is deferred by default, and top-levelawaitis allowed.
// counter.js
export let count = 0;
export function inc() { count++; }
// app.js
import { count, inc } from './counter.js';
inc();
console.log(count); // 1: live binding
// count = 5; // TypeError: imports are read-only
// load code only when needed
const { renderChart } = await import('./chart.js');Dynamic import() returns a promise for the module namespace object. It works anywhere, including in CommonJS files and inside conditions, and is the basis of code splitting and lazy loading.
Circular imports are allowed, but accessing a binding before its module has finished evaluating throws a ReferenceError because of the temporal dead zone.
Note: Prefer named exports over default exports in shared code. They are easier to find with search, rename consistently in editors, and tree shake more predictably.
28. What is the iteration protocol in JavaScript, and how do you make your own object iterable with Symbol.iterator?
The iteration protocol is a pair of conventions that let any object work with for...of, spread syntax, destructuring, Array.from, Promise.all and similar consumers.
- Iterable: an object with a
[Symbol.iterator]()method that returns an iterator. - Iterator: an object with a
next()method returning{ value, done }. Whendoneistrue, iteration stops.
Arrays, strings, Maps, Sets, arguments and NodeLists are iterable out of the box. Plain objects are not, which is why for (const x of {}) throws a TypeError.
class Range {
constructor(from, to) { this.from = from; this.to = to; }
[Symbol.iterator]() {
let current = this.from;
const last = this.to;
return {
next() {
return current <= last
? { value: current++, done: false }
: { value: undefined, done: true };
},
return() { // called on break or early exit
console.log('cleanup');
return { done: true };
}
};
}
}
[...new Range(1, 4)]; // [1, 2, 3, 4]
for (const n of new Range(1, 100)) { if (n === 3) break; } // logs 'cleanup'Things worth mentioning in an interview:
- The optional
return()method runs when a consumer stops early throughbreak,returnor an exception, so the iterator can release resources. - Iterators can be lazy and infinite. Values are produced only when
next()is called. - Most built-in iterators are single-use. Once exhausted, they stay done, while calling
[Symbol.iterator]()on an array gives a fresh one each time.
Note: In practice you rarely write next() by hand. A generator method, *[Symbol.iterator]() { ... }, implements the whole protocol for you, including return().
29. What are generator functions, how do yield and next() work, and where are generators useful in real code?
A generator function, declared with function*, returns a generator object instead of running its body. Each call to next() runs the body until the next yield, pauses there, and hands out the yielded value. The function's local state survives between calls.
function* idGenerator(prefix) {
let n = 1;
while (true) {
const reset = yield `${prefix}-${n++}`;
if (reset) n = 1;
}
}
const ids = idGenerator('ORD');
ids.next().value; // 'ORD-1'
ids.next().value; // 'ORD-2'
ids.next(true).value; // 'ORD-1': the argument became the value of yieldTwo-way communication:
next(value)resumes the generator and makes the pausedyieldexpression evaluate tovalue.return(value)finishes it early, running anyfinallyblocks.throw(error)raises an error at the pausedyield, which the generator can catch.yield*delegates to another iterable or generator.
Where they help in practice:
- Lazy sequences. Paging through an API or producing IDs without building a huge array.
- Implementing iterables. A
*[Symbol.iterator]()method is the shortest way to make a class iterable. - Pipelines. Composing
take,filterandmapsteps that process one item at a time. - Control flow. Redux-Saga models side effects as yielded descriptions, and generators were how async/await was emulated before it existed.
Note: Generators are pull-based and synchronous. For values that arrive over time, such as network pages or stream chunks, use an async generator (async function*) and consume it with for await...of.
30. How do async iterators and for await...of work, and when would you use an async generator?
An async iterator is like a normal iterator except that next() returns a promise of { value, done }. Objects expose one through [Symbol.asyncIterator](), and for await...of consumes it, awaiting each step before the loop body runs.
The easiest way to create one is an async generator, async function*, which can both await and yield.
async function* fetchAllOrders(url) {
let next = url;
while (next) {
const res = await fetch(next);
const page = await res.json();
yield* page.items; // hand out items one by one
next = page.nextPageUrl; // null when finished
}
}
for await (const order of fetchAllOrders('/api/orders')) {
if (order.total > 10000) { flag(order); break; }
}Why this pattern is good:
- Lazy and memory-friendly. Only the current page is held in memory, and
breakstops further requests. - Natural backpressure. The producer does not fetch the next page until the consumer asks for it.
- Clean separation. Paging logic lives in one place; callers just loop.
Where you meet them: paginated APIs, reading files line by line, Node.js readable streams (which are async iterable), and the body of a fetch response in modern runtimes.
Two cautions. First, for await processes items sequentially; if the items are independent and you want speed, collect promises and use Promise.all with a concurrency limit instead. Second, for await...of is only valid inside an async function or at the top level of a module.
Note: for await...of also accepts a plain array of promises, awaiting each in order. That is convenient but differs from Promise.all, because a rejection is only noticed when the loop reaches it.
31. When should you use a Map or a Set instead of a plain object or an array?
Map is a key-value collection and Set is a collection of unique values. Both keep insertion order and are designed for frequent additions and removals.
Choose a Map over an object when:
- Keys are not strings. Map accepts any key, including objects, functions and numbers without converting them. Object keys are always strings or symbols, so
obj[1]andobj['1']are the same. - Keys come from user input. A key like
__proto__orconstructorcan collide with inherited properties on an object. A Map has no such risk. - You need the size or frequent changes.
map.sizeis direct, and engines optimise Maps for repeated adds and deletes. - You iterate a lot. Maps are directly iterable in insertion order.
Keep a plain object when the shape is fixed and known, like a record or a config, or when you need JSON serialisation, because JSON.stringify turns a Map into {}.
const visits = new Map();
visits.set(userObj, 3); // object as key
visits.get(userObj); // 3
const unique = [...new Set([3, 1, 3, 2, 1])]; // [3, 1, 2]
const tags = new Set(['js', 'node']);
tags.has('js'); // true, fast lookup
// Map to and from objects
const m = new Map(Object.entries({ a: 1 }));
const o = Object.fromEntries(m);Choose a Set over an array for membership checks and deduplication: set.has(x) is effectively constant time, while array.includes(x) scans the whole array. Newer engines also add Set methods such as union, intersection and difference.
Equality in both uses the SameValueZero algorithm, so NaN matches NaN, but two different objects with identical contents are separate keys.
Note: If keys are objects and you do not want the collection to keep them alive, use a WeakMap or WeakSet instead.
32. What are WeakMap, WeakSet and WeakRef, and how do they interact with garbage collection?
These are collections and references that hold objects weakly: they do not stop the garbage collector from reclaiming an object that nothing else references.
- WeakMap: keys must be objects (or non-registered symbols). When a key object becomes unreachable elsewhere, its entry disappears automatically.
- WeakSet: a set of objects with the same weak behaviour, useful for tagging objects, for example “already processed”.
- WeakRef: a single weak reference.
ref.deref()returns the object, orundefinedif it has been collected. - FinalizationRegistry: lets you register a callback that may run after an object is collected.
Because entries can vanish at any time, WeakMap and WeakSet are not iterable and have no size or clear(). You can only get, set, has and delete by a key you already hold.
// cache derived data per object without leaking memory
const cache = new WeakMap();
function getStats(order) {
if (!cache.has(order)) cache.set(order, expensiveStats(order));
return cache.get(order);
}
// private data per instance (pre-#private fields pattern)
const secrets = new WeakMap();
class Token {
constructor(v) { secrets.set(this, v); }
reveal() { return secrets.get(this); }
}Typical uses: memoising results per object, attaching metadata to DOM nodes or third-party objects you cannot modify, and tracking visited objects in a deep clone or graph walk.
Cautions: garbage collection timing is unpredictable, so never build program logic that depends on when a WeakRef empties or a finalizer runs. A finalizer may run late or not at all. Use them for caches and cleanup hints, not for correctness.
Note: A WeakMap only helps if the key is the object you want to release. A WeakMap whose values reference their own keys still works, but storing the key strongly anywhere else, such as in an array, keeps the entry alive.
33. What are Proxy and Reflect in JavaScript, and what are some practical uses for them?
A Proxy wraps a target object and lets you intercept fundamental operations such as reading, writing, deleting, checking with in, enumerating keys and calling functions. Each interception is a trap defined on a handler object.
Reflect is a namespace of functions that perform those same default operations: Reflect.get, Reflect.set, Reflect.has, Reflect.ownKeys and so on. Inside a trap, calling the matching Reflect method is the correct way to fall back to normal behaviour, and it forwards the receiver so getters and inheritance still work.
function validated(target) {
return new Proxy(target, {
set(obj, key, value, receiver) {
if (key === 'age' && !Number.isInteger(value)) {
throw new TypeError('age must be an integer');
}
return Reflect.set(obj, key, value, receiver);
},
get(obj, key, receiver) {
if (!(key in obj)) console.warn(`Unknown property: ${String(key)}`);
return Reflect.get(obj, key, receiver);
}
});
}
const user = validated({ name: 'Neha' });
user.age = 30; // ok
user.age = '30'; // TypeErrorPractical uses:
- Reactivity. Vue 3 and MobX use proxies to detect reads and writes so they can re-render automatically.
- Validation and defensive objects, like the example above, or throwing on typos in config keys.
- Immutable-update helpers. Immer records changes made to a proxy draft and produces a new object.
- Logging, API clients and mocks that generate methods on demand.
Limitations: proxies add overhead on every intercepted operation, so avoid them on hot paths. Built-ins with internal slots, such as Map or Date, do not work transparently through a proxy because their methods check the real object. And a proxy is a different identity from its target, so proxy === target is false.
Note: Proxy.revocable() creates a proxy you can switch off later, which is useful for handing temporary access to an object to untrusted code.
34. What are the most common causes of memory leaks in JavaScript applications, and how do you find them?
JavaScript engines use mark-and-sweep garbage collection: anything reachable from the roots (globals, the current call stack, active closures) is kept, and everything else is freed. A leak is therefore memory that stays reachable by accident, even though the program no longer needs it.
Common causes:
- Forgotten timers and listeners. A
setIntervalor an event listener onwindowkeeps its callback, and everything the callback closes over, alive until removed. - Unbounded caches. A module-level Map or object that only ever grows. Use an LRU limit, a TTL, or a WeakMap keyed by the owning object.
- Detached DOM nodes. An element removed from the page but still referenced from a JavaScript variable or array cannot be collected, together with its whole subtree.
- Closures capturing large data. A small callback that happens to capture a big array keeps the array alive for as long as the callback lives.
- Accidental globals in sloppy mode, created by assigning to an undeclared variable.
- Subscriptions without teardown, such as observables, WebSockets or store subscriptions in components that unmount.
How to find them:
- Reproduce with a repeatable action, for example opening and closing a modal 20 times.
- In Chrome DevTools, take a heap snapshot before and after, and use the Comparison view to see which object types grew.
- Use the Allocation instrumentation timeline to see what is allocated and never freed.
- Follow the retainers panel to find the reference path keeping the object alive, then remove that reference.
// cleanup pattern that prevents a listener leak
const controller = new AbortController();
window.addEventListener('resize', onResize, { signal: controller.signal });
// on teardown:
controller.abort(); // removes every listener registered with this signalNote: Rising memory is not always a leak. The garbage collector runs lazily, so look for memory that keeps growing across repeated cycles after forcing collection, rather than a single high reading.
35. What is a Symbol in JavaScript, and what are well-known symbols used for?
A Symbol is a primitive type whose values are guaranteed to be unique. Symbol('id') === Symbol('id') is false; the description is only a label for debugging.
Why they exist: symbols make property keys that cannot clash with any string key, which lets libraries and the language itself add behaviour to objects safely.
const meta = Symbol('meta');
const user = { name: 'Kiran', [meta]: { createdBy: 'import' } };
Object.keys(user); // ['name']: symbol keys are skipped
JSON.stringify(user); // '{"name":"Kiran"}'
Object.getOwnPropertySymbols(user); // [Symbol(meta)]
// global registry: same key returns the same symbol
Symbol.for('app.id') === Symbol.for('app.id'); // trueProperties of symbol keys: they are ignored by for...in, Object.keys and JSON.stringify, but visible through Object.getOwnPropertySymbols and Reflect.ownKeys. So they are hidden, not private. For real privacy, use #private class fields.
Well-known symbols are built-in symbols that let your objects hook into language behaviour:
Symbol.iteratormakes an object work withfor...ofand spread.Symbol.asyncIteratormakes it work withfor await...of.Symbol.toPrimitivecontrols conversion to a number or string.Symbol.toStringTagchanges the output ofObject.prototype.toString, for example[object Money].Symbol.hasInstancecustomisesinstanceof.
You cannot create a symbol with new Symbol(), and symbols do not convert implicitly to strings: 'x' + sym throws a TypeError, while String(sym) or sym.description works.
Note: Symbol.for() uses a registry shared across realms such as iframes, so it is the right choice when separate bundles must agree on the same key.
36. What are property descriptors in JavaScript, and how do getters, setters and Object.defineProperty work?
Every object property has a descriptor that controls its behaviour. You can read it with Object.getOwnPropertyDescriptor and set it with Object.defineProperty.
- Data descriptors have
valueandwritable. - Accessor descriptors have
getand/orsetfunctions instead of a value. - Both kinds have
enumerable(shows up infor...inandObject.keys) andconfigurable(can be deleted or redefined).
Properties created by normal assignment have all flags set to true. Properties created with defineProperty default every flag to false, which surprises many people.
const product = { _price: 100 };
Object.defineProperty(product, 'id', {
value: 'SKU-1', writable: false, enumerable: true
});
Object.defineProperty(product, 'price', {
get() { return this._price; },
set(v) {
if (v < 0) throw new RangeError('price cannot be negative');
this._price = v;
},
enumerable: true
});
product.price = 250; // runs the setter
product.id = 'X'; // ignored, or TypeError in strict modeGetters and setters can also be written directly in object literals and classes with get price() {} and set price(v) {}.
Where this matters:
- Computed or validated properties that look like plain fields to callers.
- Making constants or library internals read-only and non-enumerable.
- Understanding why built-in methods like
Array.prototype.mapdo not appear infor...in: they are non-enumerable. - Vue 2's reactivity system, which used
definePropertygetters and setters before Vue 3 moved to Proxy.
Note: Once a property is defined with configurable set to false, you cannot delete it or change it back to configurable. The only changes still allowed are updating the value while it is writable, and turning writable from true to false.
37. What are currying and partial application in JavaScript, and how would you implement a generic curry function?
Both techniques turn a function that takes several arguments into functions that take fewer, but they are not the same thing.
- Partial application fixes some arguments now and returns a function that takes the rest.
fn.bind(null, a)is built-in partial application. - Currying transforms
f(a, b, c)intof(a)(b)(c), one argument per call. Practical curry helpers usually also accept several arguments at once.
function curry(fn) {
return function curried(...args) {
if (args.length >= fn.length) {
return fn.apply(this, args);
}
return (...more) => curried.apply(this, [...args, ...more]);
};
}
const addTax = (rate, discount, price) => price * (1 - discount) * (1 + rate);
const gst = curry(addTax)(0.18);
const gstSale = gst(0.1);
gstSale(1000); // 1062
curry(addTax)(0.18, 0.1, 1000); // also worksThe implementation relies on fn.length, the number of declared parameters, and on a closure that accumulates arguments until there are enough. It will not work for functions with default parameters or rest parameters, because those are not counted in length.
Why interviewers ask: it tests closures, rest and spread syntax, and this forwarding in one small function.
Where it is useful:
- Creating specialised helpers from general ones, like a
logError = log('error'). - Building configurable callbacks for
mapandfilter, such asitems.filter(byCategory('books')). - Point-free function composition in libraries such as Ramda.
Note: In everyday code, a small arrow function that fixes one argument is often clearer than a generic curry helper. Mention that you pick currying when it genuinely makes call sites simpler.
38. How do you add a timeout or cancellation to a fetch call or other async operation using AbortController?
Promises cannot be cancelled on their own, so the platform provides AbortController. You create a controller, pass its signal to any API that supports it, and call abort() when the work is no longer needed. The operation then rejects with an AbortError (or a TimeoutError for timeout signals).
// 1. cancel a stale search when the user types again
let controller;
async function search(term) {
controller?.abort();
controller = new AbortController();
try {
const res = await fetch(`/api/search?q=${term}`, { signal: controller.signal });
return await res.json();
} catch (err) {
if (err.name === 'AbortError') return null; // expected, ignore
throw err;
}
}
// 2. a simple timeout
const res = await fetch('/api/report', { signal: AbortSignal.timeout(5000) });
// 3. combine user cancel and timeout
const signal = AbortSignal.any([controller.signal, AbortSignal.timeout(5000)]);Making your own functions abortable: accept a signal option, check signal.aborted or call signal.throwIfAborted() between steps, and listen for the abort event to stop timers or close resources.
function wait(ms, { signal } = {}) {
return new Promise((resolve, reject) => {
signal?.throwIfAborted();
const id = setTimeout(resolve, ms);
signal?.addEventListener('abort', () => {
clearTimeout(id);
reject(signal.reason);
}, { once: true });
});
}The older Promise.race with a timer rejects in time but leaves the original request running. AbortController actually stops the network request and frees resources. The same signal also works with addEventListener, Node.js streams and many libraries.
Note: A controller can only abort once. Create a new AbortController for each operation rather than reusing an already-aborted one.
39. How do you create custom error types in JavaScript, and what is the cause option on Error used for?
Custom errors let calling code tell failures apart reliably instead of parsing message strings. The standard way is to extend Error.
class ValidationError extends Error {
constructor(message, { field, cause } = {}) {
super(message, { cause });
this.name = 'ValidationError';
this.field = field;
}
}
class NotFoundError extends Error {
constructor(resource, id) {
super(`${resource} ${id} not found`);
this.name = 'NotFoundError';
this.status = 404;
}
}
try {
await saveUser(input);
} catch (err) {
if (err instanceof ValidationError) showFieldError(err.field, err.message);
else throw err; // rethrow what you cannot handle
}Points to get right:
- Call
super(message)so the message and stack trace are set up correctly. - Set
name, which appears in stack traces and logs. - Add structured fields such as
status,codeorfieldfor callers and logging. - Catch narrowly and rethrow anything you do not expect. Swallowing errors is how bugs hide.
The cause option (ES2022) chains errors. When you catch a low-level error and throw a higher-level one, pass the original as cause so the root reason is not lost:
try {
config = JSON.parse(text);
} catch (err) {
throw new Error('Could not load settings file', { cause: err });
}Modern consoles and Node.js print the cause chain, and logging tools can walk err.cause.
Other built-ins worth knowing: AggregateError, thrown by Promise.any when every promise rejects, holds all failures in an errors array. And always throw Error objects, never strings, so you get a stack trace.
Note: When code crosses realms, such as iframes or workers, instanceof checks can fail. Checking err.name or a custom code property is more robust in those cases.
40. Which JavaScript array methods mutate the original array and which return a new one, and what did ES2023 add?
Mixing up mutating and non-mutating methods is a common source of bugs, especially with React or Redux state where mutation breaks change detection.
| Mutate the array | Return a new value |
|---|---|
push, pop, shift, unshift | map, filter, slice, concat |
splice, sort, reverse | flat, flatMap, reduce |
fill, copyWithin | toSorted, toReversed, toSpliced, with |
ES2023 “change array by copy” methods are non-mutating twins of the old mutating ones:
const scores = [40, 100, 9];
const sorted = scores.toSorted((a, b) => a - b); // [9, 40, 100]
const reversed = scores.toReversed(); // [9, 100, 40]
const updated = scores.with(1, 75); // [40, 75, 9]
const removed = scores.toSpliced(0, 1); // [100, 9]
scores; // unchanged: [40, 100, 9]
scores.findLast(n => n > 30); // 100 (also ES2023)
scores.at(-1); // 9 (ES2022)Gotchas to mention:
- Default sort is by string.
[10, 9, 1].sort()gives[1, 10, 9]. Always pass a comparator for numbers. Sort has been guaranteed stable since ES2019. sortandreversereturn the same array they mutated, soconst b = a.sort()does not copy;aandbare the same object.- Copies are shallow.
toSortedorslicegive a new array, but objects inside it are shared. forEachreturnsundefinedand cannot be stopped early; usefor...of,someorfindwhen you need to break.
Before ES2023, the usual pattern was to copy first: [...arr].sort(). The new methods say the same thing more clearly and also work on typed arrays.
Note: Object.groupBy (ES2024) is another useful non-mutating helper. It groups an array into an object by a key function, replacing a common reduce pattern.
41. Which ES2020 and later JavaScript features do you use most, and what problems do they solve?
A good answer picks features you actually use and says what they replace, rather than reciting a list. These are the ones most worth knowing.
ES2020
- Optional chaining
?.stops atnullorundefinedinstead of throwing:user?.address?.city. - Nullish coalescing
??gives a default only fornullorundefined, so0and''are kept. - BigInt for integers beyond
Number.MAX_SAFE_INTEGER, Promise.allSettled, globalThis, and dynamicimport().
ES2021
- Logical assignment such as
opts.retries ??= 3anda &&= b. replaceAll, Promise.any, numeric separators (1_00_000), and WeakRef.
ES2022
- Top-level
awaitin modules, class fields and #private members, static blocks. .at(-1)for the last element,Object.hasOwnas a safe replacement forhasOwnProperty, and Errorcause.
ES2023 onwards
toSorted,toReversed,withandfindLastfor non-mutating array work.- ES2024:
Object.groupByandPromise.withResolvers. - ES2025: Set methods like
unionandintersection, iterator helpers such as.map()and.take()on iterators, andPromise.try.
const port = config.server?.port ?? 3000; // 0 would be kept
const last = orders.at(-1);
const byCity = Object.groupBy(users, u => u.city);
const { promise, resolve } = Promise.withResolvers();Mention how you use them safely: check your target browsers or Node.js version, and let a build tool such as Babel, TypeScript or esbuild transpile syntax, while polyfilling missing built-in methods where needed.
Note: A classic follow-up: explain why value ?? fallback is safer than using the OR operator for defaults. OR treats 0, empty string and false as missing, which silently discards valid values.
42. Why does 0.1 + 0.2 not equal 0.3 in JavaScript, and how do you handle money and large integers safely?
All JavaScript numbers are 64-bit IEEE 754 floating point. Many decimal fractions, including 0.1 and 0.2, cannot be represented exactly in binary, so small rounding errors appear:
0.1 + 0.2; // 0.30000000000000004
0.1 + 0.2 === 0.3; // false
(1.005).toFixed(2); // '1.00', not '1.01'
// compare with a tolerance
Math.abs(0.1 + 0.2 - 0.3) < Number.EPSILON; // trueHandling money:
- Store amounts as integers in the smallest unit, such as paise instead of rupees. Rs 499.50 becomes
49950. Integer arithmetic is exact within the safe range. - Round only at the edges, when displaying or when a rule requires it, and use a decimal library (such as decimal.js or big.js) for percentages, tax and currency conversion.
- Format with
Intl.NumberFormat, which handles Indian digit grouping:new Intl.NumberFormat('en-IN', { style: 'currency', currency: 'INR' }).format(123456.5)gives “₹1,23,456.50”.
Large integers: numbers are only exact up to Number.MAX_SAFE_INTEGER (2 to the power 53, minus 1, which is 9007199254740991). Beyond that, 9007199254740993 silently becomes 9007199254740992. Use BigInt for bigger values:
const big = 9007199254740993n;
big + 2n; // 9007199254740995n
Number.isSafeInteger(9007199254740992); // false
// 1n + 1 throws TypeError: cannot mix BigInt and NumberBigInt has trade-offs: you cannot mix it with Number without explicit conversion, Math functions do not accept it, division truncates, and JSON.stringify throws on it. A common real-world case is 64-bit IDs from a database or an external API, which should be sent as strings to avoid precision loss.
Note: NaN is the only value not equal to itself, and 0 and -0 compare equal with ===. Use Number.isNaN() and Object.is() when those cases matter.
43. What are tagged template literals, and what are they used for in real JavaScript code?
A tagged template is a template literal preceded by a function name. Instead of producing a string directly, JavaScript calls the function with the literal's pieces:
- The first argument is an array of the static string parts. It also has a
rawproperty holding the strings with escape sequences left unprocessed. - The remaining arguments are the interpolated values, unconverted, so the tag can inspect their types.
The function can return anything: a string, an object, or even a query descriptor.
const ENTITIES = { '&': '&', '<': '<', '>': '>', '"': '"' };
const escapeHtml = s => String(s).replace(/[&<>"]/g, ch => ENTITIES[ch]);
function safeHtml(strings, ...values) {
return strings.reduce((out, str, i) =>
out + str + (i < values.length ? escapeHtml(values[i]) : ''), '');
}
const name = '<img src=x onerror=alert(1)>';
safeHtml`<p>Hello ${name}</p>`;
// '<p>Hello <img src=x onerror=alert(1)></p>'Real uses:
- Safe SQL. Libraries such as
postgresand Prisma's$queryRawturnsql`SELECT * FROM users WHERE id = ${id}`into a parameterised query, so values are never concatenated into the SQL text. - CSS-in-JS such as styled-components, and GraphQL documents with
gql. - HTML templating in lit-html, which also uses the fact that the strings array is the same object on every call from one call site, so it can cache parsed templates.
String.raw, a built-in tag that keeps backslashes as typed, useful for regex sources and Windows paths.- Internationalisation helpers that look up translations by the static parts.
Note: The main security value of tags is structural: static parts come from the developer and dynamic parts from data, so the tag can treat them differently. That is why they suit escaping and parameterisation.
44. What are the common gotchas with destructuring, default parameters and rest or spread syntax in JavaScript?
Destructuring and defaults make code shorter, but a few rules catch people out.
- Defaults apply only to
undefined. Anullvalue is kept, so an API returningnullbypasses your default. - Destructuring
nullorundefinedthrows. Give the whole parameter a default when it is optional. - Nested destructuring needs its own default, otherwise a missing parent object throws.
- Default values are evaluated at call time, every call, and may refer to earlier parameters. So
function f(list = [])gets a fresh array each time, unlike Python's shared default.
function connect({ host = 'localhost', port = 5432, tls: { ca } = {} } = {}) {
return { host, port, ca };
}
connect(); // works because of the outer = {}
connect({ port: null }); // port is null, not 5432
const { id: userId, ...rest } = user; // rename, and collect the rest
[a, b] = [b, a]; // swap without a temp variableRest and spread details:
- Spread copies are shallow.
{ ...obj }shares nested objects with the original. - Object spread copies only own enumerable properties, so prototype methods and getters defined on a class are not carried over, and getters are evaluated into plain values.
- Later properties win in
{ ...defaults, ...options }, which is exactly how you merge options. But an explicitundefinedinoptionsstill overwrites the default. - Rest must be last in both parameter lists and destructuring patterns.
- Parameters with defaults and rest parameters are not counted in
fn.length, which affects helpers such as curry.
Another subtle one: a statement that starts with { is read as a block, so assigning with destructuring to existing variables needs parentheses: ({ a, b } = obj);.
Note: Array destructuring works on any iterable, not just arrays, so it can pull the first values out of a Set, a Map entry or a generator.
45. How would you implement memoization in JavaScript, and what are its limitations?
Memoization caches the result of a function for each set of inputs, so repeated calls with the same arguments return instantly. It only works correctly for pure functions, whose output depends only on their inputs.
function memoize(fn, { key = (...args) => JSON.stringify(args), max = 500 } = {}) {
const cache = new Map();
return function (...args) {
const k = key(...args);
if (cache.has(k)) {
const v = cache.get(k);
cache.delete(k); cache.set(k, v); // mark as recently used
return v;
}
const result = fn.apply(this, args);
cache.set(k, result);
if (cache.size > max) cache.delete(cache.keys().next().value); // evict oldest
return result;
};
}
const slowSquare = n => { /* expensive work */ return n * n; };
const fastSquare = memoize(slowSquare);Because a Map remembers insertion order, deleting and re-inserting on each hit turns it into a simple LRU cache.
Limitations and design decisions:
- Cache keys.
JSON.stringifyfails on functions, circular objects and BigInt, and treats objects with different key order as different. For a single object argument, aWeakMapkeyed by the object is cleaner and does not leak. - Memory growth. An unbounded cache is a memory leak. Cap its size or add a time to live.
- Stale data. Anything that reads external state, such as time, the network or a database, can return outdated results.
- Async functions. Cache the promise, not the resolved value, so concurrent callers share one request, and remove the entry if the promise rejects so failures are not cached forever.
- Cost versus benefit. Hashing arguments costs time too. Memoize only when the function is genuinely expensive and called repeatedly with the same inputs.
A textbook demonstration is recursive Fibonacci: memoizing the recursive calls turns exponential time into linear time.
Note: React's useMemo and React.memo are memoization with a cache size of one, keyed by dependencies or props. Mentioning this link shows you understand the underlying idea, not just the hook.
46. What is the difference between for...in, Object.keys, Object.getOwnPropertyNames and Reflect.ownKeys, and in what order are keys returned?
These all list property keys, but they differ in whether they include inherited, non-enumerable and symbol keys.
| Method | Inherited | Non-enumerable | Symbols |
|---|---|---|---|
for...in | Yes (enumerable ones) | No | No |
Object.keys / values / entries | No | No | No |
Object.getOwnPropertyNames | No | Yes | No |
Object.getOwnPropertySymbols | No | Yes | Only symbols |
Reflect.ownKeys | No | Yes | Yes |
Key order is defined by the spec for own properties:
- Integer-like keys, such as
'2'or'10', in ascending numeric order. - Other string keys in insertion order.
- Symbol keys in insertion order (only for methods that return symbols).
const obj = { b: 1, 10: 'x', a: 2, 2: 'y' };
Object.keys(obj); // ['2', '10', 'b', 'a']
const child = Object.create({ inherited: true });
child.own = 1;
for (const k in child) console.log(k); // 'own', 'inherited'
Object.keys(child); // ['own']
Object.hasOwn(child, 'inherited'); // false
'inherited' in child; // truePractical advice:
- Avoid
for...infor arrays. It iterates string indexes, includes any enumerable properties added toArray.prototype, and is slower. Usefor...ofor array methods. - If you do use
for...inon objects, guard withObject.hasOwn(obj, key). - Use
into check the whole prototype chain andObject.hasOwnfor own properties only. - If you need insertion order for numeric-looking keys, such as IDs, use a Map, which never reorders.
Note: JSON.stringify follows the same order as Object.keys, so an object with numeric-looking keys may serialise in a different order than it was written. Do not rely on key order when comparing JSON strings.
47. In what order do logs print when an async function awaits, compared with synchronous code, promise callbacks and setTimeout?
The key idea is that an async function runs synchronously until its first await. At that point it returns a pending promise to its caller, and the rest of the function is scheduled as a microtask once the awaited value settles.
async function a() {
console.log('a1');
await b();
console.log('a2');
}
async function b() { console.log('b'); }
console.log('start');
setTimeout(() => console.log('timeout'), 0);
a();
Promise.resolve().then(() => console.log('then'));
console.log('end');
// start, a1, b, end, a2, then, timeoutWalking through it:
startprints. ThesetTimeoutcallback goes to the macrotask (timer) queue.a()starts synchronously and printsa1, then callsb(), which printsband returns an already-resolved promise.awaitpausesaand queues its continuation as a microtask.- The
.thencallback is queued as a microtask after it. endprints and the synchronous script finishes.- The microtask queue drains in order:
a2, thenthen. - Only then does the event loop take the next macrotask:
timeout.
Rules to remember:
- Code before the first
awaitis synchronous, so an async function that never awaits runs completely before the caller continues. - Everything after an
awaitruns later, even when awaiting a non-promise value such asawait 5. - All microtasks run before the next timer or I/O callback, so a long chain of promise work can delay timers.
- Errors thrown after an
awaitbecome rejections of the returned promise, not synchronous exceptions for the caller.
Note: Awaiting a thenable, or returning a promise from an async function, adds extra microtask ticks. Interview puzzles sometimes rely on this, but real code should never depend on exact tick counts; use explicit await or Promise.all to express ordering.
48. How do you correctly add and remove DOM event listeners, and what do the once, passive, capture and signal options do?
addEventListener(type, listener, options) takes an options object that controls how the listener behaves:
once: trueremoves the listener automatically after it runs the first time.capture: trueruns the listener during the capture phase, on the way down to the target, instead of the bubbling phase.passive: truepromises that the listener will not callpreventDefault(). The browser can then scroll immediately without waiting for your code, which is why it matters fortouchstartandwheel.signaltakes anAbortSignal; callingabort()on its controller removes every listener registered with it.
Removing listeners correctly is where bugs hide. removeEventListener only works if you pass the same function reference and the same capture value that were used to add it.
// does NOT remove: each arrow function is a new reference
btn.addEventListener('click', () => save());
btn.removeEventListener('click', () => save());
// does NOT remove: bind returns a new function every time
btn.addEventListener('click', this.save.bind(this));
// correct: keep the reference
const onClick = () => save();
btn.addEventListener('click', onClick);
btn.removeEventListener('click', onClick);
// cleanest: one controller for a component's listeners
const ac = new AbortController();
window.addEventListener('resize', onResize, { signal: ac.signal });
document.addEventListener('keydown', onKey, { signal: ac.signal });
ac.abort(); // tear down bothRelated points worth knowing:
stopPropagation()stops the event reaching other elements;stopImmediatePropagation()also stops other listeners on the same element.- Adding the identical listener twice with the same options registers it only once.
- You can dispatch your own events with
new CustomEvent('cart:updated', { detail: { count: 3 } })andel.dispatchEvent(...), which is handy for decoupled widgets.
Note: Inside a listener defined with a regular function, this is the element the listener is attached to, the same as event.currentTarget. With an arrow function, use event.currentTarget instead.
49. How do you compare two objects or arrays for equality in JavaScript, and what is the difference between === and Object.is?
For objects, arrays and functions, both == and === compare references, not contents. Two separately created objects with identical properties are never equal:
({ a: 1 }) === ({ a: 1 }); // false: different objects
const x = { a: 1 }; const y = x;
x === y; // true: same referenceObject.is is identical to === except for two edge cases:
Object.is(NaN, NaN)istrue, whileNaN === NaNisfalse.Object.is(0, -0)isfalse, while0 === -0istrue.
React uses Object.is to decide whether state changed, which is why mutating an object and setting it again does not re-render.
Comparing contents needs a deep equality check. A simplified version:
function deepEqual(a, b) {
if (Object.is(a, b)) return true;
if (typeof a !== 'object' || typeof b !== 'object' || !a || !b) return false;
if (Array.isArray(a) !== Array.isArray(b)) return false;
const keysA = Object.keys(a), keysB = Object.keys(b);
if (keysA.length !== keysB.length) return false;
return keysA.every(k => Object.hasOwn(b, k) && deepEqual(a[k], b[k]));
}A production-grade version also handles Dates, RegExps, Maps, Sets, typed arrays, prototypes and circular references, which is why teams usually rely on lodash.isEqual or Node.js's util.isDeepStrictEqual.
Why JSON.stringify(a) === JSON.stringify(b) is a poor shortcut: it depends on key order, drops undefined and functions, turns NaN into null and Dates into strings, and throws on circular structures and BigInt.
Also mention shallow equality, which compares only the top-level properties with Object.is. It is cheap and is what React.memo uses for props.
Note: Deep comparison is expensive on large structures. If you find yourself comparing deeply on every render or request, immutable updates with reference checks are usually the better design.
50. What are pure functions and higher-order functions, and why do they matter in JavaScript code?
A pure function always returns the same output for the same input and has no side effects: it does not modify its arguments, global state, the DOM, storage or the network, and it does not read changing values like Date.now() or Math.random().
// impure: mutates the argument and depends on the clock
function addItem(cart, item) {
cart.items.push({ ...item, addedAt: Date.now() });
return cart;
}
// pure: returns a new cart, time is passed in
function addItemPure(cart, item, now) {
return { ...cart, items: [...cart.items, { ...item, addedAt: now }] };
}Why purity matters:
- Easy to test. No mocks or setup: call it and check the result.
- Predictable and safe to reuse, because it cannot break something elsewhere.
- Cacheable. Only pure functions can be memoized correctly.
- Required by frameworks. Redux reducers and React render logic are expected to be pure; mutation there causes missed updates.
A higher-order function takes a function as an argument, returns a function, or both. JavaScript supports them naturally because functions are first-class values.
- Taking functions:
map,filter,reduce,sortandaddEventListener. - Returning functions:
debounce,memoize,bind, and middleware factories.
const pipe = (...fns) => x => fns.reduce((v, f) => f(v), x);
const withGst = pipe(n => n * 1.18, Math.round);
withGst(1000); // 1180Real applications still need side effects. The practical pattern is “functional core, imperative shell”: keep business rules in pure functions and push I/O, logging and DOM updates to a thin outer layer.
Note: Be honest about trade-offs in an interview. Creating new objects on every change costs allocations, so for very hot loops a local, contained mutation inside an otherwise pure function is perfectly acceptable.