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.
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:
render everything
|
v
send everything
możemy zrobić:
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:
request
|
v
product data
|
v
recommendations
|
v
render
|
v
response
Cały dokument czeka na rekomendacje.
W streamingu:
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.
┌─> 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:
nazwa produktu
cena
zdjęcie
stan magazynowy
opinie
rekomendacje
historia zakupów użytkownika
Najważniejsze dla pierwszego ekranu są zwykle:
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:
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ć:
<h1>Mechanical Keyboard</h1>
ale browser nadal może czekać na stylesheet:
<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:
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:
render fails
|
v
return 500
Przy streamingu część odpowiedzi mogła już zostać wysłana.
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:
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:
server produces bytes
-> network sends bytes
Ale odbiorca może czytać wolniej niż producent generuje dane.
Warstwy pomiędzy nimi mają ograniczone bufory:
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ę:
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:
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ć:
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:
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ć:
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.