Astro & Cloudflare 28 września 2026 11 min czytania

Cache-Control w Astro na Cloudflare Workers nic nie cache’uje. Workers Cache i trzy pułapki

ŁD

Autor: Łukasz Dramiński (Web Developer Freelancer)

Pomiary pochodzą z tej strony: Astro 5, Storyblok, Cloudflare Workers.

Ustawiasz na stronie Cache-Control: public, s-maxage=3600, widzisz ten nagłówek w odpowiedzi i zakładasz, że Cloudflare trzyma kopię przez godzinę. Nie trzyma. Na Cloudflare Workers ten nagłówek przez lata był dekoracją — każde wejście uruchamiało kod od nowa i, jeśli strona pobierała treść z CMS-a, odpytywało jego API. Ten wpis pokazuje, jak to wykryć jednym poleceniem, co zmieniło się w lipcu 2026 roku i o co łatwo się potknąć przy włączaniu nowego mechanizmu.

KOGO DOTYCZY Projekt w Astro z output: 'server' i adapterem @astrojs/cloudflare, wdrażany jako Worker — szczególnie jeśli podstrony pobierają treść z zewnętrznego API (Storyblok, Contentful, Sanity, własny backend). Mechanika jest cechą platformy, więc SvelteKit, Nuxt i Remix na Workers zachowują się tak samo.

Pomiar na tej stronie, 28 września 2026

Strona główna tego serwisu pobiera treść ze Storybloka i od początku miała w kodzie „inteligentny cache”. Tak wyglądał ten fragment:

// src/pages/index.astro — strona główna pobiera treść ze Storybloka
const isPreview = Astro.url.searchParams.has('_storyblok') || import.meta.env.DEV;

if (isPreview) {
    Astro.response.headers.set('Cache-Control', 'no-cache, no-store, must-revalidate');
} else {
    // "Cache for 1 hour on CDN" — tak mówił komentarz. Nie działało.
    Astro.response.headers.set('Cache-Control', 'public, s-maxage=3600, stale-while-revalidate=86400');
}

Wygląda rozsądnie: godzina w pamięci współdzielonej, doba podawania starszej wersji w tle, a podgląd dla redaktora zawsze świeży. Sprawdzenie zajmuje jedno polecenie:

curl -sS -D- -o /dev/null https://twojadomena.pl/ | grep -i -E "cf-cache-status|cache-control"

W odpowiedzi był cache-control dokładnie taki, jak w kodzie — i nie było nagłówka cf-cache-status w ogóle. Nie MISS, nie BYPASS, po prostu brak. To znaczy, że odpowiedź w ogóle nie przeszła przez warstwę cache Cloudflare.

Czas do pierwszego bajtu, mediana z piętnastu prób z Warszawy:

Adres Skąd treść Mediana Najgorsza próba
/API Storybloka154 ms415 ms
/uslugikod101 ms121 ms
/blogkod104 ms113 ms
/regulaminkod99 ms122 ms

Uczciwie: w medianie to około pięćdziesięciu milisekund różnicy, nic dramatycznego. Ważniejsza jest ostatnia kolumna. Podstrony renderowane z kodu mają stabilny czas, a strona główna ma rozrzut — bo jej czas zależy od odpowiedzi cudzego serwera, odpytywanego przy każdym wejściu. Cache miał dokładnie to wyeliminować i tego nie robił.

Dlaczego Worker omijał cache

Na klasycznym układzie Cloudflare stoi przed Twoim serwerem, czyta jego Cache-Control i decyduje, czy zapisać kopię. Na Workers było odwrotnie: Worker był serwerem, ale stał przed warstwą cache, a nie za nią. Odpowiedź wygenerowana w kodzie Workera szła prosto do użytkownika. Nagłówek był przekazywany dalej, ale żadna warstwa po drodze go nie interpretowała.

Zostawała przeglądarka — a ta dyrektywę s-maxage pomija z definicji, bo dotyczy ona wyłącznie pamięci współdzielonej (serwerów pośredniczących i CDN-ów), nie prywatnej. Przy braku max-age przeglądarka też niczego nie przechowywała. Nagłówek, który miał obsłużyć dwie warstwy, nie obsługiwał żadnej.

Do lipca 2026 roku jedyną drogą było ręczne użycie Cache API w kodzie Workera: sprawdzenie caches.default przed renderowaniem i zapis odpowiedzi w tle przez waitUntil. Działało, ale w Astro wymagało własnego punktu wejścia albo middleware z dostępem do kontekstu wykonania — czyli kodu, którego większość projektów nigdy nie napisała. Stąd mnóstwo stron z pięknym s-maxage, które nigdy nic nie cache’owały.

Workers Cache: jedna linijka w konfiguracji

6 lipca 2026 roku Cloudflare udostępnił Workers Cache — warstwę cache ustawioną przed Workerem. Jeśli odpowiedź jest w pamięci i jest świeża, Cloudflare podaje ją bez uruchamiania Twojego kodu. Jeśli nie, uruchamia Workera i zapisuje wynik według standardowych nagłówków HTTP. Funkcja jest ogólnodostępna (nie beta), działa na każdym planie, a trafienie w cache jest liczone jako zwykłe żądanie bez kosztu czasu procesora.

// wrangler.jsonc — wymaga Wrangler 4.69.0 lub nowszego
{
  "cache": {
    "enabled": true
  }
}

To jest cała zmiana, jeśli chodzi o włączenie. I właśnie dlatego trzeba się przy niej zatrzymać: przełącznik działa na cały Worker, czyli na każdą trasę Twojej aplikacji naraz. Trzy rzeczy wychodzą dopiero przy lekturze dokumentacji konfiguracji, nie w ogłoszeniu.

Pułapka 1: po włączeniu cache’owane są też trasy bez nagłówka

Intuicja podpowiada, że skoro nie ustawiłeś Cache-Control, to nic się nie zmieni. Jest odwrotnie. Odpowiedź bez Cache-Control i bez Expires dostaje świeżość wyliczoną heurystycznie według RFC 9111:

  • status 200, 203, 204 — 2 godziny,
  • 300 i 301 — 20 minut,
  • 404 i 410 — 3 minuty.

Na stronie firmowej, gdzie treść podstron siedzi w kodzie, to zwykle zaleta: po włączeniu zyskują wszystkie trasy, nie tylko te, o których pomyślałeś. Ale jeśli jakakolwiek trasa zwraca coś zależnego od użytkownika albo od chwili (panel klienta, koszyk, wynik formularza renderowany na serwerze, dostępność terminów), musi dostać jawny zakaz:

// strona, która nie może trafić do wspólnego cache
Astro.response.headers.set('Cache-Control', 'private, no-store');

Automatycznie pomijane są odpowiedzi z nagłówkiem Set-Cookie oraz z dyrektywą private lub no-store. Uwaga na no-cache: to nie jest zakaz zapisu. Oznacza „zapisz, ale przed każdym użyciem sprawdź świeżość”. Jeśli chcesz, żeby odpowiedź nigdy nie trafiła do pamięci, jedynym właściwym słowem jest no-store.

Pułapka 2: s-maxage wyłącza stale-while-revalidate

Nagłówek public, s-maxage=3600, stale-while-revalidate=86400 jest w dziesiątkach poradników do Astro, Nuxt i Next. Na Workers Cache działa w połowie. Dokumentacja mówi wprost, że podawanie starej wersji w tle jest wyłączone, jeśli odpowiedź zawiera s-maxage, must-revalidate albo proxy-revalidate. Po godzinie pierwszy odwiedzający czeka więc na pełne wygenerowanie strony — dokładnie to, przed czym stale-while-revalidate miało chronić.

Rozwiązaniem jest rozdzielenie instrukcji dla Cloudflare i dla przeglądarki. Cloudflare czyta nagłówki w kolejności: Cloudflare-CDN-Cache-Control (najwyższy priorytet, usuwany przed wysłaniem do użytkownika), potem CDN-Cache-Control, na końcu zwykły Cache-Control. Wersja dla strony głównej tego serwisu:

if (isPreview) {
    // podgląd w CMS: nigdy nie zapisuj
    Astro.response.headers.set('Cache-Control', 'no-store');
} else {
    // dla Cloudflare: godzina świeżości, doba podawania starej wersji
    // w tle i doba na wypadek awarii CMS-a
    Astro.response.headers.set(
        'Cloudflare-CDN-Cache-Control',
        'max-age=3600, stale-while-revalidate=86400, stale-if-error=86400'
    );
    // dla przeglądarki: nie trzymaj u siebie, pytaj serwer
    Astro.response.headers.set('Cache-Control', 'public, max-age=0');
}

Dwie zmiany poza samym nagłówkiem. W podglądzie no-store zamiast no-cache, no-store, must-revalidate — znaczy to samo, a jest jednoznaczne. I dopisane stale-if-error: jeśli API Storybloka przestanie odpowiadać, odwiedzający dostaną ostatnią dobrą wersję strony z cache zamiast wersji awaryjnej. Na tej stronie wersja awaryjna istnieje (statyczne sekcje w kodzie), ale jest starsza od treści w CMS-ie — więc przy awarii ktoś widziałby nieaktualną ofertę.

Pułapka 3: publikacja w CMS-ie to nie wdrożenie

Domyślnie numer wersji Workera jest częścią klucza cache. Każde wdrożenie zaczyna więc od pustej pamięci i nie trzeba niczego czyścić ręcznie. Dla podstron, których treść jest w kodzie, to idealne zachowanie: nowa wersja strony nigdy nie zostanie przykryta starą kopią.

Kliknięcie „Publikuj” w Storybloku nie jest jednak wdrożeniem. Wersja Workera się nie zmienia, więc nowy tekst pojawi się na stronie dopiero po wygaśnięciu max-age — przy ustawieniach wyżej maksymalnie po godzinie, a z stale-while-revalidate pierwszy odwiedzający po tej godzinie zobaczy jeszcze starą wersję i uruchomi odświeżenie w tle. Dla strony firmowej to zwykle akceptowalne. Dla promocji z godziną startu — nie.

Jeśli potrzebujesz natychmiastowej publikacji, są dwa kierunki: krótszy max-age (minuty zamiast godziny) albo nagłówek Cache-Tag na odpowiedzi i czyszczenie po tagu z webhooka CMS-a przez ctx.cache.purge({ tags: [...] }). Drugie jest lepsze, ale to kolejny punkt w aplikacji, który trzeba zabezpieczyć i przetestować. Ustawienie cross_version_cache: true robi odwrotną rzecz — dzieli cache między wersjami Workera — i ma sens dopiero wtedy, gdy czyszczenie po tagach masz już opanowane.

A jeśli masz już Astro 7

Astro 7 dostało stabilne cache’owanie tras z dostawcą dla Cloudflare w adapterze @astrojs/cloudflare od wersji 14. Pod spodem korzysta z tego samego Workers Cache, a zamiast ręcznego ustawiania nagłówków wywołujesz Astro.cache.set():

// astro.config.mjs — Astro 7 i @astrojs/cloudflare 14
import { defineConfig } from 'astro/config';
import { cacheCloudflare } from '@astrojs/cloudflare/cache';

export default defineConfig({
    cache: { provider: cacheCloudflare() },
});

// na stronie
Astro.cache.set({ maxAge: 3600, swr: 86400, tags: ['home'] });

Dostawca sam ustawia Cloudflare-CDN-Cache-Control i Cache-Tag, a czyszczenie po tagu albo ścieżce robi cache.invalidate(). Ta strona stoi na Astro 5 i adapterze 12, więc tej drogi tu nie opisuję z własnego doświadczenia — ale trzy pułapki wyżej dotyczą jej tak samo, bo wynikają z zachowania Workers Cache, nie z frameworka.

Jak to sprawdzić po wdrożeniu

Trzy polecenia, każde sprawdza co innego:

# 1. dwa żądania z rzędu — przy drugim oczekujesz HIT
curl -sS -D- -o /dev/null https://twojadomena.pl/ | grep -i cf-cache-status

# 2. adres podglądu z CMS-a — tu HIT jest błędem
curl -sS -D- -o /dev/null "https://twojadomena.pl/?_storyblok=1" | grep -i cf-cache-status

# 3. nagłówek dla Cloudflare nie powinien dotrzeć do przeglądarki
curl -sS -D- -o /dev/null https://twojadomena.pl/ | grep -i cloudflare-cdn-cache-control

Najważniejsze jest drugie. Dokumentacja Workers Cache nie opisuje wprost, jak traktuje parametry zapytania w kluczu cache, a od tego zależy, czy redaktor w CMS-ie zobaczy swój szkic, czy opublikowaną kopię. Odpowiedź z no-store nie zostanie zapisana, więc przy poprawnej konfiguracji adres podglądu nigdy nie pokaże HIT. Jeśli pokaże — zatrzymaj się, zanim redaktor zgłosi, że „zmiany się nie zapisują”.

Tak jak przy nagłówkach bezpieczeństwa, lokalny astro dev nic tu nie powie — nie ma w nim warstwy cache Cloudflare. Sprawdzaj na wdrożonej wersji albo na adresie podglądu wersji.

STAN NA DZIEŃ PUBLIKACJI Pomiar „przed” pochodzi z tej strony i jest z 28 września 2026 roku. Konfiguracja „po” jest zgodna z dokumentacją Cloudflare z tego samego miesiąca. Wyniki pomiaru po wdrożeniu na tej stronie dopiszę w tym miejscu — łącznie z odpowiedzią na pytanie o adres podglądu.

Najczęstsze pytania

Pytania, które wracają przy tym temacie — od osób wdrażających Astro na Cloudflare i od klientów, którzy chcą wiedzieć, czy ich strona jest szybka, czy tylko tak wygląda w konfiguracji.

Dlaczego s-maxage nie działa na Cloudflare Workers?

Bo do lipca 2026 roku Worker stał przed warstwą cache Cloudflare, a nie za nią. Odpowiedź wygenerowana przez kod Workera szła prosto do użytkownika i nagłówek Cache-Control był przekazywany dalej, ale nikt po drodze go nie czytał. Dyrektywa s-maxage dotyczy wyłącznie pamięci współdzielonej, więc przeglądarka ją pomija. Efekt: nagłówek wygląda poprawnie w narzędziach deweloperskich, a każde wejście na stronę uruchamia kod od nowa. Brak nagłówka cf-cache-status w odpowiedzi to najprostszy dowód.

Co to jest Workers Cache i ile kosztuje?

To warstwa cache ustawiona przed Workerem, ogłoszona przez Cloudflare 6 lipca 2026 roku jako funkcja ogólnodostępna, nie beta. Włącza się ją jednym wpisem cache.enabled w pliku konfiguracyjnym Wranglera. Jest dostępna na każdym planie bez osobnej opłaty. Trafienie w cache jest liczone jako zwykłe żądanie, ale nie zużywa czasu procesora, bo kod Workera się wtedy nie wykonuje.

Czy po włączeniu Workers Cache wszystkie podstrony zaczną być cache’owane?

Tak, i to jest najważniejsza rzecz do sprawdzenia przed włączeniem. Odpowiedź bez nagłówka Cache-Control dostaje świeżość wyliczoną heurystycznie według RFC 9111, dla statusu 200 są to dwie godziny. Nie trafiają do cache odpowiedzi z nagłówkiem Set-Cookie, z dyrektywą private albo no-store. Zanim przełączysz ustawienie, przejdź po wszystkich trasach i dopisz no-store tam, gdzie treść zależy od użytkownika albo musi być świeża co do sekundy.

Dlaczego stale-while-revalidate nie działa razem z s-maxage?

Tak zapisano to w dokumentacji Workers Cache: obecność s-maxage, must-revalidate albo proxy-revalidate wyłącza podawanie starej wersji w tle. Nagłówek public, s-maxage=3600, stale-while-revalidate=86400, który krąży po poradnikach do frameworków, działa więc na Cloudflare tylko w połowie — po godzinie pierwszy użytkownik czeka na pełne wygenerowanie strony. Rozwiązaniem jest osobny nagłówek Cloudflare-CDN-Cache-Control z max-age zamiast s-maxage.

Czy podgląd w Storyblok albo innym CMS-ie nie zacznie pokazywać starej wersji?

Nie powinien, pod dwoma warunkami. Odpowiedź dla podglądu musi mieć Cache-Control: no-store, bo samo no-cache nie omija cache, tylko każe sprawdzić świeżość. Adres podglądu musi też różnić się od publicznego, na przykład parametrem _storyblok. Dokumentacja Workers Cache nie opisuje wprost, jak traktuje parametry zapytania w kluczu cache, dlatego po wdrożeniu sprawdź adres podglądu poleceniem curl — status HIT na nim oznacza problem.

Co dzieje się z cache po wdrożeniu nowej wersji strony?

Domyślnie numer wersji Workera jest częścią klucza cache, więc każde wdrożenie zaczyna od pustej pamięci. Dla strony, której treść siedzi w kodzie, to wygodne: nie trzeba niczego czyścić ręcznie. Zmiana treści w CMS-ie nie jest jednak wdrożeniem — opublikowany tekst pojawi się dopiero po wygaśnięciu max-age albo po wyczyszczeniu cache po tagu. Ustawienie cross_version_cache pozwala dzielić cache między wersjami, ale wtedy odpowiedzialność za czyszczenie przechodzi na Ciebie.

Czy cache na Workers poprawi pozycje strony w Google?

Sam z siebie nie. Czas odpowiedzi serwera jest częścią wskaźnika LCP, więc lepszy cache poprawia jeden składnik Core Web Vitals, ale na małej stronie firmowej zysk liczy się w dziesiątkach milisekund, a nie w sekundach. Większą wartością jest to, że strona przestaje odpytywać zewnętrzny CMS przy każdym wejściu — awaria albo spowolnienie API nie przenosi się wtedy od razu na odwiedzających.

Podsumowanie

Na Cloudflare Workers nagłówek Cache-Control niczego nie cache’uje, dopóki nie włączysz Workers Cache — a brak cf-cache-status w odpowiedzi mówi to w sekundę. Po włączeniu trzeba pamiętać o trzech rzeczach: cache obejmuje też trasy bez nagłówka (dwie godziny dla statusu 200), s-maxage wyłącza stale-while-revalidate, a publikacja w CMS-ie nie czyści cache tak jak wdrożenie.

To drugi wpis z tej samej serii. Pierwszy dotyczył podobnej pułapki na tej samej platformie: plik _headers nie działa na podstronach generowanych przez Workera. Wspólny mianownik obu: konfiguracja przeniesiona z klasycznego hostingu na Workers nie zgłasza błędu, tylko po cichu przestaje działać.

Jeżeli planujesz stronę i chcesz wiedzieć, ile kosztuje warstwa, której nie widać — rozbicie kosztów opisałem we wpisie ile kosztuje strona internetowa. Zakres i widełki dla poszczególnych branż są na stronie usług.

// Warstwa, której nie widać, a od której zależy wynik

Potrzebujesz strony zrobionej porządnie także pod spodem?

Robię strony w Astro na Cloudflare — z cache, nagłówkami, adresami kanonicznymi i danymi strukturalnymi sprawdzonymi na wdrożonej wersji, a nie tylko wpisanymi w konfigurację. Napisz, co planujesz, a odpiszę z zakresem i widełkami.