Phỏng Vấn Frontend Senior: Rendering Pipeline, Hydration, Memory Leak và INP
Bài này có bản tiếng Anh. Read in English →
Nguyen Hai Trinh • Senior Software Engineer @ SmileViet
Ở vòng Frontend Senior, phần framework thường chỉ là khởi động. Phần phân loại ứng viên nằm ở câu hỏi khác: bên dưới framework, trình duyệt đang làm gì?
Mình ngồi cả hai phía bàn phỏng vấn. Pattern lặp lại khá đều: ứng viên giỏi React, nhưng không giải thích được vì sao animation giật, vì sao tab ngốn RAM sau một giờ dùng, hay vì sao nút bấm chậm dù Lighthouse xanh. Sáu nhóm câu hỏi dưới đây là những cái mình gặp nhiều nhất. Mấy chuyện kể ra thì gom từ nhiều dự án, không của riêng công ty nào.
1. “Từ lúc gõ URL đến lúc thấy pixel đầu tiên, trình duyệt làm gì?”
Trả lời yếu: “Gửi request, nhận HTML, parse rồi render.” Đúng, mà chẳng có gì để đào. Interviewer phải tự kéo từng chữ.
Trả lời mạnh: kể theo pipeline, và chỉ đúng chỗ nào bị chặn.
- Parser đọc HTML theo byte stream, dựng DOM dần dần (không cần chờ tải hết).
- Gặp
<link rel="stylesheet">, trình duyệt tải và dựng CSSOM. CSS là render-blocking: chưa có CSSOM thì chưa vẽ gì, vì vẽ sai style rồi vẽ lại còn tệ hơn. - Gặp
<script>đồng bộ, parser dừng lại để tải và chạy script, vì script có thểdocument.writehoặc sửa DOM. Script cũng phải chờ CSSOM của các stylesheet phía trên nó, phòng khi nó đọc style. - DOM + CSSOM kết hợp thành render tree (chỉ chứa node nhìn thấy;
display: nonebị loại,visibility: hiddenthì vẫn có). - Layout tính kích thước và vị trí hình học, Paint tạo draw command cho từng layer, Composite ghép các layer (thường trên GPU) thành khung hình cuối.
Gom lại thành một đường chạy từ byte HTML tới pixel:
flowchart TD A["HTML theo byte stream"] --> B["Parser dựng DOM dần"] B -->|"gặp thẻ link stylesheet"| C["Tải CSS, dựng CSSOM"] B -->|"gặp script đồng bộ"| S["Parser dừng, chạy script rồi đi tiếp"] B --> R["Render tree: chỉ node nhìn thấy"] C --> R S --> R R --> L["Layout: kích thước và vị trí"] L --> P["Paint: draw command cho từng layer"] P --> K["Composite: ghép layer, thường trên GPU"]
Nên làm gì với chuyện này? CSS đặt trong <head> và giữ nhỏ. Script thì dùng defer (chạy sau khi parse xong, giữ thứ tự) hoặc async (chạy ngay khi tải xong, không đảm bảo thứ tự, hợp với analytics). Module script (type="module") mặc định đã hành xử như defer.
<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>
Lưu ý crossorigin trên preload font là bắt buộc kể cả khi cùng origin, vì font luôn được tải ở chế độ CORS. Thiếu nó thì trình duyệt tải font hai lần.
Follow-up bẫy: “defer có làm LCP nhanh hơn không?” Không tự động. defer chỉ bỏ việc chặn parser. LCP phụ thuộc tài nguyên của phần tử LCP (ảnh hero, text block) được phát hiện và tải sớm đến đâu. Nếu ảnh hero là background-image trong CSS hoặc được JS chèn vào, preload scanner không thấy nó sớm; khi đó <img fetchpriority="high"> hoặc rel="preload" as="image" mới có tác dụng. Người trả lời tốt tách LCP thành bốn phần: TTFB, độ trễ phát hiện tài nguyên, thời gian tải, thời gian render, rồi nói mình tối ưu phần nào.
2. Layout, paint, composite: vì sao transform mượt còn left thì giật?
Trả lời yếu: “Vì transform dùng GPU.” Nửa đúng. Hỏi tiếp một câu là tắc.
Trả lời mạnh: mỗi nhóm thuộc tính CSS chạm vào một đoạn khác của pipeline.
- Đổi
width,height,margin,top/left,font-size: chạy lại layout, rồi paint và composite. Layout của một phần cây có thể kéo theo cả anh em, cha và con. - Đổi
color,background-color,box-shadow: bỏ qua layout, nhưng vẫn phải paint lại. - Đổi
transform,opacity(vàfiltertrong nhiều trường hợp) trên phần tử đã được promote lên layer riêng: chỉ cần composite, có thể chạy trên compositor thread, không phụ thuộc main thread đang bận.
Chỗ ăn tiền nằm ở hai thread khác nhau: main thread và compositor thread. Animation transform chạy tiếp mượt kể cả khi main thread đang ngốn CPU cho JS. Animation left thì bắt main thread tính layout cho từng frame. Có long task là lag ngay.
/* Giật khi main thread bận: mỗi frame kích hoạt layout */
.drawer { transition: left 200ms; }
/* Mượt hơn: chỉ composite */
.drawer { transition: transform 200ms; will-change: transform; }
Nhưng will-change không miễn phí: mỗi layer tốn bộ nhớ GPU, và rải will-change khắp nơi là một dạng memory leak có chủ đích. Dùng đúng thì bật ngay trước khi animate, gỡ sau khi xong. Hoặc để dành cho phần tử animate liên tục thật.
Follow-up bẫy: “Vậy opacity luôn rẻ?” Không hẳn. Nếu phần tử chưa nằm trong layer riêng, đổi opacity có thể khiến trình duyệt tạo layer ngay lúc đó. Muốn chắc thì mở tab Layers và Rendering > Paint flashing trong DevTools. Đừng đoán.
3. Layout thrashing: đọc ghi xen kẽ trong một vòng lặp
Interviewer hỏi: “Đoạn code này chạy chậm với 2000 phần tử. Vì sao?”
const items = document.querySelectorAll<HTMLElement>(".card");
items.forEach((el) => {
const h = el.offsetHeight; // đọc: buộc layout
el.style.height = `${h + 10}px`; // ghi: làm layout bị invalid
});
Trả lời yếu: “Loop 2000 lần thì chậm, đổi sang for thường.” Chưa chạm vào vấn đề thật.
Trả lời mạnh: đây là forced synchronous layout. Bình thường trình duyệt gom thay đổi style rồi tính layout một lần cuối frame. Nhưng ghi style xong đọc ngay một thuộc tính hình học (offsetHeight, getBoundingClientRect(), scrollTop, getComputedStyle(...).width…) thì trình duyệt buộc phải tính layout ngay để trả về số đúng. Xen kẽ đọc-ghi trong vòng lặp biến một lần layout thành N lần. Người ta gọi nó là layout thrashing.
Cách sửa cũng gọn: tách pha. Đọc hết trước, ghi hết sau.
const items = Array.from(document.querySelectorAll<HTMLElement>(".card"));
// Pha đọc: một lần layout duy nhất
const heights = items.map((el) => el.offsetHeight);
// Pha ghi: không đọc hình học nữa
items.forEach((el, i) => {
el.style.height = `${heights[i] + 10}px`;
});
Với codebase lớn, nhiều module cùng đọc ghi DOM, người ta dùng scheduler kiểu fastdom: gom tác vụ đọc và ghi vào requestAnimationFrame. Còn ResizeObserver và IntersectionObserver thì cho bạn số đo sẵn, khỏi tự ép layout.
Follow-up bẫy: “Trong React có bị thrashing không?” Có, và hay gặp nhất trong useLayoutEffect: đo DOM, setState, render lại, đo tiếp. useLayoutEffect chạy đồng bộ trước khi trình duyệt paint, nên mọi đo đạc trong đó đều là forced layout. Giữ nó cho việc thật sự cần tránh nhấp nháy (đo tooltip để định vị), đừng dùng cho việc không liên quan tới hình học.
4. Hydration và streaming SSR: nhìn thấy rồi mà bấm chưa ăn
Interviewer hỏi: “SSR giúp gì cho performance? Hydration giải quyết vấn đề gì và tạo ra vấn đề gì?”
Trả lời yếu: “SSR cho SEO và load nhanh hơn.” Thiếu mất: nhanh hơn cái gì?
Trả lời mạnh: SSR cải thiện thời gian tới nội dung đầu tiên (FCP, thường cả LCP) vì HTML có sẵn dữ liệu, không phải chờ bundle JS tải, chạy rồi fetch. Nhưng trang đó chưa tương tác được cho tới khi hydration xong: React phải tải JS, dựng lại cây component trong bộ nhớ, đối chiếu với DOM có sẵn và gắn event handler. Khoảng giữa “nhìn thấy” và “bấm được” là chỗ người dùng click vào nút mà không có gì xảy ra. Hydration cũng tốn CPU theo kích thước cây component, và dữ liệu được serialize gửi kèm HTML, nên payload bị trả hai lần: dạng HTML và dạng state.
Streaming SSR (React 18+) cắt nút thắt “all or nothing” của renderToString: server không phải chờ phần chậm nhất mới gửi gì đó.
// 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); // gửi phần shell ngay, các Suspense boundary đến sau
},
onShellError() {
res.statusCode = 500;
res.send("<h1>Có lỗi xảy ra</h1>");
},
});
setTimeout(abort, 10_000); // đừng giữ stream mãi mãi
});
function Page() {
return (
<>
<Header />
<Suspense fallback={<ReviewsSkeleton />}>
<Reviews /> {/* chậm: stream sau, không chặn phần còn lại */}
</Suspense>
</>
);
}
Shell (phần ngoài Suspense) gửi trước; khi Reviews sẵn sàng, server stream thêm HTML và một đoạn script nhỏ để thay fallback. Ở client, selective hydration cho phép hydrate từng boundary độc lập: người dùng click vào boundary chưa hydrate thì React ưu tiên hydrate đúng chỗ đó và replay sự kiện click.
Về hydration mismatch: nguyên nhân kinh điển là render phụ thuộc môi trường (Date.now(), Math.random(), typeof window, locale/timezone khác nhau giữa server và client). Sửa thì giữ output ban đầu giống nhau, rồi cập nhật sau khi mount:
function Clock() {
const [now, setNow] = useState<string | null>(null);
useEffect(() => {
setNow(new Date().toLocaleTimeString());
}, []);
return <span>{now ?? "--:--"}</span>;
}
Follow-up bẫy: “React Server Components có thay thế SSR không?” Không. SSR sinh HTML cho lần tải đầu; RSC quyết định component nào không bao giờ gửi JS xuống client. Hai thứ trực giao, thường dùng cùng nhau. Lợi ích của RSC là giảm lượng JS cần hydrate, tức giảm trực tiếp chi phí ở đoạn trên.
5. Memory leak: ba thủ phạm quen mặt
Interviewer hỏi: “Ứng dụng dashboard để mở cả ngày thì RAM tăng dần. Em nghĩ đến những nguyên nhân nào?”
Trả lời yếu: “Chắc do quên clear interval.” Đúng một trường hợp, mà thường không đủ để tìm ra thủ phạm thật.
Trả lời mạnh: garbage collector chỉ thu hồi object không còn reachable từ root (window, stack, module scope…). Leak nghĩa là bạn vô tình giữ một đường tham chiếu tới thứ không còn dùng nữa. Ba họ nguyên nhân hay gặp:
a) Detached DOM. Node đã bị gỡ khỏi document nhưng JS vẫn giữ tham chiếu, kéo theo cả cây con và listener của nó.
const cache = new Map<string, HTMLElement>();
function showRow(id: string) {
const row = document.createElement("tr");
cache.set(id, row); // giữ tham chiếu mãi mãi
table.appendChild(row);
}
function clearTable() {
table.innerHTML = ""; // DOM đã gỡ, nhưng cache vẫn giữ row: detached
}
Cách sửa: cache.delete(id) khi gỡ, hoặc dùng WeakMap/WeakRef nếu key là object và bạn không muốn ngăn GC.
Nhìn từ root xuống, đường giữ row sống dài đúng bốn mắt:
flowchart LR W["Window"] --> M["Module scope"] M --> C["Map cache"] C --> R["Detached row"] R --> L["Listener và cây con"]
b) Event listener hoặc subscription không gỡ. addEventListener trên window, document, hoặc một store toàn cục tạo ra tham chiếu từ root tới callback, và callback giữ closure. Trong React, đây là effect thiếu cleanup:
useEffect(() => {
const onResize = () => setWidth(window.innerWidth);
window.addEventListener("resize", onResize);
return () => window.removeEventListener("resize", onResize);
}, []);
// Hoặc dùng AbortController: gỡ nhiều listener bằng một signal
useEffect(() => {
const ac = new AbortController();
window.addEventListener("resize", onResize, { signal: ac.signal });
window.addEventListener("online", onOnline, { signal: ac.signal });
return () => ac.abort();
}, []);
Cùng họ này còn có setInterval, WebSocket, EventSource, ResizeObserver, subscription RxJS/Redux không unsubscribe.
c) Closure giữ quá nhiều. Closure giữ toàn bộ scope mà nó có thể dùng, tuỳ engine tối ưu tới đâu, nhưng đừng dựa vào đó. Một callback sống lâu vô tình giữ luôn mảng dữ liệu lớn:
function attachHandler(button: HTMLElement) {
const hugeData = new Array(1_000_000).fill("x");
const summary = hugeData.length;
button.addEventListener("click", () => {
console.log(summary); // chỉ cần số, nhưng nếu closure khác trong cùng scope
// dùng hugeData thì nó bị giữ sống cùng scope
});
}
Quy tắc an toàn: tính sẵn giá trị cần dùng, đặt hugeData vào hàm riêng để scope kết thúc, hoặc gán null khi xong. Cũng cần kể đến cache không giới hạn (Map làm memoize không có eviction), danh sách log/message tăng mãi, và console.log object khi DevTools mở (DevTools giữ tham chiếu tới object đã log, nên “leak” chỉ xuất hiện lúc debug).
Follow-up bẫy: “Tăng RAM có chắc là leak không?” Không. Heap tăng rồi GC thu hồi theo chu kỳ hình răng cưa là bình thường. Leak là khi đáy của răng cưa, sau GC thủ công, cứ tăng dần theo thời gian trong khi bạn lặp cùng một thao tác.
6. Tìm memory leak bằng DevTools, từng bước
Interviewer hỏi: “Giả sử bạn nghi ngờ trang có leak. Bạn làm gì, từng bước?”
Trả lời yếu: “Mở DevTools xem Memory.” Chẳng có quy trình gì.
Trả lời mạnh: quy trình mình dùng, lặp lại được:
- Tái hiện thao tác cho xác định. Ví dụ mở rồi đóng một modal 20 lần, hoặc điều hướng qua lại giữa hai route.
- Mở tab Performance (hoặc Performance monitor), theo dõi
JS heap size,DOM Nodes,JS event listenerskhi lặp thao tác. NếuDOM NodesvàListenerschỉ tăng không giảm, bạn đã có manh mối về loại leak trước khi mở heap snapshot. - Dùng kỹ thuật ba snapshot ở tab Memory:
- Bấm biểu tượng thùng rác (force GC), chụp Snapshot 1 sau khi thao tác khởi động một lần, để loại bỏ lazy init.
- Lặp thao tác nghi ngờ N lần, force GC, chụp Snapshot 2.
- Lặp thêm N lần, force GC, chụp Snapshot 3.
- Ở Snapshot 3, chọn view Objects allocated between Snapshot 1 and Snapshot 2: thứ được cấp phát ở giai đoạn đầu mà vẫn còn sống ở cuối là ứng viên leak.
- Gõ
Detachedvào ô filter của Class filter để liệt kê cácDetached HTMLDivElement… Chọn một object, nhìn khung Retainers bên dưới để xem đường tham chiếu giữ nó sống: ví dụMap→cache→ module →Window. Chỗ cần sửa nằm ở đó. - Sau khi sửa, lặp lại quy trình và so sánh: số object loại đó phải ngừng tăng.
Trong snapshot có hai cột dễ lẫn. Shallow size là bộ nhớ của chính object. Retained size là bộ nhớ sẽ được giải phóng nếu object đó biến mất, gồm cả thứ chỉ nó giữ. Sort theo Retained size thì leak lộ ra nhanh nhất.
Còn Allocation instrumentation on timeline thì vẽ cột xanh (còn sống) và cột xám (đã bị thu hồi) theo thời gian, thấy được đúng khoảnh khắc cấp phát mà không bị thu hồi. Với Node/SSR, tương đương là --inspect rồi chụp heap snapshot từ Chrome DevTools; leak ở server nặng hơn vì process sống lâu.
Follow-up bẫy: “Vì sao phải force GC trước khi chụp?” Vì snapshot lẫn cả object rác chưa kịp thu hồi. Không GC trước thì bạn đuổi theo những thứ không phải leak. Chụp snapshot bản thân cũng tự động chạy GC, nhưng bấm thùng rác trước các thao tác giúp so sánh các lần đo nhất quán.
7. INP: vì sao nút bấm “chậm” dù Lighthouse xanh
Interviewer hỏi: “INP là gì? Khác FID thế nào? Bạn cải thiện nó ra sao?”
Trả lời yếu: “Là thời gian phản hồi khi click, nên giảm JS.” Mơ hồ quá.
Trả lời mạnh: INP (Interaction to Next Paint) đo độ trễ của các tương tác (click, tap, nhấn phím) trong toàn bộ vòng đời trang, lấy giá trị gần với tương tác chậm nhất (theo ngưỡng của Google, dưới 200 ms được xem là tốt, trên 500 ms là kém). INP thay thế FID trong Core Web Vitals từ tháng 3/2024. Khác biệt cốt lõi: FID chỉ đo độ trễ trước khi handler bắt đầu chạy, chỉ của tương tác đầu tiên; INP đo từ lúc input đến khi khung hình kế tiếp được vẽ, của mọi tương tác.
Một tương tác gồm ba pha:
- Input delay: chờ main thread rảnh (do long task đang chạy).
- Processing time: thời gian chạy event handler.
- Presentation delay: từ khi handler xong đến khi frame được paint (style, layout, paint).
Vì vậy cách cải thiện phụ thuộc pha nào dài. Lighthouse là lab test, thường không có tương tác thật, nên không bắt được INP; cần dữ liệu field (CrUX, thư viện web-vitals trong production) và DevTools Performance ở chế độ throttle CPU để tái hiện.
import { onINP } from "web-vitals/attribution";
onINP(({ value, attribution }) => {
// attribution cho biết phần tử/ pha nào chậm
navigator.sendBeacon("/rum", JSON.stringify({
name: "INP",
value,
target: attribution.interactionTarget,
inputDelay: attribution.inputDelay,
processingDuration: attribution.processingDuration,
presentationDelay: attribution.presentationDelay,
}));
});
Chiến thuật sửa theo pha:
- Processing quá dài: chia nhỏ công việc và nhường main thread để trình duyệt kịp paint. Việc cần phản hồi ngay làm trước, phần còn lại hoãn lại.
// Nhường main thread giữa các lát công việc
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(); // phản hồi thị giác ngay
await yieldToMain(); // cho trình duyệt paint
for (const chunk of chunks(records, 200)) {
process(chunk);
await yieldToMain();
}
}
scheduler.yield() chưa có ở mọi trình duyệt nên cần fallback như trên.
- Trong React: đánh dấu cập nhật tốn kém là không khẩn cấp bằng
startTransitionhoặcuseDeferredValue, để React ngắt render đó khi có input mới.
const [query, setQuery] = useState("");
const [isPending, startTransition] = useTransition();
const [results, setResults] = useState<Item[]>([]);
function onChange(e: React.ChangeEvent<HTMLInputElement>) {
setQuery(e.target.value); // ô nhập phản hồi ngay
startTransition(() => setResults(filter(all, e.target.value))); // danh sách có thể trễ
}
- Input delay cao: tìm long task (thanh đỏ trên track Main trong Performance) và chuyển việc nặng sang Web Worker, giảm JS của bên thứ ba (tag manager, widget chat).
- Presentation delay cao: giảm kích thước DOM, tránh layout thrashing (mục 3), dùng
content-visibility: autocho vùng nằm ngoài viewport.
Follow-up bẫy: “debounce ô search có cải thiện INP không?” Có thể giảm số lần xử lý nặng, nhưng không sửa việc mỗi lần xử lý vẫn chặn main thread. INP đo tương tác chậm nhất, nên debounce che triệu chứng chứ không thu nhỏ long task. Ghép debounce với transition hoặc worker mới đủ.
Cách mình chấm câu trả lời
Ngồi phía bên kia bàn, mình để ý ba thứ. Một, ứng viên nói được cơ chế hay chỉ lặp lại best practice (“dùng transform vì GPU”). Hai, có công cụ để kiểm chứng không. Nói được “mình mở Performance, xem track Main, tìm Recalculate Style và Layout bị lặp” thuyết phục hơn hẳn “mình nghĩ là do…”. Ba, biết giới hạn của giải pháp mình vừa đề xuất: will-change tốn bộ nhớ, SSR vẫn phải trả giá bằng hydration, debounce không chữa được INP.
Đang ôn cho vòng này thì thử một bài tập: dựng một trang có đủ layout thrashing, một leak detached DOM và một handler nặng. Rồi tự tìm từng cái bằng Performance và Memory panel, đo trước đo sau.
Đọc tiếp
- Phỏng Vấn Frontend Lead: Frontend System Design Với Micro-Frontend, Monorepo Và Trade-offPhỏng Vấn & Sự Nghiệp
- Phỏng Vấn Fullstack Senior: Auth & Security với Session, JWT, CSRF, XSS và OAuth2Phỏng Vấn & Sự Nghiệp
- Phỏng Vấn Frontend Senior: TypeScript Sâu — Generics, Conditional Types và Type System Thực ChiếnPhỏng Vấn & Sự Nghiệp
Cần người cho dự án contract hoặc freelance?
Mình nhận dự án fullstack — backend Go, frontend React/Next.js, hoặc cả hai đầu. Gửi vài dòng về phạm vi công việc và thời gian, mình phản hồi trong 48 giờ.