Frontend Engineer Deep Dive


Nice set of deep JavaScript questions — I’ll answer each one thoroughly with code examples, edge-cases and practical tips you can apply right away.


1) Implement your own Promise.allSettled() and handle edge cases

Promise.allSettled(iterable) returns a promise that resolves after all of the input promises settle (either fulfilled or rejected). The result is an array of objects { status: "fulfilled", value } or { status: "rejected", reason } in the same iteration order.

Here's a robust polyfill that handles common edge cases:

  • Accepts non-iterable → throws TypeError.

  • Accepts non-Promise values → treats them as fulfilled.

  • Preserves order of input.

  • Handles thenables (objects with .then) safely (wrap with Promise.resolve).

  • Works with sparse arrays and other iterables.

// Polyfill: myAllSettled function myAllSettled(iterable) { return new Promise((resolve, reject) => { if (iterable == null || typeof iterable[Symbol.iterator] !== 'function') { return reject(new TypeError('Input is not iterable')); } const results = []; let index = 0; let settledCount = 0; let total = 0; // Convert iterable to array of items with index to preserve order const items = Array.from(iterable); total = items.length; if (total === 0) { return resolve([]); // immediate resolve for empty iterable } items.forEach((item, i) => { // Wrap with Promise.resolve to handle non-promises and thenables Promise.resolve(item) .then(value => { results[i] = { status: 'fulfilled', value }; }) .catch(reason => { results[i] = { status: 'rejected', reason }; }) .finally(() => { settledCount += 1; if (settledCount === total) { resolve(results); } }); }); }); }

Usage & examples

const p1 = Promise.resolve(1); const p2 = Promise.reject(new Error('fail')); const p3 = 3; // non-promise myAllSettled([p1, p2, p3]).then(console.log); // -> [{status:'fulfilled', value:1}, {status:'rejected', reason:Error('fail')}, {status:'fulfilled', value:3}]

Edge cases to watch

  • If the input is not iterable (e.g., null), throw TypeError (matches built-in).

  • If a thenable’s .then throws synchronously, Promise.resolve will turn it into a rejection — our wrapper handles it.

  • If you want the native behavior in older engines, ensure finally exists on Promise.prototype or polyfill finally.


2) Optimizing expensive computations using memoization

Approach / principles

  • Memoization caches results of pure functions (same inputs → same output) to avoid recomputation.

  • Key decisions:

    • How to represent keys: primitive arguments are easy; objects require WeakMap or serialization.

    • Cache eviction: unlimited cache may leak memory — use LRU or size limit.

    • Synchronous vs asynchronous functions: memoizing Promises needs care (cache the Promise itself).

    • Side-effect free: ensure the function is pure, or memoization will cause stale results.

Simple sync memoize (primitives)

function memoize(fn) { const cache = new Map(); return function(...args) { const key = JSON.stringify(args); // ok for simple primitives; slow for big objects if (cache.has(key)) return cache.get(key); const result = fn.apply(this, args); cache.set(key, result); return result; }; } // Example: expensive Fibonacci function slowFib(n) { if (n < 2) return n; return slowFib(n - 1) + slowFib(n - 2); } const fastFib = memoize(function fib(n) { if (n < 2) return n; return fib(n - 1) + fib(n - 2); });

Better: Support object args with WeakMap

  • Use a nested WeakMap/Map structure keyed by each argument to avoid serializing objects and to let garbage collection free memory when objects are gone.

function deepArgMemoize(fn) { const root = new Map(); // for primitive keys, use Map; for object keys, use WeakMap function getMapForKey(map, key) { const isObj = key !== null && (typeof key === 'object' || typeof key === 'function'); const store = isObj ? (map._weak || (map._weak = new WeakMap())) : map._prim || (map._prim = new Map()); return store; } return function memoized(...args) { let node = root; for (const arg of args) { const isObj = arg !== null && (typeof arg === 'object' || typeof arg === 'function'); if (isObj) { node._weak = node._weak || new WeakMap(); if (!node._weak.has(arg)) node._weak.set(arg, {}); node = node._weak.get(arg); } else { node._prim = node._prim || new Map(); if (!node._prim.has(arg)) node._prim.set(arg, {}); node = node._prim.get(arg); } } if ('result' in node) return node.result; const result = fn.apply(this, args); node.result = result; return result; }; }

Async memoize (cache in-flight Promises)

If fn returns a Promise, cache the Promise so concurrent callers share the same in-flight work. Also handle rejection — sometimes you want to remove cache entries on rejection.

function memoizeAsync(fn, { cacheFailures = false } = {}) { const cache = new Map(); return function(...args) { const key = JSON.stringify(args); // or use better keying if (cache.has(key)) return cache.get(key); const promise = fn.apply(this, args).then( value => { if (!cacheFailures) cache.set(key, Promise.resolve(value)); // keep resolved return value; }, err => { // remove from cache if we don't want to cache failures if (!cacheFailures) cache.delete(key); throw err; } ); cache.set(key, promise); return promise; }; }

LRU cache example (prevent unbounded memory)

Use an LRU strategy for expensive computations that could blow up memory:

class LRUCache { constructor(limit = 100) { this.limit = limit; this.map = new Map(); } get(key) { if (!this.map.has(key)) return undefined; const val = this.map.get(key); // refresh to the end (most recently used) this.map.delete(key); this.map.set(key, val); return val; } set(key, value) { if (this.map.has(key)) this.map.delete(key); this.map.set(key, value); if (this.map.size > this.limit) { const oldestKey = this.map.keys().next().value; this.map.delete(oldestKey); } } }

Practical tips

  • Use WeakMap for caching results keyed off objects to avoid memory leaks.

  • If function takes large objects, consider hashing specific properties instead of serializing the entire object.

  • Don’t memoize functions that have side effects.

  • For CPU-heavy tasks, consider memoizing partial results (dynamic programming).

  • For UI expensive renders, use techniques like virtualization, requestAnimationFrame, useMemo in React (with careful dependency arrays).


3) How the event loop prioritizes microtasks vs macrotasks in complex async flows

Short summary

  • A macrotask (task) queue contains things like setTimeout, setInterval, setImmediate (Node), I/O callbacks, UI rendering tasks.

  • A microtask queue contains Promise callbacks (.then/.catch/.finally), queueMicrotask, and process.nextTick (special high-priority queue in Node).

  • After the current macrotask finishes, the runtime runs all microtasks (until empty) before picking the next macrotask. That means microtasks always run earlier than the next macrotask.

Browser ordering (typical)

  1. Start macrotask A (e.g., script execution or setTimeout callback).

  2. During it, microtasks are queued (Promise callbacks).

  3. When macrotask A completes, run the microtask queue until empty (new microtasks appended are also run).

  4. Handle rendering/paint if needed.

  5. Pick next macrotask B from task queue.

Node specifics

  • process.nextTick runs before other microtasks — Node treats it as higher priority than Promise microtasks.

  • setImmediate and setTimeout have different phases in Node’s event loop; ordering can be subtle.

Example to illustrate ordering

console.log('script start'); setTimeout(() => console.log('timeout (macrotask)'), 0); Promise.resolve() .then(() => console.log('promise1 (microtask)')) .then(() => { console.log('promise2 (microtask chain)'); setTimeout(() => console.log('timeout inside promise (macrotask)'), 0); }); console.log('script end'); // Expected order: // 1. script start // 2. script end // 3. promise1 (microtask) // 4. promise2 (microtask chain) // 5. timeout (macrotask) // 6. timeout inside promise (macrotask)

Complex cases

  • If a microtask enqueues another microtask, it will run in the same microtask checkpoint (no macrotask in between).

  • If a microtask schedules a macrotask, the macrotask will run only after microtasks are drained and after the next macrotask selection.

  • requestAnimationFrame runs during rendering phase - happens between microtasks and next macrotask with paint? Practical behavior varies across browsers.

Node note

// Node-specific ordering process.nextTick(() => console.log('nextTick')); Promise.resolve().then(() => console.log('promise')); setTimeout(() => console.log('timeout'), 0); // Expected // nextTick // promise // timeout

Why it matters

  • Microtasks allow prioritized work completion (e.g., promise chain resolution) before the UI or timers proceed.

  • If you flood microtasks (e.g., in a loop) you can starve rendering and macrotasks — avoid infinite microtask generation.


4) Build a custom localStorage wrapper with expiry & fallback logic

Requirements:

  • Save keys with TTL (expiry).

  • Safe JSON serialize/deserialize.

  • Fallback to in-memory store if localStorage is unavailable (private mode, quotas).

  • Gracefully handle storage corruption / quota exceeded.

  • Optional cleanup and cross-tab sync using storage event.

Here's a practical implementation:

class StorageWithExpiry { constructor({ prefix = 'app:', fallback = true } = {}) { this.prefix = prefix; this.available = this._testLocalStorage(); this.memoryStore = {}; this.fallback = fallback; } _testLocalStorage() { try { const key = '__test_key__'; window.localStorage.setItem(key, '1'); window.localStorage.removeItem(key); return true; } catch (e) { return false; } } _fullKey(key) { return this.prefix + key; } set(key, value, ttlMs = null) { const payload = { v: value, t: ttlMs ? Date.now() + ttlMs : null, }; const str = JSON.stringify(payload); const fullKey = this._fullKey(key); if (this.available) { try { window.localStorage.setItem(fullKey, str); } catch (err) { // quota exceeded or other storage errors if (this.fallback) { this.memoryStore[fullKey] = str; } else { throw err; } } } else { this.memoryStore[fullKey] = str; } } get(key, { autoRemove = true } = {}) { const fullKey = this._fullKey(key); let raw; if (this.available) { raw = window.localStorage.getItem(fullKey); } if (raw == null && fullKey in this.memoryStore) { raw = this.memoryStore[fullKey]; } if (raw == null) return null; try { const parsed = JSON.parse(raw); if (parsed.t && Date.now() > parsed.t) { if (autoRemove) { this.remove(key); } return null; // expired } return parsed.v; } catch (e) { // corrupted data this.remove(key); return null; } } remove(key) { const fullKey = this._fullKey(key); if (this.available) { try { window.localStorage.removeItem(fullKey); } catch (e) { // ignore } } delete this.memoryStore[fullKey]; } clear() { if (this.available) { try { const keys = []; for (let i = 0; i < localStorage.length; i++) { const k = localStorage.key(i); if (k && k.startsWith(this.prefix)) keys.push(k); } keys.forEach(k => localStorage.removeItem(k)); } catch (e) { // ignore } } this.memoryStore = {}; } // optional cleanup routine to purge expired items (to call occasionally) cleanup() { if (this.available) { for (let i = localStorage.length - 1; i >= 0; i--) { const k = localStorage.key(i); if (!k || !k.startsWith(this.prefix)) continue; const item = localStorage.getItem(k); try { const p = JSON.parse(item); if (p.t && Date.now() > p.t) localStorage.removeItem(k); } catch (e) { localStorage.removeItem(k); // corrupted } } } // cleanup memoryStore Object.keys(this.memoryStore).forEach(k => { try { const p = JSON.parse(this.memoryStore[k]); if (p.t && Date.now() > p.t) delete this.memoryStore[k]; } catch (e) { delete this.memoryStore[k]; } }); } }

Cross-tab sync

  • Use window.addEventListener('storage', ...) to listen for changes from other tabs and update UI.

  • The storage event is only fired in other windows (not the one that wrote).

Quota and corruption handling

  • Catch QuotaExceededError when setItem fails; fallback to in-memory or try evicting expired items.

  • On JSON.parse errors, treat as corrupted → remove and return null.


5) Explain prototype inheritance with a real example (not theory)

Let’s demonstrate with a concrete example: animals, and dogs inheriting from Animal. We'll use constructor functions and also show how prototypes are modified and how instances see changes.

// Constructor function function Animal(name) { this.name = name; this.ancestry = ['animal']; } // Define methods on prototype (shared across instances) Animal.prototype.speak = function() { return `${this.name} makes a noise.`; }; Animal.prototype.addAncestry = function(tag) { this.ancestry.push(tag); }; // Dog "subclass" function Dog(name, breed) { Animal.call(this, name); // inherit instance properties this.breed = breed; } // Set up prototype chain: Dog.prototype -> Animal.prototype Dog.prototype = Object.create(Animal.prototype); Dog.prototype.constructor = Dog; // Override or extend methods on Dog.prototype Dog.prototype.speak = function() { // call Animal's speak as part of it return `${this.name} barks. (${Animal.prototype.speak.call(this)})`; }; // Usage const d1 = new Dog('Buddy', 'Beagle'); const d2 = new Dog('Rex', 'German Shepherd'); console.log(d1.speak()); // Buddy barks. (Buddy makes a noise.) d1.addAncestry('mammal'); // uses prototype method that affects d1's own ancestry array console.log(d1.ancestry); // ['animal', 'mammal'] console.log(d2.ancestry); // ['animal'] -> each instance has its own ancestry array because it's set in constructor // Demonstrate prototype modification after instances created Animal.prototype.describe = function() { return `${this.name} (ancestry: ${this.ancestry.join(', ')})`; }; console.log(d1.describe()); // method added to prototype is available to existing instances

Key "real" takeaways from the code

  • Animal.prototype stores methods shared by all Animal (and Dog) instances. Methods are not duplicated per instance.

  • Instance properties defined in the constructor (this.ancestry = [...]) are unique per instance. If you mistakenly put an array on the prototype, it will be shared — leading to bugs.

  • Object.create(Animal.prototype) creates a fresh object whose [[Prototype]] is Animal.prototype. This sets up inheritance without calling the Animal constructor again.

  • Changing the prototype (adding methods) affects all instances immediately.

  • instanceof works because of the prototype chain: d1 instanceof Dog and d1 instanceof Animal will both be true.

Pitfall example: shared mutable prototype

function Bad() {} // dangerous: shared array on prototype Bad.prototype.arr = []; const a = new Bad(); const b = new Bad(); a.arr.push(1); console.log(b.arr); // [1] -> unexpected shared state

Always put instance-specific state inside constructor, not on prototype.


6) Detecting & fixing memory leaks in a large JavaScript codebase

Common sources of leaks

  1. Global variables — accidental globals (e.g., foo = 1) or long-lived objects on window.

  2. Forgotten timers and intervalssetInterval, setTimeout not cleared.

  3. Event listeners not removed — on DOM nodes or global event emitters (Node EventEmitter).

  4. Closures holding large objects — functions capturing large scopes and are kept alive.

  5. Detached DOM nodes — DOM nodes removed from document but still referenced by JS (e.g., via closure/event listeners).

  6. Unbounded caches — Maps/arrays/generic caches that grow without eviction.

  7. Third-party library leaks — libraries creating hidden references.

Detection tools & techniques

  • Chrome DevTools: Memory tab

    • Heap snapshot (compare snapshots before/after action).

    • Allocation instrumentation / timeline: watch memory usage over time, see retained objects.

    • Record allocation stack traces to figure out where objects are created.

    • "Detached DOM tree" filter shows nodes not in document but kept alive.

  • Performance Timeline: watch for memory growth during interactions.

  • Node: --inspect and Chrome DevTools for server processes; heapdump / v8-profiler for heap snapshots.

  • Process metrics: process.memoryUsage() (Node) to monitor RSS/heap over time.

  • Automated tests: simulate long flows and record memory.

Workflow to find a leak

  1. Reproduce scenario that grows memory (e.g., opening/closing view repeatedly).

  2. Take baseline heap snapshot.

  3. Perform action repeatedly (e.g., open/close modal 100x).

  4. Take another snapshot, compare retained objects and allocation stacks.

  5. Identify objects that keep growing and their retaining paths.

  6. Inspect code at the retainers to find listeners/timers/clojures/etc.

Examples & fixes

  1. Forgotten event listeners

Leaky code:

function createWidget(node) { function onClick() { /* uses node extensively */ } node.addEventListener('click', onClick); // node removed from DOM later, but listener still references node via closure }

Fix:

function createWidget(node) { function onClick() { /* */ } node.addEventListener('click', onClick); return function destroy() { node.removeEventListener('click', onClick); }; } // call destroy() when you remove node
  1. Interval left running

Leaky code:

function startTicker() { setInterval(() => { // uses resources }, 1000); // never cleared }

Fix:

let timer; function startTicker() { timer = setInterval(...); } function stopTicker() { clearInterval(timer); }
  1. Detached DOM nodes

Symptoms: heap snapshot shows lots of nodes under Detached DOM tree.

Fix: ensure references to nodes are removed, listeners removed, and caches cleared. Use element.remove() and break closures that reference the element.

  1. Unbounded caches

Leaky code:

const cache = new Map(); function expensive(op, input) { const key = JSON.stringify(input); if (cache.has(key)) return cache.get(key); const result = heavyCompute(input); cache.set(key, result); // never evicted return result; }

Fix: use WeakMap for object keys, or LRU with size limit, or TTL eviction.

  1. Large closure capturing big data

Leaky code:

function init() { const bigArray = new Array(1e6).fill(0); element.onclick = function() { // closure holds bigArray until element removed and listener removed console.log(bigArray[0]); }; }

Fix: move bigArray out or ensure you clear element.onclick = null when done; avoid capturing entire context if only small part needed.

Continuous monitoring

  • Add monitoring/alerts when memory grows beyond thresholds (Node: heap growth rate; Browser: use telemetry).

  • Run periodic memory tests in CI for critical pages/components.

Pro tips

  • Prefer WeakMap/WeakSet for caches keyed by objects so GC can release entries.

  • Avoid creating functions inside render loops (component frameworks) without need — those create many closures.

  • For single page apps, carefully detach long-lived resources when changing routes.

  • Use requestAnimationFrame for recurring UI work to avoid unnecessary timer scheduling.


Final practical checklist you can apply now

  • For async flows: remember microtasks (Promises) run before the next macrotask — that explains often-surprising ordering.

  • When memoizing: choose key strategy carefully (primitive JSON key vs WeakMap) and apply a bounded cache (LRU) in production.

  • For allSettled: always wrap items with Promise.resolve and preserve iteration order.

  • For client-side storage: always guard localStorage access with try/catch and have an in-memory fallback.

  • For leaks: instrument with heap snapshots, find retaining paths, then remove listeners/clear timers/dereference caches.


JavaScript Execution Context – 15 Questions & Answers


1. What exactly is an Execution Context, and when is it created?

Answer:
An Execution Context is the environment where JavaScript code is evaluated and executed.
It is created whenever:

  • The JavaScript engine starts running your file (Global Execution Context)

  • A function is invoked (Function Execution Context)

  • You run code inside eval() (Eval Execution Context)

Think of it like a “box” that contains everything needed to run the code inside it.


2. How many Execution Contexts can exist in memory at the same time?

Answer:
Many Execution Contexts can exist simultaneously in memory, but only one can run at a time because JavaScript is single-threaded.

Example: During nested function calls:

global → a() → b() → c()

All contexts are stored in the Call Stack, but only the top one executes.


3. What are the three types of Execution Contexts in JavaScript?

Answer:

  1. Global Execution Context (GEC)
    Created when the JS file starts.

  2. Function Execution Context (FEC)
    Created for each function call.

  3. Eval Execution Context
    Created when code runs inside eval().


4. What happens internally during the Creation Phase of an Execution Context?

Answer:
The Creation Phase does 4 key things:

  1. Create the Lexical Environment

  2. Create the Variable Environment

  3. Bind this value

  4. Hoisting happens:

    • var → hoisted and initialized as undefined

    • let/const → hoisted but uninitialized (TDZ)

    • Function declarations → hoisted with full function definition


5. What is the Call Stack, and how does it manage nested function calls?

Answer:
Call Stack is a LIFO (Last In First Out) stack that stores Execution Contexts.

Example:

function a() { b(); } function b() { console.log("Hi"); } a();

Call Stack flow:

  1. Push GEC

  2. Push a() FEC

  3. Push b() FEC

  4. Pop b()

  5. Pop a()

  6. End


6. What exactly lives inside a Lexical Environment?

Answer:
A Lexical Environment contains:

1. Environment Record

  • Variables (let, const, var)

  • Function declarations

  • Parameters

  • Inner functions

2. Reference to Outer Lexical Environment

This enables scope chaining.


7. How does a Lexical Environment form the Scope Chain?

Answer:
Each Lexical Environment has a pointer to its parent environment.

Example:

let x = 10; function a() { let y = 20; function b() { let z = 30; console.log(x, y, z); } b(); } a();

Scope Chain inside b():

ba → global

JavaScript searches variables up the chain.


8. What happens when JavaScript cannot find a variable in the current scope?

Answer:
It moves up the Scope Chain to the parent Lexical Environment.

If it reaches the Global Environment and still doesn't find it →
ReferenceError: variable is not defined


9. What is the difference between Scope and Execution Context?

FeatureScopeExecution Context
DefinitionRules that define where variables are accessibleEnvironment where code is executed
CreatedAt code write timeAt runtime
PurposeControls visibility of variablesStores variables, this, and manages execution
ExamplesBlock scope, function scopeGlobal EC, Function EC

10. How is the value of this decided inside a Function Execution Context?

Answer:
this depends on HOW the function is called.

Rules:

  • Simple function call → this = undefined (or window in non-strict mode)

  • Method call → this = the object

  • Constructor → this = new instance

  • Arrow function → no own this (it inherits from parent scope)

  • call/apply/bind → manually set this

Example:

const obj = { name: "A", say() { console.log(this.name); } }; obj.say(); // "A"

11. Why do let and const throw a ReferenceError before initialization?

Answer:
Because they are in the Temporal Dead Zone (TDZ).

They are hoisted, but not initialized, so accessing them before the initialization line results in:

ReferenceError: Cannot access 'x' before initialization

12. What exactly is the Temporal Dead Zone, and when does it exist?

Answer:
TDZ is the period between:

  • variable hoisting

  • actual variable initialization

Example:

console.log(a); // ❌ ReferenceError (TDZ) let a = 10;

13. Why is Execution Context the foundation of Closures?

Answer:
Closures work because:

Even after a function has returned, its Lexical Environment is preserved in memory.

Example:

function outer() { let x = 10; return function inner() { console.log(x); }; } const fn = outer(); fn(); // 10 ← closure

Even though outer() finished execution, its Execution Context stays alive due to closure.


14. What happens to a function’s Execution Context after it returns another function?

Answer:
Normally a function’s Execution Context is removed from the call stack.
But if an inner function uses its variables, that Lexical Environment is not garbage collected.

It is kept alive inside the closure.

So:

Execution Context removed Lexical Environment preserved

15. Why does understanding Execution Context directly improve debugging in React, Vue, and Angular?

Answer:
Because these frameworks heavily rely on:

  • closures (state, hooks)

  • scope behavior

  • asynchronous event loops

  • lexical scoping

  • call stack trace

Examples:

React

  • stale state closure issues

  • useEffect dependency issues

  • state updates batched in microtasks

Vue

  • reactive proxies depend on lexical closures

Angular

  • Zone.js patches event loop → closures store task contexts

Understanding Execution Context lets you debug:

✔ undefined variables
✔ closure bugs
✔ async behavior
✔ stale state
✔ memory leaks



REACT.JS — Actual Technical Round Q&A

Great set. Below are focused, interview-ready answers with practical code examples and trade-offs for each question.


Q1 — How do you split React components for both performance & maintainability?

Answer (short)
Split by responsibility (single responsibility), reusability, and render cost. Use a mix of small presentational components and larger container/feature components. Lazily load rarely-used pieces and avoid rerenders by isolating state.

Practical guidelines

  • Feature / domain folders: group components, hooks, styles by feature (e.g., /orders/, /cart/). Easier to navigate and change.

  • Presentational vs Container: Presentational (UI-only) + Container (data/state) separation keeps pure UI components cheap to test and memoize.

  • Granularity: Break into small components only when it improves readability or avoids expensive re-renders; otherwise keep a small number of components to reduce overhead.

  • Single Responsibility: One component = one reason to change.

  • Avoid prop drilling: prefer context, custom hooks, or composition.

  • Split by render cost: Components that change frequently should be separated from ones that rarely change to reduce props causing re-renders.

  • Code-splitting: React.lazy + Suspense for route-level or heavy UI.

  • Optimize expensive parts: React.memo, useMemo, useCallback only where necessary (measure first).

  • Hook extraction: Move logic to custom hooks to make components declarative and unit-testable.

Example folder structure

/features /checkout CheckoutPage.jsx CheckoutForm.jsx // presentational useCheckout.js // container logic (hook) api.js

Micro-optimizations

  • Memoize child components that receive stable props.

  • Use lists with key stable and minimal props.

  • Use shouldComponentUpdate/PureComponent patterns or React.memo.

  • Keep expensive calculation outside render (useMemo, web worker).


Q2 — What happens internally when React reconciles a component tree?

Answer (short)
React performs a reconciliation (diff) to compute the minimal DOM updates. Modern React uses the Fiber architecture, which breaks work into units, supports priorities, interruption, and incremental rendering.

Key phases

  1. Render (Reconciliation) phase

    • React builds a new fiber tree from JSX — pure, side-effect free.

    • Compares new tree vs current tree using heuristics:

      • If type is same, it updates props/state on the same fiber.

      • If type differs, it marks the old subtree for deletion and creates new fibers.

      • For lists, keys determine identity — keys prevent unnecessary re-creation.

    • Scheduling & priority (lanes) decide if this work can be interrupted.

  2. Commit phase

    • Apply DOM mutations (insert/update/delete) in order.

    • Run lifecycle methods: useLayoutEffect sync after DOM update, then useEffect (async).

    • This phase is not interruptible.

Important internals / optimizations

  • Keys are critical for list stability; wrong keys cause re-create, losing state.

  • Fiber allows preemption — expensive renders can be split and yielded.

  • Bailing out: If shouldComponentUpdate/memo indicates no change, React reuses fiber without traversing children.

  • Hydration: server-rendered markup gets matched to fiber tree.

Example: list diff

  • Old: [A(id=1), B(id=2), C(id=3)]

  • New: [A(id=1), C(id=3), D(id=4)]
    React uses keys to move C and remove B, create D — minimal DOM work.


Q3 — How would you implement a global error boundary system for API & UI failures?

Answer (short)
Use React Error Boundaries for rendering errors + a global error handling layer for API failures (interceptors, context) and a centralized UI to show errors (toasts / modal). Combine with a logging/reporting service.

Key parts

  1. UI Error Boundary: catches render-time errors in descendant tree.

  2. Global API error interceptor: catch HTTP errors and route them to error handling context.

  3. Error Context: central store to surface UI-friendly errors and allow retry.

  4. Logging: send error events to Sentry / LogRocket / custom endpoint.

Implementation sketch

// ErrorBoundary.jsx import React from 'react'; export default class ErrorBoundary extends React.Component { state = { error: null }; static getDerivedStateFromError(error) { return { error }; } componentDidCatch(error, info) { // log to remote sendError({ error, info }); } render() { if (this.state.error) { return this.props.fallback || <div>Something went wrong.</div>; } return this.props.children; } }
// ErrorContext.js import React, { createContext, useReducer, useContext } from 'react'; const ErrorContext = createContext(); export const useError = () => useContext(ErrorContext); export function ErrorProvider({ children }) { const [state, dispatch] = useReducer((s, a) => ({...s, ...a}), { uiError: null }); const showError = (err) => dispatch({ uiError: err }); const clearError = () => dispatch({ uiError: null }); return <ErrorContext.Provider value={{...state, showError, clearError}}>{children}</ErrorContext.Provider> }
// api.js (axios-like) import axios from 'axios'; const api = axios.create(); api.interceptors.response.use( res => res, err => { // normalized error const normalized = { message: err.message, status: err.response?.status }; // broadcast via event bus or ErrorContext (e.g., window.dispatchEvent) window.dispatchEvent(new CustomEvent('apiError', { detail: normalized })); // also log sendError(normalized); return Promise.reject(normalized); } ); export default api;
// App.jsx <ErrorProvider> <ErrorBoundary fallback={<GlobalErrorUI />}> <AppRoutes/> </ErrorBoundary> <ApiErrorListener /> {/* subscribes to apiError events and calls showError */} </ErrorProvider>

UX & robustness

  • Show friendly messages with retry buttons for transient errors.

  • Use error categories (network/timeouts/authorization) to decide fallback UI.

  • Provide global retry and refresh strategies (invalidate cache, re-auth).

  • Don’t use error boundaries to suppress errors silently — always log.


Q4 — How do you handle race conditions in React when multiple API calls fire simultaneously?

Answer (short)
Use cancellation (AbortController) and “stale-while-revalidate” patterns, sequence tokens, or last-wins semantics to ensure only the desired response is used.

Strategies

  1. AbortController (recommended for fetch/axios): cancel previous request in useEffect cleanup.

  2. Sequence tokens / request id: attach incremental id and ignore responses with outdated id.

  3. Deduping in data layer: coalesce multiple identical requests so only one runs.

  4. Optimistic UI with reconciliation: use latest-confirmed response to commit state.

  5. Locking / semaphores: rarely used; if two operations conflict, queue or merge them.

Example: useEffect + AbortController

useEffect(() => { const controller = new AbortController(); setLoading(true); fetch(`/search?q=${q}`, { signal: controller.signal }) .then(r => r.json()) .then(data => { if (!controller.signal.aborted) setResults(data); }) .catch(err => { if (err.name !== 'AbortError') setError(err); }) .finally(() => setLoading(false)); return () => controller.abort(); // cancels previous fetch }, [q]);

Example: sequence token

let lastRequestId = 0; function useSearch(q) { useEffect(() => { const id = ++lastRequestId; fetch(`/api?q=${q}`).then(res => res.json()).then(data => { if (id !== lastRequestId) return; // stale setData(data); }); }, [q]); }

When to prefer what

  • If backend supports cancellation: use AbortController.

  • If network/cancellation not available: use tokens to ignore stale responses.

  • For identical concurrent requests: dedupe at hook or network layer.


Q5 — How would you build a custom hook for data fetching with caching + refetch logic?

Answer (short)
Build a hook similar to SWR / React Query: centralized cache store (Map), dedupe in-flight requests, TTL, stale-while-revalidate, manual refetch, and subscription for updates.

Core features

  • Cache by key

  • Deduplication of in-flight requests

  • TTL & stale-time

  • Background revalidation

  • Manual refetch() and invalidate()

  • Error handling and retries

  • Abort support on unmount

Implementation (concise)

// useFetch.js (simplified) import { useState, useEffect, useRef } from 'react'; const cache = new Map(); // key -> { data, expiry, subscribers: Set, promise } const DEFAULT_TTL = 1000 * 60; // 1 min export function useFetch(key, fetcher, { ttl = DEFAULT_TTL, revalidateOnMount = true } = {}) { const [, rerender] = useState({}); const mountedRef = useRef(true); useEffect(() => () => { mountedRef.current = false }, []); const subscribe = () => rerender({}); useEffect(() => { cache.get(key)?.subscribers?.add(subscribe); return () => cache.get(key)?.subscribers?.delete(subscribe); }, [key]); async function doFetch({ force = false } = {}) { let entry = cache.get(key); const now = Date.now(); if (!entry) { entry = { data: undefined, expiry: 0, subscribers: new Set(), promise: null, error: null }; cache.set(key, entry); } const isStale = now > (entry.expiry || 0); if (entry.promise) return entry.promise; // dedupe in-flight if (!force && entry.data !== undefined && !isStale) return entry.data; entry.promise = (async () => { try { const data = await fetcher(); entry.data = data; entry.expiry = Date.now() + ttl; entry.error = null; return data; } catch (err) { entry.error = err; throw err; } finally { entry.promise = null; entry.subscribers.forEach(s => s()); } })(); return entry.promise; } // initial read from cache const entry = cache.get(key); const data = entry?.data; const error = entry?.error; const isLoading = !!entry?.promise; useEffect(() => { if (revalidateOnMount) { doFetch().catch(() => {}); } }, [key]); return { data, error, isLoading, refetch: () => doFetch({ force: true }), invalidate: () => { cache.delete(key); // notify subscribers cache.get(key)?.subscribers?.forEach(s => s()); } }; }

Usage

function MyComponent({ userId }) { const { data, error, isLoading, refetch } = useFetch( `user:${userId}`, () => fetch(`/api/user/${userId}`).then(r => r.json()), { ttl: 30_000 } ); ... }

Production concerns

  • Add retries/backoff, request dedupe across tabs (BroadcastChannel), stale-while-revalidate semantics, and cache serialization (IndexedDB) for persistence.

  • Add concurrency limits and cancellation.

  • Expose optimistic updates and mutation helpers if writing.


Q6 — What would be your approach to rendering 10k+ DOM nodes efficiently (beyond just virtualization)?

Answer (short)
Virtualization is the primary approach but there are complementary and alternative techniques: DOM simplification, progressive rendering, canvas/WebGL/SVG, server-side rendering + pagination, DOM recycling, batching and idle-time rendering, and offloading work to workers.

Techniques & trade-offs

  1. Virtualization + windowing

    • Only mount nodes visible in viewport. Use variable-height virtualization (cell measurement) if needed.

  2. DOM simplification & lighter markup

    • Reduce node depth and remove unnecessary wrappers.

    • Avoid expensive CSS (box-shadow, complex filters).

    • Use simpler elements (divs vs heavy component tree).

    • Reuse CSS classes instead of inline styles.

  3. DOM recycling (recycler pattern)

    • Reuse existing DOM nodes and update their content instead of creating/destroying nodes (useful for scrolling grids).

  4. Canvas / WebGL / SVG

    • If nodes are mostly visual (lists, charts), render them on a <canvas> or WebGL; far fewer DOM nodes, but lose accessibility and easy semantics.

  5. Progressive / chunked rendering

    • Render items in chunks using requestIdleCallback or setTimeout(...,0) to avoid long frames.

    • Render a skeleton or top N items first, lazy-render rest.

  6. Pagination & server-side aggregation

    • Don’t ask UI to show 10k at once — provide meaningful pagination, filtering, or search.

  7. Offload heavy computation to Web Workers

    • Precompute layout or transform data off-main-thread; main thread only updates DOM with minimal diffs.

  8. Reduce reflows & layout thrashing

    • Batch DOM writes and reads. Use transform for animations.

    • Use CSS contain: layout and will-change to isolate layout.

  9. Use virtualization + placeholder reuse

    • Combine virtualization with DOM recycling to keep the number of mounted elements bounded and reuse them.

  10. Server-side rendering + static snapshots

    • Pre-render initial subset, then hydrate lazily. Avoid hydrating all 10k nodes at once.

Concrete pattern: chunked render example

function ChunkedList({ items, chunkSize = 200 }) { const [visibleCount, setVisibleCount] = useState(chunkSize); useEffect(() => { if (visibleCount >= items.length) return; const id = requestIdleCallback(() => setVisibleCount(c => Math.min(items.length, c + chunkSize))); return () => cancelIdleCallback(id); }, [visibleCount, items.length]); return <>{items.slice(0, visibleCount).map(i => <Item key={i.id} item={i} />)}</>; }

When to use Canvas/WebGL

  • If list items are purely graphical (icons, shapes, not interactive controls), use canvas to draw thousands of elements quickly.

  • Trade-offs: less semantic DOM, accessibility and event handling complexity.

Other optimizations

  • Avoid heavy component trees per list item — flatten structure.

  • Use requestAnimationFrame for animations and passive event listeners for scroll.

  • Use IntersectionObserver for image lazy loading.

  • Monitor paint times and FPS using performance profiles — optimize based on real metrics.


TL;DR / Practical checklist

  • Split components by feature/responsibility and isolate expensive rendering with memoization + lazy-loading.

  • Reconciliation builds new fiber tree and applies minimal DOM changes; keys are crucial.

  • Global error handling = Error Boundaries (UI) + API interceptors + error context + logging + retries.

  • Race conditions solved with AbortController, tokens, and deduping.

  • Custom data hook should include caching, TTL, dedupe, manual refetch and subscription model (like simplified SWR).

  • 10k+ nodes: combine virtualization with DOM simplification, recycling, chunked rendering, canvas/WebGL when appropriate, and server-driven pagination.



Post a Comment

Previous Post Next Post