Konrad Kowalski (rootsher)Principal Platform & Reliability Architect111100000001011110110001101010101110100101111101

Streaming SSR: nie czekaj na cały render

data
kategoria
Frontend
czytanie
3 min / 610 słów

Klasyczny SSR ma prosty model: serwer czeka, aż cały widok będzie gotowy, a dopiero potem wysyła HTML.

To działa dobrze, dopóki wszystkie zależności kończą się szybko.

Problem pojawia się wtedy, gdy jedna część strony jest znacznie wolniejsza od reszty.

text
product          -> 20 ms
reviews          -> 80 ms
recommendations  -> 500 ms

Jeśli odpowiedź jest atomowa, użytkownik czeka 500 ms na wszystko.

Streaming zmienia tę kolejność.

HTML nie musi być jednym gotowym artefaktem

Zamiast:

text
render everything
  |
  v
send everything

możemy zrobić:

text
render shell
  |
  v
send

render product
  |
  v
send

render reviews
  |
  v
send

render recommendations
  |
  v
send

Browser może zacząć parsować dokument zanim serwer skończy generować całą odpowiedź.

To oznacza, że wolny fragment nie musi blokować szybszych.

Problemem jest najwolniejsza zależność

Załóżmy stronę produktu.

Header i podstawowe dane produktu są dostępne niemal natychmiast.

Rekomendacje wymagają odpytania zewnętrznego systemu.

W klasycznym SSR:

text
request
  |
  v
product data
  |
  v
recommendations
  |
  v
render
  |
  v
response

Cały dokument czeka na rekomendacje.

W streamingu:

text
request
  |
  v
product data
  |
  v
render product
  |
  v
flush HTML
  |
  v
recommendations continue loading
  |
  v
flush recommendations later

Użytkownik może już czytać stronę, mimo że część systemu nadal pracuje.

Streaming skraca blocking dependency chain

To ważniejsze niż samo „wysyłanie HTML kawałkami”.

Największa korzyść pojawia się wtedy, gdy różne fragmenty strony mają niezależne zależności.

text
                  ┌─> reviews
request -> product
                  └─> recommendations

Jeżeli render wymaga wszystkich danych przed wysłaniem odpowiedzi, cały graph zostaje zredukowany do najwolniejszej ścieżki.

Streaming pozwala zachować większą równoległość.

Komercyjny przykład: e-commerce

Załóżmy PDP złożony z:

text
nazwa produktu
cena
zdjęcie
stan magazynowy
opinie
rekomendacje
historia zakupów użytkownika

Najważniejsze dla pierwszego ekranu są zwykle:

text
title
price
image
availability

Rekomendacje mogą pojawić się później.

Zamiast blokować całą odpowiedź na wolnym backendzie rekomendacyjnym, serwer może wysłać shell i podstawowe dane od razu.

Dopiero później do streamu dołączany jest kolejny fragment.

W praktyce współczesne frameworki często łączą ten model z granicami typu Suspense.

Ciekawostka

Streaming HTML działa szczególnie dobrze dlatego, że parser HTML sam jest strumieniowy.

Browser nie musi czekać na EOF odpowiedzi, żeby zacząć pracę.

Może:

text
receive bytes
  |
  v
tokenize
  |
  v
build DOM
  |
  v
discover resources

jeszcze podczas trwania requestu.

To oznacza, że wcześniej przesłany HTML może również wcześniej ujawnić browserowi zależności takie jak CSS, obrazy czy moduły.

Streaming wpływa więc nie tylko na rendering serwera, ale także na resource discovery po stronie klienta.

Ale wcześniejsze bajty nie oznaczają wcześniejszych pikseli

To ważne ograniczenie.

Serwer może bardzo szybko wysłać:

html
<h1>Mechanical Keyboard</h1>

ale browser nadal może czekać na stylesheet:

html
<link rel="stylesheet" href="/app.css">

Jeżeli CSS blokuje rendering, HTML jest już w browserze, ale nie musi być jeszcze widoczny.

Podobnie może być z fontami, layoutem czy main-thread contention.

Dlatego:

text
earlier HTML
!=
automatically earlier paint

Streaming skraca część critical path, ale nie eliminuje reszty pipeline'u.

Streaming komplikuje błędy

W klasycznym SSR sytuacja jest prosta.

Jeżeli coś nie działa:

text
render fails
  |
  v
return 500

Przy streamingu część odpowiedzi mogła już zostać wysłana.

text
HTTP 200
<html>
<header>...</header>
<main>

A potem backend rekomendacji rzuca wyjątek.

Nie można już cofnąć wysłanych bajtów i zmienić całej odpowiedzi na klasyczny 500.

System potrzebuje lokalnego modelu obsługi błędów:

text
boundary A -> success
boundary B -> error fallback
boundary C -> still loading

To jeden z powodów, dla których streaming dobrze łączy się z granicami komponentowymi.

Backpressure ma znaczenie

Streaming brzmi jak:

text
server produces bytes
-> network sends bytes

Ale odbiorca może czytać wolniej niż producent generuje dane.

Warstwy pomiędzy nimi mają ograniczone bufory:

text
renderer
  |
  v
runtime buffer
  |
  v
HTTP server
  |
  v
proxy/CDN
  |
  v
TCP/QUIC
  |
  v
browser

Jeżeli downstream nie nadąża, producer powinien zwolnić.

To właśnie backpressure.

W normalnym artykule frontendowym rzadko trzeba o tym myśleć, ale przy dużych streamowanych odpowiedziach jest to realna właściwość całego pipeline'u.

Proxy może zepsuć streaming

Jest jeszcze jeden praktyczny problem.

Aplikacja może flushować HTML poprawnie, ale po drodze znajduje się:

text
application
  |
  v
reverse proxy
  |
  v
CDN
  |
  v
browser

Jeżeli któraś warstwa buforuje odpowiedź, użytkownik może dostać wszystko dopiero na końcu.

Czyli aplikacja „streamuje”, ale efektywnie:

text
chunks
  |
  v
proxy buffer
  |
  v
buffer
  |
  v
buffer
  |
  v
send everything

Dlatego streaming jest właściwością całej ścieżki delivery, nie tylko renderer API.

Streaming nie usuwa hydration

Jeżeli przesłane fragmenty są interaktywne, browser nadal potrzebuje client-side runtime'u.

Możemy więc mieć:

text
server:
stream HTML progressively

browser:
show content progressively
  |
  v
load JavaScript
  |
  v
hydrate interactive regions

Streaming odpowiada przede wszystkim na pytanie:

kiedy możemy dostarczyć output?

Nie odpowiada na pytanie:

ile kodu musi później wykonać browser?

To osobna oś architektury.

Granica całej strony zaczyna zanikać

I tu dochodzimy do ważnej konsekwencji.

Do tej pory mówiliśmy:

text
ta strona jest SSR

ta strona jest SSG

ta strona jest dynamiczna

Ale jeśli możemy renderować różne fragmenty niezależnie i dostarczać je w różnym czasie, dlaczego cała strona musi mieć jedną strategię?

Możemy przecież mieć:

text
static header
static product description
dynamic price
dynamic cart
slow recommendations

Każda część ma inne wymagania dotyczące cache, freshness i latency.

Wtedy jednostką decyzji przestaje być cały route.

Staje się nią fragment strony.

I właśnie to prowadzi do kolejnego kroku: Partial Prerendering.