Frontend Interview Prep — In Depth

Explanations, code, decision rules, caveats & the architecture reasoning behind each.

Why / reasoning Architecture & LLD Caveat / gotcha Say it out loud

⚛️ React Deep Dive

1 Global vs local state — and Context vs Redux vs TanStack Query

The single most important framing: there are two completely different kinds of "state," and most teams break their app by treating them as one.

Server stateClient state
Data you fetched from an API. You don't own it — the server does. It can go stale. Needs caching, refetching, dedup, retries. Data the UI owns. Theme, "is sidebar open", the current cart, form input. Never stale — it's the truth the moment you set it.
TanStack Query / SWR / RTK QueryuseState → Context → Zustand / Redux
Architecture / LLD lensThe mistake that creates unmaintainable apps: dumping fetched API data into Redux. Now you're hand-writing loading flags, error flags, cache invalidation, and refetch logic for every endpoint — reinventing a cache, badly. Server-cache libraries exist precisely because "fetched data" is a different problem with different rules (staleness, background refetch, request dedup) than "UI state." Picking the right tool per category is the actual skill.

The decision ladder (climb only as high as you need)

  1. Local useState — one component owns it. Default. Stay here until it hurts.
  2. Lift state up — two siblings need it → move it to their parent, pass props down.
  3. Context — many components deep in the tree need a low-frequency value (theme, locale, current user). Solves "prop drilling."
  4. Zustand / Redux — global client state that changes often and is read in many places, where Context would re-render too much.
  5. TanStack Query — the data came from a server. Different axis entirely; you'll often use it alongside one of the above.

Context — what it actually is, and its trap

Context is a dependency-injection / transport mechanism, not a state manager. It moves a value down the tree without prop drilling. It has no optimization built in.

// 1. Create
const ThemeContext = createContext('light');

// 2. Provide
function App() {
  const [theme, setTheme] = useState('light');
  return (
    <ThemeContext.Provider value={{ theme, setTheme }}>
      <Dashboard />
    </ThemeContext.Provider>
  );
}

// 3. Consume — anywhere below, no prop drilling
function ThemeToggle() {
  const { theme, setTheme } = useContext(ThemeContext);
  return <button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>{theme}</button>;
}
The Context caveat that gets askedEvery component that calls useContext re-renders whenever the Provider's value changes — and a new object literal (value={{ theme, setTheme }}) is a new reference on every render. So if a high-frequency value (mouse position, form text) lives in Context, the entire subtree re-renders constantly.

Fix 1 — useMemo the value object so its reference is stable across renders that don't change its contents:

function App() {
  const [theme, setTheme] = useState('light');

  // ❌ Without useMemo: a NEW object every render → all consumers re-render
  // const value = { theme, setTheme };

  // ✅ Stable reference — only changes when `theme` actually changes
  const value = useMemo(() => ({ theme, setTheme }), [theme]);

  return <ThemeContext.Provider value={value}><Dashboard /></ThemeContext.Provider>;
}

Fix 2 — split into two contexts: the changing value and the stable setter. Components that only dispatch (a toggle button) subscribe to the setter context and never re-render when theme changes:

const ThemeValueContext = createContext(null);   // changes often
const ThemeSetContext   = createContext(null);   // stable forever

function ThemeProvider({ children }) {
  const [theme, setTheme] = useState('light');
  // setTheme from useState is referentially stable — no useMemo needed
  return (
    <ThemeSetContext.Provider value={setTheme}>
      <ThemeValueContext.Provider value={theme}>{children}</ThemeValueContext.Provider>
    </ThemeSetContext.Provider>
  );
}

// ✅ Only re-renders when `theme` changes
function ThemeLabel() {
  const theme = useContext(ThemeValueContext);
  return <span>{theme}</span>;
}

// ✅ Subscribes to the SETTER only → NEVER re-renders when theme changes
function ThemeToggle() {
  const setTheme = useContext(ThemeSetContext);
  return <button onClick={() => setTheme(t => t === 'light' ? 'dark' : 'light')}>flip</button>;
}
Why Fix 2 is the stronger oneFix 1 stops re-renders caused by unrelated parent renders, but every consumer still re-renders when theme genuinely changes — even a button that only needs setTheme. Fix 2 separates "who reads the value" from "who triggers the change," so dispatch-only components opt out entirely. This is exactly the model selector stores like Zustand give you for free (Q1 table).

Zustand — when Context's re-render model breaks down

Zustand gives you a global store with selector-based subscriptions: a component re-renders only when the specific slice it selected changes — not the whole store. That's the thing Context can't do natively.

import { create } from 'zustand';

const useCartStore = create((set) => ({
  items: [],
  addItem: (item) => set((s) => ({ items: [...s.items, item] })),
  clear: () => set({ items: [] }),
}));

// This component re-renders ONLY when items.length changes —
// not when other parts of the store update.
function CartBadge() {
  const count = useCartStore((s) => s.items.length);
  return <span>{count}</span>;
}

Redux Toolkit — when you actually need it

Redux's value isn't "global variables." It's a strict, predictable update contract: state is read-only, the only way to change it is dispatching an action, and a pure reducer computes the next state. That gives you time-travel debugging, a single audit log of every change, and middleware (sagas, logging, analytics).

import { createSlice, configureStore } from '@reduxjs/toolkit';

const cartSlice = createSlice({
  name: 'cart',
  initialState: { items: [] },
  reducers: {
    addItem: (state, action) => { state.items.push(action.payload); }, // Immer = safe "mutation"
    clear: (state) => { state.items = []; },
  },
});

export const { addItem, clear } = cartSlice.actions;
export const store = configureStore({ reducer: { cart: cartSlice.reducer } });
Architecture / LLD lens — why the reducer pattern?Redux is the Command pattern + event-sourcing applied to UI state. Actions are commands ("ADD_ITEM"); the reducer is a pure function (state, action) => newState. Purity is what buys you the superpowers: replayable history, testability (no mocks — just call the reducer), and a single choke point where every state transition is observable. You pay for that with boilerplate, so it only earns its keep in large apps with complex, interdependent client state and many engineers who benefit from the enforced discipline.

TanStack Query — server state done right

Replaces the entire useEffect + useState fetch dance. You declare what data you want with a key; it handles caching, dedup, background refetch, retries, and loading/error states.

const { data, isLoading, error } = useQuery({
  queryKey: ['user', userId],        // cache identity — same key = shared cache, deduped
  queryFn: () => fetchUser(userId),
  staleTime: 60_000,                // fresh for 60s → no refetch on remount
});

// Mutations + cache invalidation:
const qc = useQueryClient();
const mutation = useMutation({
  mutationFn: updateUser,
  onSuccess: () => qc.invalidateQueries({ queryKey: ['user', userId] }), // refetch fresh
});
Why this beats useEffect fetchingHand-rolled useEffect fetching has no shared cache (two components = two requests), no dedup, no background refresh, no retry, and a race-condition bug if the user navigates fast (old response overwrites new). TanStack Query solves all of those by default. The queryKey is the cache identity — same key anywhere in the app reads the same cached data.
UseWhen
useStateOne component owns it.
ContextLow-frequency value (theme/auth/locale) needed deep in the tree. Few writes.
ZustandGlobal client state, frequent updates, need selective re-renders, want minimal boilerplate.
Redux ToolkitLarge app, complex interdependent state, many devs, need middleware / audit / time-travel.
TanStack QueryThe data came from a server. Almost always.
Say it out loud"I split state into server and client. Server state goes to TanStack Query — it's a cache, not state. Client state climbs a ladder: local, then Context for low-frequency values, then Zustand or Redux when Context would over-render. Context is dependency injection, not a state manager — that distinction drives most of my decisions."

TanStack Query — deep dive

The one idea everything hangs on: TanStack Query is an async cache, not a state manager. People install it thinking "Redux replacement" — wrong model. Its job is managing the lifecycle of data that lives somewhere else (a server) where you only hold a copy.

The core problem it solvesA local copy of remote data has a problem Redux/Zustand never have: it can go stale the moment you fetch it. Someone else edited the record; the price changed. Your copy is now a lie and you don't know it. Everything TanStack Query does serves that one problem: keep a local copy of remote data fresh, fast, and consistent — without rewriting the same boilerplate 50 times.

What you're actually replacing

The hand-rolled fetch everyone writes first is broken in six ways — and TanStack Query fixes all six by default. That's the real value: correctness you didn't know you were missing, not nicer syntax.

// The naive version — looks fine, isn't
function useUser(id) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);
  useEffect(() => {
    setLoading(true);
    fetch(`/api/users/${id}`).then(r => r.json())
      .then(setData).catch(setError).finally(() => setLoading(false));
  }, [id]);
  return { data, loading, error };
}
#Bug in the naive version
1No shared cache — two components calling useUser(5) = two requests for identical data.
2Race condition — change id 5→6→5 fast, responses arrive out of order, you render the wrong user (no cancellation).
3No background refresh — fetched once, then rots. Come back an hour later, still stale.
4No retry — one network blip = error screen.
5Refetches on every mount — navigate away and back = full reload + spinner flash, even though you had it 2s ago.
6No dedup — 5 widgets needing the same data on one screen = 5 requests.

Mechanic 1 — the query key is cache identity

const { data, isLoading, error } = useQuery({
  queryKey: ['user', id],          // ← the cache address
  queryFn: () => fetchUser(id),
});

Same key anywhere in the app = same cached entry. Mount this in 10 components with ['user', 5]one network request, one shared result, all 10 update together. That's automatic dedup + sharing, and it's the single most important concept. Keys are hierarchical and serializable — ['todos', { status: 'done', page: 2 }] — so you can invalidate broadly (['todos'] kills every todo query) or narrowly.

Mechanic 2 — staleTime vs gcTime (the #1 confusion)

staleTimegcTime (was cacheTime)
Answers"Is my data fresh enough to NOT refetch?""How long do I keep unused data in memory?"
Default0 (instantly stale)5 minutes
Controlswhen a background refetch triggerswhen data is garbage-collected after no component uses it
useQuery({
  queryKey: ['feed'],
  queryFn: fetchFeed,
  staleTime: 60_000,   // trust this data 60s — no refetch on remount within window
  gcTime: 5 * 60_000, // keep it 5min after the LAST component unmounts
});
The staleTime trapPeople leave staleTime: 0 (default) on data that barely changes — a user profile, app config — then wonder why it refetches on every single navigation. Default behavior: serve cached value instantly AND refetch in background (stale-while-revalidate). Fix for stable data: bump staleTime (e.g. staleTime: 1000 * 60 * 5 for a profile that changes monthly). That one knob is most of "how do I tune this."

Mechanic 3 — refetch triggers (free freshness)

By default it refetches on window refocus, network reconnect, component remount, and key change. The refocus one is the magic: alt-tab away, come back, data silently refreshes. All configurable per-query or globally.

Mechanic 4 — mutations + invalidation (the write path)

Queries read; mutations write, then tell the cache what's now stale.

const qc = useQueryClient();
const mutation = useMutation({
  mutationFn: (updates) => api.updateUser(id, updates),
  onSuccess: () => qc.invalidateQueries({ queryKey: ['user', id] }), // mark stale → auto-refetch
});
mutation.mutate({ name: 'New Name' });

invalidateQueries is the heart of write-then-read consistency: you changed the server, so the cached copy is a lie — mark it stale and let it refetch.

Mechanic 5 — optimistic updates (the advanced move)

Update the UI before the server confirms; roll back if it fails. The pattern — snapshot → optimistic update → rollback on error → reconcile — is worth memorizing cold.

useMutation({
  mutationFn: toggleLike,
  onMutate: async (postId) => {
    await qc.cancelQueries({ queryKey: ['post', postId] }); // stop in-flight refetch
    const previous = qc.getQueryData(['post', postId]);    // snapshot for rollback
    qc.setQueryData(['post', postId], (old) => ({ ...old, liked: !old.liked })); // instant UI
    return { previous };                                   // context → onError
  },
  onError: (_err, postId, ctx) => qc.setQueryData(['post', postId], ctx.previous), // revert
  onSettled: (_d, _e, postId) => qc.invalidateQueries({ queryKey: ['post', postId] }), // reconcile w/ truth
});

When to use it — and when NOT to

The whole rule: use TanStack Query when the data comes from a server you don't control.

✅ Use it for❌ Don't use it for
Fetching over REST/GraphQL/any async sourcePure client/UI state — modal open, theme, form input, wizard step → useState/Zustand
Data others can change (so yours goes stale)Real-time streams — WebSocket push, live cursors → use a socket (can bridge via setQueryData, but not its job)
Caching, background refresh, retries, pagination, infinite scrollLocal-first / offline-CRDT as source of truth
Read-heavy screens — dashboards, feeds, lists, detail pagesAnything that never leaves the client
The clean lineServer state → TanStack Query. Client state → useState/Zustand/Redux. Most apps need both, side by side. That's the deep version of the Q1 framing.

What kinds of projects use it

Project typeWhy it fits
Dashboards / admin panelsMany read endpoints, tables, filters, pagination — its sweet spot; caching + background refresh shine.
E-commerceProduct lists, detail, cart, inventory. Optimistic add-to-cart, invalidation on checkout.
Social feedsuseInfiniteQuery for infinite scroll, optimistic likes/comments, refetch on focus.
SaaS / CRUD appsRead records, edit, mutate, invalidate — the bread-and-butter case.
Any React SPA on a REST/GraphQL APIThe default choice for client-side data fetching in React today.
Next.js boundary — senior signalIn the Next.js App Router, server components + fetch with Next's caching cover much of what you'd use TanStack Query for on the server. TanStack Query stays king for client-side interactivity — mutations, optimistic UI, infinite scroll, refetch-on-focus. Knowing that boundary is the senior signal.

Worked scenario — stock-trading dashboard

The "do you understand the boundary?" questionPrices update via WebSocket every 200ms; account balance & order history come from a REST API. What goes where?
  • Account balance, order history → TanStack Query. Request/response REST data that goes stale and benefits from caching, refetch-on-focus, and invalidation after a trade (optimistic order placement + reconcile).
  • Live prices → NOT a query. A 200ms push stream is the socket's job; querying can't keep up and would hammer the server. Hold prices in a socket-fed store (Zustand) or, if you want them unified, write socket ticks into the query cache via setQueryData — but the socket, not useQuery, is the source.
The lesson: request/response → TanStack Query; continuous push → socket.
Say it out loud"TanStack Query is an async cache for server state, not a state manager. It solves the problems hand-rolled fetching misses — shared cache, dedup, race cancellation, background refresh, retries, stale-while-revalidate — keyed by a query key that is the cache identity. I tune freshness with staleTime and memory with gcTime, write through mutations plus invalidateQueries, and do optimistic updates with snapshot-rollback-reconcile. Rule of thumb: request/response server data goes in it; pure UI state and real-time socket streams don't."

2 memo vs useMemo vs useCallback — when, why, and the caveats

All three cache something to avoid wasted work. The confusion is that they cache different things. Lock this table in:

APICachesSignaturePurpose
React.memoa component's render outputHOC wrapping a componentSkip re-render if props are shallow-equal
useMemoa computed valueuseMemo(() => compute(), deps)Don't recompute expensive value every render
useCallbacka function referenceuseCallback(fn, deps)Keep a stable function identity across renders

The mental model that ties them together

React re-renders a component when its parent renders. React.memo says "skip me if my props didn't change." But it compares props with shallow equality (Object.is) — and in JS, {} !== {} and (() => {}) !== (() => {}). So every render, a parent creates new object and function props, which breaks React.memo's comparison. useMemo and useCallback exist to keep those references stable so React.memo can do its job. They're a team.

The problem — feel it first

function Parent() {
  const [count, setCount] = useState(0);

  // ❌ New array + new function created EVERY render
  const rows = data.filter(r => r.active);
  const handleClick = (id) => console.log(id);

  return (
    <>
      <button onClick={() => setCount(count + 1)}>{count}</button>
      <ExpensiveList rows={rows} onClick={handleClick} /> // re-renders on every count++
    </>
  );
}

Every time count changes, rows and handleClick are recreated, so <ExpensiveList> re-renders even though its data didn't change.

The fix — all three together

const ExpensiveList = React.memo(function ExpensiveList({ rows, onClick }) {
  /* heavy render */
});

function Parent() {
  const [count, setCount] = useState(0);

  const rows = useMemo(() => data.filter(r => r.active), [data]);     // stable value
  const handleClick = useCallback((id) => console.log(id), []);       // stable reference

  // Now ExpensiveList only re-renders if rows/onClick actually change.
}
Why useCallback is useless on its ownA stable function reference does nothing unless something downstream checks reference equality — i.e. a React.memo child, or a useEffect/useMemo dependency array. Wrapping every handler in useCallback "just in case" only adds overhead (React still allocates the closure + stores it + compares deps). The reference being stable has to matter to something.
Caveats that get asked
  • Memoization isn't free. React stores the cached value/fn and compares dependencies every render. For cheap computations the bookkeeping costs more than just recomputing.
  • React.memo only does shallow comparison. Pass a nested object that's structurally equal but a new reference → it still re-renders. (You can pass a custom comparator as the 2nd arg, but deep-comparing is usually worse than the re-render.)
  • Stale closures with empty deps. useCallback(fn, []) captures the values from the first render forever. If fn reads count, it'll see the initial count. Fix the deps array or use the functional updater setCount(c => c + 1).
  • It's a perf tool, not correctness. Never rely on useMemo to "run only once" for side effects — that's useEffect's job. React may discard memoized values under memory pressure.
Currency flag — the React CompilerThe React Compiler (React 19) auto-memoizes components, values, and functions at build time. The endgame is that you write the naive version and the compiler inserts the memoization. Manual useMemo/useCallback/React.memo are becoming an escape hatch rather than a daily tool. Say this — it shows you're current and that you understand these were always a workaround for a compiler React didn't yet have.
Say it out loud"React.memo memoizes a component, useMemo a value, useCallback a function reference. The key insight: memo compares props by reference, and JS recreates objects and functions every render — so useMemo and useCallback exist to keep those props stable enough for memo to skip. None of them are free, and useCallback is pointless unless a memoized child or a dependency array actually checks that reference. With the React Compiler, most of this becomes automatic."

3 Preventing unnecessary re-renders in large apps

First, the mental model interviewers want: a component re-renders when (1) its own state changes, (2) its parent re-renders, (3) a context it consumes changes, or (4) — if memoized — its props change. The non-obvious one: a child re-renders whenever its parent does even if props are identical, unless wrapped in React.memo. So "props didn't change" does not stop a re-render on its own — memoization is what makes props the deciding factor. Get that right and the rest follows.

Always diagnose before optimizing

React DevTools Profiler → record an interaction → it highlights what rendered and "why did this render?" Optimizing blind is how you add 40 useMemos that slow the app down. Measure first.

Technique 1 — Composition (the most underrated)

Pass expensive subtrees as children. A component's children prop is created by the parent above it, so when state changes in the middle component, children keeps its reference and doesn't re-render.

// ❌ ExpensiveTree re-renders every time `open` toggles
function Layout() {
  const [open, setOpen] = useState(false);
  return <div><Toggle open={open} onToggle={setOpen} /><ExpensiveTree /></div>;
}

// ✅ Move state into a wrapper; pass ExpensiveTree as children
function Layout({ children }) {
  const [open, setOpen] = useState(false);
  return <div><Toggle open={open} onToggle={setOpen} />{children}</div>;
}
// <Layout><ExpensiveTree /></Layout> — ExpensiveTree no longer re-renders on toggle

Technique 2 — State colocation

Move state down to the smallest component that needs it. If a search box's text lives at the app root, every keystroke re-renders the whole app. Push it into the search component and only that subtree updates.

Technique 3 — React.memo + stable props

(See Q2.) Memoize pure, expensive children; keep their object/function props stable with useMemo/useCallback.

Technique 4 — Context splitting / selectors

Split a fast-changing value out of a shared context, or use a selector store (Zustand) so components subscribe to slices, not the whole object. (See Q1's Context caveat.)

Technique 5 — List virtualization

Rendering 10,000 rows mounts 10,000 DOM nodes. Virtualization (TanStack Virtual, react-window) renders only the ~20 rows in the viewport and recycles them as you scroll. DOM size becomes constant regardless of data size.

const rowVirtualizer = useVirtualizer({
  count: rows.length,
  getScrollElement: () => parentRef.current,
  estimateSize: () => 35,        // row height px
});
// render only rowVirtualizer.getVirtualItems() — ~20 nodes, not 10,000
Architecture / LLD lensRe-render cost scales with tree depth × frequency. The architectural fixes (composition, colocation, splitting state by update frequency) attack the problem structurally — they reduce how much of the tree is even eligible to re-render. The tactical fixes (memo, useMemo) just make eligible renders cheaper. Senior signal: reach for structure first, memoization second. A well-shaped tree needs far less memoization.

CaveatNever use array index as key for dynamic lists. On insert/reorder/delete, React mismatches elements to DOM nodes — causing wrong state, lost input focus, and visual bugs. Use a stable unique id.
Say it out loud"I profile first with the React DevTools Profiler. Then I attack it structurally before reaching for memo — composition to keep subtrees out of the render path, colocating state so updates stay local, and splitting context by update frequency. Memo and useMemo are the tactical layer on top, and virtualization for big lists so DOM size stays constant."

4 Suspense for data fetching

Suspense is a declarative boundary for "not ready yet." A component can suspend — signal to React "I'm waiting on something" — and the nearest <Suspense> ancestor shows its fallback until the component is ready. Mechanically, a suspending component throws a promise; React catches it, shows the fallback, and retries the render when the promise resolves.

<Suspense fallback={<Spinner />}>
  <Profile userId={id} />   // suspends while its data loads
</Suspense>

Why it's better than isLoading flags

The problem it solvesWithout Suspense, every data-fetching component carries its own if (isLoading) return <Spinner/>. Ten components = ten scattered spinners, ten loading states to coordinate, and "waterfalls" where each fetch waits for the one above. Suspense hoists loading state out of the components and into the tree — one boundary describes the loading UI for everything beneath it, and you can place boundaries to control granularity.

How you actually wire it (you don't throw promises by hand)

  • TanStack Query: useSuspenseQuery — the hook suspends instead of returning isLoading.
  • Next.js App Router / RSC: async server components + a loading.tsx file is an automatic Suspense boundary.
  • React 19 use() hook: unwraps a promise directly and suspends.
// React 19 — the use() hook
function Profile({ userPromise }) {
  const user = use(userPromise);   // suspends until resolved
  return <h1>{user.name}</h1>;
}

// Next.js App Router — async server component
async function Page() {
  const data = await getData();   // loading.tsx shows while this awaits
  return <Dashboard data={data} />;
}

Two things to pair it with

  • Error Boundaries — Suspense handles the pending state; an Error Boundary handles the rejected state. They're complementary; you wrap both.
  • Streaming SSR — on the server, React streams HTML in chunks: it sends the shell with fallbacks immediately, then streams each section's HTML as its data resolves. This is what makes RSC feel fast.
Architecture / LLD lensSuspense inverts control: instead of each component imperatively reporting "I'm loading," it declaratively suspends, and a parent decides how to present that. This is the same separation-of-concerns win as Error Boundaries (errors bubble to a handler) — loading and error presentation become cross-cutting concerns owned by boundary components, not duplicated in every leaf. It also enables concurrent rendering: React can prepare a suspended tree off-screen and swap it in only when ready, avoiding spinner flicker.
Caveat 1 — waterfallsSuspense doesn't fetch anything — it only reacts to a thrown promise. Naive use creates request waterfalls (child suspends, then its child suspends sequentially). Avoid by hoisting fetches / prefetching in parallel above the boundary.
Caveat 2 — the promise must be stableYou cannot do use(fetch(url)) directly in render. Every render creates a new promise → React suspends, re-renders, creates another promise → infinite loop. The promise must be created outside render or cached (e.g. by TanStack Query, a cache, or passed down as a prop created higher up). This is the #1 mistake people make hand-rolling Suspense — and why you almost always use a library that caches the promise for you.
Say it out loud"Suspense is a declarative boundary for the not-ready state — a component throws a promise, React shows the nearest fallback and retries when it resolves. It moves loading state out of scattered isLoading checks and into the tree, pairs with Error Boundaries for the failure case, and is what powers streaming SSR and RSC. I wire it through useSuspenseQuery or Next's async components rather than throwing promises by hand."

5 Structuring a large-scale component library

The goal of a component library is consistency and accessibility by default across many apps and teams — change a token once, every product updates. Here's the architecture I'd defend:

1. Layer the components (atomic-ish)

  • Primitives: Button, Input, Text, Box — the alphabet.
  • Composites: SearchBar, FormField, Card — primitives combined.
  • Patterns: DataTable, Modal, Wizard — full interaction units.

2. Headless behavior + token-driven styling (the key architectural call)

Build on headless primitives — Radix UI or React Aria — which give you correct behavior and accessibility (focus traps, ARIA roles, keyboard nav, screen-reader support) with zero styling. You layer your own styles on top via design tokens.

Architecture / LLD lens — why headlessThis is separation of behavior from presentation. Accessibility and interaction logic (a focus trap, roving tabindex, ARIA wiring) are hard, identical across companies, and easy to get subtly wrong. Outsourcing them to a battle-tested headless lib means every component is accessible by default and you can't ship a Dropdown that traps keyboard users. Your team's value-add — the visual brand — stays fully under your control. It's the Strategy pattern: behavior is fixed and correct; styling is the pluggable strategy.

3. Design tokens as the single source of truth

/* tokens.css — one place defines the system */
:root {
  --color-primary: #1f6feb;
  --space-md: 12px;
  --radius-md: 8px;
  --font-body: 'Inter', sans-serif;
}
/* Components consume tokens, never hard-coded values.
   Rebrand or dark-mode = swap the token layer, nothing else. */

4. Repo & tooling

  • Monorepo (pnpm workspaces + Turborepo) — library, docs, and demo apps version together; Turborepo caches builds.
  • Storybook — develop & document each component in isolation, every state/variant on display.
  • Chromatic (or Playwright snapshots) — visual regression testing catches "this PR shifted Button padding by 2px" automatically.
  • Changesets — semver versioning + changelogs for releases.

5. API design discipline (LLD of a component)

  • Controlled + uncontrolled support (accept value or defaultValue).
  • forwardRef so consumers can reach the DOM node.
  • Polymorphic as prop — render a Button as an <a> when it's a link.
  • Spread rest props to the root element so consumers can pass aria-*, data-*, etc.
  • TypeScript-first — props are the contract; good types are the docs.
const Button = forwardRef(({ as: Tag = 'button', variant = 'primary', ...rest }, ref) => (
  <Tag ref={ref} className={styles[variant]} {...rest} />   // rest = consumer escape hatch
));
Say it out loud"I build on headless primitives like Radix for behavior and accessibility, layer token-driven styling so a rebrand is a one-file change, and ship it from a Turborepo documented in Storybook with Chromatic visual regression and Changesets versioning. The architectural bet is separating behavior from presentation — accessibility comes for free and the brand stays ours."

R Virtual DOM — what it is and why it's fast

The Virtual DOM is a lightweight JS object tree describing what the UI should look like. When state changes, React builds a new tree, diffs it against the previous one (reconciliation), and applies only the minimal set of real DOM changes.

Real-world analogyEditing the real DOM directly is like demolishing and rebuilding a house room by room every time you change the paint — DOM operations trigger expensive layout/reflow/repaint. The Virtual DOM is the architect's blueprint: you scribble changes on paper (cheap JS objects), compare old vs new blueprint, then send the builders in once to change only what actually differs.

The honest senior caveatThe Virtual DOM is not "fast" in absolute terms — diffing has a cost. Hand-tuned vanilla DOM updates are faster. What the VDOM buys is fast enough while letting you write declarative UI = f(state) code instead of manual imperative DOM surgery. Newer frameworks (Svelte, Solid) skip the VDOM entirely by compiling to direct DOM updates — proof the VDOM is a trade-off, not a law.
Say it out loud"The VDOM is an in-memory tree React diffs to compute the minimal real-DOM mutation. Its real value isn't raw speed — it's letting me write declarative UI-as-a-function-of-state while React batches and minimizes the expensive DOM work."

R useState vs useReducer (and lazy initial state)

Both hold local state. The split is about how complex the transitions are.

useStateuseReducer
Independent values, simple setX(newValue)Next state depends on previous state + an "action type"
A toggle, an input, a counterMulti-field forms, state machines, "several values change together"
Logic lives inline in handlersLogic centralized in a pure reducer(state, action) — testable, no mocks
// useReducer: transitions are explicit, centralized, testable
function reducer(state, action) {
  switch (action.type) {
    case 'increment': return { count: state.count + 1 };
    case 'reset':     return { count: 0 };
    default: throw new Error('unknown action');
  }
}
const [state, dispatch] = useReducer(reducer, { count: 0 });
// dispatch({ type: 'increment' })  — same Command pattern as Redux, but local
And lazy initial state (the gist's "useState accepting a function")Passing a function to useState/useReducer makes the expensive init run once on mount, not on every render. useState(expensiveCalc()) calls expensiveCalc every render and throws the result away; useState(() => expensiveCalc()) calls it once. Same reason it matters for reading localStorage on init.

Say it out loud"useState for independent simple values; useReducer when the next state depends on the previous one or several values move together — it centralizes transitions in a pure, testable reducer. It's local Redux. And I pass a function for initial state so expensive setup runs once, not every render."

R useRef & list virtualization

useRef — the two jobs

A ref is a mutable box whose .current persists across renders but does NOT trigger a re-render when it changes. Two uses:

  • Access a DOM node — focus an input, measure size, play a video: <input ref={inputRef}/> then inputRef.current.focus().
  • Hold a mutable value that shouldn't cause renders — a timer/interval ID, the previous value of a prop, a "has this mounted yet" flag.
The distinction that gets testedState change → re-render. Ref change → no re-render. So if a value should appear on screen, it's state; if it's bookkeeping the render doesn't need (interval IDs, previous values), it's a ref. Mutating a ref during render is a bug — do it in effects/handlers.

Virtualization (windowing)

Rendering 10,000 rows means 10,000 DOM nodes → the browser chokes. Virtualization renders only the ~20 rows visible in the viewport (plus a small buffer) and recycles them as you scroll, using padding to fake the full scroll height.

Real-world analogyYou don't print a 10,000-page book to read page 50 — you turn to the pages you can see. react-window / @tanstack/virtual do exactly that: the DOM only ever holds what's on screen, so a million-row list scrolls at 60fps with constant memory.

import { FixedSizeList } from 'react-window';
<FixedSizeList height={600} itemCount={10000} itemSize={40} width={'100%'}>
  {({ index, style }) => <div style={style}>Row {index}</div>}  /* only ~15 mounted at once */
</FixedSizeList>

R Meta-frameworks, React without one & when to build an SPA

What's a "meta-framework"?

React is just a UI library — it renders components and nothing else. A meta-framework (Next.js, Remix/React Router, TanStack Start) wraps React with the things a real app needs: routing, data fetching, SSR/SSG, bundling, code splitting, API layer. "Meta" = a framework built on top of a library.

React without a meta-framework

Totally valid: Vite + React + a router (React Router / TanStack Router) gives you a pure client-side SPA. You choose this when you don't need SSR/SEO — internal tools, dashboards behind a login, admin panels.

When to build an SPA (vs MPA/SSR)

Build an SPA when…Avoid an SPA (use SSR/MPA) when…
App-like, highly interactive (editor, dashboard, Figma-style)Content site that lives or dies on SEO (blog, store, marketing)
Behind a login — SEO irrelevantFirst-paint speed on slow devices is critical
Lots of client state & transitions between viewsMostly static, read-heavy pages
You can afford a heavier initial JS download onceYou need shareable, crawlable URLs with rich previews
Say it out loud"React is a UI library; a meta-framework like Next adds routing, data, SSR and bundling on top. For an SEO-less internal tool I'll happily ship a Vite SPA with a router. I reach for SSR/a meta-framework when SEO, first-paint, or social previews matter — content sites — and keep SPAs for app-like, behind-login experiences."

R WAI-ARIA — what it is and the #1 rule

WAI-ARIA (Accessible Rich Internet Applications) is a spec of attributesrole, aria-label, aria-expanded, aria-live — that describe the purpose and state of custom UI to assistive tech (screen readers), when native HTML can't.

The #1 rule (and the favorite gotcha)"No ARIA is better than bad ARIA." First rule of ARIA: don't use it if a native element exists. A <button> is keyboard-focusable, clickable with Enter/Space, and announced as a button — for free. A <div role="button"> gives you the label but none of the behavior; you must re-add tabindex, keydown handlers, and focus styles by hand, and people usually forget. Use semantic HTML first; reach for ARIA only for genuinely custom widgets (tabs, comboboxes, live regions).
// Native — accessible by default, zero ARIA needed
<button onClick={open}>Menu</button>

// Custom widget that HAS no native element — ARIA describes state to screen readers
<div role="tablist">
  <button role="tab" aria-selected={active} aria-controls="panel-1">Tab 1</button>
</div>
<div role="region" aria-live="polite">{statusMessage}</div>  // announces updates
Say it out loud"ARIA is attributes that expose role and state to assistive tech. The first rule is don't use it when native HTML works — a real <button> beats a div role=button because it brings keyboard and focus behavior free. ARIA is for custom widgets HTML can't express: tabs, comboboxes, live regions."

🟨 JavaScript Core — the language questions seniors get

These are the "do you actually know the language, not just React" questions. Each one has a concrete reason it exists — interviewers use them to separate people who memorized syntax from people who understand the engine.

JS Map vs Object — when and why

Both store key→value. The instinct is "they're the same." They are not — they're optimized for different jobs.

Real-world analogyAn Object is a struct / record — a fixed shape you designed: { name, email, age }. A Map is a dictionary / hash table — a bag of arbitrary entries you add and remove at runtime: "userId → cart", "socketId → connection". If you're describing one known thing, use an Object. If you're indexing many unknown things, use a Map.

DimensionObjectMap
Key typesstrings & symbols only (numbers get coerced to strings)any value — objects, functions, DOM nodes, numbers
Orderinsertion order, but integer-like keys jump to the frontguaranteed insertion order, always
SizeObject.keys(o).length — O(n)map.size — O(1)
Iterationneed Object.entries/keysdirectly iterable: for (const [k,v] of map)
Default keysinherits __proto__, toString… → prototype-pollution riskno inherited keys — clean slate
Perf for frequent add/deleteslower; engines de-opt objects used as dictionariesoptimized for it
// The killer difference: Map keys can be ANY value
const el = document.querySelector('#btn');
const meta = new Map();
meta.set(el, { clicks: 0 });        // DOM node as a key — impossible with Object
meta.get(el).clicks++;

// Object footgun: numeric keys become strings, and inherited keys exist
const o = {};
o[1] = 'a'; console.log(Object.keys(o)); // ['1']  — number became a string
console.log(o['toString']);                // ƒ toString() — a key you never set!
Architecture / LLD lensAny time you build a cache, lookup index, or registry (request dedup, memoization keyed on arguments, "which socket belongs to which user") — reach for Map. Using a plain object there invites the prototype-pollution security bug (an attacker sends a key named __proto__) and the de-opt. Use objects for known-shape records you serialize to JSON.
Say it out loud"Object is a record with a known shape; Map is a hash table for arbitrary, runtime keys. Map gives any-type keys, guaranteed order, O(1) size, no prototype baggage, and it's faster for heavy add/delete — so caches and indexes are Maps, DTOs are objects."

JS Map vs WeakMap — the memory-leak question

A WeakMap is a Map whose keys are held weakly: if the only reference left to a key object is the WeakMap entry, the garbage collector is free to delete it.

Real-world analogyA Map is a coat-check that keeps your coat forever, even after you've left the building — it won't let go. A WeakMap is a coat-check that throws out your coat the moment you leave: the entry only survives as long as something else still cares about the key.

// Use case: attach private data to an object WITHOUT leaking when it dies
const privateData = new WeakMap();

class Widget {
  constructor(node) {
    privateData.set(this, { node, listeners: [] });
  }
}
// When a Widget is no longer referenced, its WeakMap entry is GC'd automatically.
// With a normal Map, the entry would pin the Widget in memory forever → leak.
MapWeakMap
Keysany valueobjects only (not primitives)
Referencesstrong — pins keys in memoryweak — GC can reclaim keys
Iterable / .sizeyesno — can't enumerate (GC timing is nondeterministic)
Use forcaches you control & iteratemetadata tied to an object's lifetime
The trapYou can't iterate or get .size of a WeakMap — because entries vanish whenever the GC runs, so any count would be a lie. If you need to list entries, you need a Map (and you own the leak risk).
Say it out loud"WeakMap holds keys weakly, so attaching per-object metadata doesn't prevent that object from being garbage-collected. It's the leak-proof way to associate private data with DOM nodes or class instances — at the cost of not being enumerable."

JS === vs Object.is() (and why == is banned)

Three equality checks, increasingly precise:

  • == (loose) — coerces types before comparing. 0 == '', 1 == true, null == undefined are all true. Source of bugs → never use it (except the one idiom x == null to catch both null & undefined).
  • === (strict) — no coercion; same type AND same value. The default you should reach for.
  • Object.is() — like === with two fixes.
// The two cases where === lies and Object.is tells the truth:
NaN === NaN;            // false  — but NaN really IS NaN
Object.is(NaN, NaN);   // true  ✅

-0 === +0;               // true  — but they're distinguishable
Object.is(-0, +0);      // false ✅  (1/-0 = -Infinity, 1/+0 = +Infinity)
Where this actually mattersReact's bailout (useState/useMemo dependency comparison) uses Object.is semantics, not ===. That's why setting state to NaN twice doesn't loop forever, and why -0 vs 0 edge cases behave predictably. Knowing React uses Object.is is a strong signal you understand the reconciler.

Say it out loud"=== is strict equality with no coercion. Object.is is the same except it treats NaN as equal to itself and distinguishes +0 from -0 — which is exactly the comparison React uses internally for bailouts."

JS Object.freeze() — and why it's shallow

Object.freeze(obj) makes an object immutable: no adding, deleting, or reassigning properties. In strict mode, attempts throw; otherwise they fail silently.

const config = Object.freeze({ api: '/v1', retries: 3 });
config.retries = 5;          // silently ignored (throws in strict mode)
Object.isFrozen(config);     // true

// ⚠️ SHALLOW — nested objects are still mutable!
const state = Object.freeze({ user: { name: 'Sonali' } });
state.user.name = 'Changed'; // ✅ works — freeze didn't reach inside

// Deep freeze = recurse
function deepFreeze(o) {
  Object.keys(o).forEach(k => {
    if (typeof o[k] === 'object' && o[k] !== null) deepFreeze(o[k]);
  });
  return Object.freeze(o);
}
Architecture / LLD lensUse it for true constants — config objects, enums, action-type maps — so a teammate can't accidentally mutate shared state at runtime. It's a runtime guard; TypeScript's as const / readonly is the compile-time version. The shallow caveat is the whole question: interviewers want to hear "freeze is one level deep; nested objects need a recursive deepFreeze."

JS async/await vs .then() chains

Same Promises underneath — async/await is syntax sugar over .then(). The difference is readability and control flow.

// .then chain — pyramid grows, error handling is a separate .catch
fetchUser(id)
  .then(user => fetchPosts(user.id))
  .then(posts => render(posts))
  .catch(err => showError(err));

// async/await — reads like sync code, try/catch is normal
async function load(id) {
  try {
    const user  = await fetchUser(id);
    const posts = await fetchPosts(user.id);
    render(posts);
  } catch (err) { showError(err); }
}
The senior trap: accidental serializationawait in a loop or back-to-back makes independent requests run one after another. If they don't depend on each other, run them in parallel with Promise.all:
// ❌ 3 sequential round-trips (~3× latency)
const a = await getA(); const b = await getB(); const c = await getC();

// ✅ fired together, await once (~1× latency)
const [a, b, c] = await Promise.all([getA(), getB(), getC()]);
When .then still winsFor a quick one-off transform or fire-and-forget side effect, .then() is tighter. And .then() composes nicely in points-free pipelines. But for anything with multiple dependent steps or branching, async/await + try/catch is clearer and easier to debug (real stack traces).

Say it out loud"They're the same Promises — await is sugar over .then. I default to async/await for readable, debuggable control flow, but I watch for accidental serialization: independent awaits should be Promise.all'd so they run in parallel."

JS Generators — functions that pause

A generator (function*) is a function that can pause at yield and resume later, producing a sequence of values lazily — one at a time, on demand, instead of all at once.

Real-world analogyA normal function is a vending machine that dumps the entire stock when you press the button. A generator is a Pez dispenser: each .next() pops one item; it remembers where it left off and produces the next only when asked. That "remember + resume" is the whole point.

function* idGenerator() {
  let id = 1;
  while (true) yield id++;   // infinite, but lazy — only runs when asked
}
const gen = idGenerator();
gen.next().value; // 1
gen.next().value; // 2  — function resumed where it paused

// Lazy sequence: only computes what you consume
function* take(iter, n) {
  for (const x of iter) { if (n-- <= 0) return; yield x; }
}
Where they show up in real codeYou rarely write generators by hand, but they power things you use daily: Redux-Saga models async flows as yielded "effects" (pausable, testable, cancellable), and async/await itself is conceptually a generator that yields Promises. They're also the cleanest way to express infinite or huge sequences (pagination cursors, ID streams) without materializing them in memory. Interview value: they prove you understand lazy evaluation and that functions don't have to run start-to-finish.
Say it out loud"A generator is a pausable function — yield suspends it and .next() resumes it, producing values lazily. That powers lazy/infinite sequences and libraries like Redux-Saga; conceptually async/await is a generator yielding promises."

JS Hoisting & the Temporal Dead Zone

Before any code runs, the engine scans the scope and registers declarations. That's hoisting. How they're registered differs by keyword — and that's the whole question.

DeclarationHoisted?Usable before its line?
varyes, initialized to undefinedyes — but you get undefined (silent bug)
let / constyes, but uninitializedno — ReferenceError (Temporal Dead Zone)
function declarationyes, fullyyes — callable above its definition
classyes, but in the TDZno — ReferenceError
console.log(a); // undefined  — var hoisted & initialized
var a = 1;

console.log(b); // ❌ ReferenceError — let is in the TDZ until its line
let b = 1;

greet();        // ✅ works — function declarations are fully hoisted
function greet() { return 'hi'; }
The point of the TDZlet/const are hoisted too — but accessing them before declaration throws instead of returning undefined. That "dead zone" exists deliberately: it turns a silent var bug into a loud error. This is why modern code uses const by default, let when reassigning, and var essentially never.

🎨 Modern CSS — the questions that show you keep up

CSS questions at senior level aren't "center a div." They're about architecture (how do you style a 200-component app without chaos) and knowing what shipped recently (container queries, subgrid, nesting). Here's the real story behind each.

CSS CSS-in-JS, why Meta moved off it, and StyleX

CSS-in-JS (styled-components, Emotion) means writing styles as JavaScript, scoped to a component. It solved a real problem — global namespace collisions — by generating unique class names so no two components' styles clash.

const Button = styled.button`
  background: ${p => p.primary ? '#6e8efb' : '#2d333b'};
  padding: 10px 16px;
`;  // → generates a unique class, scoped, dynamic from props
Why Meta (and others) moved away — the runtime costTraditional CSS-in-JS does work in the browser at runtime: on every render it serializes styles, inserts <style> tags, and recomputes when props change. At Facebook's scale that's measurable jank, a bigger JS bundle, and it fights React Server Components (which render with no client JS). The lesson the industry learned: styling should be resolved at build time, not request time.
What replaced itStyleX (Meta's library, open-sourced) and zero-runtime tools (Vanilla Extract, Linaria, Tailwind, Panda CSS) keep the authoring ergonomics — colocated, type-safe, scoped styles — but compile to static .css files at build time. No runtime style injection, atomic class output (tiny, deduplicated), and full RSC compatibility. StyleX specifically uses an atomic model: every property becomes one reusable class, so the CSS size grows sub-linearly as the app grows.

// StyleX — looks like CSS-in-JS, but compiles to static atomic CSS
import * as stylex from '@stylexjs/stylex';
const s = stylex.create({
  box: { padding: 16, background: '#161b22' },
});
// <div {...stylex.props(s.box)} />  → static classes, zero runtime
Say it out loud"CSS-in-JS solved scoping but pays a runtime tax — style injection on every render — that hurts at scale and breaks RSC. The industry moved to zero-runtime: StyleX, Vanilla Extract, Tailwind. Same colocated authoring, but styles compile to static atomic CSS at build time."

CSS Native nesting, container queries & subgrid

Three features that shipped to all major browsers recently and show up as "are you current?" questions.

1 · Native CSS nesting

You can now nest selectors in plain CSS — no Sass/preprocessor needed.

.card {
  padding: 16px;
  & .title { font-weight: 700; }     /* & = the parent selector */
  &:hover { border-color: #6e8efb; }
}

2 · Container queries — the big one

Media queries respond to the viewport. Container queries respond to the parent element's size. That's the difference that makes components truly reusable.

Real-world analogy & why it mattersA media query asks "how big is the window?" A container query asks "how big is the box I'm sitting in?" The same <ProductCard> might sit in a wide hero, a narrow sidebar, and a 3-column grid on the same page. With media queries it can only know the window size — useless. With container queries it adapts to its own available space. This is what finally makes a component library responsive per component instead of per page.

.sidebar { container-type: inline-size; }   /* declare a containment context */

/* "when MY container is ≥ 400px wide, go horizontal" — ignores the viewport */
@container (min-width: 400px) {
  .card { display: grid; grid-template-columns: 120px 1fr; }
}

3 · Subgrid

Lets a nested grid inherit its parent grid's tracks, so children of different cards line up across cards.

The problem it solvesThree cards in a row, each with a title + body + footer of different lengths. Without subgrid, every card sizes its rows independently → titles and footers don't align across cards (ragged). With grid-template-rows: subgrid, all cards share the parent's row lines, so every title row is the same height and footers sit on one baseline.

.cards { display: grid; grid-template-columns: repeat(3, 1fr); grid-template-rows: auto 1fr auto; }
.card  { display: grid; grid-row: span 3; grid-template-rows: subgrid; }  /* inherit parent rows */
Say it out loud"Native nesting kills the Sass dependency. Container queries let a component respond to its own container instead of the viewport — that's what makes a design system truly reusable. Subgrid lets nested grids share the parent's tracks so content lines up across cards. All three ship in modern browsers now."

▲ Next.js & Rendering Strategies

The questions here are really about one decision: where and when does the HTML get built — at build time, at request time on a server, or in the browser? Every Next.js API is just a tool for picking that point. Get the mental model and the rest falls out.

NX SSR vs SSG vs ISR vs CSR — and the legacy data APIs

Real-world analogySSG = a printed newspaper: typeset once, mass-produced, instant to hand out, but a day old. SSR = a barista making your coffee to order: fresh and personalized, but you wait at the counter. CSR = IKEA flat-pack: they ship you the parts (JS) and your browser assembles it. ISR = the newspaper that reprints a page only when it's gone stale. The interview skill is matching the strategy to the data's freshness needs.

StrategyHTML built…Best forApp RouterLegacy (Pages) API
SSG (static)at build timeblogs, docs, marketing — same for everyonedefault (no dynamic data)getStaticProps
ISR (incremental)build time, then re-built on a timere-commerce catalogs — mostly static, occasionally changes{ next: { revalidate: 60 } }getStaticProps + revalidate
SSR (dynamic)at request time on the serverpersonalized dashboards, anything per-user/per-requestcache: 'no-store' / dynamicgetServerSideProps
CSR (client)in the browser after JS loadsprivate app behind a login, highly interactive'use client' + fetchuseEffect fetch
// Pages Router (legacy) — the API the gist asks about:
export async function getStaticProps() {      // runs at BUILD time → SSG
  const posts = await getPosts();
  return { props: { posts }, revalidate: 60 };  // add revalidate → ISR
}
export async function getServerSideProps(ctx) {  // runs on EVERY request → SSR
  const user = await getUser(ctx.req.cookies.token);
  return { props: { user } };               // personalized, never cached
}

// App Router (current) — the SAME choices, expressed on the fetch itself:
await fetch(url);                              // cached → SSG-like
await fetch(url, { next: { revalidate: 60 } });  // ISR
await fetch(url, { cache: 'no-store' });        // SSR (per request)
Architecture / LLD lensThe decision tree: Is the data the same for every user? → static (SSG). Does it change occasionally but you can tolerate seconds of staleness? → ISR (you get static speed + freshness without rebuilding the whole site). Is it per-user or must be real-time? → SSR. Is it behind auth and doesn't need SEO? → CSR is fine. Most real apps mix all four across different routes.

NX getStaticPaths & next/image

getStaticPaths — pre-rendering dynamic routes

For a dynamic route like /blog/[slug], SSG needs to know which slugs to build pages for. getStaticPaths returns that list.

export async function getStaticPaths() {
  const posts = await getPosts();
  return {
    paths: posts.map(p => ({ params: { slug: p.slug } })), // build these at build time
    fallback: 'blocking',  // unknown slug? render on-demand & cache (don't 404)
  };
}
The fallback questionfalse = any path not listed 404s (fine for a fixed set). true = serve a loading skeleton, then fill in (risk: layout shift). 'blocking' = SSR it on first request, then cache as static — the usual choice for large/growing catalogs where you can't build every page up front.

next/image — why not just <img>

Images are the #1 LCP killer. next/image automates every image best-practice you'd otherwise do by hand:

  • Auto format + resize — serves WebP/AVIF and the right size per device (no shipping a 4000px image to a phone).
  • Lazy-loads by default — off-screen images don't block load.
  • Reserves space from width/heightprevents CLS (no layout jump when the image arrives).
  • Blur placeholder while loading → better perceived perf.
<Image src="/hero.jpg" width={1200} height={600} priority placeholder="blur" />
// priority = preload this one (it's the LCP element); skip lazy-loading it
Connects to Web Vitals (Q8)This single component directly improves two of three Core Web Vitals: LCP (right-sized modern formats load faster) and CLS (reserved dimensions stop the jump). That's why "how does next/image help performance" is really a Web Vitals question in disguise.

NX API routes, deploying off-Vercel, serverless, CDN & Server Components

API routes

Next.js is full-stack: a file in app/api/ (or pages/api/) becomes a backend endpoint in the same project — same repo, same deploy. Great for BFF (backend-for-frontend) work: hiding API keys, proxying, light mutations.

// app/api/users/route.js  → GET /api/users
export async function GET() {
  const users = await db.query('SELECT * FROM users');
  return Response.json(users);
}

Deploying off Vercel

Next.js is open-source and not locked to Vercel. Options: next start on any Node server (Docker → AWS/GCP/Render/Railway), output: 'standalone' for a minimal self-contained Docker image, adapters like OpenNext for AWS Lambda/Cloudflare, or output: 'export' for a pure static bundle on any CDN/S3 (loses SSR/ISR/API routes — static only). Vercel is just the zero-config first-party host.

Serverless mode & the static CDN

Why serverless suits Next.jsEach SSR page / API route deploys as an independent serverless function that scales to zero when idle and spins up per request — you pay per execution, not for an always-on box, and it scales horizontally automatically. Meanwhile all the static output (SSG pages, JS, images) is pushed to a global CDN edge so it's served from a city near the user. The combo — static on the CDN, dynamic on serverless — is why Next feels fast and scales cheaply. The trade-off is cold starts (first hit after idle is slower).

React Server Components — the current model

In the App Router, components are Server Components by default: they render on the server, can await data directly, and ship zero JavaScript to the browser. You opt into client interactivity with 'use client'.

// Server Component (default) — no useState/onClick, runs server-side, 0 client JS
async function ProductList() {
  const products = await db.products.findAll(); // query DB right here — no API layer
  return <ul>{products.map(p => <li key={p.id}>{p.name}</li>)}</ul>;
}

'use client';  // ↓ this one ships JS — it needs state/handlers
function AddToCart() { const [n, setN] = useState(0); /* … */ }
Architecture / LLD lensThe win is shipping less JavaScript: static content and data-fetching stay on the server (the browser gets HTML), and only the genuinely interactive leaves ('use client') cost bundle size. You also delete an entire layer — Server Components query the database directly, so no "build an API endpoint just to feed the page." The mental model: server by default, client only where you need state or events. AMP is the old, now-deprecated answer to the same "ship less" goal.
Say it out loud"App Router is server-first: components render on the server and ship zero JS by default, fetching data directly — I add 'use client' only for interactive leaves. Static output goes to the CDN, dynamic routes run as serverless functions. It's not Vercel-locked — next start in Docker or an adapter runs it anywhere."

🧠 Performance & Optimization

6 Handling slow API responses or large payloads

Split the problem into two questions: (a) how do I move less data / fewer times? and (b) how do I make waiting feel instant? Senior answers cover both — actual perf and perceived perf.

Reduce the data on the wire

  • Pagination / cursor paging: never fetch 10k rows. Cursor-based (?after=id) beats offset paging — offset gets slow and drifts when rows are inserted mid-scroll.
  • Fetch only needed fields: GraphQL, or REST sparse fieldsets (?fields=id,name). Stops over-fetching.
  • Compression: gzip/brotli at the server/CDN — often 70%+ smaller for JSON.
  • BFF (Backend-for-Frontend): a thin server layer that aggregates and trims responses for the UI, so the client makes one shaped call instead of five raw ones.

Reduce the number of requests

  • Dedup in-flight requests — TanStack Query collapses identical concurrent queries into one.
  • Debounce/throttle user-driven requests (search-as-you-type).
  • Cancel stale requests with AbortController so a fast typist doesn't race responses.
function useSearch(term) {
  return useEffect(() => {
    const ctrl = new AbortController();
    fetch(`/search?q=${term}`, { signal: ctrl.signal })
      .then(r => r.json())
      .catch(e => { if (e.name !== 'AbortError') throw e; });
    return () => ctrl.abort();   // cancel previous on new keystroke / unmount
  }, [term]);
}

Make waiting feel instant (perceived performance)

  • Skeleton screens over spinners — they preview layout, feel faster, and prevent layout shift.
  • Optimistic updates — update the UI immediately, roll back if the server rejects.
  • stale-while-revalidate — show cached data instantly, refetch in the background, swap when fresh.
  • Streaming — render the page shell immediately, stream slow sections in (Suspense + RSC).
// Optimistic update with TanStack Query
useMutation({
  mutationFn: toggleLike,
  onMutate: async (id) => {
    await qc.cancelQueries({ queryKey: ['post', id] });
    const prev = qc.getQueryData(['post', id]);
    qc.setQueryData(['post', id], p => ({ ...p, liked: !p.liked })); // instant UI
    return { prev };                                            // rollback context
  },
  onError: (_e, id, ctx) => qc.setQueryData(['post', id], ctx.prev),  // revert
});
Architecture / LLD lensThe biggest wins are usually not on the client — they're moving work to the right layer: aggregate at a BFF, cache at a CDN/edge, paginate at the DB. Client tricks (debounce, virtualize, optimistic UI) shave the last mile. Name the layer where each fix lives — that's the system-thinking signal.
Say it out loud"I attack it on two axes: reduce data — pagination, field selection, compression, a BFF to shape responses — and reduce perceived wait — skeletons, optimistic updates, stale-while-revalidate, streaming. And I dedupe and cancel requests with AbortController so a fast typist doesn't race responses."

7 Bundle optimization — tree shaking, lazy loading, dynamic imports

The framing that makes these click: tree shaking shrinks the bundle at build time; code splitting defers parts of it to runtime. Different problems. One removes code you never use; the other delays code you don't need yet.

Tree shaking — remove dead code

The bundler statically analyzes import/export and drops exports nobody uses. Requirements:

  • ES modules, not CommonJS. Tree shaking relies on static import/exportrequire() is dynamic and can't be analyzed.
  • Named imports. import { debounce } from 'lodash-es' shakes; import _ from 'lodash' pulls the whole library.
  • "sideEffects": false in package.json tells the bundler "importing this file does nothing but export" so it can safely drop unused ones. (List exceptions like CSS imports.)

Code splitting + lazy loading — defer to runtime

import { lazy, Suspense } from 'react';

const Dashboard = lazy(() => import('./routes/Dashboard')); // own chunk, loaded on demand

<Suspense fallback={<PageSkeleton />}>
  <Dashboard />
</Suspense>

Dynamic imports — load heavy things only when used

// Don't ship a 300kb charting lib in the main bundle —
// load it only when the user opens the analytics panel.
async function openCharts() {
  const { renderChart } = await import('./heavy-charts'); // returns a promise
  renderChart();
}

Other levers

  • Analyze: vite-bundle-visualizer / webpack-bundle-analyzer — see what's actually big.
  • Swap heavy deps: moment.js (~70kb) → day.js/date-fns; lodash → lodash-es or native.
  • Dedupe React — two copies of React is a classic bundle bloat + runtime bug.
  • Compression + hashed filenames for long-term caching (see Q9).
Architecture / LLD lensThe architectural decision is your split boundaries. Route-level splitting is the highest-leverage line to draw — it maps chunks to navigation, so users download only the page they visit. Then split heavy, rarely-used widgets (editors, charts, maps). Splitting too finely creates a waterfall of tiny requests; too coarsely defeats the purpose. The boundary is a design choice, not an afterthought.
Say it out loud"Tree shaking removes dead code at build time — it needs ES modules, named imports, and a sideEffects flag. Code splitting and dynamic imports defer code to runtime — I split at route boundaries first, then lazy-load heavy widgets. I verify with a bundle analyzer and swap bloated deps like moment for dayjs."

8 Web Vitals — measuring and improving LCP, INP, CLS

FID is dead — lead with thisGoogle replaced FID with INP (Interaction to Next Paint) as a Core Web Vital in March 2024. Saying "FID" in 2026 dates you. Answer with INP; if they say FID, correct it gracefully (one-liner below).

The three Core Web Vitals

MetricMeasuresGoodMain fixes
LCP
Largest Contentful Paint
Loading — when the biggest element (hero image/heading) paints≤ 2.5sOptimize/preload hero image, fetchpriority="high", fast TTFB/CDN, cut render-blocking CSS/JS, SSR
INP
Interaction to Next Paint
Responsiveness — lag from any interaction to the next paint, across the whole session≤ 200msBreak up long tasks, useTransition for non-urgent updates, web workers for heavy compute, debounce, yield to main thread
CLS
Cumulative Layout Shift
Visual stability — how much content jumps around≤ 0.1Set width/height on images & embeds, reserve space for ads/dynamic content, avoid inserting above existing content, use font-display: optional/swap

Why INP replaced FID (the depth)

The reasoningFID measured only the input delay of the very first interaction — and only the delay before the handler ran, not how long the UI took to update. It was easy to game and didn't reflect real frustration. INP measures every interaction all session long and the full time until the next paint — so a janky dropdown on click #20 now counts. It captures responsiveness as users actually experience it.

How to measure — lab vs field

  • Lab (synthetic): Lighthouse, PageSpeed Insights, Chrome DevTools — reproducible, but one machine/network. INP isn't fully measurable in lab (needs real interactions).
  • Field (RUM — real user monitoring): the web-vitals npm library reports real users' metrics to your analytics; Chrome UX Report (CrUX) is field data Google actually ranks you on.
import { onLCP, onINP, onCLS } from 'web-vitals';

onLCP(sendToAnalytics);
onINP(sendToAnalytics);   // real-user field data — what Google ranks on
onCLS(sendToAnalytics);
If they say "FID""FID only measured the delay of the first interaction; INP superseded it in March 2024 because it captures responsiveness across every interaction in the session, not just the first tap." — that one sentence wins the room.
Say it out loud"The three are LCP for load, INP for responsiveness — that replaced FID in 2024 — and CLS for visual stability. I measure in two places: Lighthouse for lab and the web-vitals library feeding RUM for field data, since Google ranks on field. LCP I fix by preloading the hero and cutting render-blocking resources; INP by breaking up long tasks and using useTransition; CLS by reserving space for images and dynamic content."

9 Caching strategy for frontend apps

There's no single cache — there are layers, each catching a different request closer to the user. Name them as a stack; that's the senior framing.

LayerCaches whatMechanism
Browser HTTP cachestatic assets, API responsesCache-Control, ETag, max-age
CDN / edgestatic assets, cacheable APIedge nodes near the user
Service Workeroffline shell, assets, dataWorkbox cache strategies
App data cachefetched server stateTanStack Query / SWR in-memory
Persistent storagecross-session datalocalStorage / IndexedDB

1. HTTP caching + content hashing (the foundation)

Build tools fingerprint filenames: app.9f2a1c.js. Because the hash changes when content changes, you can cache these forever (Cache-Control: max-age=31536000, immutable) and bust the cache by shipping a new filename. The index.html stays no-cache so it always points at the latest hashed assets.

2. The cache strategies (name them)

  • Cache-first: serve from cache, only hit network on miss. For immutable assets (fonts, hashed JS).
  • Network-first: try network, fall back to cache. For data that should be fresh but must work offline.
  • Stale-while-revalidate: serve cache instantly and refetch in background, update next time. The sweet spot for most data — instant + eventually fresh. This is TanStack Query's default model.
// TanStack Query caching knobs
useQuery({
  queryKey: ['feed'],
  queryFn: fetchFeed,
  staleTime: 30_000,   // "fresh" 30s → served from cache, no refetch
  gcTime: 5 * 60_000, // kept in memory 5min after unused, then garbage-collected
});
Caveat — the hard part is invalidation"There are only two hard things in CS: cache invalidation and naming things." Stale data is the #1 caching bug. That's why content-hashing (immutable URLs) and key-based invalidation (invalidateQueries) win — they make invalidation explicit and deterministic instead of guessing at TTLs.
Architecture / LLD lensEach layer trades freshness for speed/cost, and the right TTL depends on how tolerant the data is to staleness — a stock price (seconds) vs a blog post (days) vs a logo (forever). The architecture is choosing the staleness budget per data type and placing each in the cheapest layer that satisfies it. CDN for "same for everyone," app cache for "per user, short-lived," immutable HTTP cache for "never changes within a build."
Say it out loud"I cache in layers: content-hashed assets cached forever on the CDN and browser, busted by filename; a service worker for offline; and TanStack Query for server state with stale-while-revalidate. The real design work is the invalidation strategy — I lean on immutable hashed URLs and key-based invalidation so freshness is deterministic, not a TTL guess."

10 Identifying and fixing memory leaks in React

A leak is memory the app no longer needs but the garbage collector can't reclaim because something still references it. In React, that "something" is almost always a subscription, timer, or listener that outlived its component.

The four classic causes — and fixes

  • Uncleaned timers/subscriptions: setInterval, event listeners, WebSocket, store subscriptions. → Return a cleanup function from useEffect.
  • State update after unmount: async resolves after the component is gone, calls setState on a dead component. → Abort the request / guard with a flag.
  • Closures capturing large objects in long-lived references: a callback stored in a ref, a global subscription, or module-level cache closes over a big structure (or a whole component's props/state) and keeps it alive after it's no longer needed.
  • Listeners on window/document: not removed → the handler (and everything it closes over) stays alive forever.
function LiveClock() {
  const [now, setNow] = useState(() => Date.now()); // lazy init

  useEffect(() => {
    const id = setInterval(() => setNow(Date.now()), 1000);
    return () => clearInterval(id);   // ✅ cleanup — without this, the timer leaks on unmount
  }, []);

  return <span>{now}</span>;
}

// Async fetch — cancel to avoid "setState on unmounted component"
useEffect(() => {
  const ctrl = new AbortController();
  fetch(url, { signal: ctrl.signal }).then(setData).catch(() => {});
  return () => ctrl.abort();         // ✅ cancel in-flight on unmount
}, [url]);

How to detect (this is what separates juniors)

  1. Chrome DevTools → Performance Monitor: watch "JS heap size" while using the app. A sawtooth that trends upward and never drops after GC = leak.
  2. Memory tab → Heap snapshots: take a snapshot, perform the suspect action (open/close a modal 10×), take another, and compare. Filter for "Detached" DOM nodes — DOM removed from the page but still referenced by JS — the smoking gun.
  3. Allocation timeline: records allocations over time so you can see what's growing.
Architecture / LLD lensThe root cause is almost always a lifecycle mismatch: a resource's lifetime is tied to something longer-lived than the component that created it (a global event bus, window, a module-level singleton). The discipline is "every subscription has a matching unsubscription, co-located in the same effect." useEffect's setup/cleanup pairing is React encoding exactly that contract — acquire on mount, release on unmount. Leaks happen when you acquire and forget to release.
Caveat — StrictModeIn dev, React 18 StrictMode intentionally mounts → unmounts → remounts each component to surface missing cleanups. If your effect breaks under StrictMode, you have a cleanup bug — that's the feature working, not a React bug.
Say it out loud"Most React leaks are missing useEffect cleanups — timers, listeners, subscriptions, or setState after unmount. The fix is the setup/cleanup contract: every acquire has a matching release in the same effect, plus AbortController for fetches. I confirm with heap snapshots in DevTools, comparing before and after an action and hunting for detached DOM nodes that never get collected."

⚙️ Build, CI/CD & Tooling

11 Environment configs for different builds

The core decision is one most people get wrong: build-time config vs runtime config. Get this distinction right and the rest is detail.

Build-time config (the default)

Values are baked into the bundle when you build. Vite exposes only VITE_-prefixed vars via import.meta.env; everything else stays out of the client for safety.

# .env.development
VITE_API_URL=http://localhost:3000

# .env.production
VITE_API_URL=https://api.myapp.com
const apiUrl = import.meta.env.VITE_API_URL;  // inlined at build time

Vite auto-loads .env.[mode] based on the --mode flag. CI runs a separate build per environment.

Runtime config (for containers / one-build-everywhere)

Why this mattersBuild-time config means one build artifact per environment — you rebuild for staging and again for prod. That breaks the Docker/Kubernetes ideal of "build one immutable image, promote it through environments." The fix: runtime config — the app fetches /config.json on startup, or the container injects values into window.__ENV__ at boot. One image, configured per environment at deploy time.
// public/config.json — swapped per environment at deploy, not build
{ "apiUrl": "https://api.myapp.com" }

// app bootstraps from it
const cfg = await fetch('/config.json').then(r => r.json());
Caveat — secretsAnything in the frontend bundle is public. View-source, network tab, decompile — it's all visible. API keys, DB creds, signing secrets never go in client env vars (the VITE_ prefix is a safety guard, not encryption). Secrets live server-side; the client gets only public config and short-lived tokens.
Architecture / LLD lensThis is the 12-Factor App "config in the environment" principle. The trade-off: build-time is simpler and faster but couples artifact to environment; runtime decouples them (immutable promotable images) at the cost of one startup fetch. Pick build-time for static-hosted SPAs (Netlify/Vercel), runtime for containerized deploys where the same image flows dev → staging → prod.
Say it out loud"Per-environment .env files for build-time config, with Vite's VITE_ prefix gating what reaches the client. For containerized deploys I switch to runtime config — fetch a config.json or read injected window env — so one immutable image promotes through all environments. And secrets never touch the bundle; anything shipped to the client is public by definition."

12 Workflow with Webpack / Vite / Turbopack

The thing to understand isn't the tools — it's the architectural shift they represent: from "bundle everything before serving" to "serve native ES modules and only bundle for production."

ToolDev modelProd buildSpeed
WebpackBundles the whole app before the dev server can serve itWebpackSlow dev startup; scales poorly with app size
ViteServes native ESM, transforms on-demand via esbuild (Go) — no pre-bundleRollupNear-instant startup & HMR, flat regardless of app size
TurbopackRust, incremental, function-level caching; bundles in dev but lazilyTurbopackVery fast; default in Next.js 16 for both dev & build

Why Vite's dev server is so much faster (the depth)

The reasoningWebpack builds a dependency graph and bundles everything before serving the first page — so dev startup time grows with your app. Vite exploits that browsers now support native ES modules: it serves your source files directly as ESM and only transforms a file when the browser requests it. Startup is near-instant and stays flat no matter how big the app gets. It uses esbuild (written in Go, 10–100× faster than JS-based tooling) to pre-bundle dependencies. Production still bundles — via Rollup — because shipping hundreds of unbundled module requests to real users would be slow.
Caveat — why not esbuild for prod too?esbuild is blazing but lacks some advanced optimizations and plugin maturity for production bundles, so Vite uses Rollup for the prod build. This dev/prod tool split is occasionally a source of "works in dev, breaks in build" surprises — test the production build, don't just trust dev.
Architecture / LLD lensThe trend is moving build tooling to native-speed languages (esbuild in Go, Turbopack/SWC in Rust) because JS parsing/transforming JS is the bottleneck at scale. The other shift is incremental/lazy compilation — only build what's requested or changed, not the world. Both target the same enemy: dev feedback loop latency, which dominates engineer productivity.
Say it out loud"Vite for new SPAs — it serves native ESM in dev so startup is instant and stays flat as the app grows, using esbuild for speed, then Rollup for the prod bundle. Webpack when I inherit it or need its mature plugin ecosystem. Turbopack on modern Next.js. The underlying shift is from bundle-everything-upfront to lazy native-ESM dev, and moving tooling to Rust and Go for speed."

13 Managing feature flags

The one-sentence purpose: feature flags decouple deploy from release. You ship code to production turned off, then flip it on for whoever you choose, whenever you choose — no redeploy.

What they unlock

  • Gradual rollout: enable for 1% → 10% → 100%, watching metrics.
  • Kill switch: something's on fire → flip off instantly, no rollback deploy.
  • A/B testing: show variant A to half, B to the other half.
  • Trunk-based development: merge incomplete features to main behind a flag instead of long-lived branches.
  • Targeting: beta users, internal staff, specific plans/regions.

Implementation

// Provider wraps the app; flags evaluated per-user from a service
<FlagProvider userId={user.id} attributes={{ plan: user.plan, region }}>
  <App />
</FlagProvider>

// Consume with a hook
function Checkout() {
  const newFlow = useFlag('new-checkout-flow');
  return newFlow ? <NewCheckout /> : <LegacyCheckout />;
}
  • Tools: LaunchDarkly (the standard), Split.io, Unleash (open source), or a homegrown service + config endpoint.
  • Client vs server eval: client-side is simple but flag values are visible to users (don't gate security with them); server-side keeps logic private and avoids flicker.
Caveat — flags are tech debtEvery flag is a branch in your code = 2× the states to test. Stale flags rot — they become permanent dead branches nobody dares delete. Discipline: every flag gets an owner and an expiry/cleanup ticket. Remove it once the feature is fully rolled out. A codebase with 200 ancient flags is a maintenance nightmare.
Architecture / LLD lensFlags turn release into a runtime decision instead of a deploy event — that's the architectural shift enabling continuous delivery. But it pushes complexity into combinatorial state: N flags = 2^N possible app states, most untested. Mitigate by keeping flags short-lived, scoping them narrowly, and treating long-lived config (plan tiers) differently from temporary release flags. Avoid flag interdependencies — flag B's behavior depending on flag A's state is where the combinatorics bite.
Say it out loud"Feature flags decouple deploy from release — ship dark, flip on for a cohort, kill instantly if it breaks. They enable gradual rollouts, A/B tests, and trunk-based dev. I'd use LaunchDarkly or Unleash with a useFlag hook and per-user targeting. The discipline that matters is treating flags as tech debt — each one doubles the state space, so they get owners and expiry dates and get deleted after rollout."

14 Monitoring & logging frontend issues (Sentry, Datadog)

Frame it as three distinct questions you're answering with different tools: What broke? How does it feel for real users? What are people actually doing?

QuestionCategoryTools
What broke & why?Error trackingSentry — exceptions, stack traces, source maps, session replay
How does it perform for real users?RUM / APMDatadog, New Relic — web vitals, traces, latency in the field
What are users doing?Product analyticsPostHog, Amplitude — funnels, events, retention

What I actually wire up

  • Error boundaries → Sentry: catch React render errors and report them with component stack.
  • Source map upload in CI: the killer detail — without it, prod stack traces are minified gibberish (a.b.c is not a function). Upload maps on deploy so traces map back to real source.
  • Release tracking: tag errors with the release/commit so you know which deploy introduced a spike — and regressions are obvious.
  • web-vitals → analytics: field performance data (see Q8).
  • Alerting: error-rate spike or new error type → Slack/PagerDuty.
import * as Sentry from '@sentry/react';

Sentry.init({
  dsn: import.meta.env.VITE_SENTRY_DSN,
  release: import.meta.env.VITE_COMMIT_SHA,   // tie errors to a deploy
  tracesSampleRate: 0.1,                     // sample perf traces (cost control)
  integrations: [Sentry.replayIntegration()], // session replay of the crash
});
Architecture / LLD lensThe principle is observability: you can't fix what you can't see, and prod is a black box without instrumentation. The design trade-offs are sampling (100% capture is expensive and noisy → sample traces, keep all errors), PII scrubbing (session replay can capture passwords/PII → mask sensitive fields), and cardinality/cost (every custom tag multiplies storage). Good observability is deliberate about what to capture, not "log everything."
Say it out loud"Three layers: Sentry for error tracking with source maps and release tagging so prod traces are readable and I know which deploy caused a spike; Datadog RUM for real-user web vitals and latency; and product analytics like PostHog for behavior. The detail people miss is uploading source maps in CI — without it your stack traces are minified noise. And I sample traces and scrub PII because observability has real cost."

🌐 System Design (Frontend Focus)

15 Design a dashboard that scales to 10K concurrent users

First, reframe it like a senior would: "10K concurrent" is mostly a backend/infra problem — the static frontend scales trivially. The real frontend challenge is real-time data fan-out and rendering efficiency." Saying that up front shows you know where the hard part actually lives.

1. The app shell scales for free

The HTML/JS/CSS are just files. Put them on a CDN and 10K or 10M users cost the same per-user — edge nodes serve cached static assets. Zero origin load. This part is solved.

2. Real-time data is the actual problem

Why WebSockets don't trivially scale to 10KA naive "every user holds a WebSocket, server pushes every update to everyone" means each data change fans out to 10K sockets — and each server can only hold so many open connections. The fixes:
  • SSE (Server-Sent Events) if data flows one-way (server→client) — lighter than WebSocket, auto-reconnects, rides plain HTTP.
  • Managed pub/sub (Ably, Pusher, or a Redis pub/sub + message broker behind your own gateway) to handle connection fan-out at scale instead of your app server.
  • Server-side throttling/batching: don't push 50 updates/sec to the client — coalesce into one update every 250ms. The human eye can't use more anyway.

3. Rendering — don't melt the client

  • Granular subscriptions: each widget subscribes to its data slice, so one metric tick re-renders one widget, not the whole dashboard. (Selector store — Q1/Q3.)
  • Virtualization for big tables/lists (Q3) — constant DOM size.
  • Aggregate server-side: ship computed summaries, not 100k raw rows for the client to crunch.
  • Web workers for any heavy client-side computation so the main thread stays responsive (protects INP).

4. Resilience & data freshness

  • Reconnect with exponential backoff on socket drop; degrade gracefully to polling if sockets fail.
  • Optimistic UI for user actions; stale-while-revalidate for reads.
  • Backpressure: if updates arrive faster than render, drop/coalesce intermediate frames — show the latest, not every one.
Architecture / LLD lensThe mental model: separate the read path from the live path. Initial load = cacheable HTTP through CDN. Live updates = pub/sub with fan-out pushed off your app servers onto infrastructure built for it. The frontend's job is to subscribe granularly, throttle/coalesce, and render only deltas. The scaling bottleneck is connections and fan-out, not React — so the design moves that concern to the right layer (broker/edge) and keeps the client doing minimal, localized work.
Say it out loud"The static app scales via CDN trivially — the real challenge is real-time fan-out. I'd push connection handling to managed pub/sub or SSE, batch and throttle updates server-side, and on the client subscribe each widget granularly so one tick doesn't re-render the whole board. Aggregate server-side, virtualize big tables, offload heavy compute to web workers, and reconnect with backoff degrading to polling. The bottleneck is connections, not React, so I move that to infrastructure built for it."

16 Ensuring consistent design and behavior across modules

Consistency doesn't come from telling people to be careful — it comes from making the consistent path the only easy path. You enforce it with tooling, not discipline.

The layers of enforcement

  • Design tokens — one source of truth for color/space/type (Q5). Modules can't drift because they all consume the same tokens.
  • Shared component library — every module uses the same Button, Modal, Table. Behavior is consistent because it's literally the same code.
  • Linting + formatting as gates: ESLint, Prettier, Stylelint, run in CI and pre-commit hooks (Husky + lint-staged) so non-conforming code can't merge.
  • Shared TypeScript types / contracts — modules agree on data shapes; a contract change breaks the build, not production.
  • Visual regression (Chromatic) — catches unintended visual drift in PRs automatically.
  • Documentation (Storybook) — devs reach for the existing component because they can find and see it.

For micro-frontends specifically

The hard caseWhen modules are independently deployed apps (micro-frontends), consistency is harder because they don't share a build. Solutions: a versioned shared design-system package all MFEs depend on; Module Federation to share singleton dependencies (one React, one design system) at runtime; and contract testing so a shared API change can't silently break a consumer.
Architecture / LLD lensThis is the DRY principle applied to UI + "make the right thing the easy thing." The architectural choice is centralization vs autonomy: a shared library maximizes consistency but couples teams to its release cadence; full autonomy maximizes team speed but causes drift. The usual answer is shared primitives + tokens (centralized), composed freely per module (autonomous) — consistency at the foundation, flexibility at the edges. Automated gates (CI, visual regression) are what make consistency scale past the point where humans can police it.
Say it out loud"I make consistency the path of least resistance: shared tokens and a component library so modules use identical building blocks, enforced by ESLint, Prettier, and visual regression in CI so drift can't merge. For micro-frontends, a versioned design-system package plus Module Federation for shared singletons and contract testing. The principle is centralize the primitives, stay autonomous in composition, and automate the gates so consistency scales beyond human review."

17 Code splitting and lazy loading routes

The whole point: a user visiting the login page shouldn't download the admin dashboard's code. Route-level splitting maps your bundle to your navigation so people download only what they use.

The standard pattern

import { lazy, Suspense } from 'react';
import { Routes, Route } from 'react-router-dom';

const Dashboard = lazy(() => import('./routes/Dashboard'));
const Settings  = lazy(() => import('./routes/Settings'));

function App() {
  return (
    <Suspense fallback={<PageSkeleton />}>
      <Routes>
        <Route path="/dashboard" element={<Dashboard />} />
        <Route path="/settings"  element={<Settings />} />
      </Routes>
    </Suspense>
  );
}

lazy(() => import(...)) tells the bundler to split that route into its own chunk, fetched only when the route is hit. Suspense shows a fallback during the fetch.

The senior detail — prefetching to hide the latency

The trade-off lazy loading introducesLazy loading shrinks the initial bundle but adds a fetch delay when you navigate — click a link, wait for the chunk. The fix is prefetching: load the next route's chunk before the user clicks, on hover or when the link enters the viewport or during idle time. The user never feels the delay because the chunk's already there. Frameworks (Next.js) prefetch visible links automatically.

// Prefetch on hover — chunk is ready by the time they click
<Link
  to="/dashboard"
  onMouseEnter={() => import('./routes/Dashboard')}
>Dashboard</Link>

Where to draw split boundaries

  • Routes — the primary, highest-leverage boundary.
  • Heavy below-the-fold / on-interaction widgets — charts, rich-text editors, maps, modals.
  • Large conditional features — admin-only panels, rarely-used flows.
Caveat — don't over-splitSplitting every tiny component creates a request waterfall — dozens of round-trips, each with latency, often slower than one slightly bigger chunk. Split at meaningful boundaries (routes, heavy features), not everywhere. Measure the network panel.
Architecture / LLD lensThis is lazy loading + spatial/temporal locality applied to code delivery. The design tension: initial load time vs navigation latency. Aggressive splitting wins TTI (time-to-interactive) but risks janky navigation; prefetching resolves the tension by shifting the load earlier in time, off the critical path. The art is choosing boundaries that match real user journeys — split where behavior diverges, prefetch along the likely path.
Say it out loud"I split at route boundaries with React.lazy and dynamic imports so users download only the page they visit, then lazy-load heavy widgets like charts and editors. The catch is navigation latency, which I hide by prefetching the next chunk on hover or idle. The discipline is not over-splitting — too many tiny chunks become a request waterfall that's slower than one. I draw boundaries along real user journeys."

18 Ensuring accessibility (a11y) and cross-browser compatibility

Accessibility — semantics first, ARIA last

The principle that drives everythingThe #1 a11y mistake is <div onClick> instead of <button>. A real <button> is keyboard-focusable, fires on Enter/Space, announces its role to screen readers, and works for free. A clickable div gives a screen-reader user nothing. So: use semantic HTML first; reach for ARIA only when no native element fits. "No ARIA is better than bad ARIA" — wrong ARIA is worse than none.
  • Semantic HTML: <button>, <nav>, <main>, <label> for inputs, heading hierarchy.
  • Keyboard navigation: every interactive element reachable and operable by keyboard; visible focus indicators; logical tab order.
  • Focus management: open a modal → trap focus inside + return it on close. (This is why headless libs like Radix are worth it — Q5.)
  • Color contrast: WCAG AA — 4.5:1 for body text.
  • Screen-reader labels: aria-label on icon buttons, alt on images, live regions for dynamic updates.

How to test a11y

  • Automated: eslint-plugin-jsx-a11y (catches issues as you type), axe DevTools, Lighthouse a11y audit. Catches ~30–40% — the mechanical stuff.
  • Manual: the other 60% — unplug your mouse and navigate by keyboard; turn on VoiceOver (Mac) / NVDA (Windows) and listen to your key flows. Automation can't tell you if the experience makes sense.

Cross-browser compatibility

  • browserslist — declare your target browsers in one place; Babel, PostCSS, and Autoprefixer all read it.
  • Autoprefixer / PostCSS — adds vendor prefixes automatically based on your targets.
  • Babel — transpiles modern JS to what your targets support; polyfills (core-js) for missing APIs.
  • Feature detection over UA sniffing — check if ('IntersectionObserver' in window), don't parse the user-agent string (brittle, spoofable).
  • Progressive enhancement — core functionality works everywhere; enhancements layer on where supported.
  • Cross-browser testing: Playwright runs your suite across Chromium/Firefox/WebKit; BrowserStack/Sauce for real-device matrices.
Architecture / LLD lensBoth reduce to the same principle: build on a robust, standards-based foundation and layer enhancements on top — progressive enhancement. Semantic HTML is accessible and cross-browser by default because it's the platform's own contract. The architectural discipline is "don't reinvent what the platform gives you" — native elements, native APIs with feature detection, declared browser targets driving automated tooling. You make correctness the default and treat a11y/compat as build-time gates (lint, axe, Playwright matrix), not a manual QA afterthought.
Say it out loud"Accessibility starts with semantic HTML — a real button is keyboard-operable and screen-reader-friendly for free; ARIA is the last resort, because bad ARIA is worse than none. I automate with eslint-plugin-jsx-a11y and axe, then manually test keyboard-only and a screen reader on key flows since automation only catches a third. For cross-browser, browserslist drives Autoprefixer and Babel, I use feature detection over UA sniffing, and Playwright tests across engines. Both come down to progressive enhancement — build on the platform, layer on top."

🧮 DSA Prep — Concepts, Methods & Question Bank

What senior frontend DSA actually looks like: it's real, but it skews differently from generic SWE loops. Heavy on arrays, strings, hash maps, stacks/queues, recursion & trees (trees mirror the DOM), plus binary search and sorting fundamentals. Hardcore dynamic programming and advanced graph theory show up far less. Many rounds blend DSA with JS-implementation problems (debounce, deep clone, event emitter).

Senior-level expectationIt's rarely "did you solve it." It's: state the Big O before coding, pick the right data structure, handle edge cases, and explain the time/space trade-off. A working brute-force you can analyze and then optimize out loud beats a memorized optimal you can't explain.

⚙️ Core JS concepts (the ones DSA & interviews lean on)

Prototype chain & prototypal inheritance

Every JS object has a hidden link ([[Prototype]]) to another object. When you read a property, JS looks on the object, then walks up each link until it finds it or hits null. That walk is the prototype chain — it's how inheritance works in JS.

const arr = [1, 2, 3];
// arr.map() works even though arr has no `map` of its own:
// arr → Array.prototype (map, filter, push…) → Object.prototype (toString…) → null
Object.getPrototypeOf(arr) === Array.prototype;            // true
Object.getPrototypeOf(Array.prototype) === Object.prototype; // true
Object.getPrototypeOf(Object.prototype) === null;            // end of chain
  • ES6 class is sugar over thisextends just wires one prototype to another. No classical inheritance underneath; it's prototypes all the way down.
  • hasOwnProperty checks only the object itself (not the chain) — useful when iterating to skip inherited props.
  • Perf: a property found 4 links up costs 4 lookups vs 1. Deep chains in a hot loop add up — though engines optimize heavily.
arr = [1,2,3] own: length, 0,1,2 Array.prototype map, filter, push… Object.prototype toString, hasOwnProperty… null [[Proto]] [[Proto]] [[Proto]] arr.toString() → not on arr → not on Array.prototype → FOUND on Object.prototype ✓ (if it reached null without finding it → returns undefined)

Property lookup walks the chain left → right until found (or hits null).

access obj.prop on the object itself? return value ✓ has a prototype (not null)? obj = obj's prototype return undefined yes no yes no retry lookup ↑

The lookup algorithm as a flowchart — this loop is the prototype chain.

Closures

A function remembers the variables from the scope where it was created, even after that outer function has returned. This is what powers debounce/throttle (the timer lives in the closure), memoization (the cache lives in the closure), and module privacy.

function counter() {
  let count = 0;              // private — survives because the inner fn closes over it
  return () => ++count;
}
const next = counter();
next(); next(); // 1, 2 — `count` persists between calls
counter() scope let count = 0 (lives on the heap) () => ++count the returned inner function closes over ↑ count const next = counter() counter() has returned — but `count` is NOT garbage-collected next() → 1, next() → 2 … returned

The inner function keeps a live reference to count, so it survives after counter() returns — that's the closure.

Hoisting, this, == vs ===, value vs reference

  • Hoisting: var & function declarations are moved to the top of scope (var = undefined until assigned). let/const hoist too but sit in the temporal dead zone — accessing before declaration throws.
  • this: determined by how a function is called, not where it's defined. Arrow functions have no own this — they inherit it from the enclosing scope (why they're used for callbacks).
  • == vs ===: == coerces types (0 == '' is true); === doesn't. Always use ===.
  • Value vs reference: primitives copy by value; objects/arrays copy by reference. This is why a shallow copy ({...obj}) shares nested objects — and why deep-clone questions exist.

📚 Array methods — full reference (mutates vs pure)

Interview goldKnowing which methods mutate the original array vs return a new one is a classic probe — and a real source of bugs (mutating state in React). The mutators: push, pop, shift, unshift, splice, sort, reverse, fill, copyWithin. Everything else is pure.
MethodDoesBig ODSA use
push / popadd/remove at endmutatesO(1)stack
shift / unshiftremove/add at startmutatesO(n)queue (shift is slow — reindexes)
spliceadd/remove/replace anywheremutatesO(n)insert/delete mid-array
slicecopy a sub-rangepureO(n)copy without mutating; sliding window
sortsort in place (default lexicographic!)mutatesO(n log n)pass comparator: (a,b)=>a-b for numbers
reversereverse in placemutatesO(n)two-pointer problems
maptransform each → new arraypureO(n)transform
filterkeep matching → new arraypureO(n)subset selection
reducefold to a single valuepureO(n)sum, group-by, build frequency map
forEachiterate (no return)pureO(n)side effects
find / findIndexfirst match (value / index)pureO(n)search
some / everyany / all match → booleanpureO(n)validation, short-circuits
includes / indexOfmembership / positionpureO(n)presence check (use Set for O(1))
flat / flatMapflatten nested / map+flattenpureO(n)nested arrays
concatmerge → new arraypureO(n)combine
joinarray → stringpureO(n)build output string
Array.from / spreaditerable/array-like → arraypureO(n)copy, dedup with Set: [...new Set(a)]
fill / copyWithinfill range / copy range in placemutatesO(n)init arrays: Array(n).fill(0)

🔤 String methods — full reference

Key factStrings are immutable in JS — every "modifying" method returns a new string; none mutate. To build a string in a loop efficiently, push chars to an array and join('') at the end (repeated += can be O(n²) in the worst case).
MethodDoesBig ODSA use
charAt / s[i]char at indexO(1)access
charCodeAt / codePointAtchar → number codeO(1)anagram counts, char math
slice / substringextract sub-stringO(n)sliding window (slice allows negatives)
splitstring → arrayO(n)split('') to char array; split(' ') words
indexOf / includesfind / contains substringO(n·m)substring search
startsWith / endsWithprefix / suffix checkO(n)matching
toUpperCase / toLowerCasechange caseO(n)case-insensitive compare
trimremove surrounding whitespaceO(n)input cleanup
replace / replaceAllswap matches (regex or string)O(n)sanitize, transform
repeatrepeat string n timesO(n)build patterns
padStart / padEndpad to lengthO(n)formatting, fixed-width
match / matchAllregex matchesvariespattern extraction

🗺️ Map & Set — your O(1) lookup workhorses

The single biggest frontend-DSA leverMost "optimize from O(n²) to O(n)" answers = use a hash map or set for O(1) lookups instead of nested loops. Frequency counts, "seen" sets, two-sum, dedup — all hinge on this. Know these cold.
Structure / opDoesBig ODSA use
new Map()key→value, any key type, keeps insertion orderfrequency maps, memo cache, adjacency list
map.set / get / has / deletecore opsO(1)count chars, lookup
new Set()unique valuesdedup, "visited" tracking
set.add / has / deletecore opsO(1)seen-before check, cycle detection
{} plain objectstring keys only, no order guarantees for some keysO(1)*quick counts — but prefer Map for non-string keys
// Frequency map (the #1 frontend-DSA pattern)
const freq = new Map();
for (const ch of str) freq.set(ch, (freq.get(ch) || 0) + 1);

// Two-sum in O(n) with a Map of value→index
function twoSum(nums, target) {
  const seen = new Map();
  for (let i = 0; i < nums.length; i++) {
    const need = target - nums[i];
    if (seen.has(need)) return [seen.get(need), i];
    seen.set(nums[i], i);
  }
}

🎯 The core patterns to recognize

PatternSignal it appliesTypical Big O
Hash map / set"have I seen this?", counts, pairs summing to targetO(n)
Two pointerssorted array, pair/triplet, palindrome checkO(n)
Sliding window"longest/shortest substring/subarray with…"O(n)
Stackmatching brackets, "next greater", undo, nestingO(n)
Recursion / DFStrees, nested structures, the DOM, permutationsO(n)
BFS (queue)shortest path, level-order, "nearest"O(n)
Binary searchsorted input, "find threshold/boundary"O(log n)

📝 Frontend-flavored DSA question bank

Easy — get these instant Easy

Show problems
  • String palindrome (two pointers)
  • Reverse a string / array (two pointers)
  • Find duplicates in array (Set)
  • Valid anagram (frequency map)
  • Balanced brackets (stack) — extremely common
  • First non-repeating character (frequency map)
  • FizzBuzz, two-sum (Map)
  • Flatten a nested array (recursion) — frontend favorite

Medium — the bread and butter Medium

Show problems
  • Longest substring without repeating chars (sliding window) — top-asked
  • Group anagrams (map of sorted-key → list)
  • Binary search & variants (find boundary)
  • Merge intervals (sort + sweep)
  • Binary tree traversals (DFS in/pre/post, BFS level-order) — DOM-relevant
  • Implement JSON.stringify / deep clone (recursion) — frontend signature problem
  • Debounce / throttle / event emitter / curry (closures — the DSA/JS boundary)
  • LRU cache (Map + ordering) — very common at senior level

Hard — senior/staff differentiators Hard

Show problems
  • Minimum window substring (sliding window + map)
  • Serialize / deserialize a binary tree
  • Merge K sorted lists (heap)
  • Implement a Promise / Promise.all from scratch (async + DSA)
  • Virtual DOM diff (simplified) — tree comparison, very frontend
  • Word finder with wildcards (trie + DFS)

📚 Sources — DSA practice for frontend (cite these)

  1. GreatFrontend — Algorithmic Coding Questions — 92 DSA problems curated for frontend engineers, filterable by topic/company/difficulty (sorting, stack, trees, DFS/BFS, sliding window, intervals, binary search).
  2. Front End Interview Handbook — Algorithms & Data Structures — which DS&A topics matter for frontend and how to prioritize.
  3. BigFrontEnd.dev — large bank of JS + algorithm problems with an in-browser editor.
  4. LeetCode — filter by the frontend-relevant tags: Array, String, Hash Table, Two Pointers, Sliding Window, Stack, Tree, Recursion, Binary Search.
  5. NeetCode 150 — the curated pattern-based list; focus on Arrays/Hashing, Two Pointers, Sliding Window, Stack, Trees for frontend.
  6. sudheerj/javascript-interview-questions — JS fundamentals (prototype chain, closures, hoisting, this).

Topic emphasis above synthesized from GreatFrontend's 92-question algo bank and the Frontend Interview Handbook (fetched June 2026). Frontend DSA leans on arrays/strings/hash maps/trees/recursion; deprioritize heavy DP and advanced graphs unless the company is known for them.

🛠️ Frontend LLD & Machine Coding — Practice

Frontend "low-level design" (LLD) rounds are usually machine-coding: build a working component live in 45–90 min, or design a component's API + architecture on a whiteboard. They test what the theory questions can't — can you actually ship a correct, accessible, performant widget under time pressure.

The framework to run every machine-coding round

  1. Clarify scope & cut it down (2–3 min). "Should the carousel autoplay? Keyboard support? Infinite loop?" Agree on an MVP first — finishing a small thing beats half-building everything.
  2. Sketch the component API & state (3–5 min). What props? What's the minimal state? What lives where? Say it out loud before typing.
  3. Build the MVP, talk as you go. Get something rendering fast, then layer features.
  4. Then address the cross-cutting concerns — this is where senior candidates separate: accessibility (keyboard + ARIA), performance (debounce/throttle, cleanup, virtualization), edge cases (empty/error/loading), and cleanup (timers, listeners, AbortController).
  5. Narrate trade-offs. "I'd cache responses here; I'm skipping it for time but here's how." Interviewers score communication as much as code.
What they're really scoringCorrectness, component API design, state modeling, handling async & race conditions, accessibility, performance instincts (cleanup, debouncing), edge cases, and communication. Not "did you finish" — how you think while building.

🟢 Tier 1 — JS utilities (warm-ups, often a first round)

Implement debounce & throttle Easy

Write debounce (fire after calls stop) and throttle (fire at most once per interval). Then explain where each is used in a UI.

Approach & what they probe
  • Debounce: clear a timer on each call, set a new one; fire when quiet. Use for search-as-you-type, resize.
  • Throttle: ignore calls until the interval elapses. Use for scroll, mousemove.
  • Probes: closures, this/args forwarding, leading/trailing options, a cancel() method.

Event Emitter / Pub-Sub Easy

Build on, off, emit, once.

Approach & what they probe
  • A map of event → set of callbacks. off must remove the exact reference; once wraps and self-removes.
  • Probes: data structure choice (Map + Set), memory leaks from never removing listeners.

Reimplement Promise.all / Promise.race Easy

Hand-roll the promise combinators.

Approach & what they probe
  • all: track a counter + results array by index, resolve when counter hits length, reject on first rejection.
  • Probes: async reasoning, preserving order, empty-array edge case.

🟡 Tier 2 — Core components (the bread-and-butter round)

Autocomplete / Typeahead Medium

Search input that fetches suggestions as you type, with a dropdown of results. The single most-asked frontend component.

Approach & what they probe
  • Debounce the input so you don't fire a request per keystroke.
  • Cancel stale requests with AbortController — a fast typist must not see an old response overwrite a new one (race condition).
  • Cache results per query; handle loading / empty / error states.
  • Accessibility: arrow-key navigation, Enter to select, Esc to close, aria-activedescendant, role="combobox".
  • Probes: this one question covers debounce, async races, caching, and a11y at once — that's why it's a favorite.

Modal / Dialog Medium

Accessible modal with backdrop.

Approach & what they probe
  • Focus trap (Tab cycles inside), restore focus to the trigger on close, Esc to close, click-outside, role="dialog" + aria-modal.
  • Render via portal so it escapes parent overflow/z-index. Lock body scroll.
  • Probes: this is the #1 accessibility deep-test — focus management is hard and reveals real DOM knowledge.

Tabs / Accordion Easy–Med

Tabbed panels (or collapsible accordion).

Approach & what they probe
  • Single source of truth for active tab; arrow-key roving tabindex; aria-selected, role="tab"/tabpanel".
  • Probes: controlled vs uncontrolled API, keyboard a11y, clean component composition.

Star Rating Easy

Interactive star rating with hover preview.

Approach & what they probe
  • Two states: committed rating + hover rating. Render filled up to hover ?? rating.
  • Probes: derived state, keyboard support, half-stars as a stretch.

Todo List / Data Table Medium

CRUD list, or a sortable/filterable/paginated table.

Approach & what they probe
  • Normalize state, stable keys (never index), derive filtered/sorted views with useMemo.
  • Table stretch goals: client vs server pagination, column sort, row selection.
  • Probes: state modeling, key correctness, separating data from derived view.

🔴 Tier 3 — Hard / scale (senior & staff rounds)

Infinite Scroll / Virtualized List Hard

Feed that loads more on scroll and stays smooth at 10k+ items.

Approach & what they probe
  • IntersectionObserver on a sentinel element to trigger the next page (better than scroll listeners).
  • Virtualization so DOM size stays constant regardless of item count.
  • Cursor pagination, dedup, loading/error/end states.
  • Probes: performance at scale, observer cleanup, combining paging + windowing.

Design a News Feed (component-level) Hard

Whiteboard the architecture: data fetching, caching, pagination, optimistic actions, rendering at scale.

Approach & what they probe
  • Component tree + data flow, server-state caching (stale-while-revalidate), infinite scroll, optimistic likes, image lazy-loading, accessibility.
  • Probes: this is frontend system design — see the awesome-front-end-system-design repo in Sources for the RADIO framework (Requirements, Architecture, Data, Interface, Optimizations).

Design a Component Library / Design System Hard

API design for reusable components, theming, a11y, versioning.

Approach & what they probe
  • Headless behavior + token-driven styling, controlled/uncontrolled APIs, forwardRef, polymorphic as, Storybook + visual regression. (Full answer in Q5.)
  • Probes: API discipline, consistency-at-scale thinking, a11y by default.

📚 Sources — practice banks & where these came from (cite these)

  1. GreatFrontend — UI Coding Questions — 59 build-this-component questions across Easy/Medium/Hard (counter, tabs, carousel, modal, data table, autocomplete, file explorer, etc.). The most comprehensive curated bank.
  2. GreatFrontend — JavaScript Utility Questions — debounce, throttle, memoize, curry, deep clone, Promise.all/race/any, event emitter, promisify.
  3. awesome-front-end-system-design (GitHub) — frontend system-design case studies (news feed, autocomplete, carousel, chat, e-commerce) and the RADIO framework.
  4. Front End Interview Handbook — open-source companion covering coding, system design, and behavioral rounds.
  5. BigFrontEnd.dev (BFE) — large bank of JS implementation + coding problems with a practice editor.
  6. sudheerj/javascript-interview-questions (GitHub) — 1000+ JS questions, widely used for fundamentals.

Component lists above were compiled from GreatFrontend's UI & JS question banks and the awesome-front-end-system-design repo (fetched June 2026). Verify exact prompts on each site — banks update over time.