Konrad Kowalski (rootsher)Principal Platform & Reliability Architect101001110100101110011000110110001100101001000111

Koszt wysyłania aplikacji do browsera

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

SPA i CSR rozwiązały problem interaktywności.

Browser przestał być tylko rendererem dokumentów i stał się runtime'em aplikacji. To umożliwiło płynne przejścia, lokalny stan, optimistic updates i UI, które mogło działać przez wiele godzin bez pełnego reloadu.

Ale pojawił się nowy koszt.

Nie chodziło już tylko o to, ile danych trzeba pobrać.

Chodziło o to, ile pracy trzeba wykonać zanim aplikacja stanie się użyteczna.

HTML przestał być końcowym produktem

W klasycznym webie odpowiedź serwera była niemal gotowym interfejsem.

text
request
  |
  v
HTML
  |
  v
parse
  |
  v
paint

W dużej aplikacji CSR ścieżka wygląda inaczej:

text
request
  |
  v
HTML shell
  |
  v
JavaScript
  |
  v
parse
  |
  v
compile
  |
  v
execute
  |
  v
initialize runtime
  |
  v
fetch data
  |
  v
render
  |
  v
layout
  |
  v
paint

To istotna różnica.

HTML może dotrzeć po 100 ms, a użytkownik nadal nie zobaczy właściwego contentu przez kolejną sekundę.

TTFB przestaje więc opisywać realne doświadczenie użytkownika.

Bundle size to tylko część problemu

Przez długi czas frontend performance był mocno utożsamiany z rozmiarem bundle'a.

text
main.js - 700 KB

To oczywiście ma znaczenie, ale transfer size jest tylko pierwszym kosztem.

JavaScript trzeba jeszcze:

text
download
  |
  v
decompress
  |
  v
parse
  |
  v
compile
  |
  v
execute

A wykonanie kodu nie dzieje się w próżni.

W typowej aplikacji runtime musi:

text
zarejestrować moduły
zainicjalizować framework
utworzyć struktury komponentów
uruchomić efekty
zbudować początkowy state
wykonać render
podpiąć event listenery

Na desktopie z mocnym CPU może być to praktycznie niezauważalne.

Na słabszym telefonie dokładnie ten sam JavaScript może stać się głównym bottleneckiem.

Main thread jest współdzielonym zasobem

Najważniejszy problem nie brzmi:

JavaScript jest wolny.

Problem brzmi:

JavaScript konkuruje o ten sam main thread z pracą potrzebną do wyświetlenia i obsługi UI.

W uproszczeniu:

text
main thread

JS
style
layout
event handlers
more JS
paint preparation
more JS

Jeżeli podczas startupu aplikacja wykonuje 300 ms synchronicznej pracy, browser nie może w tym samym czasie obsłużyć kliknięcia.

Użytkownik może już widzieć button, ale kliknięcie zostanie przetworzone dopiero wtedy, gdy main thread odzyska możliwość wykonania kolejnego taska.

To jedna z ważniejszych różnic między:

content visible

a

application responsive.

Ciekawostka

JavaScript engine nie wykonuje source code bezpośrednio linia po linii.

Współczesne silniki najpierw parsują kod, tworzą jego wewnętrzną reprezentację, generują bytecode, a często wykonywane fragmenty mogą później trafić do optymalizującego JIT-a.

Dlatego koszt JavaScriptu zależy nie tylko od liczby bajtów.

Dwa bundle o podobnym rozmiarze mogą mieć różny koszt parsowania, kompilacji i wykonania.

To jeden z powodów, dla których metryka:

text
KB transferred

nie jest pełnym opisem kosztu aplikacji.

Data fetching może zacząć się za późno

CSR tworzy jeszcze jeden problem: dependency discovery.

Załóżmy, że browser pobiera:

html
<div id="app"></div>
<script src="/app.js"></script>

Dopiero po wykonaniu JavaScriptu aplikacja odkrywa, że potrzebuje produktu:

js
const product = await fetch("/api/products/42");

Sieciowo wygląda to tak:

text
HTML
  |
  v
discover app.js
  |
  v
request app.js
  |
  v
download
  |
  v
execute
  |
  v
discover /api/products/42
  |
  v
request data

To klasyczny waterfall.

Browser nie mógł wcześniej zacząć pobierać danych, bo nie wiedział, że będą potrzebne.

Im więcej zależności odkrywanych jest dopiero podczas wykonywania aplikacji, tym bardziej critical path staje się sekwencyjny.

Code splitting pomaga, ale zmienia problem

Naturalną odpowiedzią na duże bundle było dzielenie aplikacji.

Zamiast:

text
app.js

mamy:

text
runtime.js
vendor.js
dashboard.js
charts.js
editor.js

To pozwala nie pobierać kodu, którego użytkownik aktualnie nie potrzebuje.

Ale tworzy nowe pytanie:

kiedy browser dowie się, którego chunku potrzebuje?

Jeżeli dopiero po:

text
main bundle
  |
  v
router
  |
  v
route match
  |
  v
dynamic import

to znowu tworzymy waterfall.

Code splitting nie usuwa więc problemu dependency graphu.

Zmienia tylko jego kształt.

Komercyjny przykład: sklep internetowy

Wyobraźmy sobie stronę produktu zbudowaną jako czysty CSR.

Browser pobiera shell aplikacji.

Następnie:

text
load framework
  |
  v
load router
  |
  v
recognize /products/42
  |
  v
load product chunk
  |
  v
execute component
  |
  v
request product API
  |
  v
receive data
  |
  v
render product

Tymczasem użytkownik chce zobaczyć przede wszystkim:

text
Mechanical Keyboard
129 zł
In stock

To są dane, które serwer często zna już podczas pierwszego requestu.

Wysyłanie pustego shellu tylko po to, żeby browser później odpytał serwer o informacje potrzebne do wygenerowania pierwszego ekranu, zaczyna wyglądać jak niepotrzebny round trip.

W aplikacji typu dashboard można ten koszt zaakceptować.

W e-commerce, newsach czy publicznym katalogu produktów zaczyna być trudniejszy do obrony.

Problem nie był tylko wydajnościowy

CSR komplikował też inne właściwości webu.

Jeżeli content powstaje dopiero po wykonaniu JavaScriptu, trudniejsze stają się:

text
crawlability
link previews
generowanie metadata
dostęp do treści przy błędzie JS
szybkie wyświetlenie contentu na słabych urządzeniach

Współczesne crawlery potrafią wykonywać JavaScript znacznie lepiej niż kiedyś, ale architektonicznie nadal istnieje różnica między:

text
HTTP response contains content

a:

text
HTTP response contains program that may produce content

Wahadło zaczyna wracać

W połowie lat 2010 coraz więcej zespołów miało już doświadczenie z dużymi SPA.

Korzyści były oczywiste.

Ale oczywiste stały się też koszty:

text
more JS
-> more startup work

client-only data fetching
-> waterfalls

large runtime
-> more main-thread contention

empty shell
-> later content

Rozwiązaniem nie było porzucenie SPA.

Chodziło o zmianę miejsca, w którym wykonujemy pierwszy render.

Co jeśli serwer uruchomi ten sam kod komponentów wcześniej i wyśle browserowi gotowy HTML?

Użytkownik dostanie treść od razu, a JavaScript może później przejąć aplikację.

W ten sposób frontend wraca do idei znanej od początku webu, ale w zupełnie nowej formie.

Wraca Server-Side Rendering.