Senior Frontend Interview: Rendering Pipeline, Hydration, Memory Leaks and INP
Bài này có bản tiếng Việt. Đọc bản tiếng Việt →
Nguyen Hai Trinh • Senior Software Engineer @ SmileViet
In the Senior Frontend round, the framework part is usually just the warm-up. What sorts candidates is a different question: what is the browser doing underneath the framework?
I’ve sat on both sides of the interview table. The pattern repeats pretty consistently: a candidate is strong in React but can’t explain why an animation janks, why a tab eats RAM after an hour of use, or why a button feels slow when Lighthouse is green. The six groups of questions below are the ones I run into most. The stories are pulled together from several projects, not from any one company.
1. “From typing a URL to the first pixel, what does the browser do?”
Weak answer: “Send a request, get HTML, parse it and render.” Correct, but there’s nothing to dig into. The interviewer has to pull out every word.
Strong answer: walk through the pipeline and point to exactly where things get blocked.
- The parser reads HTML as a byte stream and builds the DOM incrementally (no need to wait for the full download).
- On a
<link rel="stylesheet">, the browser downloads it and builds the CSSOM. CSS is render-blocking: with no CSSOM nothing gets painted, because painting with the wrong styles and then repainting is worse. - On a synchronous
<script>, the parser stops to download and run the script, since it mightdocument.writeor modify the DOM. The script also has to wait for the CSSOM of the stylesheets above it, in case it reads styles. - DOM + CSSOM combine into the render tree (which only contains visible nodes;
display: noneis dropped,visibility: hiddenstays). - Layout computes geometric size and position, Paint produces draw commands for each layer, and Composite merges the layers (usually on the GPU) into the final frame.
Put together as one path from HTML bytes to pixels:
flowchart TD A["HTML as a byte stream"] --> B["Parser builds the DOM incrementally"] B -->|"hits a link stylesheet tag"| C["Download CSS, build the CSSOM"] B -->|"hits a synchronous script"| S["Parser pauses, runs the script, moves on"] B --> R["Render tree: visible nodes only"] C --> R S --> R R --> L["Layout: size and position"] L --> P["Paint: draw commands per layer"] P --> K["Composite: merge layers, usually on the GPU"]
So what do you do about it? Put CSS in the <head> and keep it small. For scripts, use defer (runs after parsing finishes, keeps order) or async (runs as soon as it’s downloaded, no ordering guarantee, good for analytics). Module scripts (type="module") already behave like defer by default.
<head>
<link rel="preload" href="/fonts/inter-var.woff2" as="font" type="font/woff2" crossorigin />
<link rel="stylesheet" href="/critical.css" />
<script src="/app.js" defer></script>
<script src="https://analytics.example/a.js" async></script>
</head>
Note that crossorigin on a font preload is required even when it’s the same origin, because fonts are always fetched in CORS mode. Without it the browser downloads the font twice.
Trap follow-up: “Does defer make LCP faster?” Not automatically. defer only removes the parser blocking. LCP depends on how early the LCP element’s resource (hero image, text block) is discovered and loaded. If the hero image is a CSS background-image or inserted by JS, the preload scanner doesn’t see it early; in that case <img fetchpriority="high"> or rel="preload" as="image" is what helps. A good answer splits LCP into four parts: TTFB, resource discovery delay, load time, render time, and then says which part they’d optimize.
2. Layout, paint, composite: why is transform smooth and left janky?
Weak answer: “Because transform uses the GPU.” Half right. One more follow-up and they’re stuck.
Strong answer: each group of CSS properties touches a different stretch of the pipeline.
- Changing
width,height,margin,top/left,font-size: re-runs layout, then paint and composite. Layout on part of the tree can drag in siblings, parents and children. - Changing
color,background-color,box-shadow: skips layout, but still has to repaint. - Changing
transform,opacity(andfilterin many cases) on an element that’s already been promoted to its own layer: only needs composite, can run on the compositor thread, and doesn’t depend on whether the main thread is busy.
The real payoff is the two different threads: the main thread and the compositor thread. A transform animation keeps running smoothly even while the main thread is burning CPU on JS. A left animation makes the main thread compute layout for every frame. Any long task and it lags right away.
/* Janks when the main thread is busy: every frame triggers layout */
.drawer { transition: left 200ms; }
/* Smoother: composite only */
.drawer { transition: transform 200ms; will-change: transform; }
But will-change isn’t free: every layer costs GPU memory, and sprinkling will-change everywhere is a deliberate kind of memory leak. Used properly, you turn it on right before animating and remove it afterwards. Or keep it for elements that really do animate continuously.
Trap follow-up: “So opacity is always cheap?” Not quite. If the element isn’t in its own layer yet, changing opacity can make the browser create a layer right then. To be sure, open the Layers tab and Rendering > Paint flashing in DevTools. Don’t guess.
3. Layout thrashing: interleaving reads and writes in a loop
The interviewer asks: “This code runs slowly with 2000 elements. Why?”
const items = document.querySelectorAll<HTMLElement>(".card");
items.forEach((el) => {
const h = el.offsetHeight; // read: forces layout
el.style.height = `${h + 10}px`; // write: invalidates layout
});
Weak answer: “Looping 2000 times is slow, switch to a plain for.” That doesn’t touch the real problem.
Strong answer: this is forced synchronous layout. Normally the browser batches style changes and computes layout once at the end of the frame. But if you write a style and then immediately read a geometric property (offsetHeight, getBoundingClientRect(), scrollTop, getComputedStyle(...).width…), the browser has to compute layout right now to return the correct number. Interleaving reads and writes in a loop turns one layout into N layouts. This is called layout thrashing.
The fix is simple too: separate the phases. Read everything first, write everything after.
const items = Array.from(document.querySelectorAll<HTMLElement>(".card"));
// Read phase: a single layout
const heights = items.map((el) => el.offsetHeight);
// Write phase: no more geometry reads
items.forEach((el, i) => {
el.style.height = `${heights[i] + 10}px`;
});
In a large codebase where many modules read and write the DOM, people use a scheduler like fastdom, which batches reads and writes into requestAnimationFrame. And ResizeObserver and IntersectionObserver hand you the measurements, so you don’t have to force layout yourself.
Trap follow-up: “Does thrashing happen in React?” Yes, most often in useLayoutEffect: measure the DOM, setState, re-render, measure again. useLayoutEffect runs synchronously before the browser paints, so every measurement in it is a forced layout. Keep it for things that truly need to avoid flicker (measuring a tooltip to position it), and don’t use it for anything unrelated to geometry.
4. Hydration and streaming SSR: visible, but clicks don’t work yet
The interviewer asks: “How does SSR help performance? What problem does hydration solve and what problem does it create?”
Weak answer: “SSR is for SEO and loads faster.” Missing: faster than what?
Strong answer: SSR improves time to first content (FCP, often LCP too) because the HTML already has the data, with no waiting for the JS bundle to download, run and then fetch. But the page isn’t interactive until hydration finishes: React has to download the JS, rebuild the component tree in memory, reconcile it with the existing DOM and attach event handlers. The gap between “visible” and “clickable” is where a user clicks a button and nothing happens. Hydration also costs CPU proportional to the size of the component tree, and the serialized data ships along with the HTML, so the payload is paid twice: once as HTML and once as state.
Streaming SSR (React 18+) removes the “all or nothing” bottleneck of renderToString: the server doesn’t have to wait for the slowest part before sending something.
// server (Node)
import { renderToPipeableStream } from "react-dom/server";
app.get("/", (req, res) => {
const { pipe, abort } = renderToPipeableStream(<App />, {
bootstrapScripts: ["/client.js"],
onShellReady() {
res.statusCode = 200;
res.setHeader("Content-Type", "text/html; charset=utf-8");
pipe(res); // send the shell right away, Suspense boundaries arrive later
},
onShellError() {
res.statusCode = 500;
res.send("<h1>Something went wrong</h1>");
},
});
setTimeout(abort, 10_000); // don't hold the stream open forever
});
function Page() {
return (
<>
<Header />
<Suspense fallback={<ReviewsSkeleton />}>
<Reviews /> {/* slow: streams later, doesn't block the rest */}
</Suspense>
</>
);
}
The shell (the part outside Suspense) goes first; when Reviews is ready, the server streams more HTML plus a small script to swap out the fallback. On the client, selective hydration lets each boundary hydrate independently: if the user clicks a boundary that hasn’t hydrated, React prioritizes hydrating exactly that spot and replays the click event.
On hydration mismatch: the classic cause is rendering that depends on the environment (Date.now(), Math.random(), typeof window, locale/timezone differing between server and client). The fix is to keep the initial output identical, then update after mount:
function Clock() {
const [now, setNow] = useState<string | null>(null);
useEffect(() => {
setNow(new Date().toLocaleTimeString());
}, []);
return <span>{now ?? "--:--"}</span>;
}
Trap follow-up: “Do React Server Components replace SSR?” No. SSR generates HTML for the first load; RSC decides which components never send JS to the client. The two are orthogonal and often used together. The benefit of RSC is less JS to hydrate, which directly cuts the cost described above.
5. Memory leaks: three familiar culprits
The interviewer asks: “A dashboard app left open all day keeps growing in RAM. What causes come to mind?”
Weak answer: “Probably forgot to clear an interval.” Right in one case, and usually not enough to find the real culprit.
Strong answer: the garbage collector only reclaims objects that are no longer reachable from a root (window, stack, module scope…). A leak means you’re accidentally holding a reference path to something that’s no longer used. Three families of causes come up often:
a) Detached DOM. A node has been removed from the document but JS still holds a reference, which keeps its whole subtree and listeners alive.
const cache = new Map<string, HTMLElement>();
function showRow(id: string) {
const row = document.createElement("tr");
cache.set(id, row); // holds the reference forever
table.appendChild(row);
}
function clearTable() {
table.innerHTML = ""; // DOM removed, but cache still holds the row: detached
}
The fix: cache.delete(id) when removing, or use WeakMap/WeakRef if the key is an object and you don’t want to block GC.
Looking down from the root, the path keeping row alive is exactly four links long:
flowchart LR W["Window"] --> M["Module scope"] M --> C["Map cache"] C --> R["Detached row"] R --> L["Listener and subtree"]
b) Event listeners or subscriptions that aren’t removed. addEventListener on window, document, or a global store creates a reference from the root to the callback, and the callback holds a closure. In React, this is an effect with no cleanup:
useEffect(() => {
const onResize = () => setWidth(window.innerWidth);
window.addEventListener("resize", onResize);
return () => window.removeEventListener("resize", onResize);
}, []);
// Or use AbortController: remove many listeners with one signal
useEffect(() => {
const ac = new AbortController();
window.addEventListener("resize", onResize, { signal: ac.signal });
window.addEventListener("online", onOnline, { signal: ac.signal });
return () => ac.abort();
}, []);
The same family includes setInterval, WebSocket, EventSource, ResizeObserver, and RxJS/Redux subscriptions that never unsubscribe.
c) Closures holding too much. A closure holds the whole scope it can use, depending on how far the engine optimizes, but don’t rely on that. A long-lived callback can accidentally keep a big data array alive:
function attachHandler(button: HTMLElement) {
const hugeData = new Array(1_000_000).fill("x");
const summary = hugeData.length;
button.addEventListener("click", () => {
console.log(summary); // only needs the number, but if another closure in the same scope
// uses hugeData, it stays alive along with the scope
});
}
The safe rule: precompute the values you need, put hugeData in its own function so the scope ends, or set it to null when done. Also worth mentioning: unbounded caches (a Map used for memoization with no eviction), log/message lists that grow forever, and console.log of objects while DevTools is open (DevTools holds references to logged objects, so the “leak” only shows up while debugging).
Trap follow-up: “Is growing RAM definitely a leak?” No. A heap that grows and then gets reclaimed by GC in a sawtooth cycle is normal. A leak is when the floor of the sawtooth, after a manual GC, keeps creeping up over time while you repeat the same action.
6. Finding a memory leak with DevTools, step by step
The interviewer asks: “Say you suspect a page has a leak. What do you do, step by step?”
Weak answer: “Open DevTools and look at Memory.” No process at all.
Strong answer: the process I use, which is repeatable:
- Make the action reproducible. For example, open and close a modal 20 times, or navigate back and forth between two routes.
- Open the Performance tab (or Performance monitor) and watch
JS heap size,DOM Nodes,JS event listenersas you repeat the action. IfDOM NodesandListenersonly go up and never come down, you already have a clue about the kind of leak before you open a heap snapshot. - Use the three-snapshot technique in the Memory tab:
- Click the trash icon (force GC), take Snapshot 1 after running the action once to warm up, to rule out lazy init.
- Repeat the suspect action N times, force GC, take Snapshot 2.
- Repeat N more times, force GC, take Snapshot 3.
- In Snapshot 3, choose the view Objects allocated between Snapshot 1 and Snapshot 2: things allocated in the early phase that are still alive at the end are leak candidates.
- Type
Detachedinto the Class filter box to list theDetached HTMLDivElements… Pick an object and look at the Retainers panel below to see the reference path keeping it alive, for exampleMap→cache→ module →Window. That’s where the fix goes. - After fixing, repeat the process and compare: the count of that kind of object should stop growing.
Two columns in a snapshot are easy to mix up. Shallow size is the memory of the object itself. Retained size is the memory that would be freed if that object disappeared, including things only it holds. Sort by Retained size and leaks show up fastest.
Then there’s Allocation instrumentation on timeline, which draws blue bars (still alive) and gray bars (already reclaimed) over time, so you can see the exact moment of an allocation that never gets reclaimed. For Node/SSR, the equivalent is --inspect and then taking a heap snapshot from Chrome DevTools; a server leak is worse because the process lives so long.
Trap follow-up: “Why force GC before taking a snapshot?” Because a snapshot includes garbage objects that haven’t been collected yet. Without GC first, you chase things that aren’t leaks. Taking a snapshot runs GC automatically as well, but clicking the trash icon before each step keeps your measurements consistent.
7. INP: why a button feels “slow” even when Lighthouse is green
The interviewer asks: “What is INP? How is it different from FID? How would you improve it?”
Weak answer: “It’s the response time on click, so reduce JS.” Too vague.
Strong answer: INP (Interaction to Next Paint) measures the latency of interactions (click, tap, key press) across the whole lifetime of the page, taking a value close to the slowest interaction (by Google’s thresholds, under 200 ms is good and over 500 ms is poor). INP replaced FID in Core Web Vitals in March 2024. The core difference: FID only measures the delay before the handler starts running, and only for the first interaction; INP measures from the input until the next frame is painted, for every interaction.
An interaction has three phases:
- Input delay: waiting for the main thread to be free (because of a running long task).
- Processing time: the time the event handler runs.
- Presentation delay: from when the handler finishes until the frame is painted (style, layout, paint).
So how you improve it depends on which phase is long. Lighthouse is a lab test, usually without real interactions, so it can’t catch INP; you need field data (CrUX, the web-vitals library in production) and DevTools Performance with CPU throttling to reproduce it.
import { onINP } from "web-vitals/attribution";
onINP(({ value, attribution }) => {
// attribution tells you which element / phase is slow
navigator.sendBeacon("/rum", JSON.stringify({
name: "INP",
value,
target: attribution.interactionTarget,
inputDelay: attribution.inputDelay,
processingDuration: attribution.processingDuration,
presentationDelay: attribution.presentationDelay,
}));
});
Fix strategy by phase:
- Processing too long: break the work into pieces and yield the main thread so the browser can paint. Do what needs an immediate response first, and defer the rest.
// Yield the main thread between slices of work
const yieldToMain = (): Promise<void> =>
"scheduler" in globalThis && "yield" in (globalThis as any).scheduler
? (globalThis as any).scheduler.yield()
: new Promise((r) => setTimeout(r, 0));
async function onSave() {
showSpinner(); // immediate visual feedback
await yieldToMain(); // let the browser paint
for (const chunk of chunks(records, 200)) {
process(chunk);
await yieldToMain();
}
}
scheduler.yield() isn’t available in every browser, so you need a fallback like the one above.
- In React: mark expensive updates as non-urgent with
startTransitionoruseDeferredValue, so React can interrupt that render when new input arrives.
const [query, setQuery] = useState("");
const [isPending, startTransition] = useTransition();
const [results, setResults] = useState<Item[]>([]);
function onChange(e: React.ChangeEvent<HTMLInputElement>) {
setQuery(e.target.value); // the input responds immediately
startTransition(() => setResults(filter(all, e.target.value))); // the list can lag
}
- High input delay: find the long tasks (the red bars on the Main track in Performance) and move heavy work into a Web Worker, and cut third-party JS (tag managers, chat widgets).
- High presentation delay: shrink the DOM size, avoid layout thrashing (section 3), and use
content-visibility: autofor regions outside the viewport.
Trap follow-up: “Does debouncing the search box improve INP?” It can reduce how many times the heavy work runs, but it doesn’t fix the fact that each run still blocks the main thread. INP measures the slowest interaction, so debounce hides the symptom without shrinking the long task. Pair debounce with a transition or a worker and then it’s enough.
How I grade the answers
From the other side of the table, I watch for three things. One, whether the candidate can explain the mechanism or just repeats best practice (“use transform because of the GPU”). Two, whether they have tools to verify it. “I opened Performance, looked at the Main track, and found repeated Recalculate Style and Layout” is far more convincing than “I think it’s because…”. Three, whether they know the limits of the solution they just proposed: will-change costs memory, SSR still pays for it with hydration, and debounce doesn’t cure INP.
If you’re prepping for this round, try an exercise: build a page that has layout thrashing, a detached DOM leak and a heavy handler all at once. Then find each one yourself with the Performance and Memory panels, measuring before and after.
Keep reading
- Frontend Lead Interview: Frontend System Design With Micro-Frontends, Monorepos and Trade-offsInterview & Career
- Senior Fullstack Interview: Auth & Security with Sessions, JWT, CSRF, XSS and OAuth2Interview & Career
- Senior Frontend Interview: Deep TypeScript — Generics, Conditional Types and the Type System in PracticeInterview & Career
Need a developer for a contract or freelance project?
I take on fullstack projects — Go backends, React/Next.js frontends, or both ends. Send a few lines about the scope and timeline and I'll reply within 48 hours.