Питання на співбесіді: Браузерні 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 (асинхронна база в браузері).
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, що використовує його всередині.
Збирати адреси склеюванням рядків - джерело помилок: забуті ? і &, неекрановані пробіли, кирилиця й спецсимволи. Для цього є вбудовані класи.
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) - без них збережеться лише останнє значення.
Браузер застосовує політику одного джерела (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 не потрібен.
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(). Він простіший для маршрутизаторів, але підтримується ще не всіма браузерами.
Обидві технології дають змогу серверу надсилати дані в браузер без запиту від клієнта. Різниця - у напрямку, протоколі й складності.
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 чи окремий сервіс.
Опція 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 з помилками.
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), тож формати на сервері й у браузері збігаються.
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.
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')));
}
});
Життєвий цикл:
- install - підготовка: завантажити й закешувати потрібні файли;
- waiting - новий воркер чекає, доки закриються всі вкладки зі старим. Це захищає від ситуації, коли одна сторінка працює зі старими файлами, а запити обслуговує новий воркер;
- activate - новий воркер бере керування; тут видаляють старі кеші;
- fetch - перехоплення запитів.
self.skipWaiting() і clients.claim() прискорюють перемикання, але ціною можливої невідповідності версій.
Стратегії кешування:
- cache first - для хешованих статичних файлів, що не змінюються;
- network first - для HTML і API: свіже, а кеш лише як запас при відсутності мережі;
- stale-while-revalidate - миттєво з кешу, паралельно оновити кеш з мережі.
Бібліотека Workbox реалізує ці стратегії й генерує воркер під час збирання (у Vite - vite-plugin-pwa).
Чим небезпечна помилка:
- воркер живе довше за сторінку. Він встановлений у браузері користувача й перехоплює запити навіть після того, як ви виправили сайт. Якщо воркер помилково кешує HTML «назавжди», користувачі бачитимуть стару версію сайту, і деплой цього не виправить;
- оновлення самого воркера - браузер перевіряє, чи змінився
sw.js, при навігації на сайт (за замовчуванням в обхід HTTP-кешу, і щонайменше раз на 24 години). URLsw.jsмає лишатися незмінним: якщо перейменувати файл, старий воркер про новий так і не дізнається; - план відступу: заздалегідь мати «вимикач» - новий
sw.js, що видаляє кеші й знімає реєстрацію (self.registration.unregister()).
Обмеження: лише HTTPS (виняток - localhost), без доступу до DOM, область дії (scope) - каталог, з якого віддано скрипт, і нижче.
Для звичайного сайту (не PWA) Service Worker часто зайвий: HTTP-кешування з хешованими іменами файлів дає більшу частину вигоди без ризиків.
Дві суперечливі вимоги: статичні файли мають кешуватися якомога довше (швидкість, трафік), а після деплою користувачі мають одразу отримати нову версію. Розв'язок - різна політика для 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) чи розмір, а заголовки відповіді - реальну політику.
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 на краю мережі для гостей.