Przejdź do treści
Core Web Vitals

Wszystkie trasy w next build mają ƒ zamiast ○. Jedno await cookies() w layoucie kasuje statyczną powłokę

Trasa z ƒ w wyniku next build renderuje się przy każdym wejściu. Cztery wzorce z generatora, które to powodują, i jak zmierzyć koszt w TTFB przed oddaniem.

Redakcjadeveloper front-end11 minkod + tabela

W wyniku next build świeżo wygenerowanej aplikacji każda trasa ma przy sobie znak ƒ. Legenda pod tabelą tras w dokumentacji CLI Next.js tłumaczy oba możliwe znaki jednym wierszem: ○ (Static) prerendered as static content i ƒ (Dynamic) server-rendered on demand. To nie jest ostrzeżenie ani błąd, więc w logu CI nikt tego nie zauważa — build kończy się zielono i projekt idzie do odbioru.

Różnica między tymi dwoma znakami rozstrzyga, skąd przychodzi pierwszy bajt odpowiedzi. Przy ○ dokument jest gotowy przed żądaniem i może wyjść z CDN-u. Przy ƒ każde wejście — pierwsze i tysiączne, z Warszawy i z Londynu — przechodzi przez serwer aplikacji, czeka na render i na to, co ten render zdąży po drodze odpytać. Cała trasa oznaczona ƒ w projekcie, który jest blogiem albo stroną usługową, to nie decyzja architektoniczna. To zwykle cztery wzorce w kodzie, każdy wstawiony przez model w dobrej wierze.

Co znak ƒ zmienia w ścieżce żądania

Time to First Byte liczy się od startu nawigacji do przyjścia pierwszego bajtu odpowiedzi i obejmuje, zgodnie z opisem metryki na web.dev, przekierowania, start service workera, zapytanie DNS, nawiązanie połączenia i negocjację TLS oraz samo żądanie aż do początku odpowiedzi. Z tej listy przy trasie statycznej optymalizuje się głównie dystans i liczbę przekierowań. Przy trasie dynamicznej dochodzi składnik, którego przy ○ po prostu nie ma: przetwarzanie po stronie serwera, w tym każde zapytanie do bazy lub do API, na które render czeka, zanim zacznie strumieniować HTML.

Etap Trasa ○ (prerender) Trasa ƒ (render na żądanie)
Kto odpowiada CDN, bez dojścia do serwera aplikacji Serwer aplikacji przy każdym żądaniu
Render Wykonany raz, w trakcie builda Wykonywany od nowa dla każdego użytkownika
Odpytania danych w ścieżce TTFB Brak — wynik jest już w HTML Wszystkie, które render musi rozstrzygnąć przed pierwszym bajtem
Zimny start Nie dotyczy dokumentu Dotyczy, jeśli instancja nie jest rozgrzana
Koszt jednostkowy Transfer Transfer plus wywołanie funkcji, rośnie z ruchem

Ostatni wiersz jest tym, który widać na fakturze dopiero po miesiącu działania: pozycja rosnąca liniowo z liczbą żądań zachowuje się tak samo jak rachunek za automat obsługujący zgłoszenia, opisany w tekście o rachunku, który rośnie co miesiąc. W odbiorze projektu nikt o to nie pyta, bo w momencie odbioru ruchu jeszcze nie ma.

Cztery wzorce z generatora, które stawiają ƒ przy trasie

Poniższy fragment jest skrótem tego, co wychodzi z promptu „zrób layout z motywem i nagłówkiem użytkownika”. Każda z czterech zaznaczonych rzeczy osobno wystarczy, żeby trasa przestała być prerenderowana.

// app/[slug]/layout.tsx — wersja, która daje ƒ
export const dynamic = 'force-dynamic'          // 1

export default async function Layout({ children, params }) {
  const { slug } = await params                 // 2
  const theme = (await cookies()).get('theme')  // 3
  const nav = await fetch(API + '/nav', { cache: 'no-store' }).then(r => r.json())
  const renderedAt = Date.now()                 // 4

  return (
    <div data-theme={theme?.value}>
      <Nav items={nav} />
      <h1>{slug}</h1>
      {children}
      <footer>wygenerowano {renderedAt}</footer>
    </div>
  )
}
  • export const dynamic = 'force-dynamic' — najszerszy w skutkach. Przewodnik Caching and Revalidating (Previous Model) podaje, czemu dokładnie ta flaga jest równoważna: ustawieniu { cache: 'no-store', next: { revalidate: 0 } } w każdym fetch() w layoucie i stronie oraz fetchCache = 'force-no-store' w segmencie. Jedna linia w layoucie przestawia więc wszystkie odpytania w całej gałęzi tras.
  • await params na szczycie layoutu — jeśli wartość segmentu nie jest podana przez generateStaticParams, jest daną żądania i layout nie da się prerenderować. Nie dlatego, że parametr jest nieznany w ogóle, tylko dlatego, że jest awaitowany najwyżej, jak można.
  • Odczyt cookies() lub headers() bez granicy Suspense — ten sam mechanizm co przy params, z tą samą przyczyną: odczyt stoi w miejscu, które musi się rozstrzygnąć, żeby cokolwiek poniżej wyszło w HTML.
  • Date.now(), Math.random(), crypto.randomUUID() w renderze — wartość różna przy każdym wykonaniu. Model wstawia je w stopki i identyfikatory bez pytania, bo wizualnie nic nie psują.

Czwarty punkt jest najbardziej podstępny, bo stopka z czasem wygenerowania wygląda na dodatek bez znaczenia, a rozstrzyga o tym, czy dokument da się policzyć z góry.

Dlaczego model pisze kod pod poprzedni model renderowania

Obie wersje tego kodu są poprawne — tylko dla różnych wersji frameworka. Next.js 16, ogłoszony 21 października 2025 roku, wprowadził Cache Components, a dokumentacja cache’owania zapisuje zmianę wprost: „Reading cookies() here doesn’t opt-in the whole route into dynamic rendering, the way the previous rendering model did”. W poprzednim modelu odczyt ciasteczka wypisywał całą trasę z prerenderu i force-dynamic był uczciwym sposobem powiedzenia tego otwartym tekstem. W modelu z Cache Components ten sam odczyt, owinięty w <Suspense>, zostawia statyczną powłokę na miejscu i strumieniuje tylko swój fragment.

Model językowy widział w materiale treningowym oba warianty i nie wie, w którym projekcie jesteś. Rozstrzyga to jedna rzecz w konfiguracji, więc sprawdź ją przed czytaniem czyjegokolwiek kodu — także swojego z poprzedniego kwartału:

// next.config.ts
const nextConfig = {
  cacheComponents: true,     // tryb Cache Components
  partialPrefetching: true,  // ustaw jawnie, brak wartości loguje ostrzeżenie
}

Dokumentacja zaznacza, że nowe aplikacje z create-next-app mają te opcje domyślnie włączone, a Cache Components weszły w wersji 16.0.0. Projekt podniesiony z 15 do 16 bez tej flagi działa dalej według poprzedniego modelu i wtedy force-dynamic nie jest w nim pomyłką, a tylko kosztem, który ktoś wybrał.

Ile z budżetu LCP zjada sam serwer

TTFB nie jest Core Web Vital i nie ma własnego progu w raportach, ale stoi przed każdą metryką, którą widzi użytkownik. Opis metryki podaje dobrą wartość jako 0,8 sekundy lub mniej i złą jako powyżej 1,8 sekundy, mierzone na 75. percentylu ruchu rzeczywistego. Dla LCP rozbiór metryki na części podaje zalecany udział TTFB na poziomie około 40 proc. całości, przy czym autorzy nazywają te proporcje wskazówką, nie regułą.

Część LCP Zalecany udział Ile to przy budżecie 2,5 s
Time to First Byte ok. 40 proc. 1000 ms
Opóźnienie startu pobierania zasobu poniżej 10 proc. do 250 ms
Czas pobierania zasobu ok. 40 proc. 1000 ms
Opóźnienie renderu elementu poniżej 10 proc. do 250 ms

Liczby w trzeciej kolumnie to arytmetyka na zaleceniu, nie pomiar czyjejkolwiek strony. Praktyczny wniosek z nich jest taki: dopóki TTFB trzyma się poniżej 800 ms, hero i font mają z czego żyć. Trasa ƒ, która czeka na dwa odpytania bazy i zimny start, zabiera ten margines, zanim przeglądarka dowie się o istnieniu obrazka. Wtedy strojenie obrazu i fontu — zwykle pierwsze, po co się sięga — zmienia końcówkę budżetu, a nie jego początek.

Jak zmierzyć TTFB i stan powłoki przed oddaniem

Dwie rzeczy mierzy się tu osobno: strukturę (czy trasa w ogóle ma statyczną powłokę) i czas (ile trwa pierwszy bajt na produkcji). Pierwsza wychodzi z builda i jest deterministyczna, druga zależy od regionu, bazy i ruchu.

  • Struktura — next build i spis tras. Każda pozycja z ƒ wymaga odpowiedzi na pytanie, który odczyt danych żądania ją tam postawił. W trybie Cache Components pomaga nakładka deweloperska: dokumentacja opisuje wskazówkę blocking-route, która nazywa trasę i proponuje konkretną poprawkę.
  • Czas, w laboratorium — rozbicie żądania na składniki jednym poleceniem, na adresie produkcyjnym, nie na localhost:
curl -o /dev/null -s -w \
  'dns:%{time_namelookup} tcp:%{time_connect} tls:%{time_appconnect} ttfb:%{time_starttransfer} total:%{time_total}\n' \
  https://przyklad.pl/oferta

Pierwsze wywołanie po deployu pokaże zimny start, kolejne — stan rozgrzany. Obie wartości są prawdziwe i obie trafiają do użytkowników, więc notuje się je parami, a nie wybiera korzystniejszą.

  • Czas, w polu — opis metryki wskazuje raport CrUX i funkcję onTTFB z biblioteki web-vitals, a w czystym JavaScripcie PerformanceObserver na wpisach typu navigation i pole responseStart. Pomiar z jednego laptopa na światłowodzie nie zastąpi 75. percentyla z ruchu, który naprawdę przychodzi.
new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    console.log('TTFB', entry.responseStart)
  }
}).observe({ type: 'navigation', buffered: true })

Ta procedura ma wartość tylko wtedy, gdy wykona ją ktoś inny niż jej autor, w dniu, w którym autora nie ma — czyli gdy jest spisana w formie, którą da się wykonać bez dopytywania. Zasady takiego zapisu zbiera tekst o opisywaniu procesu tak, żeby ktoś inny faktycznie go wykonał.

Przeniesienie awaitu w dół drzewa zamiast force-dynamic

Poprawka nie polega na usunięciu odczytów, bo one są potrzebne. Polega na przesunięciu miejsca, w którym są awaitowane. Dokumentacja nazywa tę zasadę maksymalizowaniem statycznej powłoki: im głębiej w drzewie siedzi praca asynchroniczna, tym więcej strony da się prerenderować.

// app/[slug]/layout.tsx — wersja, która zostawia powłokę
import { Suspense } from 'react'

// bez `async`: ten layout nie awaituje niczego
export default function Layout({ children, params }) {
  return (
    <div>
      <Sidebar />
      <Suspense fallback={<h1>Ładowanie…</h1>}>
        {params.then(({ slug }) => <SlugHeading slug={slug} />)}
      </Suspense>
      {children}
    </div>
  )
}

W tej wersji Sidebar, children i treść fallbacku wchodzą do statycznej powłoki, a przy użytkowniku dolicza się tylko nagłówek z parametrem. Ta sama zamiana dotyczy cookies(), headers(), searchParams i zwykłych odpytań danych. Dla wartości losowych i znaczników czasu służy do tego connection() wywołane przed ich wygenerowaniem, również wewnątrz granicy Suspense.

Co zostaje w powłoce, co się cache’uje, co streamuje

Po przesunięciu awaitów każdy fragment strony trzeba przypisać do jednej z pięciu kategorii. Tabela poniżej jest zestawieniem narzędzi z dokumentacji Cache Components, a nie rankingiem — wybór wynika z tego, czy dana treść zależy od żądania i jak długo wolno jej być nieaktualną.

Rodzaj treści Narzędzie Gdzie trafia wynik
Stała, niezależna od żądania Zwykły render, import, odczyt pliku w zakresie modułu Statyczna powłoka w czasie builda
Wspólna dla wszystkich, odświeżana co jakiś czas 'use cache' z cacheLife Powłoka plus współdzielony magazyn
Zależna od ciasteczka lub nagłówka, ale z czasem życia 'use cache: private' Przeglądarka, w ramach prefetcha
Świeża przy każdym żądaniu <Suspense> wokół odczytu Strumieniowana po wysłaniu powłoki
Unikalna per żądanie (losowa, znacznik czasu) connection() i <Suspense> Strumieniowana przy użytkowniku

Dokumentacja zaleca przy każdej dyrektywie cache’ującej podawać cacheLife, bo bez niej stosuje się domyślny profil, a profil zbyt krótki wyklucza wynik z powłoki. To jest miejsce na rozmowę z klientem, nie na domyślną wartość: „ile minut dane mogą być nieaktualne” to pytanie biznesowe, na które programista nie zna odpowiedzi.

Czego przeniesienie awaitu nie naprawi

Część tras ma być dynamiczna i dobrze, że jest. Panel po zalogowaniu, koszyk, wynik wyszukiwania — tam znak ƒ jest zgodny z zamiarem, a miarą jakości jest wtedy czas odpowiedzi, nie obecność powłoki. Poza tym zostają granice, których sama struktura drzewa nie przesunie:

  • Zimny start, baza i region. Jeśli render czeka 400 ms na zapytanie, to czeka tyle samo w powłoce i poza nią. Przesunięcie awaitu zdejmuje ten czas ze ścieżki pierwszego bajtu, ale nie z czasu do kompletnej treści.
  • Boty i crawlery. Dokumentacja opisuje, że niektóre roboty nie radzą sobie z metadanymi dostarczanymi po powłoce, więc Next wykrywa je po user agencie i renderuje stronę dynamicznie. Dane, które były dostępne w czasie builda, muszą być dostępne również wtedy — inaczej te roboty dostają 500.
  • Trwałość cache’u. Domyślny magazyn jest w pamięci instancji i na platformach bezserwerowych nie przeżywa między żądaniami; 'use cache: remote' daje trwałość za cenę przejścia po sieci, które, jak zapisuje dokumentacja, opłaca się tylko przy wysokim współczynniku trafień.
  • Deploy. Klucz cache’u zawiera identyfikator builda, więc nowy deploy startuje z pustymi magazynami. Pomiar wykonany 15 minut po wdrożeniu mierzy rozgrzewanie, nie stan docelowy.

Rachunek za wywołania funkcji przychodzi zresztą co miesiąc, a nie raz — podobnie jak reszta pracy, która zostaje po wdrożeniu. Przełożenie tej części współpracy z projektu na stałą opłatę to osobna decyzja handlowa, rozpisana w ebooku Biznes na żądanie o przejściu z pojedynczych zleceń na usługę abonamentową.

Spis tras ƒ jako punkt odbioru

Do protokołu odbioru wchodzi jedna tabela, w której każda trasa oznaczona w buildzie ƒ ma wypełnione trzy pola: który odczyt danych żądania ją tam postawił, czy to było zamierzone, oraz jaki TTFB zmierzono na produkcji po rozgrzaniu i na zimno. Trasy niezamierzone wracają do poprawy przez przeniesienie awaitu, nie przez dopisanie kolejnej flagi. Trasy zamierzone dostają zapis w dokumentacji przekazania, bo to one będą generować rachunek i pytania o wolne wejścia.

Jeśli taka tabela w aktualnym projekcie nie istnieje, wypełnienie jej zajmuje jeden build i tyle wywołań curl, ile masz tras. Zrób to przed rozmową o wydajności z klientem, a nie po niej.