Увійти Реєстрація
Блог Серії
Кар'єра
Вакансії Компанії
Навчання
Документація Співбесіди Тестування Відео
Екосистема
Пакети Ресурси Проєкти Інструменти Події
Інше
Про нас Реклама

Питання на співбесіді: Браузерні API й мережа

Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.

12 питань

Усі три зберігають дані в браузері, але з різним терміном життя, обсягом і, головне, - різною поведінкою щодо сервера.

localStorage sessionStorage cookies
живе доки не видалять доки відкрита вкладка до Expires/Max-Age або до закриття браузера
видимість усі вкладки домену лише поточна вкладка усі вкладки домену
обсяг ~5 МБ на домен ~5 МБ ~4 КБ на cookie
іде на сервер ні ні з кожним запитом
доступ з JS так так так, якщо не HttpOnly

Web Storage (localStorage, sessionStorage):

localStorage.setItem('theme', 'dark');
localStorage.getItem('theme');              // 'dark'
localStorage.removeItem('theme');

// лише рядки - об'єкти через JSON
localStorage.setItem('filters', JSON.stringify({ city: 'Київ' }));
const filters = JSON.parse(localStorage.getItem('filters') ?? '{}');
  • зберігаються лише рядки: setItem('count', 5) збереже '5';
  • API синхронний - великі записи блокують головний потік;
  • зміни в одній вкладці викликають подію storage в інших вкладках того самого домену - спосіб синхронізувати вкладки;
  • у приватному режимі, при заповненому сховищі чи заблокованих cookies доступ може кидати виняток - звертання варто обгортати в try.

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

Безпека - головне:

  • не зберігайте токени автентифікації в localStorage. Будь-який скрипт на сторінці (включно з XSS чи скомпрометованою бібліотекою) прочитає їх і відправить зловмиснику;
  • сесійна cookie з прапорцем HttpOnly недоступна JavaScript взагалі - XSS не зможе її вкрасти. Так працює автентифікація Laravel (і Sanctum для SPA на тому ж домені);
  • Secure - лише через HTTPS, SameSite - захист від підробки запитів з інших сайтів.

Що куди класти:

  • тема, згорнуті панелі, чернетка форми - localStorage;
  • стан майстра, що не повинен «перетікати» в іншу вкладку, - sessionStorage;
  • сесія, автентифікація - HttpOnly-cookie від сервера;
  • великі обсяги чи бінарні дані - IndexedDB (асинхронна база в браузері).

Докладніше в документації: Window.localStorage

FormData збирає дані форми у формат, який браузер відправляє так само, як звичайна HTML-форма, - зокрема з файлами.

const form = document.querySelector('#profile-form');

form.addEventListener('submit', async (event) => {
  event.preventDefault();

  const data = new FormData(form);          // усі поля з атрибутом name, включно з файлами
  data.append('source', 'web');             // додаткове поле

  const response = await fetch(form.action, {
    method: 'POST',
    body: data,
    headers: { Accept: 'application/json' },
  });
});

Найчастіша помилка - вручну задати Content-Type:

headers: { 'Content-Type': 'multipart/form-data' }   // зламає запит

Для multipart/form-data заголовок обов'язково містить межу (boundary), що розділяє частини: multipart/form-data; boundary=----WebKitFormBoundary.... Браузер генерує її сам, якщо тіло - FormData. Вказаний вручну заголовок межі не має - сервер не зможе розібрати запит.

Корисні методи:

data.get('email');                    // значення поля
data.getAll('tags[]');                // усі значення поля з однаковою назвою
data.set('email', 'new@example.com'); // замінити
data.delete('password_confirmation');
Object.fromEntries(data);             // в об'єкт (для полів без повторів)

Файли: <input type="file" name="avatar"> потрапляє у FormData автоматично. Додати файл чи Blob вручну - data.append('avatar', file, 'avatar.png').

Особливості з Laravel:

  • PUT/PATCH з файлами: PHP історично розбирає multipart/form-data лише для POST. Для оновлення з файлами відправляють POST з підміною методу: data.append('_method', 'PUT');
  • CSRF: для форм, що відправляються fetch-ем, токен з поля _token потрапить у FormData сам, якщо в Blade-формі є @csrf;
  • Accept: application/json - щоб Laravel при помилці валідації повернув JSON 422 з полем errors, а не редирект назад;
  • масиви називають як у HTML: tags[], items[0][qty] - Laravel розбере їх у вкладені масиви.

Коли не FormData: якщо файлів немає, а API приймає JSON, простіше body: JSON.stringify(payload) з Content-Type: application/json.

Прогрес завантаження великого файлу fetch наразі не показує. Для смуги прогресу використовують XMLHttpRequest (подія upload.onprogress) або бібліотеку на кшталт axios, що використовує його всередині.

Докладніше в документації: FormData

Збирати адреси склеюванням рядків - джерело помилок: забуті ? і &, неекрановані пробіли, кирилиця й спецсимволи. Для цього є вбудовані класи.

URL - розбір і побудова адреси:

const url = new URL('https://laravelukraine.com/jobs?page=2#list');

url.hostname;     // 'laravelukraine.com'
url.pathname;     // '/jobs'
url.search;       // '?page=2'
url.hash;         // '#list'

// відносна адреса відносно базової
new URL('/api/users', window.location.origin).href;

URLSearchParams - параметри запиту:

const params = new URLSearchParams(window.location.search);
params.get('page');          // '2' - завжди рядок або null
params.has('q');
params.getAll('tags[]');     // для повторюваних параметрів

params.set('page', 3);
params.append('tags[]', 'php');
params.delete('page');
params.toString();           // 'tags%5B%5D=php'

Найзручніше - разом:

const url = new URL('/api/vacancies', location.origin);
url.searchParams.set('q', 'вакансії php');
url.searchParams.set('remote', '1');

await fetch(url);   // '/api/vacancies?q=%D0%B2%D0%B0...&remote=1'

Екранування робиться автоматично: кирилиця, пробіли, & і = у значеннях не зламають адресу.

Побудова з об'єкта:

const query = new URLSearchParams({ q: 'laravel ukraine', tag: 'php&js' });
query.toString();   // 'q=laravel+ukraine&tag=php%26js'

Пробіл кодується як + (формат форм), а не %20 - сервери розуміють обидва варіанти.

Корисні прийоми:

  • оновити адресу без перезавантаження (фільтри в каталозі):
const url = new URL(location.href);
url.searchParams.set('sort', 'price');
history.replaceState(null, '', url);
  • перевірка коректності: URL.canParse(value) - без try, бо конструктор new URL на некоректному рядку кидає TypeError;
  • безпека: перед переходом за адресою з даних користувача варто перевірити url.protocol - щоб не перейти на javascript:....

Пастки:

  • params.get повертає рядок: params.get('page') + 1 дасть '21'. Перетворюйте явно: Number(params.get('page') ?? 1);
  • URLSearchParams з об'єкта з масивом ({ ids: [1, 2] }) дасть ids=1%2C2 - для масивів потрібні окремі append.

Laravel читає повторювані параметри як масив лише з дужками (tags[]=php&tags[]=js) - без них збережеться лише останнє значення.

Докладніше в документації: URLSearchParams

Браузер застосовує політику одного джерела (same-origin policy): JavaScript зі сторінки https://app.example.com не може читати відповіді від https://api.other.com. Джерело (origin) - це схема + домен + порт: http і https, example.com і api.example.com, порти 3000 і 8000 - різні джерела.

Навіщо: інакше будь-який сайт, який ви відкрили, міг би від вашого імені (з вашими cookies) читати пошту, банківський кабінет чи адмінку.

CORS (Cross-Origin Resource Sharing) - спосіб, яким сервер дозволяє певним джерелам читати свої відповіді, через заголовки:

Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true

Попередній запит (preflight). Для «непростих» запитів браузер спершу надсилає OPTIONS, щоб запитати дозволу:

  • методи, крім GET, HEAD, POST;
  • власні заголовки (Authorization, X-Requested-With);
  • Content-Type: application/json (простими вважаються лише формові типи й text/plain).
OPTIONS /api/orders
Origin: https://app.example.com
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: content-type, authorization

Сервер відповідає, що дозволено (Access-Control-Allow-Methods, -Headers, -Max-Age для кешування дозволу). Лише після цього йде справжній запит.

Що важливо розуміти:

  • CORS виконує браузер, а не сервер. curl, Postman і бекенд-код не мають жодних обмежень. CORS - не захист API від чужих клієнтів;
  • простий запит доходить до сервера і виконується - браузер лише не дає JavaScript прочитати відповідь. Тому CORS не замінює захист від CSRF;
  • з cookies (credentials: 'include') заборонено Access-Control-Allow-Origin: * - потрібне конкретне джерело і Access-Control-Allow-Credentials: true;
  • помилка CORS у консолі часто маскує іншу: сервер повернув 500 чи 404 без CORS-заголовків, і браузер повідомляє про CORS, а не про справжню причину. Варто дивитися запит у вкладці Network.

У Laravel CORS обробляє вбудований middleware HandleCors з налаштуваннями в config/cors.php (публікується php artisan config:publish cors):

'paths' => ['api/*', 'sanctum/csrf-cookie'],
'allowed_origins' => ['https://app.example.com'],
'supports_credentials' => true,

Як обійтися без CORS: розмістити фронтенд і API на одному джерелі (Laravel віддає і сторінки, і /api), або проксувати запити через сервер розробки Vite (server.proxy). Тоді запити same-origin, і CORS не потрібен.

Докладніше в документації: CORS

History API дає змогу змінювати адресу в рядку браузера і записи в історії переходів без завантаження нової сторінки. На ньому побудовані маршрутизатори SPA (Vue Router, React Router), wire:navigate у Livewire, фільтри каталогів, що відображаються в URL.

pushState - додати новий запис в історію:

history.pushState({ page: 2 }, '', '/jobs?page=2');

Адреса змінилася, кнопка «Назад» поверне на попередню, але запиту на сервер не було і сторінка не перезавантажилася. Що показати - вирішує ваш код.

replaceState - замінити поточний запис, не додаючи нового. Для змін, які не повинні засмічувати історію: введення в поле пошуку, сортування, позиція прокрутки.

popstate - подія, коли користувач переходить по історії кнопками «Назад»/«Вперед»:

window.addEventListener('popstate', (event) => {
  renderPage(event.state?.page ?? 1);   // відновити стан для цього запису
});

Важливо: pushState і replaceState не викликають popstate. Подія спрацьовує лише при навігації по історії. Тому після pushState оновлювати інтерфейс треба самостійно.

Об'єкт стану (перший аргумент) зберігається разом із записом історії і повертається в event.state. Він серіалізується (як structuredClone), має обмеження розміру - туди кладуть невеликі дані (номер сторінки, id), а не весь список товарів.

Що треба зробити серверу: якщо користувач оновить сторінку чи відкриє посилання /jobs?page=2 напряму, запит піде на сервер. Сервер мусить уміти віддати цю сторінку - інакше 404. Для SPA це «fallback» на index.html, у Laravel - звичайний маршрут, що рендерить сторінку з відповідним станом.

Типові вимоги до SPA-навігації, які легко забути:

  • заголовок сторінки (document.title) - змінюється вручну;
  • прокрутка: при переході вперед - на початок, при «Назад» - туди, де користувач був. Браузерне history.scrollRestoration інколи доводиться вимикати й керувати самостійно;
  • фокус і доступність: зчитувачі екрана не знають, що «сторінка змінилася», - фокус переводять на заголовок нового вмісту;
  • аналітика: перегляд сторінки треба відправляти вручну.

Navigation API - новіший інтерфейс із єдиною подією navigate для всіх переходів (посилання, форми, кнопки історії) і перехопленням через event.intercept(). Він простіший для маршрутизаторів, але підтримується ще не всіма браузерами.

Докладніше в документації: History API

Обидві технології дають змогу серверу надсилати дані в браузер без запиту від клієнта. Різниця - у напрямку, протоколі й складності.

Server-Sent Events (SSE) - однобічний потік від сервера до клієнта через звичайне HTTP-з'єднання:

const source = new EventSource('/orders/42/status');

source.addEventListener('status', (event) => {
  const { status } = JSON.parse(event.data);
  updateBadge(status);
});

source.onerror = () => { /* браузер сам перепідключиться */ };

Сервер відповідає з Content-Type: text/event-stream і пише рядки:

event: status
id: 17
data: {"status":"shipped"}

  • автоматичне перепідключення вбудоване; при перепідключенні браузер надсилає Last-Event-ID, і сервер може дослати пропущене;
  • працює через звичайний HTTP: проксі, балансувальники, автентифікація cookies - як у будь-якого запиту;
  • лише текст, лише від сервера;
  • на HTTP/1.1 браузер обмежує ~6 з'єднань на домен - кілька вкладок з SSE можуть їх вичерпати. На HTTP/2 і HTTP/3 обмеження немає.

WebSocket - двобічне постійне з'єднання з власним протоколом:

const socket = new WebSocket('wss://example.com/ws');
socket.onmessage = (event) => render(JSON.parse(event.data));
socket.send(JSON.stringify({ type: 'typing', chat: 7 }));
  • обидві сторони надсилають повідомлення будь-коли, з мінімальними накладними витратами;
  • текст і бінарні дані;
  • перепідключення, «серцебиття», підписки на канали - пишете самі або беруть бібліотеку;
  • потрібен сервер, що тримає тисячі постійних з'єднань, і налаштовані проксі.

Коли що:

Задача Вибір
статус замовлення, сповіщення, прогрес задачі, стрічка подій SSE
потокова відповідь LLM («друкується» по словах) SSE / потоковий fetch
чат, спільне редагування, ігри, присутність користувачів WebSocket
оновлення раз на хвилину звичайне опитування - найпростіше

У Laravel-екосистемі:

  • WebSocket - Laravel Reverb (свій сервер) чи Pusher, на клієнті Laravel Echo; трансляція подій через ShouldBroadcast;
  • SSE - response()->eventStream() у контролері; Livewire має wire:stream для потокового оновлення частини компонента.

Головна перевага SSE - простота: не потрібен окремий сервер і протокол, це звичайний маршрут Laravel. Але кожен відкритий потік тримає процес PHP-FPM зайнятим - під багато одночасних клієнтів потрібні Octane чи окремий сервіс.

Докладніше в документації: Server-Sent Events

Опція credentials визначає, чи надсилає fetch cookies і чи приймає їх з відповіді:

  • 'same-origin' (за замовчуванням) - лише для запитів на той самий домен;
  • 'include' - і для інших доменів (потрібна відповідна CORS-конфігурація сервера);
  • 'omit' - ніколи.

Для звичайного Laravel-застосунку, де сторінки й запити з одного домену, сесійна cookie надсилається автоматично.

CSRF-захист у Laravel перевіряє, що запит, який змінює дані (POST, PUT, PATCH, DELETE), справді надіслано з вашого сайту. Способи передати токен з JavaScript:

1. Заголовок X-CSRF-TOKEN з мета-тегу:

<meta name="csrf-token" content="{{ csrf_token() }}">
await fetch('/orders', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    Accept: 'application/json',
    'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
  },
  body: JSON.stringify(order),
});

2. Заголовок X-XSRF-TOKEN з cookie. Laravel ставить cookie XSRF-TOKEN (зашифрований токен, доступний JavaScript). Axios автоматично читає її і додає заголовок для запитів на той самий домен - тому в Laravel-проєктах з axios про CSRF зазвичай не думають. З fetch це треба робити самому.

3. Поле _token у тілі запиту - якщо відправляється FormData з форми, де є @csrf.

Laravel 13 перевіряє ще й джерело запиту. Middleware PreventRequestForgery пропускає запит без токена, якщо браузер надіслав заголовок Sec-Fetch-Site: same-origin. Сучасні браузери додають його самі, а підробити з іншого сайту неможливо. Токен лишається запасним механізмом для старих браузерів і запитів без цього заголовка.

Помилка 419 (Page Expired / CSRF token mismatch) - токен не передано, він застарів (сесія закінчилася, поки сторінка була відкрита) або сесійна cookie не дійшла. Для довго відкритих сторінок варто обробляти 419: оновити токен чи запропонувати перезавантажити сторінку.

SPA на іншому піддомені зі Sanctum:

await fetch('https://api.example.com/sanctum/csrf-cookie', { credentials: 'include' });

await fetch('https://api.example.com/api/orders', {
  method: 'POST',
  credentials: 'include',
  headers: { 'X-XSRF-TOKEN': decodeURIComponent(getCookie('XSRF-TOKEN')), Accept: 'application/json' },
  body: JSON.stringify(order),
});

Потрібні також supports_credentials у config/cors.php, домен SPA у SANCTUM_STATEFUL_DOMAINS і спільний домен сесійних cookies.

Без Accept: application/json Laravel на помилку валідації відповість редиректом, а не JSON з помилками.

Докладніше в документації: fetch()

Intl - вбудований у браузер і Node.js API інтернаціоналізації. Він знає правила форматування для сотень мов, тож не потрібно писати власні функції чи підключати важкі бібліотеки.

Числа й гроші:

new Intl.NumberFormat('uk-UA', { style: 'currency', currency: 'UAH' }).format(1234567.891);
// "1 234 567,89 ₴"

new Intl.NumberFormat('uk-UA', { notation: 'compact' }).format(1500000);
// "1,5 млн"

new Intl.NumberFormat('uk-UA', { style: 'percent', maximumFractionDigits: 1 }).format(0.1234);

Українські правила - кома в дробах і нерозривний пробіл між розрядами - застосовуються автоматично.

Дати:

new Intl.DateTimeFormat('uk-UA', { dateStyle: 'long', timeZone: 'Europe/Kyiv' })
  .format(new Date('2026-10-04T10:00:00Z'));
// "4 жовтня 2026 р."

timeZone важливий: без нього дата форматується в поясі браузера користувача.

Відносний час:

const rtf = new Intl.RelativeTimeFormat('uk', { numeric: 'auto' });
rtf.format(-1, 'day');      // "учора"
rtf.format(3, 'hour');      // "через 3 години"
rtf.format(-5, 'minute');   // "5 хвилин тому"

Множина - найскладніше для української: три форми плюс дроби.

const rules = new Intl.PluralRules('uk');
rules.select(1);    // "one"   -> 1 коментар, 21 коментар
rules.select(2);    // "few"   -> 2 коментарі, 22 коментарі
rules.select(5);    // "many"  -> 5 коментарів, 11 коментарів
rules.select(1.5);  // "other" -> 1,5 коментаря

const forms = { one: 'коментар', few: 'коментарі', many: 'коментарів', other: 'коментаря' };
const label = (n) => `${n} ${forms[rules.select(n)]}`;

Ручне правило «якщо 1 - однина, інакше множина» дає «21 коментарів» і «2 коментарів».

Сортування рядків:

['їжак', 'ґанок', 'яблуко', 'єнот'].sort();
// ["яблуко", "єнот", "їжак", "ґанок"] - за кодами символів

['їжак', 'ґанок', 'яблуко', 'єнот'].sort(new Intl.Collator('uk').compare);
// ["ґанок", "єнот", "їжак", "яблуко"] - за абеткою

Продуктивність: створення форматера відносно дороге - для списків створюйте його один раз і використовуйте format() повторно.

Узгодженість з бекендом: PHP має ті самі правила ICU через розширення intl (NumberFormatter, Number::currency() у Laravel), тож формати на сервері й у браузері збігаються.

Докладніше в документації: MDN: Intl

Web Worker - JavaScript, що виконується в окремому потоці. Важкі обчислення у воркері не блокують головний потік, тож інтерфейс лишається чутливим до кліків і прокрутки.

// main.js - синтаксис, який розуміє Vite
const worker = new Worker(new URL('./csv-worker.js', import.meta.url), { type: 'module' });

worker.postMessage({ file });
worker.onmessage = (event) => renderTable(event.data.rows);
worker.onerror = (event) => showError(event.message);
// csv-worker.js
self.onmessage = async (event) => {
  const text = await event.data.file.text();
  const rows = parseCsv(text);   // секунди роботи - але не в головному потоці
  self.postMessage({ rows });
};

Обмеження воркерів:

  • немає доступу до DOM, window, document. Лише обчислення й мережа (fetch, WebSocket), IndexedDB, таймери;
  • спілкування лише повідомленнями. Дані копіюються (алгоритм structured clone): передати мегабайтний масив туди й назад - теж робота. Функції й класи з методами не передаються;
  • запуск коштує - завантаження скрипта й ініціалізація. Для обчислень на кілька мілісекунд воркер повільніший за звичайний виклик.

Передача без копіювання - transferable objects:

const buffer = new ArrayBuffer(50_000_000);
worker.postMessage(buffer, [buffer]);   // власність переходить до воркера
buffer.byteLength;                       // 0 - у головному потоці буфер більше недоступний

Працює для ArrayBuffer, MessagePort, ImageBitmap, OffscreenCanvas, потоків.

Коли воркер доречний:

  • розбір великих файлів (CSV, Excel, JSON на десятки мегабайтів);
  • обробка зображень, стиснення перед завантаженням на сервер;
  • криптографія, хешування файлів;
  • пошук і фільтрація по великому локальному набору даних;
  • рендер у OffscreenCanvas (графіки, візуалізації).

Коли не допоможе:

  • повільний рендер великого списку - проблема в DOM, а DOM недоступний воркеру. Тут допомагає віртуалізація;
  • очікування мережі - воно й так не блокує потік.

Різновиди:

  • dedicated worker - належить одній сторінці (приклад вище);
  • SharedWorker - один на кілька вкладок того самого сайту: одне спільне WebSocket-з'єднання на всі вкладки;
  • Service Worker - зовсім інша роль: проксі між сторінкою й мережею.

Зручність: бібліотека Comlink перетворює обмін повідомленнями на виклик звичайних async-функцій, приховуючи postMessage.

Докладніше в документації: Web Workers API

Service Worker - скрипт, який браузер запускає у фоні між сторінкою й мережею. Він перехоплює запити сторінок свого сайту й вирішує, що відповісти: з мережі, з кешу чи згенерувати відповідь сам. На ньому будуються офлайн-режим, PWA, push-сповіщення, фонова синхронізація.

// реєстрація на сторінці
if ('serviceWorker' in navigator) {
  navigator.serviceWorker.register('/sw.js');
}
// sw.js
const CACHE = 'static-v3';

self.addEventListener('install', (event) => {
  event.waitUntil(caches.open(CACHE).then((cache) => cache.addAll(['/offline.html'])));
});

self.addEventListener('fetch', (event) => {
  if (event.request.mode === 'navigate') {
    event.respondWith(fetch(event.request).catch(() => caches.match('/offline.html')));
  }
});

Життєвий цикл:

  1. install - підготовка: завантажити й закешувати потрібні файли;
  2. waiting - новий воркер чекає, доки закриються всі вкладки зі старим. Це захищає від ситуації, коли одна сторінка працює зі старими файлами, а запити обслуговує новий воркер;
  3. activate - новий воркер бере керування; тут видаляють старі кеші;
  4. fetch - перехоплення запитів.

self.skipWaiting() і clients.claim() прискорюють перемикання, але ціною можливої невідповідності версій.

Стратегії кешування:

  • cache first - для хешованих статичних файлів, що не змінюються;
  • network first - для HTML і API: свіже, а кеш лише як запас при відсутності мережі;
  • stale-while-revalidate - миттєво з кешу, паралельно оновити кеш з мережі.

Бібліотека Workbox реалізує ці стратегії й генерує воркер під час збирання (у Vite - vite-plugin-pwa).

Чим небезпечна помилка:

  • воркер живе довше за сторінку. Він встановлений у браузері користувача й перехоплює запити навіть після того, як ви виправили сайт. Якщо воркер помилково кешує HTML «назавжди», користувачі бачитимуть стару версію сайту, і деплой цього не виправить;
  • оновлення самого воркера - браузер перевіряє, чи змінився sw.js, при навігації на сайт (за замовчуванням в обхід HTTP-кешу, і щонайменше раз на 24 години). URL sw.js має лишатися незмінним: якщо перейменувати файл, старий воркер про новий так і не дізнається;
  • план відступу: заздалегідь мати «вимикач» - новий sw.js, що видаляє кеші й знімає реєстрацію (self.registration.unregister()).

Обмеження: лише HTTPS (виняток - localhost), без доступу до DOM, область дії (scope) - каталог, з якого віддано скрипт, і нижче.

Для звичайного сайту (не PWA) Service Worker часто зайвий: HTTP-кешування з хешованими іменами файлів дає більшу частину вигоди без ризиків.

Докладніше в документації: Service Worker API

Дві суперечливі вимоги: статичні файли мають кешуватися якомога довше (швидкість, трафік), а після деплою користувачі мають одразу отримати нову версію. Розв'язок - різна політика для HTML і для ресурсів з хешем у назві.

1. Ресурси з хешем у назві (app-3f9a1c.js, app-8b2e.css - так їх називає Vite) - кешувати «назавжди»:

Cache-Control: public, max-age=31536000, immutable

Вміст файлу ніколи не змінюється: нова версія - нова назва. immutable каже браузеру не перевіряти файл навіть при оновленні сторінки.

2. HTML - завжди перевіряти актуальність:

Cache-Control: no-cache

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

Саме HTML посилається на конкретні хешовані файли: новий HTML після деплою - нові назви ресурсів - браузер їх завантажить.

Перевірка актуальності (ревалідація):

  • сервер віддає ETag: "abc123" чи Last-Modified;
  • браузер наступного разу питає If-None-Match: "abc123";
  • якщо не змінилося - 304 Not Modified без тіла: економія трафіку, але все одно запит по мережі.

Типові помилки:

  • довге кешування файлів без хешу (/js/app.js з max-age=86400) - після деплою користувачі добу бачать старий JavaScript з новим HTML, і все ламається;
  • старі файли видалено при деплої. Користувач з відкритою вкладкою (або HTML з кешу CDN) просить app-старийхеш.js, а його вже немає - 404 і зламана сторінка. Хорошою практикою є тримати файли попередньої збірки ще якийсь час. CDN з кешем може приховувати проблему: віддає старий файл зі свого кешу, поки той не витісниться;
  • динамічні частини (import()) з тієї ж причини падають у давно відкритих вкладках - потрібна обробка помилки завантаження й пропозиція оновити сторінку;
  • Vary не вказано для відповідей, що залежать від заголовків (Accept-Encoding, мова) - CDN може віддати не той варіант.

Шари кешування: кеш браузера, CDN (Cloudflare), зворотний проксі. Для CDN є окремі директиви: s-maxage (лише для спільних кешів) і stale-while-revalidate (віддати застаріле, поки оновлюється у фоні).

У Laravel + Vite файли з public/build/assets мають хеші - для них вебсервер (Nginx, Caddy) налаштовують з immutable. HTML формує Laravel, і для гостьових сторінок кешування на рівні CDN налаштовується окремо від статики.

Перевірка: вкладка Network у DevTools - колонка Size показує (disk cache)/(memory cache) чи розмір, а заголовки відповіді - реальну політику.

Докладніше в документації: HTTP-кешування

Core Web Vitals - три метрики Google, що описують досвід реального користувача. Вони впливають на ранжування в пошуку й показуються в Search Console.

Метрика Що вимірює Добре
LCP (Largest Contentful Paint) коли відмалювався найбільший видимий елемент: головне зображення, заголовок до 2,5 с
INP (Interaction to Next Paint) затримка від взаємодії (клік, натискання клавіші) до наступного відмальовування - найгірша з типових за весь візит до 200 мс
CLS (Cumulative Layout Shift) наскільки вміст «стрибає» під час завантаження до 0,1

Оцінка - за 75-м перцентилем реальних відвідувань: сторінка «добра», якщо щонайменше 75% візитів укладаються в поріг. INP замінив FID у 2024 році: FID вимірював лише першу взаємодію й лише затримку до початку обробки.

Як вимірювати:

  • польові дані (від реальних користувачів): звіт CrUX, Search Console, PageSpeed Insights, власний збір через бібліотеку web-vitals:
import { onLCP, onINP, onCLS } from 'web-vitals';

const send = (metric) => navigator.sendBeacon('/vitals', JSON.stringify(metric));
onLCP(send);
onINP(send);
onCLS(send);
  • лабораторні дані: Lighthouse, вкладка Performance у DevTools - відтворювані, але не замінюють польові (інший пристрій і мережа). INP у лабораторії майже не виміряти - немає реальних взаємодій.

Типові причини й виправлення:

LCP:

  • головне зображення завантажується пізно - fetchpriority="high", без loading="lazy" для першого екрана, preload;
  • повільна відповідь сервера (TTFB) - кешування сторінок, CDN;
  • блокуючі CSS і JavaScript - менше критичного CSS, defer для скриптів;
  • шрифти, що затримують текст, - font-display: swap.

INP:

  • довгі задачі в головному потоці - розбити роботу (scheduler.yield(), setTimeout), перенести обчислення у Web Worker;
  • важкі обробники подій - оновити інтерфейс одразу, а повільну роботу виконати після відмальовування;
  • великий DOM і дорогі перерахунки стилів;
  • сторонні скрипти (аналітика, чати, реклама).

CLS:

  • зображення й відео без розмірів - width/height чи aspect-ratio;
  • вміст, що вставляється над наявним (банери, рекламні блоки), - зарезервувати місце;
  • зміна шрифту після завантаження - size-adjust для запасного шрифту.

Для Laravel-сайту: Vite з розділенням коду, атрибути fetchpriority, width, height у Blade-компонентах зображень, Livewire wire:navigate для швидких переходів і кешування HTML на краю мережі для гостей.

Докладніше в документації: web.dev: Web Vitals