Kỹ Thuật Chuyên Sâu • 34 phút đọc • 08/10/2026

Tại Sao SSR? Render React Ở Đâu, Streaming Chạy Thế Nào Và Cái Giá Phải Trả

Bài này có bản tiếng Anh. Read in English →

Nguyen Hai Trinh

Nguyen Hai Trinh • Senior Software Engineer @ SmileViet

Thác nước nhiều tầng
Ảnh: Dietmar Rabich · CC BY-SA 4.0

Có một câu hỏi mình hay hỏi khi phỏng vấn frontend, và nó lọc được khá nhanh:

SSR nhanh hơn CSR. Nhanh hơn ở cái gì?

Câu trả lời “nhanh hơn” là câu trả lời của người đọc blog. Câu trả lời đúng phải tách được: SSR làm nội dung tới sớm hơn, và không làm trang interactive sớm hơn. Có trường hợp nó còn làm tương tác chậm đi.

Bài này đi từ con số round-trip, tới byte thật trên dây, rồi tới cái hoá đơn.

Bài này nói về cơ chế render. Phần tính năng của Next.js mình đã viết riêng ở bài Next.js 16.

1. Câu hỏi gốc: HTML sinh ra ở đâu

Mọi lựa chọn render đều là một câu trả lời cho cùng một câu hỏi: chuỗi HTML này được sinh ra ở đâu, và vào lúc nào?

Có đúng bốn chỗ:

Sinh ở đâuLúc nàoGọi là
Máy buildTrước khi deploySSG
ServerMỗi requestSSR
ServerLần đầu, lưu lại, hết hạn thì làm lạiISR
BrowserSau khi JS tải xongCSR

Còn một biến thể thứ năm không nằm trong bảng: sinh một phần ở build, phần còn lại stream ở request — đó là PPR.

Cái làm câu chuyện phức tạp không phải là chọn chỗ nào. Mà là ai trả giá cho lựa chọn đó, và trả bằng gì. SSR không miễn phí. Nó chỉ đổi chỗ trả.

2. Waterfall: tại sao CSR chậm

Bắt đầu từ CSR, vì đó là mốc để so.

Với CSR thuần (một createRoot trong main.js), một lượt tải trang cần bốn vòng tuần tự:

sequenceDiagram
    autonumber
    participant B as Browser
    participant S as Server
    participant A as API

    B->>S: GET /  (1)
    S-->>B: HTML rỗng + thẻ script  (2)
    Note over B: chưa có nội dung gì để vẽ
    B->>S: GET /main.js  (3)
    S-->>B: bundle JS  (4)
    Note over B: parse + execute + React mount
    B->>A: GET /api/products  (5)
    A-->>B: JSON  (6)
    Note over B: render lần đầu — giờ mới thấy nội dung

Sáu bước, và không bước nào chồng lên bước nào. Mỗi bước phải xong mới tới bước sau, vì bước sau cần kết quả của bước trước.

Con số cụ thể: 1 vòng tới server gốc, 1 vòng tải bundle, 1 vòng gọi API. Ở mạng 4G tầm 100ms RTT, riêng độ trễ đã là 300ms trước khi React bắt đầu làm việc. Cộng thời gian parse bundle, thời gian execute, thời gian render — LCP rơi vào khoảng 1.5-3 giây với app tầm trung.

Cách nói chính xác về CSR không phải “chậm”. Mà là: nội dung không thể xuất hiện trước khi JavaScript tải xong. Mọi thứ khác là hệ quả.

3. SSR cổ điển: đổi waterfall lấy cái gì

SSR đổi ba vòng thành một.

sequenceDiagram
    autonumber
    participant B as Browser
    participant S as Server
    participant D as Database

    B->>S: GET / (1)
    S->>D: query (2)
    D-->>S: dữ liệu (3)
    S-->>B: HTML ĐÃ CÓ NỘI DUNG (4)
    Note over B: vẽ được ngay, chưa cần JS
    B->>S: GET /main.js (5)
    S-->>B: bundle (6)
    Note over B: hydrate — giờ mới tương tác được

Nội dung tới sau một vòng thay vì ba. Browser nhận HTML đã có dữ liệu, vẽ luôn. Không cần chờ JS.

Nhưng nhìn kỹ bước 4 và bước 6: có hai thời điểm khác nhau.

  • Sau bước 4: người dùng thấy nội dung.
  • Sau bước 6: người dùng bấm được nội dung.

Khoảng giữa hai mốc đó là chỗ toàn bộ drama của SSR diễn ra. HTML không tương tác. Nút bấm trông bấm được nhưng không phản hồi. Đó không phải bug — đó là bản chất.

Chỗ này trả lời câu hỏi ở đầu bài: SSR cải thiện FCP và LCP. Nó không cải thiện TTI hay INP. Thời gian tải JS và hydrate không biến mất; chúng chỉ bị đẩy ra sau nội dung.

Có trường hợp SSR làm INP tệ hơn CSR: nếu bạn stream một trang nặng rồi hydrate toàn bộ một lượt, main thread bị chặn ngay lúc người dùng bắt đầu bấm.

4. Ba lần render của một trang

Chỗ hay bị đếm thiếu: một trang React render ba lần, không phải một.

flowchart LR
    A["1. Render trên server<br/>React chạy component<br/>trong Node"] --> B["2. Sinh HTML<br/>renderToPipeableStream<br/>-> byte"]
    B --> C["3. Hydrate trên browser<br/>React chạy LẠI component<br/>để gắn event handler"]
    C --> D["Tương tác được"]

    style A fill:#1e3a5f,stroke:#38bdf8,color:#fff
    style C fill:#3b2f1e,stroke:#f59e0b,color:#fff
  • Lần 1 — server render. Component function của bạn chạy trong Node. Kết quả không phải DOM, mà là một cây mô tả.
  • Lần 2 — sinh HTML. Cây đó biến thành chuỗi byte gửi xuống browser. Browser vẽ. Chưa có tương tác.
  • Lần 3 — hydrate. Bundle JS tải xong. React chạy lại component function của bạn trên browser, dựng lại cây trong bộ nhớ, rồi gắn event handler vào DOM đã có sẵn.

Lần thứ ba là lần đắt nhất và ít được tính. Component function của bạn chạy lần thứ hai — trên một máy yếu hơn server rất nhiều, và trên main thread. Mọi thứ trong thân component, mọi useMemo, mọi tính toán — chạy lại hết.

Vì thế câu “component của bạn chỉ nên pure” không phải một quy tắc đạo đức. Với SSR, function của bạn thực sự chạy nhiều hơn một lần, ở hai môi trường khác nhau. Date.now(), Math.random(), đọc localStorage — tất cả đều cho kết quả khác nhau ở hai lần chạy, và đó chính là hydration mismatch.

5. Byte thật trên dây khi stream

Tới phần thú vị nhất.

Streaming SSR không phải “gửi HTML nhanh hơn”. Nó là gửi HTML thành nhiều mảnh, không theo thứ tự, rồi để một đoạn script nhỏ ở client ghép chúng vào đúng chỗ.

Định dạng wire của streaming SSR: marker, template, hidden div và lời gọi $RC

Nội dung tới muộn hơn được gửi trong <div hidden>, rồi $RC() chuyển nó vào chỗ đã đánh dấu.

5.1. Mảnh đầu: shell

React gửi shell trước — mọi thứ nằm ngoài Suspense boundary. Chỗ nào có Suspense, nó gửi một cặp marker và một cái template rỗng:

<main>
  <header>...</header>

  <!--$?--><template id="B:0"></template><div class="skeleton">Đang tải...</div><!--/$-->

  <footer>...</footer>
</main>

Đọc cặp marker:

  • <!--$?--> — $ là marker của React, ? nghĩa là boundary này còn pending, đang hiện fallback.
  • <!--/$--> — đóng boundary.
  • <template id="B:0"> — chỗ trống đã đánh dấu. B là viết tắt của boundary. Nội dung thật sẽ được đặt vào đây sau.

Nếu boundary đã xong ngay từ đầu thì marker là <!--$--> không có dấu hỏi.

5.2. Mảnh sau: nội dung thật, ở cuối stream

Khi dữ liệu về, React gửi thêm — ở cuối response, không phải tại vị trí của boundary trong HTML:

<div hidden id="S:0">
  <div class="user-profile">...</div>
</div>
<script>$RC("B:0","S:0")</script>

Ba chi tiết:

  • hidden — nội dung đã tới browser nhưng chưa hiển thị. Vẫn nằm trong DOM, chỉ bị ẩn.
  • id="S:0" — S là viết tắt của segment. Cặp B:0/S:0 là một cặp đánh số khớp nhau.
  • $RC("B:0","S:0") — Replace Content. Script này chạy ngay khi parse xong, tìm template B:0, lấy nội dung từ S:0, đặt vào chỗ trống.

5.3. Vì sao lại lằng nhằng như vậy

Vì đây là cách duy nhất để stream không theo thứ tự.

HTTP là một dòng byte một chiều. Bạn không thể chèn nội dung vào giữa một response đã gửi đi. Nếu boundary thứ hai xong trước boundary thứ nhất, bạn không thể gửi nó “đúng vị trí” — vị trí đó đã trôi qua rồi.

Nên React làm ngược lại: gửi tất cả ở cuối, mỗi mảnh mang số hiệu, và để client tự tìm chỗ. Đây gọi là out-of-order streaming, và nó là thứ khiến một trang có 3 vùng dữ liệu chậm 300ms, 800ms, 50ms vẫn hiển thị được vùng 50ms trước.

Không có cơ chế này thì streaming hoặc phải theo đúng thứ tự (nên vẫn chờ mảnh chậm nhất trước nó), hoặc không làm được.

5.4. Cái bẫy: status code

Một chi tiết hay bị bỏ qua, và nó gây bug thật:

Khi bạn đã gửi shell và bắt đầu stream, bạn không đổi được status code nữa.

Vì header đã đi rồi. Nghĩa là nếu lỗi xảy ra ở một boundary stream sau khi shell đã gửi, bạn không thể trả 500. Trang vẫn là 200, chỉ có một vùng hiện error UI.

Đó là lý do code SSR thật thường phải quyết định trước: lỗi nào đủ nghiêm trọng để chặn cả response (trả 500 trước khi pipe), lỗi nào chỉ nên hiện error boundary tại chỗ.

6. React sinh HTML bằng gì

Ở tầng dưới cùng, framework gọi một trong hai API của react-dom/server.

renderToPipeableStream — cho Node.js. Trả về { pipe, abort }. Bạn gọi pipe(response) để bắt đầu đẩy byte.

import { renderToPipeableStream } from 'react-dom/server'

const { pipe, abort } = renderToPipeableStream(<App />, {
  bootstrapScripts: ['/static/client.js'],
  onShellReady() {
    res.statusCode = didError ? 500 : 200
    res.setHeader('content-type', 'text/html; charset=utf-8')
    pipe(res)
  },
  onShellError() {
    res.statusCode = 500
    res.send('<h1>Có gì đó sai</h1>')
  },
  onError(error) {
    didError = true
    console.error(error)
  },
})

renderToReadableStream — cho runtime Web Streams (Edge, Workers, Deno, Bun). Trả về một Promise của ReadableStream, và bạn trả nó làm body. Cách chờ hết: await stream.allReady.

6.1. onShellReady và onAllReady không phải hai cách gọi cùng một thứ

Chi tiết này hiểu sai sẽ dẫn tới bug SEO rất khó thấy.

  • onShellReady bắn khi mọi thứ ngoài Suspense boundary đã render xong. Đây là cái bạn dùng cho người dùng: đẩy shell đi ngay, phần còn lại stream sau.
  • onAllReady bắn khi toàn bộ cây đã render xong, kể cả mọi boundary. Đây là cái bạn dùng cho crawler, static generation, render email, preview social card, RSS export.

Vì sao phải phân biệt: một số crawler không xử lý được stream. Chúng nhận shell, thấy nội dung rỗng ở chỗ boundary, rồi bỏ đi. Nếu bạn stream cho Googlebot, bạn có thể đang gửi cho nó một trang trông như trống.

Cách xử lý phổ biến là phân nhánh theo user-agent:

const isCrawler = /bot|crawler|spider|crawling/i.test(req.headers['user-agent'] || '')
const { pipe } = renderToPipeableStream(<App />, {
  bootstrapScripts: ['/main.js'],
  onShellReady() { if (!isCrawler) pipe(res) },
  onAllReady()   { if (isCrawler)  pipe(res) },
})

Một dòng trong tài liệu React đáng nhớ: renderToNodeStream đã bị xoá ở React 19. Nếu codebase còn dùng nó, đó là thứ phải chuyển trước — nó không hỗ trợ Suspense và không có bất kỳ bảo đảm streaming nào.

6.2. Suspense cần một tầng dữ liệu biết suspend

Chỗ nhiều người mắc kẹt khi tự làm streaming SSR:

useEffect không suspend. Fetch trong useEffect không kích hoạt Suspense, không tạo ra fallback, không stream được gì. Để Suspense hoạt động, component phải chờ dữ liệu theo cách React hiểu được:

  • use() — API của React 19.
  • Primitive của framework: fetch được Next bọc, loader của router.
  • Thư viện có adapter: useSuspenseQuery của TanStack Query.

Nếu bạn bọc <Suspense> quanh một component fetch trong useEffect, bạn có fallback hiển thị, nhưng nó chỉ là một spinner phía client. Server không biết gì để stream.

7. Hydration: lần render thứ hai

Hydrate là quá trình React lấy cây DOM đã có sẵn và gắn lại logic vào.

import { hydrateRoot } from 'react-dom/client'
hydrateRoot(document.getElementById('root'), <App />)

Nghe đơn giản. Nhưng bên trong nó làm ba việc, theo thứ tự:

  1. Dựng lại toàn bộ cây trong bộ nhớ. React chạy lại component function của bạn. Không có cách nào tắt việc này.
  2. Đối chiếu với DOM có sẵn. React đi qua cây DOM thật và so với cây nó vừa dựng trong bộ nhớ.
  3. Gắn event handler. Đây là thứ duy nhất thực sự cần thiết — nhưng phải làm xong bước 1 và 2 mới làm được.

Bước 1 là lý do hydration tốn kém. Component càng nặng, hydrate càng lâu, và nó chặn main thread.

Đây cũng là lý do “giảm bundle size” là đòn hiệu quả nhất để tăng INP trong app SSR. Không chỉ vì tải nhanh hơn, mà vì ít code hơn để chạy lại.

8. Selective hydration

Tin tốt: từ React 18, hydration không còn bắt buộc phải xong toàn bộ mới cho tương tác.

Cơ chế thật (từ thảo luận của nhóm React):

  • Ban đầu, Suspense boundary không được hydrate. Chúng đứng chờ.
  • Khi người dùng click hoặc gõ phím vào một boundary chưa hydrate, React chặn sự kiện ở capture phase và hydrate đồng bộ đúng boundary đó — nếu code của nó đã tải xong.
  • Nếu code chưa tải xong, React gọi stopPropagation() để chặn sự kiện, rồi nâng ưu tiên của boundary đó. Khi code về, boundary này hydrate trước các boundary khác.
  • Hover và focus cũng nâng ưu tiên nhưng không hydrate đồng bộ. Danh sách event persistent: focusin, mouseenter, dragenter, mouseover, pointerover.
  • Không có tương tác nào thì React vẫn hydrate dần trong các idle cycle.

Selective hydration: boundary nào chưa hydrate, và điều gì xảy ra khi người dùng bấm vào

Boundary bị bấm không cần chờ cả trang hydrate xong.

Hệ quả thực tế: một trang có sidebar nặng và một ô comment nhẹ có thể đưa ô comment lên tương tác trước, chỉ vì người dùng bấm vào nó đầu tiên. Đó là hành vi đúng, và nó ngược với trực giác “hydrate từ trên xuống”.

Điều kiện để hưởng lợi: phải có Suspense boundary. Không chia boundary thì cả trang là một khối, và selective hydration không có gì để chọn.

9. Hydration mismatch: nguồn gốc và cách chẩn đoán

Lỗi này đặc trưng đến mức đáng có mục riêng, vì nó chỉ tồn tại khi bạn SSR.

React hydrate bằng cách so cây DOM có sẵn với cây nó vừa dựng lại. Hai bên khác nhau thì mismatch. Nguyên nhân luôn nằm ở một câu: có thứ gì đó cho kết quả khác nhau giữa hai môi trường.

NguồnVì sao khácCách sửa
Date.now(), new Date()Server và client ở hai thời điểm khác nhauTính ở server, truyền xuống, hoặc chỉ render sau mount
Math.random()Hai lần chạy, hai kết quảSinh ở server, truyền xuống
localStorage, windowServer không cóĐọc trong useEffect, hoặc kiểm tra môi trường
typeof window !== 'undefined' nhánh khác nhauHai nhánh render ra hai cây khácChuyển sang state khởi tạo sau mount
HTML không hợp lệBrowser tự sửa DOM khi parseXem lại cấu trúc thẻ lồng nhau
Extension của browser chèn DOMReact thấy DOM lạKhông phải bug của bạn
Múi giờ / localeServer ở UTC, client ở localCố định locale, hoặc render sau mount

Cách chẩn đoán theo thứ tự mình dùng:

  1. Đọc thông báo lỗi. React 18/19 chỉ khá rõ chỗ lệch.
  2. Tìm mọi thứ đọc từ môi trường trong thân component: thời gian, random, storage, window, locale.
  3. Nghi ngờ đầu tiên không phải là “chỗ đó”, mà là chỗ render ra chỗ đó — mismatch thường lộ ở node cha.
  4. suppressHydrationWarning là thuốc giảm đau, không phải thuốc chữa. Nó chỉ im lỗi trên đúng một node, và chỉ nên dùng cho timestamp hiển thị.

Một điểm dễ quên: SSG cũng hydrate. Trang sinh ở build time vẫn phải chạy lại component trên browser. Nghĩa là mọi nguyên nhân mismatch ở trên áp dụng y hệt cho static site, và bug sẽ xuất hiện muộn hơn — sau khi build.

10. Cái giá của SSR

Tới phần mà các bài “SSR nhanh hơn” hay bỏ qua.

Server CPU. Mỗi request là một lần render React trong Node. Với trang 100.000 lượt/ngày, đó là 100.000 lần chạy component tree. CSR không có chi phí này — browser của người dùng trả.

TTFB tăng. SSR phải chờ dữ liệu rồi mới gửi byte đầu. CSR gửi HTML rỗng ngay lập tức. Nếu bạn chỉ đo TTFB, SSR luôn thua. Đây là lý do TTFB một mình là chỉ số dở để đánh giá SSR.

Streaming cần hạ tầng biết stream. Buffer ở proxy phá hỏng toàn bộ lợi ích. Nén phải là streaming nén. CDN phải không gom response lại. Đây là loại bug im lặng — code đúng, hạ tầng sai, và bạn chỉ thấy nó chậm hơn dự kiến.

Không phải mọi thứ đều stream được. Cần một tầng dữ liệu suspend được (mục 6.2). Cần Suspense boundary đặt đúng chỗ. Và cần chấp nhận rằng status code đã chốt sau khi shell đi.

Phức tạp tăng. Không chỉ một chỗ, mà ba: server, client, và ranh giới giữa chúng. Bug giờ có thể nằm ở môi trường nào cũng được.

11. Bảng chọn theo đúng chi phí

Bốn chiến lược render và cái giá thật của từng cái

Cùng một trang, bốn cách trả. Không có cột nào thắng ở mọi chỉ số.

Chiến lượcFCP / LCPTTI / INPTTFBChi phí serverHợp với
CSRKémKémTốt nhấtKhôngApp sau đăng nhập, không cần SEO
SSRTốtKémKémCao (mỗi request)Nội dung đổi liên tục, cần SEO
Streaming SSRTốtTốt hơn SSRKémCaoTrang nhiều vùng dữ liệu tốc độ khác nhau
SSGTốt nhấtKémTốt nhấtGần như bằng 0Nội dung ít đổi, traffic lớn
ISRTốt nhấtKémTốt nhấtRất thấpNội dung đổi theo lịch
PPRTốt nhất (shell)Tốt hơn SSRTốtThấp + phần độngVỏ tĩnh, vài lỗ cá nhân hoá

Ba điều mình rút ra từ bảng này:

Không có cột nào thắng ở mọi chỉ số. SSG thắng FCP và TTFB nhưng thua INP giống SSR. CSR thắng TTFB và thua mọi thứ còn lại. Lựa chọn là chọn cái mình sẵn sàng thua.

Streaming không cải thiện FCP. Shell vẫn phải render xong mới gửi. Nó cải thiện cảm nhận và thời điểm từng vùng nội dung xuất hiện, cùng với khả năng tương tác sớm qua selective hydration.

PPR là SSG cộng với streaming. Nó không phải một chiến lược thứ bảy. Nó là “shell SSG, lỗ streaming”, và đó là lý do nó thắng ở dòng FCP.

12. Đo cái gì, ở đâu

Ba chỉ số, ba câu hỏi khác nhau. Đo sai chỉ số thì tối ưu sai chỗ.

  • TTFB — server trả byte đầu lúc nào. Cao nghĩa là server hoặc mạng chậm. Với SSR, TTFB cao là bình thường và không nhất thiết xấu.
  • FCP / LCP — người dùng thấy nội dung lúc nào. Đây là chỉ số SSR cải thiện. Muốn nhanh hơn nữa thì cắt dữ liệu chặn shell, đẩy nó vào Suspense.
  • INP — trang phản hồi tương tác lúc nào. Đây là chỉ số SSR không cải thiện. Muốn nhanh hơn thì giảm JS phải hydrate.

Câu hỏi chẩn đoán mình dùng:

  1. Chậm ở nội dung hay ở tương tác? Nội dung → SSR/streaming/SSG. Tương tác → bundle size và số lượng client component.
  2. Có mấy vùng dữ liệu độc lập? Nếu từ hai trở lên và tốc độ chênh nhau, chúng nên nằm trong các Suspense boundary riêng.
  3. HTML có bị buffer không? Xem response trong DevTools: nếu toàn bộ HTML về một cục thay vì từng mảnh, hạ tầng đang gom lại.
  4. Hydrate mất bao lâu? Chrome Performance, xem track Main sau khi JS tải xong. Nếu chiếm phần lớn thời gian, vấn đề nằm ở bundle chứ không ở render.

13. Vài mốc để nhớ

  • SSR là bài toán round-trip, không phải bài toán tốc độ. Nó mua thời gian bằng số vòng mạng, và trả bằng CPU server. Hiểu vậy thì mọi lựa chọn khác tự sáng ra.
  • Nội dung sớm ≠ tương tác sớm. Hai mốc khác nhau, hai chỉ số khác nhau, hai cách tối ưu khác nhau. Trộn chúng là nguồn gốc của phần lớn quyết định sai.
  • Hydration là lần render thứ hai, và nó không miễn phí. Mọi thứ trong thân component chạy lại trên main thread của một thiết bị yếu. Giữ component nhẹ là yêu cầu kỹ thuật, không phải chuyện thẩm mỹ.
  • Streaming là kỹ thuật ghép nội dung, không phải kỹ thuật gửi nhanh. <!--$?-->, <template id="B:0">, $RC() tồn tại vì HTTP là một dòng byte một chiều và bạn không chèn được vào giữa.
  • Suspense boundary là đơn vị công việc, không phải đơn vị trang trí. Nó quyết định cái gì hiện trước, và cái gì hydrate trước khi người dùng bấm.
  • SSG cũng hydrate. Nếu bạn nghĩ static site không có vấn đề client, bạn sẽ gặp lại toàn bộ danh sách mismatch ở mục 9 — chỉ muộn hơn.

Cái mình chưa đo được là ngưỡng đảo chiều của streaming. Nhiều boundary hơn không luôn tốt hơn: mình từng gặp trang mà chia thành 8 boundary lại làm nhiều chuyến về server hơn, và INP tệ hơn so với để nguyên một khối, vì mỗi boundary kéo theo một lượt hydrate riêng. Mình chưa tìm ra quy tắc đoán trước con số đó. Nếu bạn từng gặp và đo được, mình muốn nghe.

Chỗ dựa của bài này

  • API renderToPipeableStream / renderToReadableStream, ngữ nghĩa onShellReady / onAllReady / onShellError / onError, và việc renderToNodeStream bị xoá ở React 19: theo tài liệu react-dom/server của React.
  • Định dạng wire — <!--$?-->, <template id="B:0">, <div hidden id="S:0">, $RC("B:0","S:0") — đối chiếu với output HTML thật của một app dùng streaming SSR.
  • Cơ chế selective hydration (capture phase, stopPropagation(), nâng ưu tiên, danh sách event persistent, hydrate trong idle cycle): theo thảo luận chính thức của nhóm React 18.
  • Giới hạn: đây là internals, không phải hợp đồng API. Tên marker và số hiệu B:/S: là chi tiết triển khai và đã đổi giữa các version. Dùng để hiểu và để đọc DevTools, đừng parse chúng trong code.
  • Các con số về round-trip và LCP trong bài là ước lượng từ mạng 4G tầm 100ms RTT, không phải số đo từ một app cụ thể. Muốn số thật thì phải đo trên app của bạn.
  • Cơ chế streaming ở đây mô tả tầng React. Cách Next.js bọc nó thêm lớp Flight và self.__next_f.push mình đã viết ở bài Next.js 16.

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ờ.