Przycisk wstecz ładuje stronę od zera, choć w kodzie nie ma unload. Od Chrome 155 blokadę wnosi nagłówek
Nawigacja wstecz nie wchodzi do bfcache i ładuje stronę od nowa. Jak ustalić powód z pola przez notRestoredReasons i co naprawić przed oddaniem.
Klient klika w wynik na liście, wchodzi na kartę produktu, wraca strzałką wstecz i czeka. Nie pół sekundy, a tyle samo, ile trwało pierwsze wejście: spinner, przeskok układu, utrata pozycji przewinięcia. W raporcie z Lighthouse ta strona ma zielone wszystkie trzy wskaźniki, bo Lighthouse mierzy pierwsze wejście, a nie powrót. Wracanie jest osobną ścieżką i ma osobny mechanizm: pamięć nawigacji wstecz i wprzód, czyli bfcache. Strona albo do niej wchodzi i wraca z zamrożonego stanu, albo nie wchodzi i ładuje się od zera.
Przez ostatnie dwa lata najczęstsza odpowiedź na pytanie „dlaczego nie wchodzi” brzmiała: usuń procedurę obsługi zdarzenia unload. Od 6 października 2026 ta odpowiedź jest martwa i trzeba szukać gdzie indziej.
Powrót do listy kosztuje tyle, ile pierwsze wejście
Strona przywrócona z bfcache nie przechodzi przez ładowanie. Nie ma żądania do serwera, nie ma parsowania, nie ma hydracji, nie wykonuje się żaden skrypt inicjujący. Przeglądarka zamraża całe drzewo dokumentu razem ze stanem JavaScriptu, a przy powrocie odmraża je i rysuje pierwszą klatkę. W dokumentacji web.dev o bfcache ta różnica jest opisana wprost: przywrócona strona nie generuje normalnego zdarzenia ładowania, więc zwykła telemetria wydajności w ogóle jej nie widzi.
Z tego wynika rzecz, która myli przy odbiorze projektu. Brak strony w bfcache nie pogarsza żadnego wskaźnika w raporcie z narzędzia. Pogarsza doświadczenie w ścieżce, której narzędzie nie dotyka. Jeśli odbiór kończy się na raporcie, ten koszt nie zostaje nigdzie zapisany i wychodzi dopiero w zgłoszeniu od klienta sformułowanym jako „wstecz działa wolno”.
Ile nawigacji o tym decyduje
Skala nie jest marginalna. Według tej samej dokumentacji web.dev około jedna na dziesięć nawigacji na komputerze i jedna na pięć na urządzeniu mobilnym to ruch wstecz albo wprzód.
Udział dla konkretnego adresu da się odczytać bez własnej telemetrii. Od zbioru z marca 2024 raport CrUX wystawia metrykę navigation_types, opisaną we wpisie Chrome o typach nawigacji w CrUX, z siedmioma frakcjami sumującymi się w przybliżeniu do jedności. Dwie z nich dotyczą tej jednej sprawy: back_forward to nawigacja historyczna, która musiała załadować stronę od nowa, a back_forward_cache to taka, którą obsłużył bfcache.
| Frakcja | Co oznacza | Wartość w przykładzie z dokumentacji |
|---|---|---|
navigate |
zwykłe wejście | 0,5335 |
navigate_cache |
wejście obsłużone z pamięci HTTP | 0,2646 |
reload |
odświeżenie | 0,0885 |
back_forward_cache |
wstecz lub wprzód przywrócone z bfcache | 0,0677 |
back_forward |
wstecz lub wprzód ładowane od zera | 0,0403 |
prerender |
wstępne wyrenderowanie | 0,0031 |
restore |
przywrócenie karty po restarcie | 0,0023 |
Te dwie frakcje warto przeliczyć na jedną liczbę, bo właśnie ona jest przedmiotem naprawy. W przykładowej odpowiedzi z wpisu Chrome dla okresu zbierania od 6 marca 2024 obie dają razem 0,1080, a nieodtworzone 0,0403 z tego, czyli nieco ponad 37 procent nawigacji historycznych nie weszło do bfcache. To arytmetyka na dwóch liczbach z tego przykładu, nie wynik dla twojego adresu: dla własnego URL-a odczytaj obie frakcje i podziel pierwszą przez ich sumę. Dla wielu adresów metryka nie przyjdzie wcale, bo wpis mówi wprost o niewystarczającym ruchu kwalifikowanym; wtedy zostaje pomiar własny opisany niżej.
Od Chrome 155 unload nie jest już powodem
Procedura obsługi unload była przez lata najczęstszą blokadą bfcache i najczęstszą poradą. Chrome wycofywał to zdarzenie etapami rozliczanymi udziałem obsłużonych odsłon, a harmonogram w dokumentacji Chrome o wycofywaniu unload kończy się na wersji 155 z 6 października 2026 i wartości 100 procent. Wcześniejsze progi tego samego harmonogramu to 1 procent w Chrome 146 z 10 marca 2026, 20 procent w Chrome 149 z 2 czerwca i 80 procent w Chrome 152 z 25 sierpnia.
Dla diagnozy ma to konkretny skutek, zapisany w dokumentacji API notRestoredReasons: od Chrome 155 przeglądarka nie zgłasza już powodu unload-listener, poza witrynami, które świadomie wróciły do zdarzenia unload przez politykę uprawnień. Powrót do niego jest możliwy, ta sama dokumentacja wskazuje też politykę dla urządzeń zarządzanych o nazwie ForcePermissionPolicyUnloadDefaultEnabled, ale w zwykłym projekcie nikt tego nie włącza.
Praktyczny wniosek dla przeglądu kodu z modelu: jeśli generator dopisał window.addEventListener('unload', ...), to nadal jest błąd do poprawy, bo samo zdarzenie w Chrome już nie odpala, więc kod w nim po prostu nie działa. Zamiennik też jest w dokumentacji wycofania: visibilitychange przy przejściu w stan ukryty do zapisu danych i pagehide przy opuszczaniu strony. Ale blokady bfcache w tym szukać już nie ma sensu.
Lab pokaże listę powodów, pole wskaże właściwy
Test w labie jest jednym kliknięciem. Według dokumentacji web.dev w Chrome DevTools wystarczy zakładka Application, sekcja Back-forward Cache i przycisk uruchamiający test; powodzenie raportowane jest komunikatem o przywróceniu z bfcache, a przy porażce dostajesz listę powodów, z których naprawialne są oznaczone jako Actionable. Ta sama strona wspomina, że Lighthouse od wersji 10.0 ma własny audyt robiący podobny test.
Ten test ma jedno ograniczenie, które decyduje o kolejności pracy: sprawdza stronę w stanie, w jakim jest na twojej maszynie, w twojej sesji, bez zalogowanego użytkownika, bez zgód na ciasteczka, bez skryptów, które ładują się tylko na produkcji. Połowa realnych blokad siedzi dokładnie w tych warunkach.
notRestoredReasons: powód z pola, nie z labu
Od Chrome 123 klasa PerformanceNavigationTiming ma właściwość notRestoredReasons, którą opisuje dokumentacja Chrome. Odczyt jest prosty, przykład z strony MDN o notRestoredReasons sprowadza się do przejścia po wpisach nawigacji:
const navEntries = performance.getEntriesByType("navigation");
for (const entry of navEntries) {
console.log(entry.notRestoredReasons);
}
Zwracany obiekt ma pola children, id, name, reasons, src i url, a dla strony najwyższego poziomu bez ramek wygląda tak:
{
"children": [],
"id": null,
"name": null,
"reasons": [{ "reason": "unload-listener" }],
"src": "",
"url": "example.com"
}
Przy wysyłce do własnej telemetrii trzeba obsłużyć trzy stany, bo mylenie ich daje fałszywy obraz. Wartość undefined oznacza, że przeglądarka nie obsługuje tej właściwości, co MDN opisuje jako dostępność ograniczoną i oznacza ją jako eksperymentalną, więc poza Chrome nie licz na dane. Wartość null oznacza, że bfcache nie dotyczy tej nawigacji; MDN wskazuje dwa takie przypadki poza zwykłym wejściem: zduplikowanie nawigacji historycznej w nowej karcie i przywrócenie karty po restarcie przeglądarki. Dokumentacja Chrome dokłada trzeci, najbardziej podstępny: null przychodzi również wtedy, gdy nie było żadnej blokady, więc ta właściwość nie odpowiada na pytanie, czy bfcache zadziałał. Na to pytanie odpowiada wyłącznie event.persisted.
Powód z reasons |
Co to znaczy | Co z tym zrobić |
|---|---|---|
response-cache-control-no-store |
odpowiedź dokumentu ma no-store w nagłówku kontroli pamięci |
sprawdzić, czy treść naprawdę jest wrażliwa; jeśli nie, zmienić na no-cache albo max-age=0 |
related-active-contents |
stronę otwarto z innej, która trzyma do niej referencję, na przykład przez duplikowanie karty | przy window.open dodać rel="noopener"; część przypadków jest poza twoją kontrolą |
masked |
blokadę wniosło poddrzewo z innej domeny, bez ujawnienia przyczyny | zawęzić uprawnienia ramki polityką uprawnień albo atrybutem sandbox |
unload-listener |
strona rejestruje obsługę unload |
w Chrome od wersji 155 już się nie pojawia; w kodzie nadal do usunięcia, bo nie działa |
Nazwy powodów nie są stabilne, co dokumentacja Chrome mówi otwarcie, podając jako przykład wcześniejsze brzmienie unload-handler z Chrome 123. Kod zbierający te dane ma więc traktować wartość jako nieznany ciąg znaków, a nie mapować ją na zamkniętą listę.
no-store, którego nikt nie wpisał
Najczęstszy realny bloker nie jest dziś niczyim błędem w kodzie, tylko domyślnym zachowaniem frameworka. Dokumentacja Next.js o samodzielnym hostingu podaje, że strony renderowane dynamicznie ustawiają nagłówek Cache-Control o wartości private, no-cache, no-store, max-age=0, must-revalidate, żeby dane konkretnego użytkownika nie trafiły do pamięci, i że dotyczy to zarówno App Routera, jak i Pages Routera. Jeden z członów tej wartości wyklucza stronę z bfcache.
Dokumentacja web.dev dokłada do tego dwa warunki, które zmieniają zakres pracy. Pierwszy: zachowanie dotyczy nagłówka na samym dokumencie strony, nie na jej podzasobach, więc nie ma sensu przeglądać nagłówków obrazków i skryptów. Drugi: dla treści, która ma być świeża, ale nie jest wrażliwa, zalecane są no-cache albo max-age=0, bo żadne z nich nie wpływa na kwalifikację do bfcache. Ta sama strona zaznacza, że Chrome pracuje nad zmianą tej reguły, więc jest to warunek do odczytania w dniu oddania projektu, nie prawda na zawsze.
Decyzja jest tu biznesowa, nie techniczna: które trasy w aplikacji naprawdę zwracają dane jednego użytkownika, a które są dynamiczne tylko dlatego, że ktoś sięgnął po nagłówek żądania w komponencie wspólnym dla całego drzewa. Pierwsze zostają z no-store i tracą bfcache świadomie. Drugie to po prostu koszt, który wszedł do projektu bez decyzji. Taka kontrola jest też naturalną pozycją w stałej umowie serwisowej, a nie jednorazową poprawką przed odbiorem; sposób przepakowania pracy projektowej w powtarzalną usługę opisuje ebook o przejściu z projektów na abonament.
Pomiar po naprawie i kolejność decyzji
Po poprawkach trzeba sprawdzić skutek, a nie założyć go. Metoda z dokumentacji web.dev opiera się na zdarzeniu pageshow: jeśli event.persisted jest prawdą, strona wróciła z bfcache i wysyłasz osobną odsłonę oznaczoną typem nawigacji z bfcache, a przy zwykłym ładowaniu odczytujesz typ z wpisu nawigacji. Potem porównujesz liczniki obu typów.
window.addEventListener('pageshow', (event) => {
const typ = event.persisted
? 'back_forward_cache'
: performance.getEntriesByType('navigation')[0].type;
wyslijOdslone({ typ });
});
Dwie pułapki w interpretacji wyniku. Pierwsza: dokumentacja web.dev ostrzega, że wartość persisted ustawiona na prawdę nie gwarantuje zapisania strony — oznacza zamiar przeglądarki, który inne czynniki mogą zablokować. Druga: stu procent trafień nie będzie, bo część nawigacji historycznych nie może skorzystać z bfcache, i ta sama strona mówi to wprost. Progu „dobrze” nie ma w żadnym z tych źródeł, więc ustal go jako porównanie z własnym stanem przed zmianą, nie z cudzą liczbą. Warto przy tym z góry zapisać, co jest mianownikiem tego udziału, bo dwa liczniki tego samego zjawiska rozjeżdżają się zwykle na definicji, a nie na danych — ten sam mechanizm przy raportowaniu skuteczności procesu rozbiera tekst o dwóch raportach za ten sam miesiąc i pytaniu, który licznik jest poprawny.
Osobno trzeba poprawić same wskaźniki, bo po przywróceniu z bfcache liczy się je inaczej. Według dokumentacji web.dev raport CrUX traktuje przywrócenia jako osobne odsłony, LCP po przywróceniu przybliża się czasem od znacznika pageshow do następnej narysowanej klatki i jest wtedy równy FCP, a bieżące wartości CLS i INP należy przy przywróceniu zerować. Biblioteka web-vitals obsługuje te przypadki sama, więc własna warstwa pomiarowa ma albo z niej korzystać, albo powtórzyć to zerowanie ręcznie.
Kolejność przed oddaniem projektu jest krótka. Odczytaj obie frakcje historyczne z navigation_types dla adresu, który klient odwiedza najczęściej — jeśli metryka nie przychodzi, od razu przejdź do pomiaru własnego. Uruchom test w zakładce Application i zapisz powody. Zbierz notRestoredReasons z pola przez tydzień, bo lab nie zobaczy zalogowanej sesji i skryptów produkcyjnych. Z zebranej listy powodów rozstrzygnij jedną rzecz: które trasy tracą bfcache dlatego, że obsługują dane jednego użytkownika, a które dlatego, że nikt nie przyjrzał się nagłówkowi, który framework wystawił sam.