Senior Frontend Interview Guide, Part 3: Advanced React 18: Concurrency, Suspense, and Performance
Knowing hooks gets you through a mid-level interview. Senior and staff interviews go further: how does the concurrent renderer schedule work, when should you reach for a transition, how does Suspense coordinate loading states, what does streaming server rendering buy you, and how do you diagnose a…

This article is Part 3 of 18 in The Senior Frontend Engineer Interview Guide, and includes 25 interview questions with model answers.
Knowing hooks gets you through a mid-level interview. Senior and staff interviews go further: how does the concurrent renderer schedule work, when should you reach for a transition, how does Suspense coordinate loading states, what does streaming server rendering buy you, and how do you diagnose a slow screen in a large enterprise application? This chapter answers those questions.
Fiber: The Engine Underneath
React 16 replaced the old recursive “stack reconciler” with Fiber. A fiber is a JavaScript object representing a unit of work for one component instance. It holds the component type, props, state (the hook list), a link to its parent, first child, and next sibling, and a pointer to its alternate (the version of the fiber from the previous render).
Because the tree is a linked list of fibers rather than a recursive call stack, React can:
- Pause work after any fiber and yield to the browser.
- Resume later from where it stopped.
- Abandon a render in progress if a more urgent update arrives.
- Assign priority (called lanes) to updates.
React keeps two trees: the current tree (what is on screen) and the work-in-progress tree being built. When rendering completes, React commits the work-in-progress tree in one synchronous pass and it becomes the current tree. This double-buffering is why users never see a half-rendered UI, even when rendering was interrupted.
Concurrent Rendering
Concurrency in React is not parallelism; JavaScript is still single-threaded. It means React can prepare multiple versions of the UI and interleave rendering work with other work such as handling input. In React 18, concurrent rendering is opt-in per update: it is only used for updates you mark as non-urgent.
The scheduler works in small time slices (about 5 ms). After each slice it checks whether the browser has something more important to do, such as processing a keystroke, and yields if so. A long render of 2,000 table rows that would freeze the page for 200 ms under synchronous rendering becomes a series of short chunks that let typing stay smooth.
Urgent versus Transition Updates
React 18 classifies updates into two broad categories:
- Urgent updates reflect direct interaction: typing, clicking, pressing. Users expect immediate feedback.
- Transition updates move the UI from one view to another: filtering a list, switching tabs, navigating. Users tolerate a short delay, especially if the old UI stays responsive.
useTransition and startTransition
function TransactionSearch({ transactions }) {
const [query, setQuery] = useState('');
const [filter, setFilter] = useState('');
const [isPending, startTransition] = useTransition();
function handleChange(e) {
setQuery(e.target.value); // urgent: keep the input responsive
startTransition(() => {
setFilter(e.target.value); // non-urgent: re-filter the big list
});
}
return (
<>
<input value={query} onChange={handleChange} aria-label="Search" />
{isPending && <span role="status">Updating results...</span>}
<TransactionList transactions={transactions} filter={filter} />
</>
);
}
What happens: the input updates immediately. React then starts rendering the list with the new filter in the background. If the user types another character before that render finishes, React throws the half-finished work away and starts again with the latest value. isPending lets you show subtle feedback while the old list remains visible.
Important constraints:
- The function passed to
startTransitionmust be synchronous; state updates scheduled after anawaitinside it are not marked as transitions in React 18. - You cannot use a transition to control a text input’s value; the input must update urgently.
startTransition(the standalone import) works outside components, for example in a router or a store. It does not give youisPending.- Transitions do not make rendering faster. They make the app feel faster by keeping interaction responsive.
useDeferredValue
useDeferredValue is the counterpart for when you do not own the state update, for example when the value comes from props.
function ResultsPanel({ query }) {
const deferredQuery = useDeferredValue(query);
const isStale = query !== deferredQuery;
return (
<div style={{ opacity: isStale ? 0.6 : 1 }}>
<ExpensiveResults query={deferredQuery} />
</div>
);
}
React first re-renders with the old deferred value (fast, because ExpensiveResults is memoized and its props did not change) and then attempts a background render with the new value. For this to help, the expensive child must be wrapped in React.memo; otherwise it re-renders with the old value anyway.
Compared with debouncing: debounce always waits a fixed delay, even on a fast machine, and does not cancel work already started. A deferred value renders as soon as the device is free and abandons outdated renders.
Suspense
Suspense lets a component “wait” for something and declaratively specify what to show meanwhile.
<Suspense fallback={<Spinner label="Loading statement" />}>
<StatementDetails id={statementId} />
</Suspense>
In React 18, Suspense officially supports:
- Code splitting with
React.lazy. - Data fetching through Suspense-enabled libraries and frameworks (Relay, TanStack Query’s
useSuspenseQuery, Next.js App Router, Remix). React 18 did not ship a stable public API for building your own Suspense data source; theusehook for promises arrived in React 19. - Streaming server rendering with selective hydration.
Code Splitting with React.lazy
const Reports = React.lazy(() => import('./features/reports/ReportsPage'));
function AppRoutes() {
return (
<Suspense fallback={<PageSkeleton />}>
<Routes>
<Route path="/reports" element={<Reports />} />
</Routes>
</Suspense>
);
}
React.lazy requires a module with a default export. Lazy components that fail to load (for example after a new deployment removed old chunk files) throw an error that an error boundary should catch, ideally offering a reload.
Suspense and Transitions Together
If a component that is already visible suspends again during an update, React would normally replace it with the fallback, causing a jarring flash. If the update is wrapped in a transition, React instead keeps showing the previous content until the new content is ready. This is why routers wrap navigation in transitions: the old page stays visible, with a pending indicator, until the next page’s code and data are loaded.
Designing Loading States
- Place Suspense boundaries where a loading state makes sense to a user: a page, a panel, a card. Too many boundaries cause “popcorn” UI; too few block the whole page on the slowest piece.
- Nest boundaries so the shell appears immediately and details stream in.
- Use skeletons that match final layout to avoid layout shift (CLS).
- Pair every Suspense boundary that loads remote code with an error boundary.
Server Rendering and Hydration in React 18
Traditional server-side rendering (SSR) had an “all or nothing” waterfall: fetch all data on the server, render all HTML, send it, load all JavaScript, then hydrate the entire page before anything is interactive. React 18 breaks each step apart:
- Streaming HTML.
renderToPipeableStream(Node) andrenderToReadableStream(web streams) send HTML as soon as the shell is ready, and stream in the content of each Suspense boundary later, along with a small inline script that puts it in place. - Selective hydration.
hydrateRoothydrates boundaries independently. If the user clicks a part of the page that has not hydrated yet, React prioritizes hydrating that part first. - Hydration errors occur when server and client output differ (dates, random values,
windowchecks during render).useId, consistent data, and moving browser-only code into effects avoid them.
React Server Components (RSC) were designed during the React 18 era and are used by frameworks such as Next.js App Router, but they became a stable React feature in React 19. In an interview, it is fine to explain them: components that run only on the server, never ship their code to the client, and can read data directly, while client components (marked 'use client') handle interactivity.
Performance: A Systematic Approach
Interviewers want to see a method, not a list of tricks. A good answer follows four steps:
- Define the problem with metrics. Is it slow initial load (LCP), slow interaction (INP), layout jumps (CLS), or memory growth? Gather field data (Real User Monitoring) and lab data (Lighthouse, WebPageTest).
- Profile. Use the Chrome Performance panel for main-thread work and the React DevTools Profiler for component render times and reasons.
- Fix the biggest cost first. Usually that is too much JavaScript, too many renders, or a slow network waterfall.
- Verify and guard. Re-measure, then add budgets in CI (bundle size, Lighthouse CI) so regressions are caught.
Core Web Vitals
| Metric | Measures | Good threshold |
|---|---|---|
| LCP (Largest Contentful Paint) | Loading: when the main content appears | 2.5 s or less |
| INP (Interaction to Next Paint) | Responsiveness to input across the visit | 200 ms or less |
| CLS (Cumulative Layout Shift) | Visual stability | 0.1 or less |
INP replaced First Input Delay (FID) as a Core Web Vital in March 2024. Concurrent features, yielding, and avoiding long tasks help INP directly.
Reducing Render Work
- Move state down. If only a small component needs some state, keep it there so updates do not re-render a large parent.
- Lift content up. Pass expensive, stable subtrees as
children; elements created by a parent that did not re-render are reused. - Split contexts. Separate rapidly changing values (a cursor position, a form field) from stable ones (user profile, theme).
- Use selectors with external stores so components subscribe to slices, not the whole store.
- Memoize selectively with
React.memo,useMemo,useCallbackwhere the profiler shows wasted renders. - Virtualize long lists with
react-windowor TanStack Virtual: render only the rows in view. A statement with 10,000 transactions should render perhaps 30 DOM rows. - Use transitions for expensive non-urgent updates.
Reducing JavaScript
- Route-level and component-level code splitting with
React.lazyand dynamicimport(). - Analyze bundles with
webpack-bundle-analyzerorrollup-plugin-visualizer; replace heavy dependencies (moment.js withdate-fnsorIntl, lodash with per-method imports or native methods). - Tree shaking requires ES modules and side-effect-free packages (
"sideEffects": false). - Share dependencies across micro frontends with Module Federation so React is downloaded once.
- Serve modern JavaScript to modern browsers; avoid unnecessary polyfills.
Reducing Network Cost
- Fetch in parallel; avoid waterfalls where a child starts fetching only after the parent has rendered.
- Prefetch the next route’s code and data on hover or when idle.
- Cache with HTTP caching headers, a service worker, and a client cache (TanStack Query).
- Use HTTP/2 or HTTP/3, compression (Brotli), and a CDN.
- Optimize images: modern formats (AVIF, WebP), responsive
srcset, explicit dimensions, lazy loading below the fold,fetchpriority="high"for the LCP image.
The React Profiler
The React DevTools Profiler records commits and shows, for each one, which components rendered, how long they took, and (with the setting enabled) why they rendered: props changed, state changed, hooks changed, or parent rendered. A “flame graph” view shows the tree; a “ranked” view sorts components by render time.
The <Profiler> component exposes the same data programmatically:
<Profiler id="TransactionTable" onRender={(id, phase, actualDuration) => {
if (actualDuration > 16) metrics.track('slow-render', { id, phase, actualDuration });
}}>
<TransactionTable rows={rows} />
</Profiler>
Advanced Patterns
Optimistic Updates
Update the UI immediately, assuming the server call will succeed, and roll back if it fails. Appropriate for low-risk actions (starring a payee, renaming an account). For money movement, do not show a transfer as completed before the server confirms it; show it as “pending” instead.
async function toggleFavorite(payee) {
const previous = payees;
setPayees(ps => ps.map(p => p.id === payee.id ? { ...p, favorite: !p.favorite } : p));
try {
await api.patch(`/payees/${payee.id}`, { favorite: !payee.favorite });
} catch {
setPayees(previous);
toast.error('Could not update favorite');
}
}
State Machines for Complex UI
Multi-step flows (onboarding, MFA enrollment, payment submission with review and confirmation) have many states and allowed transitions. Modeling them explicitly with a reducer or a library like XState prevents impossible states such as “submitting and showing success at the same time”.
const machine = {
editing: { REVIEW: 'reviewing' },
reviewing: { EDIT: 'editing', CONFIRM: 'submitting' },
submitting:{ SUCCESS: 'confirmed', FAILURE: 'failed' },
failed: { RETRY: 'submitting', EDIT: 'editing' },
confirmed: {},
};
function reducer(state, event) {
const next = machine[state.status][event.type];
return next ? { ...state, status: next, ...event.payload } : state;
}
Headless Components and Hooks
Separate behavior from markup: a useCombobox hook manages keyboard navigation, ARIA attributes, and selection, while consumers render whatever markup and styles they need. Libraries like Downshift, Radix UI primitives, React Aria, and TanStack Table follow this pattern.
Web Workers with React
For CPU-heavy work, such as parsing a 50 MB CSV statement export or computing analytics, use a Web Worker so the main thread stays free. Libraries like Comlink make worker calls look like async function calls. The component shows progress and receives results via state.
React and TypeScript
Most enterprise React code is TypeScript. Interviewers often check that you can type components idiomatically.
type ButtonProps = React.ComponentPropsWithoutRef<'button'> & {
variant?: 'primary' | 'secondary' | 'danger';
loading?: boolean;
};
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ variant = 'primary', loading = false, children, ...rest }, ref) => (
<button ref={ref} data-variant={variant} aria-busy={loading} {...rest}>
{loading ? <Spinner aria-hidden /> : null}
{children}
</button>
)
);
function useLocalStorage<T>(key: string, initial: T) {
const [value, setValue] = useState<T>(() => {
try {
const raw = localStorage.getItem(key);
return raw ? (JSON.parse(raw) as T) : initial;
} catch { return initial; }
});
useEffect(() => { localStorage.setItem(key, JSON.stringify(value)); }, [key, value]);
return [value, setValue] as const;
}
Good practices: type props with type or interface, avoid React.FC for components that do not take children (it historically implied children), use discriminated unions for variant props, use generics for reusable components like Select<T> and Table<Row>, and type API responses with runtime validation (Zod) at the boundary.
Upgrading to React 18 in an Enterprise Code Base
A practical migration answer covers:
- Upgrade
react,react-dom, and type definitions; update testing libraries (React Testing Library 13+ usescreateRoot). - Replace
ReactDOM.renderwithcreateRootandhydratewithhydrateRoot. - Enable Strict Mode gradually and fix effects that break under double invocation.
- Watch for behavioral changes from automatic batching in code that relied on synchronous renders between
setStatecalls. - Replace legacy context, string refs, and deprecated lifecycle methods (
componentWillMount,componentWillReceiveProps). - Check third-party libraries that subscribe to external stores; they need
useSyncExternalStoreto avoid tearing. - Then adopt transitions and Suspense incrementally, measuring INP improvements.
In a micro frontend architecture, coordinate the upgrade through Module Federation shared configuration (React must be a singleton), which Chapter 4 covers.
Interview Questions and Answers
Q1. What is React Fiber?
Fiber is the reconciliation engine introduced in React 16. Each component instance is represented by a fiber object in a linked tree, which lets React split rendering into small units, pause and resume work, assign priorities, and discard in-progress work. React keeps a current tree and a work-in-progress tree and commits the latter atomically.
Q2. What does “concurrent rendering” mean in React 18?
React can work on multiple versions of the UI at once and interrupt a render to handle more urgent updates. It is cooperative scheduling on a single thread, not parallelism. It is enabled by createRoot and used for updates marked as transitions or deferred values.
Q3. When would you use useTransition versus useDeferredValue?
Use useTransition when you control the state update and can wrap it in startTransition, and you want an isPending flag. Use useDeferredValue when you receive a value (often from props or a parent) and want to render the expensive part with a lagging version of it.
Q4. How is useDeferredValue different from debouncing?
Debouncing waits a fixed time before doing any work and cannot interrupt work already started. A deferred value starts rendering immediately in the background when the device has capacity, is interrupted if the value changes again, and requires no arbitrary delay. On fast devices results appear sooner; on slow devices input stays responsive.
Q5. What is Suspense, and what can it suspend on in React 18?
Suspense shows a fallback while descendants are not ready. In React 18 it officially supports lazy-loaded components, Suspense-enabled data libraries and frameworks, and streaming SSR boundaries. It coordinates loading states declaratively, so nested boundaries reveal the UI progressively.
Q6. What happens when a component inside a visible Suspense boundary suspends during a transition?
React keeps showing the existing content rather than replacing it with the fallback, and isPending becomes true. Without a transition, the boundary would switch back to the fallback, causing a flash of loading UI.
Q7. Explain streaming SSR and selective hydration.
Streaming SSR sends HTML in chunks: the shell first, then each Suspense boundary’s content as its data resolves, inserted with tiny inline scripts. Selective hydration hydrates boundaries independently and prioritizes the area the user interacts with. Together they remove the all-or-nothing waterfall of classic SSR and improve both LCP and interactivity.
Q8. What causes hydration mismatches and how do you fix them?
Rendering different output on server and client: dates and time zones, random values, reading window or localStorage during render, locale-dependent formatting, or invalid HTML nesting. Fix by rendering consistent output, using useId for IDs, moving browser-only code into effects, and passing locale/time zone explicitly.
Q9. How would you diagnose a slow page in a React app?
First identify which metric is bad using field data: LCP, INP, or CLS. For load problems, inspect the network waterfall and bundle size. For interaction problems, record in the Chrome Performance panel to find long tasks, and use the React Profiler to find components that render too often or too slowly and why. Fix the largest contributor, then verify with metrics and add performance budgets to CI.
Q10. How do you render a list of 10,000 transactions efficiently?
Virtualize it so only visible rows are in the DOM, give rows stable keys, memoize row components, keep row props stable, and paginate or stream data from the server. Do expensive sorting and filtering in memoized selectors or a Web Worker, and wrap filter changes in a transition.
Q11. What is INP, and how do React 18 features help?
Interaction to Next Paint measures the latency from user input to the next frame for interactions throughout the page’s life; a good score is 200 ms or less. Long tasks on the main thread hurt it. Transitions and deferred values let React yield to input handling, and code splitting reduces the JavaScript that must be evaluated.
Q12. What is code splitting and how do you implement it in React?
Splitting the bundle into chunks loaded on demand, so users download only what they need. Use dynamic import() with React.lazy and Suspense for routes and heavy components, and configure the bundler to create vendor chunks. Prefetch likely next chunks and handle chunk load failures with an error boundary.
Q13. When is an optimistic update inappropriate?
When failure is likely or costly, or when users must not be misled about the outcome. Payments, transfers, and trades should show a pending state until the server confirms. Optimistic updates suit reversible, low-risk actions like toggling favorites or reordering a list.
Q14. What are Server Components, and how do they differ from SSR?
Server Components run only on the server and their code never ships to the browser; they can read data directly and send a serialized UI description to the client. SSR renders client components to HTML on the server for a fast first paint, but those components still ship and hydrate on the client. They complement each other. Server Components are stable in React 19; in React 18 they appear through frameworks such as the Next.js App Router.
Q15. Why might React.lazy fail in production, and how do you handle it?
After a deployment, an old client may request chunk files that no longer exist, or the network may fail. The lazy import rejects and the error propagates to the nearest error boundary. Handle it by showing a friendly message with a reload button, retrying the import, keeping previous build assets available for a while, and using content-hashed filenames.
Q16. How do you type a reusable component that wraps a native element in TypeScript?
Extend React.ComponentPropsWithoutRef<'button'> (or ComponentPropsWithRef) with custom props, use forwardRef<HTMLButtonElement, Props>, and spread the remaining props onto the element. This preserves all native attributes, event handler types, and ARIA props.
Q17. How do you prevent impossible states in a complex form or flow?
Model the flow as an explicit state machine with a single status field and allowed transitions, rather than multiple booleans like isLoading, isError, and isSuccess that can combine in invalid ways. Use a reducer or XState, and use discriminated unions in TypeScript so each status carries only its relevant data.
Q18. What is tearing?
Tearing is when different parts of the UI show different values of the same external data in a single render, which can happen when a concurrent render yields and an external store changes in between. React-managed state cannot tear. External stores must use useSyncExternalStore to avoid it.
Q19. How would you move expensive computation off the main thread?
Use a Web Worker, potentially via Comlink, and communicate using postMessage with transferable objects for large buffers. Show progress in the UI and allow cancellation. For moderately expensive work, chunk it and yield between chunks, or wrap state updates in transitions.
Q20. What steps would you take to upgrade a large application from React 17 to 18?
Upgrade packages and testing libraries, switch to createRoot/hydrateRoot, run under Strict Mode to find effects without cleanup, review code that depended on non-batched updates, update libraries that use external stores, and roll out behind a feature flag or per micro frontend with monitoring for errors and performance regressions.
Q21. What is the difference between rendering and painting?
Rendering in React means calling components and computing the element tree. Committing applies DOM changes. Painting is the browser drawing pixels after style and layout. A React render does not necessarily cause a DOM change or a paint, and DOM changes are batched into one paint per frame.
Q22. What is the children optimization trick?
If a component that changes state often receives its expensive subtree as children from a parent, those child elements were created by the parent and do not change when the stateful component re-renders. React sees the same element references and skips re-rendering them, with no memoization needed.
Q23. How do you measure performance in production?
Collect Real User Monitoring data with the web-vitals library or a vendor SDK, segmenting by route, device class, and connection. Track custom timings with the User Timing API (performance.mark/measure) for business-critical flows, such as time to show account balances. Alert on regressions and correlate them with releases.
Q24. What are the trade-offs of putting a Suspense boundary high versus low in the tree?
A high boundary shows one loading state for a large area, which is simple but means the slowest piece blocks everything. Low boundaries let parts appear independently but can create a jumpy experience and more layout shift. Choose boundaries that match meaningful sections of the page and use skeletons with stable dimensions.
Q25. Explain the double-buffering of fiber trees.
React maintains a current tree reflecting the screen and builds a work-in-progress tree for the next state. Each fiber has an alternate pointer linking the two versions, so React can reuse objects. When the work-in-progress tree is complete, React commits it and swaps the pointers, making the update atomic from the user’s perspective.
Key Takeaways
- Fiber enables interruptible, prioritized rendering; concurrency is cooperative scheduling, not threads.
- Transitions and deferred values keep urgent interactions responsive during expensive renders.
- Suspense coordinates loading states; combine it with transitions and error boundaries.
- Streaming SSR and selective hydration remove the all-or-nothing SSR waterfall.
- Performance work starts with metrics (LCP, INP, CLS) and profiling, then targets the biggest cost.
← Part 2: React 18 Fundamentals and Hooks · All chapters · Part 4: Micro Frontends and Module Federation →







