vyvchy
    Теми розділу

    03 · Веб і мережі для AQA

    Кешування

    Зміст

    Кеш (cache) — це проміжне сховище, у якому браузер, проксі чи сервер тримають уже отримані відповіді, щоб наступного разу не тягнути їх мережею повторно. Ідея проста й стара, як сам веб: якщо файл app.js не змінився з учора, немає сенсу качати ті самі 300 кілобайтів удруге — розумніше взяти їх з диска за мілісекунди.

    Для AQA кешування — це не абстрактна оптимізація продуктивності, а один із найпідступніших джерел флаку (flakiness) і фальшивих результатів. Тест «бачить» стару версію сторінки, локатор (locator) не знаходить нову кнопку, автотест зелений на застарілому бандлі — і ти годину шукаєш баг там, де його немає. Тому механіку кешу треба розуміти не гірше, ніж власне застосунок.

    Що таке кеш браузера

    Коли браузер завантажує сторінку, він робить десятки запитів: HTML, CSS, JavaScript, шрифти, картинки, дані з API. Кожну відповідь потенційно можна зберегти локально й перевикористати. Саме цим займається HTTP-кеш браузера (HTTP cache) — сховище, яке живе між мережею та рушієм рендерингу.

    Технічно браузер тримає кілька рівнів:

    РівеньДе живеТермін життяЩо туди потрапляє
    Memory cacheОперативна памʼятьПоки вкладка відкритаРесурси поточної сесії, найшвидший доступ
    Disk cacheДискПереживає перезапуск браузераHTTP-відповіді за правилами кешування
    Service Worker CacheДиск (Cache API)Керується кодом сторінкиТе, що явно поклав розробник

    Ключова думка: браузер сам не вирішує, що і як довго зберігати. Правила диктує сервер через заголовки відповіді (response headers). Це важливо для AQA: поведінка кешу — частина контракту між бекендом і фронтендом, її можна й потрібно тестувати. Сучасні правила HTTP-кешування описані в RFC 9111, який замінив старіший RFC 7234.

    Розрізняють два принципові стани кешованої відповіді:

    • Свіжа (fresh) — термін придатності ще не минув, браузер бере копію з кешу без жодного мережевого запиту.
    • Застаріла (stale) — термін минув, і перш ніж використати відповідь, браузер має перепитати сервер. Це називають ревалідацією (revalidation).

    Уся подальша тема — про те, як сервер керує цими двома станами.

    Ні

    Так

    Так

    Ні

    Так

    Ні

    Потрібен ресурс

    Є копія в кеші?

    Запит у мережу

    Копія свіжа?

    Віддати з кешу
    без мережі

    Ревалідація на сервері

    304 Not Modified?

    Ні

    Так

    Так

    Ні

    Так

    Ні

    Потрібен ресурс

    Є копія в кеші?

    Запит у мережу

    Копія свіжа?

    Віддати з кешу
    без мережі

    Ревалідація на сервері

    304 Not Modified?

    Заголовок Cache-Control

    Cache-Control — головний інструмент керування кешем у сучасному HTTP. Це заголовок, який сервер (а іноді й клієнт) надсилає з набором директив через кому.

    HTTP/1.1 200 OK
    Content-Type: application/javascript
    Cache-Control: public, max-age=3600

    Найважливіші директиви:

    ДирективаЗначення
    max-age=<секунди>Скільки секунд відповідь вважається свіжою від моменту отримання
    no-cacheЗберігати можна, але перед кожним використанням треба ревалідувати на сервері
    no-storeНе зберігати взагалі нічого
    publicДозволено кешувати спільним кешам (CDN, проксі)
    privateКешувати може лише браузер користувача, спільним кешам — заборонено
    must-revalidateКоли відповідь застаріла, використати її без перевірки не можна
    s-maxage=<секунди>Як max-age, але лише для спільних кешів; має пріоритет над max-age для них
    immutableФайл не зміниться за час свіжості, тож не ревалідувати навіть при перезавантаженні

    Директива max-age задає відносний час у секундах, на відміну від старішого заголовка Expires, який задає абсолютну дату. Коли присутні обидва, Cache-Control: max-age перемагає Expires.

    Приклад типової пари правил на реальному сайті:

    # HTML-документ: хочемо завжди свіжий, але дозволяємо швидку перевірку
    Cache-Control: no-cache
    
    # Захешований бандл: не змінюється ніколи, кешуємо на рік
    Cache-Control: public, max-age=31536000, immutable

    (31536000 секунд — це рівно 365 днів.)

    no-cache проти no-store

    Це місце, де плутаються навіть досвідчені інженери, бо назви оманливі. no-cache не означає «не кешувати».

    • no-store — буквально: не клади цю відповідь ні в памʼять, ні на диск. Кожен запит іде мережею з нуля. Так позначають чутливі дані: сторінки з банківськими виписками, відповіді з персональними даними, одноразові токени.
    • no-cache — зберігати можна, але перед тим, як віддати збережену копію, браузер зобовʼязаний спитати сервер: «ця версія ще актуальна?» Якщо сервер відповідає 304 Not Modified — беремо з кешу; якщо 200 з новим тілом — оновлюємо. Тобто no-cache економить трафік (тіло не переказується, якщо нічого не змінилось), але не економить сам мережевий похід. За змістом no-cache приблизно відповідає max-age=0, must-revalidate.
    Аспектno-cacheno-store
    Копія лягає в кешТакНі
    Мережевий запит щоразуТак (ревалідація)Так (повне завантаження)
    Тіло переказується щоразуНі, якщо 304Так, завжди
    Типове застосуванняHTML, який має бути свіжимЧутливі/приватні дані

    Практична порада для AQA: якщо у вашому тесті сторінка щоразу тягнеться повністю й тест повільний — гляньте в мережеву панель, чи не стоїть на всьому no-store. А якщо, навпаки, тест ловить стару версію — можливо, ресурс віддається зі щедрим max-age без валідатора.

    Валідація: умовні запити

    Ревалідація працює через умовні запити (conditional requests). Браузер надсилає серверу «відбиток» тієї версії, що вже лежить у кеші, а сервер вирішує, чи вона ще актуальна. Є два механізми: за вмістом (ETag) і за датою (Last-Modified).

    ETag і 304

    ETag (entity tag) — це непрозорий ідентифікатор конкретної версії ресурсу, який сервер віддає у відповіді. Часто це хеш вмісту, але сервер може рахувати його й з метаданих файлу — розміру та часу зміни (саме так за замовчуванням роблять Apache й nginx). Клієнту байдуже, як саме, — головне, що при зміні файлу ETag змінюється.

    Перший запит і відповідь:

    GET /app.js HTTP/1.1
    Host: example.com
    HTTP/1.1 200 OK
    Content-Type: application/javascript
    Cache-Control: max-age=3600
    ETag: "33a64df5c1e9"
    
    (тіло файлу)

    Коли свіжість минула, браузер не качає файл наосліп, а надсилає збережений ETag у заголовку If-None-Match:

    GET /app.js HTTP/1.1
    Host: example.com
    If-None-Match: "33a64df5c1e9"

    Якщо на сервері вміст той самий, він відповідає без тіла:

    HTTP/1.1 304 Not Modified
    ETag: "33a64df5c1e9"
    Cache-Control: max-age=3600

    304 Not Modified каже браузеру: «бери своє з кешу, воно ще годиться». Тіло не переказується — економимо трафік, але оновлюємо термін свіжості. Якщо ж файл змінився, сервер повертає звичайний 200 з новим тілом і новим ETag.

    СерверБраузерСерверБраузерСвіжість минулаБеремо копію з кешуGET /app.js200 OK, ETag: "33a64df5c1e9"GET /app.js, If-None-Match: "33a64df5c1e9"304 Not Modified (без тіла)СерверБраузерСерверБраузерСвіжість минулаБеремо копію з кешуGET /app.js200 OK, ETag: "33a64df5c1e9"GET /app.js, If-None-Match: "33a64df5c1e9"304 Not Modified (без тіла)

    Розрізняють сильні (strong) й слабкі (weak) ETag. Слабкий позначають префіксом W/ (наприклад, W/"33a64df5") — він гарантує лише семантичну еквівалентність, а не побайтову ідентичність, тож не годиться для докачувань частинами (range requests).

    Last-Modified / If-Modified-Since

    Старіший і простіший механізм — за датою останньої зміни. Сервер віддає заголовок Last-Modified:

    HTTP/1.1 200 OK
    Cache-Control: max-age=3600
    Last-Modified: Tue, 01 Jul 2026 10:00:00 GMT
    
    (тіло)

    При ревалідації браузер надсилає цю дату в If-Modified-Since:

    GET /app.js HTTP/1.1
    Host: example.com
    If-Modified-Since: Tue, 01 Jul 2026 10:00:00 GMT

    Сервер повертає 304, якщо з того часу файл не змінювався, або 200 з новим вмістом, якщо змінювався.

    На практиці браузер часто надсилає обидва валідатори одночасно (If-None-Match та If-Modified-Since), якщо в кеші є обидва заголовки. Коли присутній ETag, він має пріоритет: за специфікацією умовних запитів сервер, обчисливши If-None-Match, If-Modified-Since уже не враховує.

    ETag / If-None-MatchLast-Modified / If-Modified-Since
    Основа порівнянняВміст (зазвичай хеш)Дата зміни
    ТочністьДо байтаДо 1 секунди
    Ловить зміну «туди й назад»Так (хеш повернеться)Ні, якщо дата не змінилась
    Ідентичні файли на різних нодахХеші можуть різнитисяДата стабільніша
    Пріоритет при наявності обохВищийНижчий

    Секундна точність Last-Modified — реальна пастка: якщо файл змінюють двічі за одну секунду, друга зміна може «загубитися». ETag від цього застрахований, тому для критичних ресурсів надійніший саме він.

    Кеш поза браузером: CDN

    Кеш браузера — лише останній рубіж. Між користувачем і вашим сервером зазвичай стоїть CDN (Content Delivery Network) — мережа проміжних серверів (їх називають edge- або крайовими вузлами), розкиданих географічно ближче до користувачів. CDN кешує відповіді, і більшість запитів на статику до вашого сервера (origin) взагалі не доходить.

    CDN — це спільний кеш (shared cache), тому він реагує на директиви, орієнтовані на спільні кеші:

    • public дозволяє йому кешувати;
    • private забороняє (це лише для браузера конкретного користувача);
    • s-maxage задає окремий термін свіжості саме для спільних кешів, який перекриває max-age.

    Два заголовки, які варто знати AQA під час дебагу через мережеву панель:

    • Age — скільки секунд відповідь уже пролежала у спільному кеші (точніше — оцінка кешу, скільки часу минуло від моменту, коли origin згенерував чи ревалідував відповідь). Якщо Age близький до max-age, копія скоро застаріє.
    • Vary — визначає, за якими заголовками запиту рахується ключ кешу. Наприклад, Vary: Accept-Encoding означає, що gzip- і brotli-версії кешуються окремо. Хибний або занадто широкий Vary (як-от Vary: User-Agent) роздуває кеш і знижує його ефективність.

    Окремі провайдери додають власні діагностичні заголовки на кшталт X-Cache: HIT / MISS або CF-Cache-Status (Cloudflare). Точні назви й формат залежать від провайдера, тож перед дебагом варто звіритися з його документацією.

    Головна операційна проблема CDN — інвалідація (invalidation). Якщо ви віддали файл з s-maxage=86400, а потім задеплоїли фікс, CDN може ще добу віддавати стару версію по всьому світу. Тому CDN мають механізми примусового скидання кешу (purge). Для AQA це прямий ризик: після деплою вашого білда edge-вузол у вашому регіоні може ще тримати попередню версію, і автотест на staging/prod легко зловить її.

    Практичний висновок: коли тест на реальному середовищі стабільно бачить старий фронтенд, а локально все свіже — підозрюйте CDN, а не свій код.

    Service Worker (оглядово)

    Service Worker — це JavaScript, який браузер виконує у фоні, окремо від сторінки, і який може перехоплювати всі мережеві запити цієї сторінки. По суті це програмований проксі на боці клієнта. Він лежить в основі PWA (Progressive Web Apps) і офлайн-режиму.

    Service worker має власне сховище — Cache API, яке ніяк не повʼязане з HTTP-кешем браузера. Це критично: навіть якщо ви правильно виставили no-store на бекенді, service worker може віддавати сторінці свою збережену копію, і HTTP-заголовки на це не вплинуть.

    Мінімальний приклад стратегії «спершу кеш» (cache-first):

    self.addEventListener('fetch', (event) => {
      event.respondWith(
        caches.match(event.request).then((cached) => {
          return cached || fetch(event.request);
        })
      );
    });

    Тут будь-який запит спершу шукається в Cache API, і лише за відсутності копії йде в мережу. Поширені стратегії:

    • cache-first — швидко, але легко «залипає» на старій версії;
    • network-first — свіжо, але повільніше й без офлайну;
    • stale-while-revalidate — віддати старе миттєво, а нове підтягнути у фоні.

    Життєвий цикл service worker (install → activate, а вже потім — обробка подій fetch) додає ще один шар підступності: новий воркер за замовчуванням не активується одразу, а переходить у стан очікування (waiting) й бере контроль лише після закриття всіх вкладок сайту (якщо код явно не викликав skipWaiting()), тож користувач і тест можуть працювати зі старою логікою кешування навіть після деплою.

    Для AQA висновок простий: service worker — окремий, найупертіший рівень кешу. Якщо тест бачить примару старої версії, а HTTP-заголовки правильні, перша підозра — саме service worker.

    Чому тест бачить стару версію сторінки і як із цим боротися

    Зберемо все докупи з погляду автотестів. «Стара версія» може прийти з чотирьох різних місць, і лікуються вони по-різному:

    1. HTTP-кеш браузера — ресурс віддано зі щедрим max-age, свіжість ще не минула.
    2. Service Worker Cache — воркер віддає свою копію в обхід мережі.
    3. CDN / проміжний проксі — edge-вузол тримає застарілу відповідь.
    4. Кеш всередині самого застосунку — стан у памʼяті, localStorage, кеш GraphQL/HTTP-клієнта. Формально це не мережеве кешування, але симптом той самий: стара картина на екрані.

    Чому це особливо болить у тестах:

    • Локатор не знаходить елемент, бо завантажився старий бандл без нової фічі — виглядає як баг застосунку, хоча це кеш.
    • Тест «іноді» падає: на одному прогоні браузер узяв свіже, на іншому — з кешу. Класичний флак.
    • Тест зелений на застарілій версії й пропускає реальну регресію.
    • Стан протікає між тестами через localStorage чи service worker — порушується ізоляція.

    Як з цим борються:

    Ізоляція браузерного профілю. Найнадійніший спосіб — не давати кешу накопичуватися між тестами. У Playwright кожен browser context — це фактично чистий профіль зі своїм сховищем; новий контекст стартує без попереднього кешу й localStorage. У Selenium аналогічно допомагає свіжий профіль на кожну сесію. Це прибирає протікання стану між тестами майже даром.

    Вимкнення HTTP-кешу на рівні протоколу. У Chromium через CDP-сесію є команда Network.setCacheDisabled, яка глушить HTTP-кеш на весь час сесії — аналог галочки «Disable cache» у DevTools. Для не-Chromium рушіїв підхід інший (свіжий профіль або перехоплення запитів).

    // Playwright, Chromium
    const client = await context.newCDPSession(page);
    await client.send('Network.setCacheDisabled', { cacheDisabled: true });

    Перехоплення мережі (network interception). Оскільки автотести й так часто мокають або спостерігають запити, перехоплення дає повний контроль: можна віддавати свій вміст, підмінювати заголовки або примусово ходити в мережу. У Playwright це page.route, у Selenium — через CDP. Побічний бонус — тест перестає залежати від реального кешу взагалі.

    Прибирання service worker. Оскільки воркер живе поза HTTP-кешем, його треба знімати окремо — інакше він переживе навіть чистий контекст, якщо профіль спільний:

    // Виконати в контексті сторінки перед тестом
    navigator.serviceWorker.getRegistrations()
      .then((regs) => regs.forEach((r) => r.unregister()));

    Для Playwright є ще опція serviceWorkers: 'block' на рівні контексту, яка не дає воркерам реєструватися взагалі — часто це найчистіший варіант для тестів, яким PWA-логіка не потрібна. Варто памʼятати, що service worker підтримують лише браузери на Chromium, тож ця опція найпомітніша саме там.

    Синхронізація з деплоєм. Проти CDN код у тесті безсилий — тут допомагає процес: очікування (wait), поки CDN віддасть новий білд, примусовий purge після деплою або перевірка версії застосунку перед стартом сюїта. Не варто маскувати проблему кешу штучними затримками — це перетворює її на плаваючий флак.

    Важливе застереження: агресивно вимикати кеш у всіх тестах — не завжди правильно. Якщо ви тестуєте саме поведінку кешування (наприклад, що 304 справді повертається, або що PWA працює офлайн), кеш має бути ввімкнений — інакше ви тестуєте не той застосунок, який поїде в прод. Розділяйте функціональні тести кешу й тести, де кеш — лише перешкода.

    Cache busting (версіонування ресурсів)

    Ми хочемо суперечливого: щоб браузер кешував статику максимально довго (швидкість) і водночас миттєво бачив нову версію після деплою (свіжість). Розвʼязання цієї суперечності називають cache busting — «підрив кешу».

    Головний прийом — фінгерпринтинг (fingerprinting): у назву файлу вшивають хеш його вмісту.

    <script src="/assets/app.a1b2c3d4.js"></script>
    <link rel="stylesheet" href="/assets/main.9f8e7d6c.css">

    Логіка тут елегантна:

    • Файл app.a1b2c3d4.js віддається з Cache-Control: public, max-age=31536000, immutable — кешуй хоч на рік, він ніколи не зміниться.
    • Коли код правиться, змінюється й хеш: app.a1b2c3d4.jsapp.e5f6a7b8.js. Для браузера це новий URL, тож старий кеш просто перестає бути релевантним — його ніхто не запитує.
    • Стару копію можна не інвалідовувати: вона мирно доживає в кеші, ні на що не впливаючи.

    Ключ, який тримає всю схему, — HTML-документ. Його не можна кешувати надовго, бо саме він містить посилання на актуальні хешовані файли. Тому HTML віддають з no-cache (або коротким max-age), а хешовані ассети — з річним immutable. HTML щоразу ревалідується й приносить свіжі імена бандлів; браузер бачить нові URL і докачує лише те, що реально змінилося.

    Так

    Ні

    Запит HTML

    HTML: no-cache
    щоразу ревалідується

    Свіжі імена хешованих бандлів

    URL бандла новий?

    Докачати новий файл

    Взяти з кешу
    max-age immutable

    Так

    Ні

    Запит HTML

    HTML: no-cache
    щоразу ревалідується

    Свіжі імена хешованих бандлів

    URL бандла новий?

    Докачати новий файл

    Взяти з кешу
    max-age immutable

    Альтернатива — версія в query-рядку:

    <script src="/assets/app.js?v=20260708"></script>

    Це працює й простіше в реалізації, але має нюанс: деякі CDN за замовчуванням ігнорують query-рядок у ключі кешу або кешують такі URL інакше, тож надійність залежить від конфігурації. Фінгерпринтинг у назві файлу від цього вільний, тому вважається надійнішим підходом за замовчуванням.

    Сучасні збирачі (Vite, webpack, esbuild) роблять фінгерпринтинг автоматично: рахують content hash і переписують посилання в HTML під час білда. Розробнику зазвичай нічого не треба робити руками.

    Чим це важливо для AQA:

    • Оракул свіжості. Хеш або версію в назві бандла можна використати як маркер: якщо після деплою в HTML усе ще старе імʼя файлу — розгортання не доїхало (або застряг CDN), і тест не має сенсу. Це швидка перевірка «а ми взагалі на правильній версії?».
    • Стабільність локаторів. Хеші в назвах файлів — норма й благо. А от хеші в CSS-класах чи id (наприклад, .button_a1b2c3 від CSS-модулів) — пастка: локатор, привʼязаний до такого класу, ламатиметься на кожному білді. Прив’язуйтеся до стабільних атрибутів (data-testid, ARIA-ролі), а не до згенерованих імен.
    • Пояснення флаку. Розуміння схеми «HTML — no-cache, ассети — immutable» одразу підказує, де шукати, коли тест бачить примару: якщо HTML свіжий, а фіча стара — майже напевно винен service worker або CDN, бо самі хешовані ассети «залипнути» на неправильному вмісті не можуть за побудовою.

    Кешування — це компроміс між швидкістю й свіжістю, розписаний у HTTP-заголовках. Хто читає ці заголовки в мережевій панелі й розуміє, з якого саме рівня прийшла відповідь, той відрізняє справжній баг застосунку від застарілої копії — і не витрачає години на полювання за примарою.