W programowaniu od lat krąży żart: „There are only two hard things in Computer Science: cache invalidation and naming things", czyli że w informatyce są podobno tylko dwa trudne problemy: unieważnianie cache'u i nazywanie rzeczy. Przez dłuższy czas uważałem, że ta pierwsza część jest trochę przesadzona. Ale teraz bym to powiedzenie jeszcze rozszerzył na całe zarządzanie cache'em, nie tylko jego unieważnianie.
Hipotetyczny scenariusz: użytkownik otwiera stronę produktu na Artovnii i widzi cenę 199 zł. Klika „dodaj do koszyka", a w koszyku produkt kosztuje już 149 zł. Nikt niczego nie zmienił, po prostu dwie różne warstwy cache nie zdążyły się ze sobą zgodzić, zanim zdążył kliknąć.
Kiedy budujesz zwykłą stronę internetową, cache rzeczywiście może wydawać się prosty. Masz stronę, która zmienia się raz na kilka godzin albo dni, wrzucasz ją na CDN, ustawiasz sensowny TTL i w większości przypadków działa.
W klasycznym sklepie robi się trudniej, ale środowisko nadal jest dość kontrolowane. Produkt ma cenę, stan magazynowy, warianty, ewentualnie promocję. Sam zarządzasz katalogiem produktów i zwykle dobrze wiesz, które dane mogą się zmienić oraz kiedy.
W marketplace liczba zależności szybko rośnie, a cache zaczyna wtedy dotyczyć nie tylko szybkości odpowiedzi, ale również spójności danych między różnymi częściami aplikacji.
Jedna karta produktu, kilka warstw cache
Wracajac do scenariusza z początku tego tekstu. Jeżeli listing korzysta z innej warstwy cache niż PDP i obie warstwy mają różne zasady świeżości, łatwo o sytuację, w której użytkownik widzi inną cenę albo inną promocję zaraz po przejściu na produkt. Podobny problem może wystąpić między PDP a koszykiem, dlatego cena, promocja czy dostępność wymagają dużo ostrożniejszego podejścia niż opis produktu albo nazwa kategorii.
W Artovnii dochodzi do tego kilka warstw:
Samo pytanie czy ten endpoint jest cache'owany niewiele więc mówi. Trzeba wiedzieć również, która warstwa może zwrócić wynik, jak długo może go przechowywać, jakie dane zawiera i co dokładnie powoduje jego invalidację.
Marketplace komplikuje nawet zwykły PDP
W prostym sklepie strona produktu może korzystać z jednego dużego payloadu i kilku dodatkowych requestów. W marketplace produkt jest powiązany z dużo większą liczbą danych.
Na typowej stronie produktu Artovnii potrzebuję między innymi:
Jest tego sporo, a co ważniejsze, wszystkie te dane mają zupełnie różne wymagania dotyczące świeżości. Nazwa kategorii może być cache'owana bardzo długo, podobnie opis produktu, który zwykle nie wymaga odświeżania co minutę - ale status sprzedawcy powinien już reagować szybciej, a stan magazynowy i cena wymagają jeszcze większej ostrożności, bo promocja może rozpocząć się albo zakończyć o konkretnej godzinie. Nie ma więc jednego dobrego TTL dla czegoś, co na froncie nazywamy po prostu „produktem" i to jest jeden z głównych problemów do rozwiązania w scenariuszu marketplace. Nie ma jednej drogi, są przemyślane kompromisy.
Każdy marketplace radzi sobie z tym nieco inaczej, co widać już na pierwszy rzut oka przeglądajac Etsy, Amazon, Ebay czy Allegro. Każde ma inne zachowania, inne priorytety renderowania, fetchowania, UX. Etsy wypada najgorzej, stosując tanie triki szczególnie w wersji desktop.
Promocje działały poprawnie, ale można było zrobić to znacznie lepiej
Artovnia już wcześniej miała invalidację na żądanie. Zmiana promocji w backendzie powodowała wywołanie endpointu revalidacyjnego storefrontu, a odpowiednie cache tags były invalidowane. Użytkownicy nie chodzili więc po marketplace i nie oglądali przez sześć godzin starych cen po ręcznej zmianie promocji.
Problem dotyczył głównie architektury i wydajności całego mechanizmu.
promotions/batch miał cache zależny od dokładnego zestawu ID produktów. Jedna siatka miała 8 produktów, inna 15, kolejna 20, homepage potrafił wysłać 77 albo 78 ID, a każdy taki zestaw tworzył inny klucz. Cache formalnie działał, ale przy dynamicznych listingach marketplace miał stosunkowo mało okazji, żeby trafić dokładnie w ten sam zestaw.
Telemetria pokazała też, że około 75 do 80 procent czasu MISS było zużywane na przeliczanie kontekstu, który wcale nie zależał od zestawu produktów. Lista aktywnych promocji, mapowanie promocji do sprzedawców i część danych shippingowych były wspólne dla wielu requestów.
Rozwiązaniem było rozdzielenie tego na dwie warstwy. L1 nadal cache'uje gotową odpowiedź dla konkretnego zestawu ID, więc jeżeli identyczny zestaw pojawi się ponownie, dostajemy bardzo tani HIT. L2 przechowuje z kolei wspólny kontekst promocji pod stabilnym kluczem, dzięki czemu przy L1 MISS nie trzeba za każdym razem ponownie wykonywać najdroższej części pracy.
Do tego doszedł TTL 60 do 120 sekund, distributed lock chroniący przed stampede, oczekiwanie na zakończenie zapisu oraz rewizja kontekstu zabezpieczająca przed późnym zapisem starego wyniku po invalidacji. Osobno promocje dostały własny TTL 120 sekund zamiast dziedziczenia sześciogodzinnego TTL danych produktowych.
Invalidacja eventowa nadal jest jednym z ważniejszych elementów: create, update i delete promocji lub kampanii, a także odpowiednie zmiany opcji dostawy, invalidują cache i tagi storefrontu, natomiast krótki TTL zabezpiecza dodatkowo zmiany wynikające wyłącznie z czasu, na przykład automatyczny start kampanii.
Event zapewnia szybką reakcję na znaną zmianę, a krótki TTL ogranicza maksymalny czas nieświeżości dla zmian wynikających z czasu oraz przypadków, których invalidacja eventowa nie obejmuje.
Jeden snapshot kategorii i kilka sposobów, żeby go przypadkiem ominąć
Kategorie przeszły kilka etapów. Sam pomysł był prosty i sensowny - zamiast wielokrotnie pobierać drzewo kategorii, storefront korzystał ze wspólnego snapshotu.
Później pojawiły się dodatkowe warstwy potrzebne przez PDP i strony kategorii, a pochodne operacje - pobranie hierarchii, wyszukanie kategorii po handle czy przygotowanie danych strony kategorii - zostały dla bezpieczeństwa również opakowane w unstable_cache.
I właśnie tutaj powstał problem. W uproszczeniu wyglądało to tak:
const getCategoryHierarchyCached = unstable_cache(
async (...) => {
const snapshot = await getCategorySnapshotCached()
// ...
}
)getCategorySnapshotCached() również korzystało z unstable_cache, więc na poziomie kodu można było łatwo uznać, że mamy po prostu dwie warstwy cache. W używanej przez mnie wersji Next.js 15 ta kompozycja nie zachowywała się jednak tak, jak zakładałem.
W ścieżkach z zagnieżdżonym unstable_cache obserwowałęm omijanie oczekiwanego wpisu Data Cache, a wewnętrzne fetche wykonywały się ponownie, tak jakby miały force-no-store. W praktyce dodatkowa warstwa, która miała ograniczyć pracę, powodowała omijanie istniejącego Data Cache w tych ścieżkach.
Telemetria pokazała 10 pobrań snapshotu w 30 minut przy dwóch ciepłych instancjach, mimo TTL wynoszącego 24 godziny. Co ciekawe, sam layout korzystał ze snapshotu prawidłowo, problem pojawiał się dopiero w określonych ścieżkach pochodnych, używanych między innymi przez PDP i logikę kategorii.
Naprawa polegała na przywróceniu jednego autorytatywnego snapshotu w Data Cache i używaniu React cache() do request-level memoization operacji wykonywanych na tym snapshotcie. Przeszukanie drzewa kilkuset kategorii w pamięci jest bardzo tanie, w przeciwieństwie do kolejnego requestu HTTP do backendu.
Im więcej niezależnych kopii i pochodnych snapshotów zaczyna istnieć w różnych cache, tym więcej rzeczy trzeba później spójnie unieważniać. Prostszy model to jeden cache danych źródłowych i tanie operacje w pamięci, okazał się łatwiejszy do kontrolowania.
Cache może też skutecznie przechować zły HTML
Przy eksperymencie z ISR dla PDP dynamiczna strona produktu została przełączona na on-demand ISR za pomocą:
generateStaticParams() {
return []
}W połączeniu ze szkieletem loading.tsx pojawiło się zachowanie, którego build nie wykrywał.
Pierwszy request wykonywał prerender, loading.tsx dostarczał fallback, a gotowe dane produktu znajdowały się dopiero w RSC payload. Problem polegał na tym, że prerenderowany HTML zawierał wyłącznie skeleton, który CDN później bardzo sprawnie cache'ował.
Request miał 200 OK, X-Nextjs-Prerender: 1 i X-Vercel-Cache: HIT, ale w <main> brakowało nazwy produktu, ceny, opisu, galerii, breadcrumbs i danych sprzedawcy. Dane były dostępne w RSC payload, ale nie zostały wyrenderowane do HTML. Dla użytkownika korzystającego z JavaScriptu sytuacja mogła wyglądać inaczej niż dla crawlera pobierającego dokument.
PDP wrócił do dynamicznego renderowania i streamingu. loading.tsx oraz Suspense mogą dawać natychmiastowy feedback podczas nawigacji, a crawler odczytujący pełną odpowiedź HTTP po zakończeniu streamu nadal otrzymuje wyrenderowaną treść produktu w HTML, natomiast dane mogą być niezależnie obsługiwane przez Data Cache, request-level cache() oraz CDN.
Ten sam błąd, dwa inne objawy
Skeleton zamrożony w cache'u nie był jedynym skutkiem generateStaticParams() { return [] }. To samo połączenie, ISR bez sparametryzowanych ścieżek, wywołało jeszcze dwa osobne incydenty produkcyjne, każdy z zupełnie innym objawem.
Pierwszy dotyczył useSearchParams() bez granicy Suspense. Na dynamicznie renderowanej trasie problem kończył się przejściem części drzewa na rendering kliencki, coś co dzieje się po cichu i nie psuje strony. Po przełączeniu tej samej trasy na prerendering ten sam brak granicy Suspense stawał się błędem renderowania i w produkcji kończył się odpowiedzią 500. Skrypt dopisał generateStaticParams naraz do 23 tras kategorii, a łańcuch, który wybuchł, prowadził przez page.tsx, renderCategoryPage, SmartProductsListing, ProductListing, aż do samego useSearchParams(). Inna trasa z identycznym wzorcem zawiodłaby tak samo, tylko dostawała mniej ruchu, więc rzadziej się to ujawniało.
Drugi incydent dotyczył metadanych. Title, description, canonical, tagi og i twitter zamiast trafić do <head>, lądowały w <body>. Przyczyna leży w tym samym loading.tsx, tylko w kombinacji z asynchronicznym generateMetadata. Streamowanie metadanych to świadomy mechanizm Next.js, nie uszkodzony HTML, ale w tej konkretnej kompozycji zaczął działać przeciwko nam: loading.tsx tworzy granicę Suspense na poziomie całej trasy, Next streamuje jego fallback jako początkowy HTML, a generateMetadata, czekające na region i dane produktu, nie zdążało rozwiązać się przed wysłaniem </head>, więc mechanizm odpowiedzialny za streamowanie metadanych wypychał je do <body>.
Test na czterech produktach przez curl z UA Googlebota pokazał to bardzo konkretnie. Bez cache-bustingu wszystkie cztery miały metadane w body. Z cache-bustingiem, czyli przy świeżym renderze, trzy z czterech miały metadane poprawnie w head, ale jeden, najnowszy z całej czwórki, miał je w body nawet przy świeżym renderze. Ten produkt akurat nie trafił jeszcze do Data Cache, więc pobranie jego danych trwało dłużej, a generateMetadata miało jeszcze mniej czasu, zanim head został wysłany.
Trzy różne objawy, jedna przyczyna: loading.tsx połączone z ISR bez sparametryzowanych ścieżek. To, który fragment kodu akurat czekał na coś asynchronicznego w chwili wysyłania odpowiedzi, decydowało o tym, czy produkcja dostanie pusty skeleton, błąd 500, czy metadane nieobecne w początkowym <head> odpowiedzi.
Pełny HTML i szybki PDP da się pogodzić
To jest szczególnie ważne w marketplace. Użytkownik nie musi otrzymać każdej sekcji strony w tej samej chwili. Tytuł, zdjęcia, cena, warianty, dostępność i możliwość zakupu są ważne od razu, natomiast karuzela podobnych produktów, inne produkty sprzedawcy czy część recenzji mogą pojawić się później.
Można więc zbudować stronę z wyraźnym podziałem na primary render path i dane odroczone. Streaming pozwala szybko dostarczyć istotną część interfejsu, podczas gdy pozostałe sekcje są dalej renderowane.
Trzeba przy tym sprawdzać rzeczywisty HTML odpowiedzi. Sam fakt, że dane istnieją w RSC payload albo że użytkownik po hydracji widzi poprawną stronę, nie oznacza jeszcze, że crawler dostał taki sam dokument. Test next build nie wystarczył do wykrycia problemu z ISR - potrzebny był produkcyjny next start i sprawdzenie prawdziwego HTML przez curl.
Rekomendacje i problem fan-outu
Proponowane produkty korzystały z podobnie kosztownej strategii, szukały rekomendacji na każdym poziomie hierarchii kategorii. Dla produktu znajdującego się w:
Dom
└── Lampy
└── Lampy stołowewykonywany był request dla każdego poziomu.
W jednej sesji telemetria pokazała 48 wywołań backendu, mimo że wiele requestów miało identyczne argumenty - dwa produkty należące do tej samej kategorii mogły wysłać dokładnie takie samo zapytanie.
Pierwszym pomysłem mogłoby być dodanie kolejnego cache, ale tutaj lepszym rozwiązaniem okazała się zmiana algorytmu. Strategia leaf-first najpierw pobiera produkty wyłącznie z najbardziej konkretnej kategorii, a jeżeli wynik wystarcza do wypełnienia karuzeli, praca się kończy - dopiero gdy produktów jest za mało, pobierani są rodzice.
Po zmianie test behawioralny potwierdził jeden fetch dla kategorii, która sama potrafiła wypełnić karuzelę, oraz trzy dla rzadkiego liścia przy głębokości drzewa wynoszącej trzy poziomy.
Cache nadal ma tutaj znaczenie, ale wcześniej warto ograniczyć liczbę operacji, które w ogóle wymagają cache'owania.
Router Cache jest częścią UX
Cache nie kończy się na API. Typowa sesja zakupowa wygląda tak:
listing → produkt → listing → kolejny produkt
Użytkownik oczekuje, że powrót do listingu będzie praktycznie natychmiastowy.
W Next.js 15 Router Cache dla dynamicznych tras miał domyślnie staleTime wynoszący 0 sekund. W produkcji nie było to zawsze oczywiste, bo kolejny request często trafiał w CDN, natomiast lokalnie brak cache był dużo bardziej widoczny.
Pierwszą poprawką było ustawienie:
experimental: {
staleTimes: {
dynamic: 30,
static: 180,
},
}Trzydzieści sekund miało pokrywać typową pętlę przeglądania: kategoria, produkt, powrót. W praktyce nie pokrywało, bo oglądanie produktu dłużej niż pół minuty jest normą, a nie wyjątkiem, więc powrót regularnie trafiał na już wygasły wpis. Ta wartość nigdy zresztą nie została zmierzona, tylko zaokrąglona w dół do starego domyślnego progu z Next 14.
Prawdziwy problem wyszedł na jaw dopiero wtedy, gdy spojrzałem na tę liczbę razem z jej sąsiadami w łańcuchu. Router Cache nie jest jedynym mechanizmem decydującym, czy powrót na produkt jest szybki. Prefetch odpalany dotknięciem karty ma własne okno, w którym odmawia ponownego pobrania tej samej trasy, a CDN ma własny czas życia odpowiedzi.
Wymagana kolejność: okno dedupu prefetchu ≥ Router Cache ≤ CDN TTL. Przed naprawą było odwrotnie, prefetch trzymał 5 minut, Router Cache tylko 30 sekund, więc powrót na produkt między 30. sekundą a 5. minutą trafiał na wygasły wpis, a prefetch odmawiał go odbudować.
Przez pewien czas było odwrotnie. Dedup prefetchu trzymał pięć minut, Router Cache tylko trzydzieści sekund. Każdy powrót na produkt oglądany między trzydziestą sekundą a piątą minutą trafiał na wpis, który już wygasł, podczas gdy prefetch nadal odmawiał go odbudować. Taki powrót szedł więc pełną ścieżką sieciową i odpalał pełnoekranową nakładkę ładowania, mimo że użytkownik oglądał ten sam produkt chwilę wcześniej.
Naprawą było wydłużenie Router Cache do trzystu sekund, nie skrócenie okna prefetchu. Prefetch odpala się przy każdym dotknięciu karty produktu, także podczas zwykłego scrollowania listingu na telefonie, więc krótsze okno dedupu zamieniłoby przewijanie w serię pobrań tej samej trasy. W naszym przypadku wydłużenie Router Cache z 30 do 300 sekund nie pogarszało przy tym efektywnej świeżości danych: ten sam payload przychodzi z CDN z sześciogodzinnym czasem życia, więc w chwili trafienia do Router Cache bywa już wielogodzinny. Zaciskanie ostatniego ogniwa łańcucha nic tu nie dawało, skoro wcześniejsze ogniwo było kilkadziesiąt razy luźniejsze, kupowało tylko dodatkowy round trip po tę samą, równie nieświeżą odpowiedź.
Koszyk i konto są w tym wszystkim bezpieczne niezależnie od tych liczb: ich mutacje idą przez Server Actions z revalidateTag, co w używanej przez nas wersji Next.js czyści cały kliencki Router Cache za jednym razem.
Sama nakładka ładowania miała jeszcze osobny problem, niezależny od cache'u. Pojawiała się po progu pięćdziesięciu milisekund, dobranym pod budżet stu milisekund na feedback interakcji, przy założeniu że nawigacja jest albo natychmiastowa, albo wolna. Pomiar produkcyjny pokazał, że jest ani jedno, ani drugie: round trip dla PDP trwał od dwustu czterdziestu do dwustu siedemdziesięciu pięciu milisekund. Nakładka pojawiała się więc na pięćdziesiąt milisekund i znikała jakieś dwieście milisekund później, pełnoekranowy skeleton migający przez ułamek sekundy, co czyta się jako usterka interfejsu, a nie postęp.
Rozwiązaniem były dwie osobne liczby zamiast jednej. Próg pokazania nakładki podniesiono do czterystu milisekund, wyraźnie powyżej typowej nawigacji, a nie tuż nad progiem natychmiastowości. Obok niego dodano minimalny czas widoczności, również czterysta milisekund: bez niego każda nawigacja lądująca tuż za progiem produkowałaby dokładnie to samo mignięcie, któremu próg miał zapobiegać. Cienki pasek postępu nad kategoriami został przy pięćdziesięciu milisekundach, bo nie wymienia całego layoutu i nic nie zasłania, więc nie ma tam nic do mignięcia. Ta sama liczba pasowałaby więc do jednego mechanizmu i szkodziła drugiemu: kiedy pokazać feedback i jak nie dopuścić do jego mignięcia to dwa różne pytania, i jedna stała nie odpowiada na oba naraz.
Router Cache, okno prefetchu, CDN i progi samej nakładki to pięć różnych liczb w pięciu różnych miejscach kodu, i żadna z nich nie jest osobnym detalem konfiguracji. Są ze sobą powiązane, więc zmiana jednej bez sprawdzenia pozostałych umie odtworzyć dokładnie ten sam błąd w zupełnie innym miejscu.
Ostatecznie trzeba mierzyć prawdziwe requesty
Jednym z ważniejszych elementów całego śledztwa, oprócz osobistych testów manualnych, była telemetria. Samo policzenie wywołań funkcji nie wystarczało - Data Cache może odtworzyć odpowiedź wraz z nagłówkami, więc nawet patrzenie na X-Cache potrafi prowadzić do błędnych wniosków.
Do pomiarów dodłem caller, fetchInvocations oraz generowany przez backend X-Origin-Response-Id. Dzięki temu można odróżnić:
- liczbę wywołań wrappera,
- liczbę odczytów z cache,
- liczbę rzeczywistych requestów do Medusy.
Po ostatnim przebiegu rekomendacji PDP były 32 wywołania fetch, ale tylko 20 unikalnych odpowiedzi backendu. 12 wywołań zostało więc obsłużonych bez nowego origin requestu, czyli 37,5 procent. Dla ogólnego listProductsLean cztery wywołania wygenerowały tylko jeden unikalny request origin.
Ostatni pomiar promocji
Po zmianach promotions/batch wyglądał już znacznie lepiej. W testowej sesji:
OPTIONS /store/products/promotions/batch: 0,- bezpośrednie GET-y przeglądarka → Medusa: 0,
- serwerowe GET-y Next → Medusa: 39,
- wszystkie requesty korzystały z nowej wersji cache.
Mediana odpowiedzi wyniosła około 40,6 ms. 26 z 39 requestów zakończyło się poniżej 50 ms, 34 z 39 poniżej 100 ms, a 37 z 39 poniżej 300 ms.
Dwa requesty były wolniejsze. Jeden dotyczył homepage i batcha 77 produktów, więc nie był typowym przypadkiem PDP, a drugi był pojedynczym outlierem związanym przede wszystkim z wolniejszym odczytem produktów i zapisem do Redis. Próg 300 ms został więc dotrzymany w 94,9 procent próbek.
W dynamicznym marketplace część kluczy będzie naturalnie unikalna, więc samo patrzenie na cache HIT ratio nie wystarcza. Czasami ważniejsze od zwiększania hit rate jest sprawienie, żeby MISS był tani.
Jak dziś patrzę na caching
Kiedyś cache widziałem mniej więcej tak:
request
↓
cache
↙ ↘
HIT MISS
↓
backendW dużej aplikacji webowej bardziej przypomina to graf zależności. Request może przejść przez Router Cache, CDN i Data Cache, a później poszczególne fragmenty strony pobierają kolejne dane niezależnie od siebie - backend ma przecież własny cache. Część odpowiedzi zależy od regionu, część od sprzedawcy, część od produktu, a część od promocji, i każda z nich może wymagać innego czasu życia: od wielu godzin po zaledwie kilkadziesiąt sekund.
Do tego dochodzą invalidacje eventowe, TTL, streaming, SSR, RSC, boty i nawigacja po stronie klienta.
Samo zapisanie czegoś do Redis jest przy tym stosunkowo prostą częścią. Najwięcej pracy wymaga określenie:
- co jest źródłem prawdy,
- gdzie istnieją kopie danych,
- jak długo każda kopia może żyć,
- jakie zdarzenia ją unieważniają,
- czy kilka ekranów korzysta z tych samych zasad świeżości,
- czy cache jednej warstwy nie omija albo nie wyłącza innej,
- czy crawler otrzymuje pełny HTML,
- czy użytkownik może szybko przechodzić między stronami,
- ile origin requestów naprawdę wykonuje aplikacja.
Wnioski
Nie wszystko wcześniej było źle skonfigurowane. Cache proponowanych produktów powstał dopiero po dodaniu tego komponentu, a część późniejszych zmian w nim była wynikiem eksperymentów, które nie zawsze okazywały się trafione ale dały cenną wiedzę. Wraz z rozwojem PDP i rozbudową kolejnych warstw cache rosła też złożoność całego łańcucha, aż wymagała dokładnej analizy i decyzji architektonicznych, a czasem i biznesowych.
Zmiany zostały sprawdzone testami obciążeniowymi k6 w dwóch wariantach: testem HTTP po pełnej sitemapie oraz osobnym testem przeglądarkowym mierzonym przez Grafana Cloud. Ten drugi ma na obecnym koncie limit 10 Browser VU, więc pełny profil 100 VU odpalał się wyłącznie w wariancie HTTP, gdzie obciążenie backendu podczas testu utrzymywało się na poziomie do 1% maksymalnego planu.
Warto rozdzielić dwa poziomy pomiaru.
Z tego samego spana czas samej bazy danych to p99=110 ms (78% spana, 18% pełnego E2E), przy stałej liczbie 22 zapytań SQL per request.
Różnica między p99 E2E (608 ms) a p99 backendu (141 ms) to 467 ms, 77% wartości k6. To czas poza handlerem Medusy: CDN, edge, Next.js SSR, network RTT, transfer HTML.
Warto zauważyć, że p50 jest wyższe w OTel (93 ms) niż w k6 (84 ms), to dwie różne populacje, nie ten sam pomiar z innego miejsca. k6 p50 to cache hit na CDN (backend nie dotknięty), OTel p50 to cold miss w backendzie. Cache ratio dla PDP wynosi blisko 74%, k6 wykonał około 4220 iteracji PDP, ale OTel zarejestrował tylko 1085 spanów pdp.primary, co oznacza że około 3135 requestów zostało obsłużonych z cache CDN/edge i nigdy nie dotarło do backendu.
Dla strony głównej cache ratio jest jeszcze wyższe, OTel zarejestrował count=1 przy około 1580 iteracjach home w k6, co daje blisko 99,9% cache hit. A co najważniejsze, ustabilizowało się zachowanie skeletonów, w tym to, co i kiedy widzą boty, oraz liczba requestów uderzających do backendu. Data Cache HIT pod obciążeniem to 91% (Vercel dashboard). Backend pod tym samym obciążeniem odpowiadał ze średnim czasem 45 ms (OTel global avg, wszystkie 2992 trace'y) i zero błędów.


