Study Index

Interview Prep · Senior Frontend · Technical Round 1

Senior Frontend Interview — Round 1

The screening technical round: a strong self-intro, how you break a requirement into work, then the JavaScript, TypeScript, React, and design-pattern questions that get asked over and over — plus the small live-build tasks (typed localStorage, a permissions helper, a useDebounce that actually works) they use to check you can actually code.

Updated July 2026Read ~55 minUse read answers aloud
Round 1 is a signal check, not a deep dive. In ~45–60 min they want to confirm three things: you communicate clearly, you know the fundamentals cold (no fumbling on closures, this, or a stale useEffect), and you can write small correct code under mild pressure. Seniority shows up not in trivia but in how you talk about trade-offs, failure modes, and why — always give the answer, then one sentence of "here's when it bites."
1.0

Your intro & work experience

The first 3–4 minutes set the tone. A rambling "tell me about yourself" makes the interviewer work to find your signal; a tight one makes them relax and trust you. Have this memorised as a shape, not a script — you want it to sound spoken, not recited.

1.1

"Tell me about yourself" — the shape

Use Present → Past → Why-here. Keep it to ~60–90 seconds. Lead with what you do now and the scale/impact, pull one or two threads from your past that explain how you got good, and land on why this role.

Present: who you are now + scope Past: the 1–2 experiences that shaped you Why here: what you want next
Fill-in template "I'm a frontend engineer with [X] years building [type of product]. Right now I work mostly in [React/TS + your stack], and most recently I [owned/led/shipped X] — the part I'm proudest of is [measurable outcome or hard problem]. Before that I [earlier role/how you levelled up]. These days I care a lot about [performance / DX / accessibility / architecture], which is why [this role/team] stood out to me."
Worked example — your real history "I'm a frontend engineer with around five years of experience, focused on React and TypeScript. For all five I've been at Bluesapling working on BestWe — an enterprise platform that helps organisations improve team effectiveness: it tracks engagement, collaboration, and performance through structured surveys and metrics, so teams can find and close gaps in their health with data rather than guesswork. My most interesting recent work there was the Genius AI assistant. The hard part wasn't only streaming the AI's responses — it was that Genius had to live in four different places in the product, each with its own UI, but behave identically. I built the shared logic as a custom hook over Server-Sent Events that owned the whole lifecycle — request, payload, the live stream, and routing each chunk to the right screen's reducer — and split the UI into reusable core and wrapper components, so the streaming UX stayed consistent while every surface kept its own layout. I really enjoy that kind of work — designing something reusable that stays fast and consistent as it spreads across the app — which is why [this role] stood out to me."
Last placeholder Only [this role] is left — tailor that line per company (name what about their team or product drew you). Optionally add one measurable outcome from the Genius work — duplication removed across the four surfaces, a perf/latency number, or an adoption figure. A concrete number is what tips this from "solid" to "senior."
Owning the "only one company?" question Five years on one product at Bluesapling is a depth-and-ownership signal — frame it that way: you've seen BestWe grow, owned the frontend as it scaled, and made architectural calls you had to live with. If they probe for breadth, pivot to the range within the product — an AI streaming layer, a branching session runtime, state-architecture decisions — which is exactly what §1.3–1.4 give you.
Gotcha Don't narrate your whole résumé chronologically ("first I did…, then I did…"). They have the résumé. Pick the 2–3 threads that prove the seniority claim and drop the rest.
1.2

"Walk me through a project" — use STAR, but lead with the hard part

Senior signal is ownership and judgment, not feature lists. Frame the project as a problem you diagnosed and a decision you made, using Situation → Task → Action → Result — but front-load the interesting technical tension so they lean in.

Weak
Feature tour

"BestWe has surveys, dashboards, team-health metrics, a feedback view, a profile page…" — a list of screens. No decision, no you.

Strong
Decision story

"Genius needed to work in four different surfaces with different UI but identical behaviour. Instead of four implementations I built one shared SSE custom hook plus reusable core/wrapper components — one source of truth for the logic, consistent streaming UX everywhere."

Have 2–3 stories ready that each showcase a different muscle. You have strong ones for each — the full write-ups are in §1.4:

1.3

Your signature story — Genius (ready to read, ~2 min)

This is your strongest project story — memorise the shape, not the words. Lead with the tension (same feature, four surfaces), then the design (shared SSE hook + core/wrapper split), then the payoff (reuse + consistent UX). Pause between paragraphs; it's ~2 minutes spoken slowly.

"One of the more interesting features I worked on recently was Genius, our AI assistant, which we rolled out across multiple parts of the app.

The key challenge was that it wasn't a single feature — the same AI interaction had to work in four different places, each with different UI and slightly different state handling, but the behaviour had to stay consistent.

Instead of building four separate implementations, I designed a shared streaming layer over Server-Sent Events. I created a custom hook that owned the whole lifecycle — starting the request, forming the payload, managing the stream connection, handling incremental responses, and routing the data to the right reducer based on the active context.

On top of that I separated the rendering from the data layer: the core logic stayed shared, and each surface used its own wrapper components for layout. That kept the UX consistent — how messages stream in, how typing states appear, how actions are handled — while still allowing flexibility per screen.

I also handled the messy edge cases — retries, partial streams, and making sure that if a user navigates away mid-response and comes back, the stream state stays consistent.

Overall it let us reuse the same underlying logic across four consumers, cut duplication, and made the AI features much easier to maintain and extend."

What this quietly signals Ownership ("I designed"), systems thinking (separation of concerns), scalability (reused across four surfaces), UX awareness (consistent streaming behaviour), and real-world rigor (retries / partial streams / resume). If they dig in, be ready to go deeper on any one layer — the hook's lifecycle, why SSE over WebSockets or fetch-streams, or how the reducer routing worked.
1.4

More stories to pull from — deep-dive backups

Three fuller stories, each flexing a different senior muscle: a scope decision, a concurrency firefight, and a subsystem architecture. Don't memorise them — hold the tension, the decision, and the one-line lesson (the green line at the end of each), and be ready to go as deep as they push. Expand as needed.

A.Scoping the active round to a session — the time I didn't reach for Redux

The tension. The "active round" — a time-boxed cycle everything keys off — already lived in global Redux state (backed by localStorage, read everywhere via helpers). A new requirement: inside a session, let a user peek at a previous round's data and come back — without changing the round for other users, or even for the rest of their own app. A temporary, local lens.

The instinct I distrusted. The default here is Redux, so I sketched a viewingPreviousRound flag + temporaryRoundId in a reducer. The moment I wrote the cleanup dispatch, I got uncomfortable: global state you must remember to tear down is a leak. If the router yanks the user away (deep link, back button) the override never clears — now the whole app thinks they're in the previous round. Every failure mode existed only because the state was global, for something whose whole point was to be narrow.

The decision. I flipped it: the temporary round lives in a React Context scoped to the session subtree, with localStorage as the only durable backing. The override's lifetime = the provider's lifetime — unmount the session and it's gone automatically. The scope is the cleanup. Consumers get a naive API (useActiveRound() / useSetActiveRoundId()) and never know they're on a real round or a temporary lens. I left the reasoning in the file so no one "helpfully" promotes it back to Redux.

/**
 * Stores activeRoundId only within this Context and localStorage (not a global
 * reducer), as global state is not needed here. Enables temporary switching to a
 * previous round locally within a session without affecting global app state or
 * switching rounds for all users.
 */

The lesson: "Should this be global?" is one of the most consequential questions in a frontend — pick the smallest scope that satisfies the requirement, not the most familiar one. Context + localStorage removed a whole class of bugs by construction instead of guarding against them one dispatch at a time.

If they dig in: how an effect resolves the real previous-round id when usingPreviousRound flips on and snaps back to initialRoundId when it flips off; why localStorage is still needed (durability across reload); the Context re-render trade-off.

B.The stream executor — turning a race-condition minefield into one small state machine

The tension. Genius streams AI over SSE. It started simple (open → pipe chunks into Redux → close), then reality piled on: multiple background streams per slide (depth levels), ad-hoc user questions that must jump the line, retries with backoff (but not for interruptions we caused), and navigation while all of that is in flight. The naive "every component opens its own stream" produced overlapping connections into the same Redux buckets, retries firing against superseded streams, dead watchdogs killing live streams, and a "Regenerate" button that appeared or not depending on callback order — emergent bugs, not one-at-a-time ones.

The decision. One owner. A singleton service is the only thing allowed to open an SSE connection; everything else enqueues intent and reacts to Redux. It holds a queue and exactly one active entry — that "one at a time" constraint turns an N-way race into a sequence of well-defined transitions. The queue is a priority queue with preemption: an ad-hoc question aborts the active background stream, pushes it back to the front to replay later, and runs now; duplicate background streams dedupe; after an ad-hoc, background prefetch for that slide is paused until the user navigates away.

Where the time actually went — every place asynchrony let two truths coexist:

  • The stale watchdog (aborts a stalled connection) kept ticking after preemption and fired against the next stream. Fix: make it per-invocation — each attempt owns its interval, cleared in a finally on every exit path. A stream can't reach out of its grave and kill its successor.
  • Abort-vs-timeout ambiguity: the SSE client resolves (doesn't reject) on abort, so an intentional preemption and a real timeout looked identical — and the shared abortController may already have been replaced. Fix: a local isWatchdogTimeout flag captured in the closure. Local truth beats shared truth when everything's moving.
  • A retry reconnecting the wrong stream: if an ad-hoc preempted during the backoff window, a naive null-check would reconnect and run two streams at once. Fix: capture the streamId at schedule time and bail if it no longer matches — identity, not presence.
  • The missing Regenerate button: the shared field could be nulled by a newer stream before the catch ran. Fix: store the failed stream from the closure params, never the shared field another stream can stomp.

The lesson: in concurrent code, prefer values captured in local scope over fields read off shared mutable state — between the write and the read, the world moved. Read live from the store when you need current truth; hold captured closures when you need the truth as of schedule time — knowing which you're in is the whole game.

If they dig in: why a singleton over per-component streams; how preemption replays a pushed-back entry; why SSE resolves-on-abort matters; how depth-level buckets nest so preempting an in-depth stream leaves the core answer's cache intact.

C.Integrated Sessions — a guided, branching, multi-modal experience engine

What it is. From the outside, a slideshow with a chatbot beside it. Underneath, the most involved subsystem I've built here: a content-driven, branching, multi-mode session runtime. Sessions are authored in Notion, rendered as slides, narrated by Genius, and the user walks a path that branches on their choices and learning level.

The design tensions I resolved.

  • Untrusted external content. Slides come from the Notion API — rich-text arrays, per-learning-level columns, flags inferred from an icons column that might be multi_select or relation. A defensive translation layer turns that into a stable internal Slide shape. The mess stops at the boundary — downstream never sees a raw Notion row.
  • One session, four modes (Scan / Chat / Read / Listen): four projections of one state tree with mode as a parameter, not four forked components that drift apart.
  • Navigation is a graph, not a line. One option ⇒ auto-advance; many ⇒ a branch the user chooses. That meant real graph traversal — walk single-option chains and stop at branches, reconstruct the jumped path with cycle guards (authored content will eventually loop), and clear now-invalid downstream state when a user diverges from a previously-walked path.

The bottleneck. Narrating lazily would stall every slide transition on a cold AI call; naive prefetch would fan out exponentially down branches and hammer the backend. Resolution: prefetch only along deterministic single-option chains, stop at branches, hard-cap the blast radius, and dedupe in-flight requests. Crucially, the prefetcher doesn't open connections itself — it hands intent to the stream executor (story B). The two were co-designed: the prefetcher can be aggressive precisely because the executor preempts cleanly when the user interrupts with a real question.

The lesson: containment. Each source of complexity is walled into one place — Notion messiness into the translation layer, streaming into the executor, path logic into the traversal helpers. No single file holds the whole beast, which is the only reason it stays maintainable.

Smaller pieces for range If they want more: depth-level nested streaming (preempting an in-depth answer wipes only its bucket, the core answer survives — a small data-model decision with a big correctness payoff); a watchdog liveness signal that treats an SSE stall as a retryable failure the protocol doesn't give you; a Listen mode that sequences narration audio over the same slide state (accessibility without a parallel data path); and Simulation slides — an extension point that drops fully interactive components into the linear flow with no per-session code.
1.5

Behavioural questions that sneak into a tech round

Q.What's the hardest bug you've debugged?
Pick one with a surprising root cause (a race condition, a stale closure, a memory leak from an un-cleaned subscription). Walk the method: reproduce → isolate → form a hypothesis → confirm with a tool (profiler, network tab, logs) → fix → add a guard/test. The method matters more than the bug.
Q.Tell me about a technical decision you disagreed with.
Show you can disagree and commit. Describe the trade-off honestly, how you argued your case with data (bundle size, a benchmark, an accessibility gap), and that once the team decided you supported it — then note what you learned. Never trash a past team or teammate.
Q.Why are you leaving / looking?
Frame it forward, not away: growth, scope, a domain you want, ownership — not "my manager was bad." Keep it to one or two sentences and pivot back to what excites you about this role.
Q.What does "senior" mean to you?
"Owning outcomes, not just tickets — I can take an ambiguous problem, scope it, make and defend technical trade-offs, unblock others, and leave the codebase and the people around me better." Then back it with one concrete example.
1.6

"How do you break a requirement down and split it across the team?"

This is the question you froze on. It's not really about project management — they're checking whether a vague sentence from a manager turns into a plan with edge cases in your head, or into code you rewrite three times. You don't need to have led a team to answer it. You need a process, and one honest example of using it on a feature.

A requirement arrives as an outcome ("clients want to see last quarter's results"), and your job is to turn it into flows → slices → tasks, with the unknowns surfaced early instead of discovered late. The order matters: understand why before what, and what before who does it.
Clarify the why Map the flow Slice it thin Hunt edge cases Assign by shape Close the loop
1.6.1

The six steps, with what you actually do in each

1.Clarify the why — ambiguity is cheapest today

The requirement has been through a game of telephone: client → manager → you. So the first thing I do is restate it back as an outcome and check I've got it: "So a team lead wants to compare this round with the previous one, without losing where they are — is that right?" That one sentence catches most misunderstandings for free.

Then three questions I try to always ask, because the answers change the design:

  • Who is this for, and what are they doing when they need it? A feature for an admin once a quarter and a feature for every user daily are different builds.
  • What does "done" look like from the client's side? The acceptance criteria, in their words — this becomes my definition of done later.
  • What's not in this? Explicitly naming what we're not building now is the cheapest scope control there is.

Question I ask when the requirement feels thin: "what happens today, and why is that painful?" The pain tells you what actually matters when you have to cut something.

2.Map the flow, not the features

I don't start by listing components. I walk the user's path out loud, step by step: they land here → they click this → they see this state → they pick an option → this changes. Each step in that walk is a candidate task, and — more usefully — each arrow between steps is where the edge cases live (what if it's slow, what if it's empty, what if they go back).

Writing the flow down is also how I catch requirements nobody stated. "They pick a previous round" quietly implies: there's a list of rounds, it has an order, it can be empty, and one of them is the current one. None of that was in the requirement, all of it is work.

3.Slice it vertically — thin, shippable, demoable

The instinct is to split horizontally — "one person does the API layer, one does the components, one does the state." It feels tidy and it's a trap: nothing works until everything works, and you find out on the last day that the pieces don't fit.

Instead I slice vertically: each slice is a thin end-to-end path that you can actually demo. Slice 1 might be "read-only, one hardcoded round, no switching, but it renders real data." That's ugly and useful — it proves the data shape, the contract, and the layout in one go, and everything after it is an increment on something real.

Horizontal
Split by layer

"You do the API, you do the UI, you do the state." Integrates once, at the end, badly. Everyone is blocked on everyone.

Vertical
Split by user-visible slice

"Slice 1: it renders one round read-only. Slice 2: you can switch. Slice 3: it survives a refresh." Each one ships and each one teaches you something before the next.

My rule of thumb for size: if a task can't be described in one sentence with a clear "done," it's still two tasks.

4.Hunt edge cases with a checklist, not with inspiration

This is the part interviewers are most listening for, and the trick is that it isn't creativity — it's a checklist you run every time. I go through the flow I mapped in step 2 and ask the same questions at each step:

  • States: loading, empty, error, partial, success. Every screen has all five whether you designed them or not — undesigned ones just look like bugs.
  • Counts: zero, one, many, absurdly many. Zero rounds, one round, 400 rounds — does the UI still make sense? Do I need pagination or virtualisation?
  • Boundaries & bad data: a very long name, a missing field, a null the API "never" sends, a different timezone or locale.
  • Permissions: who can see this, who can't, and what does the "can't" case look like? (And the client check is a UX affordance — the server still has to enforce it.)
  • Timing: slow network, the request that resolves after the user moved on, a double-click, two things in flight at once. This is where the real bugs are.
  • Navigation: refresh, back button, deep link into the middle of the flow, open in two tabs.
  • Reach: mobile width, keyboard-only, screen reader, translated text that's 40% longer.

I write the answers into the ticket as acceptance criteria. The important half is the deliberate deferrals: "400 rounds isn't handled — we'll paginate when a client passes 50." Saying that out loud turns a future bug report into a known, priced decision.

"Undesigned states are the same as broken states" — I'd rather agree on an ugly empty state now than ship a blank screen.

5.Assign by shape of the work, not by size of the person

Once it's sliced, delegation is mostly matching the shape of a task to who should hold it:

  • The uncertain/risky slice goes first, and to whoever can absorb the surprise — often me. If the streaming layer or the tricky state model is going to change the plan, I want it changed in week one, not week three.
  • Well-specified slices go to whoever's newer, with the acceptance criteria and an example to copy from. A junior with a crisp ticket outperforms a senior with a vague one.
  • Define the seams first so people can work in parallel. Agree the types/props/contract up front — once the interface is fixed, two people can build on either side of it without stepping on each other, and nobody is blocked waiting for the API to be real.
  • Nobody owns half a slice. One person owns a slice end to end; that way "done" is unambiguous and there's no seam where responsibility falls through.
6.Close the loop — early and in small pieces

The requirement came through two people, so my assumptions will be wrong somewhere. I send the breakdown back to my manager as flows and open questions before we start — "here's what I understood, here's what I'm assuming, here's what I need answered" — because a wrong assumption caught in a message costs minutes and the same one caught in review costs a week.

After that, small demos beat status updates. Showing slice 1 gets you a real correction from the client; "60% done" gets you nothing.

1.6.2

What to actually say (~90 seconds, honest version)

"I'll be upfront that I've done this mostly at feature scale rather than running a whole team's roadmap — but the process I follow is the same either way.

When a requirement comes down from a manager, it's usually an outcome, not a spec — and it's been through the client and the manager before it reached me. So the first thing I do is restate it back as a user outcome and check I've got it right, and ask what 'done' looks like from the client's side. That one conversation catches most of the misunderstandings.

Then I map the user flow step by step instead of listing features, because the gaps show up between the steps. From that flow I slice the work vertically — thin end-to-end pieces you can actually demo — rather than splitting it by layer, where nothing works until everything works.

Edge cases I handle with a checklist rather than inspiration: for every step, what does loading, empty, and error look like; zero, one, and a thousand items; who's allowed to see it; what happens on a slow network, a refresh, or the back button. I put the answers in the ticket as acceptance criteria — and I write down the ones we're deliberately not doing, so they're a decision instead of a surprise.

For splitting it up, I try to take the uncertain piece myself so the risk lands early, give the well-specified slices to whoever's newer with clear acceptance criteria, and agree the interfaces up front so people can work in parallel without blocking each other.

Then I send the breakdown and my open questions back up before we start — the assumptions are cheapest to fix when they're still in a message."

Your real example, if they ask for one Use the active-round work (§1.4 A). The requirement was an outcome — "let a user look at a previous round without it affecting anyone else." Walking the flow is what surfaced the real questions: what happens on refresh, what happens if they deep link away mid-session, does this leak to other users. Those edge cases were the design — they're exactly why it became a session-scoped Context instead of global Redux. That's a clean story of edge cases driving architecture, and it's true.
Gotcha Don't answer this like a project manager ("I'd create epics and story points…"). They want the engineering judgment: how you find the unknowns, how you sequence risk, how you keep people unblocked. And don't pretend to more team experience than you have — "I've done this at feature scale, here's the process" is a strong, credible answer; an invented one collapses on the first follow-up.
2.0

JavaScript — the fundamentals they always probe

These are the "prove you actually understand the language" questions. Answer with the definition first, then a one-line gotcha. If you can, reach for a tiny example.

Q.What is a closure? Give a real use.
A closure is a function bundled with the lexical scope it was created in — it keeps access to those outer variables even after the outer function returns. Real uses: data privacy (module pattern), function factories, memoization, and every React hook (state is held in a closure). Gotcha: closures capture variables, not values, which is the classic for (var i…) setTimeout bug.
Q.Hoisting, the TDZ, and var vs let vs const?
Declarations are moved to the top of their scope at compile time. var is function-scoped and initialised to undefined (so reading it early gives undefined). let/const are block-scoped and hoisted too, but live in the Temporal Dead Zone until the line that declares them — reading early throws ReferenceError. const can't be reassigned (but the object it points to is still mutable). Function declarations are fully hoisted; function expressions are not.
Q.How does this work?
In a normal function, this is set by how it's called: method call → the object; plain call → undefined (strict) or global; new → the new instance; call/apply/bind → whatever you pass. Arrow functions have no own this — they capture it lexically from where they're defined, which is why they're great for callbacks and useless as object methods.
Q.Explain the event loop — micro vs macro tasks.
JS is single-threaded. The engine runs the call stack to empty, then drains the microtask queue (Promise callbacks, queueMicrotask, MutationObserver) completely, then takes one macrotask (setTimeout, I/O, UI events), then drains microtasks again, and repeats. Key consequence: a resolved Promise's .then runs before a setTimeout(0). Long synchronous work or an endless microtask chain starves rendering.
console.log('A');
setTimeout(() => console.log('B'), 0);       // macrotask
Promise.resolve().then(() => console.log('C')); // microtask
console.log('D');
// Output: A  D  C  B   (sync → microtasks → macrotasks)
Q.Promises vs async/await; and == vs ===?
async/await is syntax sugar over Promises — await pauses the async function until the Promise settles, and try/catch replaces .catch. Use Promise.all for concurrency, allSettled when failures are OK, race for timeouts. === compares without type coercion; == coerces (0 == '', null == undefined are true) — always use === except the deliberate x == null to catch both null and undefined.
Q.Prototypal inheritance?
Every object has a hidden link ([[Prototype]]) to another object. Property lookup walks this prototype chain until found or it hits null. class is sugar over this — methods live on Prototype.prototype and are shared across instances (not copied). This is why adding a method to a prototype affects all existing instances.
Q.Shallow vs deep copy? How do you deep clone?
Spread ({...o}) and Object.assign copy one level — nested objects are still shared references. For a deep clone use structuredClone(obj) (built-in, handles Dates/Maps/Sets/cycles). JSON.parse(JSON.stringify(x)) works but drops functions, undefined, Date→string, and throws on cycles.
Q.Debounce vs throttle — and can you write one?
Debounce: wait until activity stops for N ms, then fire once (search-as-you-type, resize-settled). Throttle: fire at most once per N ms during continuous activity (scroll, mousemove). Debounce = "wait for quiet"; throttle = "rate limit."
function debounce(fn, wait = 300) {
  let t;
  return function (...args) {
    clearTimeout(t);
    t = setTimeout(() => fn.apply(this, args), wait);
  };
}

function throttle(fn, wait = 300) {
  let last = 0, timer;
  return function (...args) {
    const now = Date.now();
    const remaining = wait - (now - last);
    if (remaining <= 0) {                 // leading edge
      clearTimeout(timer); timer = null;
      last = now;
      fn.apply(this, args);
    } else if (!timer) {                    // trailing edge
      timer = setTimeout(() => {
        last = Date.now(); timer = null;
        fn.apply(this, args);
      }, remaining);
    }
  };
}
Q.Event bubbling, capturing, and delegation?
An event travels capture (root→target), hits the target, then bubbles (target→root). addEventListener is bubble-phase by default; pass {capture:true} for capture. Delegation = put one listener on a parent and use event.target to handle many children — fewer listeners, and it works for elements added later. stopPropagation halts travel; preventDefault cancels the default action (they're different).
Q.What is currying? Memoization?
Currying turns f(a,b,c) into f(a)(b)(c) — useful for partial application and building configured helpers. Memoization caches results by arguments so repeated calls are free — great for pure, expensive functions.
const memoize = (fn) => {
  const cache = new Map();
  return (...args) => {
    const key = JSON.stringify(args);
    if (cache.has(key)) return cache.get(key);
    const result = fn(...args);
    cache.set(key, result);
    return result;
  };
};
Q.ESM vs CommonJS?
ESM (import/export) is static — analysable at build time, enables tree-shaking, and is async-loadable. CommonJS (require/module.exports) is dynamic and synchronous, the old Node default. ESM bindings are live read-only views of the export; CJS gives you a copy of the value at require time.
The classic trap for (var i = 0; i < 3; i++) setTimeout(() => console.log(i)) logs 3 3 3 — one shared var captured by all closures. Fix: use let (per-iteration binding) → 0 1 2.
3.0

TypeScript — the questions that separate users from understanders

At senior level they expect you to reach past any: generics, narrowing, utility types, and knowing when the type system is lying to you. Answer with the concept, then when you'd use it.

Q.interface vs type?
Mostly interchangeable for object shapes. interface supports declaration merging and reads well for public API/object contracts, and can extends. type can express unions, intersections, tuples, mapped and conditional types — anything, not just objects. Rule of thumb: interface for object shapes you might extend; type for unions and computed types.
Q.any vs unknown vs never?
any switches off type checking — avoid. unknown is the safe top type: you can hold anything but must narrow before using it. never is the bottom type — no value is assignable; it's the return of a function that always throws, and the tool for exhaustiveness checks in a switch.
Q.What is structural typing?
TS types are compared by shape, not by name ("duck typing"). If an object has the right properties it's assignable, even if it was never declared to implement the interface. This is why two unrelated types with identical fields are interchangeable — great flexibility, occasional surprise.
Q.Explain generics and a constraint.
Generics parameterise a type so it works over many types while keeping the relationship. Constrain them with extends to require capabilities. Example: a typed "get property" that TS proves is safe.
function get<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key];
}
const user = { id: 1, name: 'Ada' };
get(user, 'name');   // string  ✅
get(user, 'email');  // ❌ compile error — 'email' is not keyof user
Q.Which utility types do you use?
Partial<T> (all optional), Required<T>, Readonly<T>, Pick<T,K>/Omit<T,K> (subset a type), Record<K,V> (dictionary), Return­Type<F>/Parameters<F> (extract from a function), Awaited<T> (unwrap a Promise), and NonNullable<T>. They keep types derived from one source of truth instead of duplicated.
Q.Type narrowing & discriminated unions?
Narrowing = TS shrinking a union based on runtime checks (typeof, instanceof, in, truthiness, or a custom type guard x is Foo). A discriminated union gives each variant a literal "tag" field so a switch on the tag narrows exhaustively — the cleanest way to model state like loading/success/error.
type Result<T> =
  | { status: 'loading' }
  | { status: 'success'; data: T }
  | { status: 'error'; error: Error };

function render<T>(r: Result<T>) {
  switch (r.status) {
    case 'loading': return 'Loading…';
    case 'success': return r.data;         // TS knows .data exists here
    case 'error':   return r.error.message;
    default: {
      const _exhaustive: never = r;        // compile error if a case is added and unhandled
      return _exhaustive;
    }
  }
}
Q.What is as const, and satisfies?
as const freezes a literal to its narrowest readonly type (so 'GET' is the literal 'GET', not string) — great for config objects and action types. satisfies checks a value against a type without widening it, so you get validation and keep the precise inferred type. Use satisfies over a type annotation when you want both safety and the narrow literal types.
Q.Enums vs union of string literals?
Prefer type X = 'a' | 'b' (or an as const object) in most frontend code: it's zero-runtime, tree-shakeable, and plays well with JSON. enum generates runtime code and has quirks (numeric enums are bidirectional; const enum has its own caveats). Reach for enums mainly when you need a named runtime value.
Senior signal Say out loud that types are erased at runtime — TS gives you compile-time guarantees only, so anything crossing a trust boundary (API responses, localStorage, URL params) still needs runtime validation (a type guard or a library like Zod). A cast (as) is a promise to the compiler, not a check.
4.0

React — hooks, rendering, and performance

Round-1 React is mostly about the render model and hooks correctness. If you can explain why a component re-renders and how to stop the unnecessary ones, you sound senior.

Q.How does reconciliation work, and why do keys matter?
On a state change React builds a new virtual tree and diffs it against the previous one, then applies the minimal DOM changes. It compares element by type and position. Keys give list items a stable identity so React can match, reorder, and preserve state across renders. Using the array index as a key breaks this when the list reorders/filters — state and inputs attach to the wrong row.
Q.What triggers a re-render?
Three things: its own state/useReducer updates, its parent re-renders (so children re-render by default), or a context value it consumes changes. Props changing is really just "the parent re-rendered." Re-rendering ≠ touching the DOM — React still diffs; it's the wasted render work you optimise away with memo/useMemo/useCallback.
Q.The rules of hooks — and why?
Call hooks only at the top level (never in conditions/loops/nested functions) and only from React functions. React tracks hook state by call order, so a conditional hook shifts the order and corrupts which state belongs to which hook. That's the whole reason for the rule.
Q.useEffect dependencies and cleanup?
The effect re-runs whenever a value in its dependency array changes; the returned cleanup runs before the next run and on unmount. Missing deps → stale values (a stale closure reading old state); over-broad deps → the effect thrashes. Always clean up subscriptions, timers, and listeners, and use an AbortController for fetches to avoid setting state after unmount. In dev, StrictMode mounts→unmounts→mounts to surface missing cleanup — that double-run is intentional.
useEffect(() => {
  const ctrl = new AbortController();
  fetch(`/api/user/${id}`, { signal: ctrl.signal })
    .then(r => r.json())
    .then(setUser)
    .catch(err => { if (err.name !== 'AbortError') setError(err); });
  return () => ctrl.abort();   // cancel on id change / unmount
}, [id]);
Q.useMemo vs useCallback vs React.memo — and when NOT to use them?
useMemo caches a computed value; useCallback caches a function identity; React.memo skips a child's re-render if its props are shallowly equal. They matter together: memoising a callback is pointless unless the child is memo'd. Don't reach for them by default — they cost memory and comparisons, and premature memoisation clutters code. Add them when you've measured a real re-render cost or need stable identity for a dependency array.
Q.useState vs useReducer? useRef?
Use useReducer when the next state depends on the previous in non-trivial ways, when several values change together, or when logic is worth testing in isolation. useRef holds a mutable value that persists across renders without causing one — for DOM nodes, timers, previous values, or "has this run" flags. Changing ref.current does not re-render.
Q.Controlled vs uncontrolled components?
Controlled: React state is the source of truth (value + onChange) — predictable, validate-as-you-type, easy to reset. Uncontrolled: the DOM holds the value, you read it via a ref (or on submit) — less code, fewer renders, good for simple/large forms and file inputs. Senior answer: controlled for anything interactive or validated; uncontrolled when you just need the final value.
Q.Why can Context hurt performance, and what do you do?
Every consumer re-renders when the context value changes — and a new object literal each render counts as "changed." Fixes: memoise the value, split contexts (state vs dispatch, or by domain) so unrelated consumers don't wake up, keep fast-changing state out of a wide context, or use a store (Zustand/Redux) with selectors for granular subscriptions. Context is a dependency-injection tool, not a general state manager.
Q.useEffect vs useLayoutEffect?
useEffect runs after the browser paints (async, non-blocking) — the default. useLayoutEffect runs synchronously after DOM mutation, before paint — use it only to measure layout or mutate the DOM to avoid a visible flicker, since it blocks painting.
Q.What are error boundaries, portals, and forwardRef?
Error boundaries (class components with componentDidCatch/ getDerivedStateFromError) catch render-time errors in their subtree and show a fallback — they don't catch async/event-handler errors. Portals render children into a different DOM node (modals, tooltips) while keeping React tree context and event bubbling. forwardRef lets a parent pass a ref through to a child's DOM node or imperative handle.
Q.How do you find and fix a slow React app?
Measure first with the Profiler (which components render, how often, why). Common fixes: stop unnecessary re-renders (colocate state so it doesn't live too high, split context, memo hot children), virtualise long lists (react-window), code-split with React.lazy/Suspense and route-based chunks, defer non-urgent updates with useTransition/useDeferredValue, and debounce expensive derived work. Always tie a fix to a measured cause.
Q.Server Components / SSR / SSG in one breath?
CSR: browser renders everything (fast deploys, slow first paint, weaker SEO). SSR: HTML rendered per-request then hydrated (better first paint/SEO, server cost). SSG: HTML built ahead of time (fastest, needs rebuilds for changes). React Server Components run on the server and send no JS for themselves — you interleave them with client components to shrink the bundle. Know the trade-off triangle even if the role isn't Next.js.
Q.How do you stream AI/model responses in React — SSE vs WebSocket vs fetch streams?
SSE (EventSource, or a fetch + ReadableStream) is the right default for one-directional, server→client token streaming: it's plain HTTP, auto-reconnects, and is simple to consume. Reach for WebSockets only when you need true bidirectional/real-time (chat presence, collaborative editing). On the client, keep the transport in a custom hook that owns the request, the stream lifecycle, and routing chunks into state — then batch incremental updates and clean up on unmount/navigation with an AbortController. Handle the edge cases out loud: partial streams, retries with backoff, and resuming/cancelling if the user leaves mid-response. (This is exactly the Genius design in §1.3 — be ready to defend SSE over WebSockets and to explain where the shared logic ends and per-surface UI begins.)
Gotcha Updating state in a way that reads the old value? Use the functional updater: setCount(c => c + 1), not setCount(count + 1) — the latter captures a stale value across batched or rapid updates.
5.0

Migrations & tooling — dragging a 5-year codebase forward

This is the unglamorous senior work, and it's a real differentiator: you've actually run React 16 → 18, CRA/webpack → Vite, and TypeScript 3.8 → 5.9 on a live product that shipped every week. Lead with the why (a frozen, unmaintainable dependency graph), the method (incremental, bridged, scope-disciplined), and the honesty about debt you took on knowingly — that combination reads as senior.

A migration is dependency-graph liberation, not a feature. Almost none of it ships a user-visible change the day it merges — what it buys is the ability to patch a CVE in an afternoon instead of never. Say that out loud: it reframes "why did a version bump take six weeks?" into "this restored our ability to move at all."

The timeline at BestWe, all while the product kept shipping weekly:

WhenMigration
2023React 16.12 → 18 · react-router 5 → 6 · react-redux 7 → 8 · Enzyme → React Testing Library
2023–25TypeScript 3.8 → 4.4 → 5.9 — four deliberate moves over two years
2024CRA + react-app-rewired (webpack) → Vite 5 · Jest stood up standalone
2025–26Node → 24 · Vite 6 → 8 (Rolldown) · explicit chunking, brotli/gzip, network-aware preloading
Honesty on numbers The repo never had build instrumentation, so I argue the performance wins architecturally (unbundled ESM in dev, esbuild pre-bundle, cache-stable vendor chunks) rather than quoting a build-time or bundle-size percentage. Saying "we didn't measure it, so I won't invent a number" is itself a senior signal — interviewers trust the person who won't fabricate a stat.
5.1

The three migrations — tell them as stories

Each is a `tension → decision → lesson` narrative. Hold the shape; go deep only where they push. The green line is the takeaway to land.

A.React 16 → 18 — where the framework was the easy part

The why. We were on React 16.12 (a 2019 release) and had become unable to upgrade anything else — every modern npm install hit a peer-dep wall at react@^16, and security advisories piled up against transitive deps pinned by react-scripts@3.4.1. The codebase wasn't broken, it was frozen — and frozen codebases rot on a schedule you don't control. The real goal was never "React 18 features"; it was unfreeze the dependency graph.

The shape of the work. The version bump was ~8 lines of package.json. The branch that closed around it was 719 files changed, +20k / −37k lines, ~133 commits over six weeks. That ratio is the story: the bump is a one-liner, then you spend a month discovering what quietly depended on the old behaviour.

The decision that made it reviewable — a bridge, not a big bang. react-router v6 removed withRouter, but we had a long tail of class components taking history/location/match as props. I added react-router-dom-v5-compat so v5 and v6 routing could coexist in one tree during the migration — the single decision that turned an unmergeable 700-file rewrite into a branch that could keep taking merges from development for six weeks. I also wrote a replacement withRouter HOC over the v6 hooks.

The dead end I own. My first HOC bundled everything under one router prop (the "clean" v6 shape) — which forced every consumer to be rewritten to props.router.navigate. Five weeks later I flattened it so each call site changes by one token:

- return <Component {...props} router={{ location, navigate, params }} />;
+ return <Component {...props} navigate={navigate} location={location} params={params} />;
// call site: history.push(x)  →  navigate(x)   — one token, not a prop-path rewrite

Lesson: in a migration, the API that minimises downstream diff wins, even when it reads worse in isolation. I'd optimised the HOC's aesthetics over the 40 call sites that consumed it.

The Enzyme wall. enzyme-adapter-react-16 is the whole problem — Enzyme never shipped a React 18 adapter. Enzyme tests reach into internals (shallow, instance state); RTL asserts on rendered output. Different philosophies, no mechanical port. I audited and found most protected implementation, not behaviour (snapshot-heavy chart internals), and deleted ~24k lines of test code. Deleting tests during a migration is a real risk, taken deliberately — and what I'd do differently is rebuild behavioural coverage on the critical paths before pulling the old suite, so the migration itself is guarded. We paid for that ordering: a branch literally named bugfix/the-bugs-found-after-react-update, because 18's automatic batching changed when state flushes and old sync-ish code surfaced ordering bugs and effect double-fires. Visual bugs live in the tail — because visual bugs don't throw.

Scope discipline (the Node ping-pong). The log shows Node rolled 14→18 then back to 14, twice, during the React branch. That looks like thrash; it was the opposite — bundling a runtime bump into a 719-file change means every CI failure has two possible causes. I kept the simultaneously-moving variables at one, and did Node 20 later as its own three-commit piece. StrictMode got the same honesty: it double-invokes effects in dev to surface unsafe cleanup, it lit up real bugs but hurt delivery, and I only enabled it for good ~18 months later once the debt was paid — StrictMode isn't a switch, it's a standard; turning it on before you can satisfy it just trains the team to ignore warnings.

Payoff: within a year we could bump axios, jsdom, react-router, d3, cypress and the whole eslint toolchain for CVEs — none of which was possible before.

B.CRA/webpack → Vite — the one with the visible payoff

The why. We were on react-scripts@5 + react-app-rewired + config-overrides.js — i.e. webpack we couldn't see or configure, patched by a library whose whole job is to monkey-patch a config CRA hides, while CRA itself went unmaintained. Two concrete pains: (1) the dev loop — webpack bundles the entire app before serving the first byte, and on ~1,400 components plus visx/vis-network/d3/gsap that was slow enough that people context-switched while waiting; and (2) configurability — every chunking/asset/env question was a fight with react-app-rewired.

Why Vite is fast (say this). In dev, Vite serves native ESM unbundled — the browser requests modules on demand — and pre-bundles only node_modules once with esbuild (Go-based, far faster than a JS bundler). So dev start and rebuild-on-save stop scaling with app size. For production it bundles with Rollup (Rolldown as of Vite 8). The trade-off you must name: the fast dev transform (esbuild) doesn't type-check.

The migration itself — 25 files, +3,979 / −1,120, because a bundler swap is mostly deletion:

  • react-scripts/react-app-rewired out; vite, @vitejs/plugin-react, vite-tsconfig-paths in.
  • public/index.html → a root index.html with <script type="module" src="/src/index.tsx"> — Vite treats HTML as the entry graph, not a template.
  • build script react-app-rewired buildtsc -b && vite build — splitting the typecheck out (incremental project references) from the bundle, so each does one job and the typecheck caches independently.
  • react-loadableReact.lazy (abandoned + totally webpack-coupled; one loader file went 177 → 63 lines — deleting a dep and shrinking the file is the good kind of diff).

The compromises I took knowingly — and would name in the interview: vite-plugin-node-polyfills because some transitive dep assumed global (webpack polyfilled it silently, Vite correctly doesn't); vite-plugin-environment to keep existing process.env.REACT_APP_* call sites alive instead of a large, boring, regression-prone rewrite to import.meta.envdeliberately not migrating something is a legitimate migration decision.

The subtle bug: HMR. "HMR doesn't work, it just reloads" looked like a tooling bug and was actually a code-organisation constraint: React Fast Refresh can only hot-swap a module if it can prove the module only exports components. A file exporting a component plus a helper const plus a type escalates to a full page reload — and you lose all app state, brutal in a multi-step, streaming app. Fix: split mixed-export modules — a change with zero behavioural diff, hard to justify in review until you've felt the reload.

Resilience that CRA hid. Losing CRA's browser error overlay (esbuild doesn't type-check) meant type errors reached CI instead of the dev's screen — so I added vite-plugin-checker (tsc + ESLint in a worker with an overlay), tuned to errors only because a permanently-red overlay is a permanently-ignored one. And React.lazy made chunk fetches a runtime failure mode (flaky wifi, or a stale index.html pointing at last deploy's hashed filenames → blank screen), so I rebuilt the loader with retry + timeout:

export const customLoad = (importFunc, timeoutMs = 5000) => lazy(
  () => retry(() => Promise.race([
    importFunc(),
    timeout(timeoutMs).then(() => { throw new Error('Component took too long to load'); }),
  ])),   // 5 retries, 1s apart, each racing a 5s timeout
);

The same fix repaired an ErrorBoundary that itself crashed on an unguarded errorInfo.componentStack read — an error boundary that throws is worse than none; you lose the error and the report.

Lesson: a bundler swap is mostly deletion, but the long tail is the test runner — your bundler moved to ESM and Jest didn't. Standing Jest up standalone brought months of ESM-interop pain (ESM-only packages Jest can't parse, lodash-es mapped back to lodash for tests, d3 path-mapped so barrel imports don't time out). Budget for it explicitly. Also: frontend build config is deployment config — I had to move Vite's default assets/ output to avoid colliding with the backend's asset path.

C.TypeScript 3.8 → 5.9 — four moves over two years

A different animal. No runtime risk — nothing ships differently — but every minor version tightens inference, so a codebase that's been accreting any since 2019 lights up with hundreds of new errors the moment you bump. And unlike a React bug, a type error blocks the build, which blocks everyone. That's why I did it in four deliberate moves, not one leap: 3.8 → 4.4 (rode in on the React branch, required by @typescript-eslint@5 and @types/react@18), rolled back to unblock builds, restored once stable → 4.9 → 5.9.

The strict decision I'd defend in any review. I turned strictness off during a TypeScript upgrade:

-  "strict": true,
+  "strict": false,
+  "noErrorTruncation": true,

strict: true was nominally set, but the code was full of implicit-any escapes older TS never caught and 4.4 did. Options: (1) keep strict and fix every error → the React branch grows by hundreds more files and nobody ships product while it's open; or (2) strict: false, ship, pay down incrementally. I took (2) and paired it with noErrorTruncation: true — the part I actually care about. Truncated errors ("…and 47 more") are why people give up on type errors; making every error fully legible was a bet that the team fixes more types with strictness off and errors readable than with strictness on and errors unreadable. It paid: three engineers have chipped at type errors ever since. It's still false today — the honest open item: the ratchet turned, but it hasn't finished turning.

The moduleResolution two-step. The big coordinated bump (TS 5.9 + Node 24 + Vite 6) first set moduleResolution: node16, then four days later:

-  "module": "node16",   "moduleResolution": "node16",
+  "module": "ESNext",   "moduleResolution": "bundler",

node16 makes TS emulate Node's ESM/CJS resolution — correct if Node runs your code, wrong if a bundler does: it demands explicit file extensions and gets strict about package.json exports maps, and it rejected imports Vite resolves fine. bundler (new in TS 5.0) exists for exactly this: let the type-checker resolve modules the way the bundler actually will. Getting it wrong for four days is a neat illustration of why the TS 5 upgrade had to follow the Vite one — moduleResolution: bundler isn't even a sane option while your bundler is CRA's hidden webpack.

The config that fell out — every line is downstream of a migration:

SettingThen → NowWhy
targetes5ESNextVite downlevels via browserslist; no reason to ship ES5 to the bundler
jsxreactreact-jsxthe new JSX transform — no more import React just for JSX
isolatedModules— → truerequired by Vite: esbuild transpiles file-by-file with no cross-file type info
noEmit— → truetsc is now a checker, not a compiler — Vite emits

Lesson: isolatedModules is the constraint that most shaped day-to-day code afterwards — it forbids re-exporting a type without export type, and understanding why it's required (a per-file transpiler can't see other files' types) is understanding why Vite is fast.

5.2

Migration method — the principles to state aloud

If they ask "how do you approach a big migration?", these six lines are the senior answer. Each is backed by a concrete moment above.

01
The version bump is 1% of the work

8 lines of package.json cost six weeks. Estimate from the codebase's coupling to the old thing, never from the size of the version diff.

02
Bridges beat big bangs

If a migration can't be done incrementally, the first task is to build the thing that makes it incremental — the v5-compat shim and custom withRouter are what let a 700-file branch keep taking merges.

03
Cut scope ruthlessly, even mid-flight

Rolling Node back twice looked like thrash; it kept the simultaneously-moving variables at one, so every CI failure had a single cause.

04
Turn gates into ratchets

strict:false + noErrorTruncation:true: don't let a quality bar block the migration, but make the violations maximally visible so they get fixed continuously.

05
Name the compromises out loud

Node polyfills, kept process.env, 24k deleted tests — all debt taken knowingly. The failure mode isn't taking the debt; it's taking it silently, so no one knows it's outstanding.

06
Migrations are liberation, not features

Nothing shipped a user-visible win on merge day. What it bought: patch a CVE in an afternoon, ride four Vite majors without drama, and actually reason about our own bundle.

5.3

Anticipated Q&A on migrations

Q.What actually changed from React 16 to 18?
The headline four: automatic batching (state updates now batch across promises, timeouts, and native handlers — not just React event handlers), concurrent rendering with useTransition/useDeferredValue to mark non-urgent updates, the new root API (createRoot replacing ReactDOM.render), and Suspense improvements for SSR streaming. Plus StrictMode now double-invokes effects in dev, and useId for stable SSR ids. The practical migration pain is the first two: they change when updates flush, so code written against 16's synchronous-ish behaviour surfaces ordering bugs and effect double-fires.
Q.Why move from webpack/CRA to Vite — how is it faster?
Dev: webpack bundles the whole app before serving byte one; Vite serves native ESM unbundled (the browser pulls modules on demand) and pre-bundles only node_modules once with esbuild, so start/rebuild stop scaling with app size. Prod: Vite bundles with Rollup (Rolldown in v8). Trade-offs to name: dev and prod use different engines, and the esbuild dev transform doesn't type-check — so you add a checker plugin, and you must set isolatedModules. It's not "faster for free," it's a different architecture with its own constraints.
Q.How do you run a large migration without freezing the product?
Make it incremental. Build a bridge/compat layer so old and new coexist in one tree, and keep the branch able to take merges from main. Change one axis at a time — don't bundle a Node bump into a React bump. Take necessary debt knowingly and visibly (ratchets, not gates). And rebuild critical-path behavioural test coverage before deleting the old suite, so the migration itself is guarded.
Q.What are moduleResolution: "bundler" and isolatedModules?
bundler (TS 5.0+) tells the type-checker to resolve imports the way a bundler does — no required file extensions, respects exports maps — versus node16, which emulates Node's runtime ESM/CJS rules and is wrong when a bundler, not Node, runs your code. isolatedModules guarantees every file can be transpiled alone with no cross-file type info (which is how esbuild/Vite get their speed); it forbids things that need whole-program knowledge, e.g. re-exporting a type without export type.
Q.Enzyme → React Testing Library — how, and why delete tests?
Enzyme never got a React 18 adapter, and there's no mechanical port: Enzyme tests assert on internals (shallow, instance state), RTL asserts on rendered output — different philosophies. So you audit what each test protects: keep/rebuild the ones covering behaviour on critical paths, drop the internals-coupled snapshots that only pin implementation. The discipline I'd add: rebuild the behavioural coverage before removing the old suite, so you're never unguarded mid-migration.
Q.How did you handle code splitting and chunking after CRA?
react-loadable (abandoned, webpack-coupled) → React.lazy + Suspense, wrapped with retry + timeout so a failed chunk fetch (flaky network, or a stale index.html after a deploy) doesn't blank the screen. Then explicit manual chunks: isolate vendor-react/vendor-redux (they change rarely, so an app-code deploy doesn't invalidate them in users' caches — cache stability), and split heavy viz libs (d3, vis-network) into their own chunks so screens without charts don't download them. Chunk priority matters because a module matching several rules must land in exactly one.
6.0

Patterns you're "supposed to know"

Two buckets: classic software patterns that show up in JS, and React-specific composition patterns. You rarely have to name them — but recognising them lets you say "this is basically the observer pattern," which reads as senior.

6.1

General patterns in JS

Module
Encapsulation

Closures (or ESM) expose a public API and hide internals. The base pattern behind most libraries.

Singleton
One instance

A shared instance (a config store, a cache). In ESM a module is effectively a singleton.

Observer / PubSub
Subscribe → notify

Subjects publish; listeners react. Powers event emitters, state stores, and React's own updates.

Factory
Create without new

A function returns configured objects — hides construction and branching.

Strategy
Swap behaviour

Pass the algorithm in (a comparator, a formatter) so callers pick behaviour at runtime.

Proxy
Intercept access

JS Proxy traps get/set — reactive state (Vue, MobX, Valtio) is built on it.

// Observer / PubSub — a tiny type-safe event emitter
type Handler<T> = (payload: T) => void;

class Emitter<Events extends Record<string, unknown>> {
  private listeners: { [K in keyof Events]?: Set<Handler<Events[K]>> } = {};

  on<K extends keyof Events>(event: K, fn: Handler<Events[K]>): () => void {
    (this.listeners[event] ??= new Set()).add(fn);
    return () => this.listeners[event]?.delete(fn);   // unsubscribe
  }

  emit<K extends keyof Events>(event: K, payload: Events[K]): void {
    this.listeners[event]?.forEach(fn => fn(payload));
  }
}

const bus = new Emitter<{ login: { userId: string }; logout: void }>();
const off = bus.on('login', ({ userId }) => console.log('hi', userId));
bus.emit('login', { userId: 'u1' });
off();
6.2

React composition patterns

Custom hooks
Share logic

The modern default — extract stateful logic (useDebounce, useFetch, useLocalStorage) and reuse it, no wrapper components.

Compound components
Shared implicit state

<Tabs><Tabs.List/><Tabs.Panel/> — parent holds state via context, children read it. Flexible, declarative APIs.

Provider
Inject dependencies

Context provides theme/auth/store to a subtree without prop-drilling.

Render props / HOC
Older logic-sharing

A function-as-child or a wrapping component. Mostly superseded by hooks, but you'll see them in libraries.

Container / Presentational
Split data & UI

One component fetches/holds state, a dumb child just renders props — easier to test and reuse.

State reducer
Invert control

Let consumers hook into or override how state transitions happen — advanced library API design (Downshift).

// Compound components via context
const TabsCtx = createContext<{ active: string; setActive: (id: string) => void } | null>(null);

function Tabs({ defaultId, children }: { defaultId: string; children: React.ReactNode }) {
  const [active, setActive] = useState(defaultId);
  return <TabsCtx.Provider value={{ active, setActive }}>{children}</TabsCtx.Provider>;
}
function Tab({ id, children }: { id: string; children: React.ReactNode }) {
  const ctx = useContext(TabsCtx)!;
  return (
    <button aria-selected={ctx.active === id} onClick={() => ctx.setActive(id)}>
      {children}
    </button>
  );
}
Tabs.Tab = Tab;
// <Tabs defaultId="a"><Tabs.Tab id="a">A</Tabs.Tab><Tabs.Tab id="b">B</Tabs.Tab></Tabs>
7.0

The small live-build tasks

Round 1 often ends with a 15-minute "write this" task in a shared editor. They're checking clean, correct, typed code — edge cases and API design, not cleverness. Here are the ones that recur, fully worked so you can practise typing them from memory.

7.1

A typed localStorage read/writer

A frequent ask ("wrap localStorage with types"). Good answers handle: JSON (de)serialisation, the SSR/private-mode case where localStorage throws, a default value, and a typed key map.

// Describe your storage shape once — keys are type-safe, values are typed per key.
interface StorageSchema {
  'auth.token': string;
  'user.prefs': { theme: 'light' | 'dark'; lang: string };
  'cart': { id: string; qty: number }[];
}

function createStorage<Schema extends Record<string, unknown>>(
  store: Storage = window.localStorage,
) {
  const available = (() => {
    try { const k = '__t'; store.setItem(k, k); store.removeItem(k); return true; }
    catch { return false; }               // SSR, private mode, quota, disabled
  })();

  return {
    get<K extends keyof Schema & string>(key: K, fallback: Schema[K]): Schema[K] {
      if (!available) return fallback;
      const raw = store.getItem(key);
      if (raw === null) return fallback;
      try { return JSON.parse(raw) as Schema[K]; }
      catch { return fallback; }          // corrupt value → don't crash the app
    },

    set<K extends keyof Schema & string>(key: K, value: Schema[K]): void {
      if (!available) return;
      try { store.setItem(key, JSON.stringify(value)); }
      catch { /* quota exceeded — swallow or evict */ }
    },

    remove(key: keyof Schema & string): void {
      if (available) store.removeItem(key);
    },
  };
}

const storage = createStorage<StorageSchema>();
storage.set('user.prefs', { theme: 'dark', lang: 'en' });   // ✅ typed
const prefs = storage.get('user.prefs', { theme: 'light', lang: 'en' });
// storage.set('user.prefs', { theme: 'blue' });            // ❌ compile error
Talk track while you type Call out the decisions: "I'm feature-detecting because localStorage throws in private mode and doesn't exist during SSR", "I take a fallback so callers never get null", "keys are typed against a schema so you can't fetch a value at the wrong type." That narration is what earns the senior grade.
7.2

A permissions / RBAC helper

"Given a user and a resource, can they do X?" This tests modelling and clean boolean logic. A simple, honest answer is role → allowed-action set, with an escape hatch for ownership rules.

type Role = 'admin' | 'editor' | 'viewer';
type Action = 'read' | 'create' | 'update' | 'delete';

interface User { id: string; roles: Role[]; }
interface Doc  { id: string; ownerId: string; }

// Static grants per role. Functions allow ownership / contextual checks.
type Rule = boolean | ((user: User, resource: Doc) => boolean);

const permissions: Record<Role, Partial<Record<Action, Rule>>> = {
  admin:  { read: true, create: true, update: true, delete: true },
  editor: { read: true, create: true, update: true,
            delete: (user, doc) => doc.ownerId === user.id },   // editors delete only their own
  viewer: { read: true },
};

function can(user: User, action: Action, resource: Doc): boolean {
  return user.roles.some((role) => {
    const rule = permissions[role]?.[action];
    if (rule === undefined) return false;
    return typeof rule === 'function' ? rule(user, resource) : rule;
  });
}

// can(editor, 'delete', ownDoc)      → true
// can(editor, 'delete', othersDoc)   → false
// can(viewer, 'update', anyDoc)      → false
Senior framing Mention the ideas even if you don't build them: RBAC (roles → permissions) is the common case; ABAC (attribute/policy-based) scales better for complex rules; and permissions are a UX affordance, not security — the server must re-check every action, because anything in the client can be bypassed.
7.3

A useLocalStorage hook (ties it together)

They love asking you to turn the storage helper into a React hook — it tests hooks, generics, and lazy init.

function useLocalStorage<T>(key: string, initial: T) {
  const [value, setValue] = useState<T>(() => {        // lazy init: read once
    try {
      const raw = window.localStorage.getItem(key);
      return raw !== null ? (JSON.parse(raw) as T) : initial;
    } catch { return initial; }
  });

  useEffect(() => {
    try { window.localStorage.setItem(key, JSON.stringify(value)); }
    catch { /* ignore quota / unavailable */ }
  }, [key, value]);

  return [value, setValue] as const;   // tuple, like useState
}

// const [theme, setTheme] = useLocalStorage<'light' | 'dark'>('theme', 'light');
7.4

The one you got asked: useDebounce for a live address

The task: an input updates address on every keystroke, and a heading below should show the address delayed — only once typing settles. It looks like a five-minute question and it's really a hooks mental-model question: a debounced value in React is not a function you call, it's a piece of state that lands later.

// ❌ The attempt — four separate things are wrong
const useDebounce = (value, wait = 0) => {
  let timeout;                       // (2) new variable every render — clears nothing
  async function fn() {
    if (timeout) clearTimeout(timeout);
    let val;
    await function () {              // (3) awaits a function *object*, never calls it
      timeout = setTimeout(() => { val = value; }, wait);
    };
    return val;                      // always undefined
  }
  return fn();                       // (1) returns a Promise, during render
};

const debouncedAddress = useDebounce(address);   // (4) no state → nothing re-renders
7.4.1

Why each part fails

Don't think "call a debounce function and get a delayed value back." Think: a second piece of state that follows the first one, late. The value changes now; an effect starts a timer; if the value changes again first, the cleanup cancels that timer; when the user finally stops, the timer fires and sets state — and that is what re-renders the heading.
// ✅ The idiomatic fix — debounce a *value*
import { useEffect, useState } from "react";

function useDebounce(value, wait = 500) {
  const [debounced, setDebounced] = useState(value);

  useEffect(() => {
    const id = setTimeout(() => setDebounced(value), wait);
    return () => clearTimeout(id);   // ← the whole trick: next keystroke cancels this one
  }, [value, wait]);

  return debounced;
}

export default function App() {
  const [address, setAddress] = useState("");
  const debouncedAddress = useDebounce(address, 500);

  return (
    <div className="App">
      <input value={address} onChange={(e) => setAddress(e.target.value)} />
      <h2>Address: {debouncedAddress}</h2>
    </div>
  );
}

Read the effect as a sentence: "whenever value changes, schedule a write in 500 ms — and if it changes again before then, throw the old schedule away." The cleanup running before every re-run is what makes this a debounce; there's no extra bookkeeping. The id is safe as a plain local because it lives in the effect's closure, and the exact same closure's cleanup is the only thing that reads it.

Two things to say out loud while typing The default wait matters: their skeleton had wait = 0, which debounces nothing — 300–500 ms is the sane default for search-as-you-type. Seed state with value, not "", so the first paint isn't blank for no reason.
7.4.2

"Didn't I need a useRef here?"

Your instinct was half right, and it's worth knowing exactly which half — this is a great thing to be able to explain in the room.

For a debounced value: no. The timer id needs to survive from "the effect runs" to "the effect cleans up," and the closure already does that — the cleanup is the same closure. A ref would work, but it's strictly more machinery for the same result.

For a debounced callback: yes. The moment you want debouncedSearch(query) — a function you call from an event handler rather than a value you read — there's no effect to hold the closure, and the timer has to live across renders. That's exactly what a ref is for: a mutable box that persists across renders and doesn't cause one.

// ✅ Debounce a *callback* — this is where the ref earns its place
function useDebouncedCallback(fn, wait = 500) {
  const timerRef = useRef(null);
  const fnRef = useRef(fn);

  // keep the latest fn without re-creating the debounced function → no stale closure
  useEffect(() => { fnRef.current = fn; }, [fn]);

  // cancel a pending call if we unmount mid-wait
  useEffect(() => () => clearTimeout(timerRef.current), []);

  return useCallback((...args) => {
    clearTimeout(timerRef.current);
    timerRef.current = setTimeout(() => fnRef.current(...args), wait);
  }, [wait]);
}

// const onSearch = useDebouncedCallback((q) => fetchResults(q), 400);
// <input onChange={(e) => onSearch(e.target.value)} />
The senior sentence "Debouncing a value is an effect with a cleanup; debouncing a callback is a ref holding a timer. Both are the same idea — cancel the previous schedule — the difference is just where the timer id can safely live." Then add the failure mode: debounce delays, it doesn't cancel work — if that callback fires a request, a slow earlier response can still land after a newer one, so you also need an AbortController or a request-id check to ignore stale results. Saying that unprompted is the whole senior signal on this question.
7.5

The FizzBuzz mystery — "some numbers didn't log in non-strict mode"

Standard FizzBuzz for 1–100, some numbers silently missing — but the same logic working under strict mode. Silence is the clue: sloppy mode's whole personality is failing quietly where strict mode speaks up. Two mechanisms produce exactly this, depending on which "strict mode" was in play.

// the baseline everyone writes
for (let i = 1; i <= 100; i++) {
  let out = "";
  if (i % 3 === 0) out += "Fizz";
  if (i % 5 === 0) out += "Buzz";
  console.log(out || i);
}
7.5.1

If it was JS strict mode: an implicit global ate your counter

In sloppy mode, assigning to an undeclared variable doesn't throw — it silently creates a property on window. Strict mode makes the exact same line a ReferenceError. So this runs "fine" in a script or the console, and refuses to run at all under "use strict" (or inside a module, which is always strict — every React file, every ES module):

function fizzbuzz(n) {
  for (i = 1; i <= n; i++) {      // no let/var → this is window.i
    console.log(label(i));
  }
}

function label(x) {
  for (i = 0; i < 3; i++) { /* any inner loop, also undeclared */ }
  return x % 15 === 0 ? "FizzBuzz" : x % 3 === 0 ? "Fizz" : x % 5 === 0 ? "Buzz" : x;
}

Both loops share one global i. The inner loop leaves it at 3 every time, so the outer counter jumps around instead of walking 1→100 — numbers get skipped, some repeat, and nothing errors. The moment you declare the variables properly (which strict mode forces you to do), each loop gets its own scoped counter and the skips vanish. That's why "fixing it for strict mode" fixed the bug — the declaration was the fix; strict mode just refused to let you ship without it.

The sibling trap Same family: at the top level of a script, var and implicit globals land on window, where names like name, length, top, and status already exist with special behaviour — some are read-only or coerce your value to a string. In sloppy mode a write that fails is silently ignored; in strict mode it throws a TypeError and you find it in one second. Same lesson: strict mode doesn't change your logic, it stops hiding the failure.
Diagnose it in 10 seconds Add "use strict"; at the top of the exact snippet that misbehaves. If it now throws a ReferenceError: i is not defined (or a TypeError on an assignment), you've found it — an implicit global was being written by two places at once. If it stays silent, it's the React reading below.
7.5.2

If it was React <StrictMode>: you were reading the console wrong

If the FizzBuzz ran in a component body in a sandbox, "strict mode" probably meant React's <StrictMode>, and then the direction is inverted from what it looks like. StrictMode double-invokes your render in dev to surface side effects — so the loop runs twice and you get 200 logs. Combined with a console that collapses consecutive identical messages into one line with a ×2 count badge (Chrome does this; sandbox consoles also cap and batch output), the two runs look very different, and lines genuinely disappear from view without disappearing from the program.

The real lesson underneath: a console.log loop in a render body is a side effect in the wrong place — that's exactly the thing StrictMode is designed to make visible. Put it in an effect (useEffect(() => { … }, [])) or an event handler and the "mystery" goes away, because you're no longer relying on how many times React chose to render you.

Both readings land on the same sentence, and it's a good one to say out loud: strict mode never changes what your code means — it changes whether a mistake is loud or silent. Sloppy mode gave you implicit globals and ignored writes; React's StrictMode gave you a second render you weren't accounting for. If code works in one and not the other, the bug was always there — one of them was just being polite about it.
7.6

Other likely one-shots

Keep these in muscle memory — any could be the closer.

// retry with exponential backoff + jitter
async function retry<T>(fn: () => Promise<T>, tries = 4, base = 200): Promise<T> {
  let lastErr: unknown;
  for (let attempt = 0; attempt < tries; attempt++) {
    try { return await fn(); }
    catch (err) {
      lastErr = err;
      const delay = base * 2 ** attempt + Math.random() * base;   // jitter
      await new Promise((r) => setTimeout(r, delay));
    }
  }
  throw lastErr;
}
8.0

Rapid-fire: browser, CSS & web platform

Round 1 sometimes mixes in platform basics. One or two crisp sentences each is plenty.

Q.What is CORS?
A browser security mechanism: cross-origin requests are allowed only if the server opts in with Access-Control-Allow-Origin (and friends) response headers. For non-simple requests the browser sends a preflight OPTIONS first. It's enforced by the browser, not the server, and it protects users — it isn't about hiding your API.
Q.CSS specificity & the box model?
Specificity ranks selectors: inline > id > class/attr/pseudo-class > element; ties go to the last rule; !important overrides (avoid it). Box model: content → padding → border → margin; set box-sizing: border-box so width includes padding+border.
Q.Why does a flex item overflow its container (e.g. long text won't shrink)?
Because flex items default to min-width: auto (or min-height: auto in a column), which refuses to shrink them below their content size — so a long child or text blows past the container. The fix is min-width: 0 on the flex item (or min-height: 0 for a column), which lets it shrink; then overflow: hidden/auto and text-overflow: ellipsis control clipping. Key nuance: overflow alone treats the symptom — the root cause is the min-size default.
Q.How do you improve web performance / Core Web Vitals?
Ship less JS (code-split, tree-shake), lazy-load below-the-fold images and routes, use font-display: swap, cache aggressively, avoid layout shift by reserving space (helps CLS), prioritise the hero image/critical CSS (LCP), and keep the main thread free to cut input delay (INP). Measure with Lighthouse and real-user metrics.
Q.Accessibility basics you always do?
Semantic HTML first (button, nav, headings in order), labels tied to inputs, keyboard operability and visible focus, sufficient colour contrast, alt text, and ARIA only to fill gaps semantics can't (roles/states on custom widgets). Test with keyboard-only and a screen reader.
Q.How do you store auth tokens — cookie vs localStorage?
localStorage is readable by any JS, so it's exposed to XSS. An HttpOnly, Secure, SameSite cookie can't be read by JS and is the safer default for session tokens (pair with CSRF protection). Never put secrets or long-lived tokens in localStorage if you can avoid it.
8.1

One-line recall

closures capture variables microtasks before macrotasks arrow = lexical this structuredClone unknown > any satisfies discriminated unions keys = identity functional setState clean up effects split context measure before memo types erased at runtime HttpOnly cookies min-width:0 to shrink flex smallest scope that satisfies the scope is the cleanup one owner tames the race identity, not presence local truth over shared the mess stops at the boundary slice vertically, not by layer undesigned states are broken states edge cases via checklist, not inspiration debounced value = effect + cleanup debounced callback = ref + timer strict mode makes mistakes loud version bump is 1% bridges beat big bangs one axis at a time gates → ratchets name the compromise migrations = CVE liberation moduleResolution: bundler isolatedModules → Vite is fast
9.0

Closing the round well

The last five minutes are yours. Good questions signal seniority and let you screen the team too.

Ask
About the work

"What does the frontend architecture look like today, and what's the biggest pain point you'd want a senior to fix first?"

Ask
About the team

"How do decisions get made — RFCs, tech leads, consensus? How is code reviewed?"

Ask
About growth

"What does success look like at 3 and 6 months for this role?"

Ask
About quality

"What's your testing and release story — how confident are you shipping on a Friday?"

For every answer: state it plainly, then add the trade-off. "Here's how it works… and here's when it bites." That one habit — pairing the fact with the failure mode — is the single clearest senior signal you can give in round 1.