HTTP, CORS и кеширование
«У вас в консоли CORS-ошибка. Что вы будете делать?» Кандидат, который отвечает «добавлю заголовок в fetch», не проходит дальше. Разберёмся, почему.
CORS (Cross-Origin Resource Sharing) — механизм, которым сервер разрешает браузеру отдать ответ коду со чужого источника. Это разрешение выдаёт сервер, а исполняет запрет — браузер.
Вопрос-крючок
Фронтенд крутится на http://localhost:3000, API живёт на https://api.example.com. Код выглядит абсолютно нормально:
const res = await fetch('https://api.example.com/orders', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ productId: 42 })
});
const order = await res.json();
А в консоли — красное:
Access to fetch at 'https://api.example.com/orders' from origin 'http://localhost:3000'
has been blocked by CORS policy: Response to preflight request doesn't pass access
control check: No 'Access-Control-Allow-Origin' header is present on the requested resource.
Главный вопрос собеседования: почему ошибку показывают фронтенду, а править надо бэкенд?
Ответ: источник, а не сервер
Источник (origin) — это тройка «схема + хост + порт». http://localhost:3000 и https://api.example.com различаются во всех трёх — значит, запрос кросс-доменный. Того же эффекта достаточно от смены только порта: localhost:3000 и localhost:8080 — разные источники.
По политике одного источника (same-origin policy) браузер не даёт JavaScript-коду читать ответ с чужого источника — иначе любой сайт мог бы вашими куками сходить в интернет-банк и прочитать баланс. Единственный способ снять запрет — чтобы сервер сам сказал «этому источнику можно» заголовком Access-Control-Allow-Origin. Никакой заголовок, добавленный в fetch на клиенте, разрешения не создаёт: доверять клиенту, который сам себя разрешает, бессмысленно.
Отсюда три честных способа починки:
- Правильный: бэкенд отдаёт корректные CORS-заголовки для вашего источника.
- Для разработки: прокси в дев-сервере — браузер ходит на свой же источник, а Vite/webpack пересылает запрос дальше уже без участия браузера.
- В проде: отдавать фронт и API с одного источника (например,
/apiна том же домене через nginx) — тогда CORS вообще не возникает.
// vite.config.js — обход CORS только на время разработки
export default {
server: {
proxy: {
'/api': {
target: 'https://api.example.com',
changeOrigin: true
}
}
}
};
Расширение «Allow CORS» в браузере на собеседовании упоминать можно — но обязательно с оговоркой «только чтобы локально что-то проверить, у пользователей его нет».
Preflight: откуда взялся запрос OPTIONS
Если запрос «простой» — метод GET, HEAD или POST, Content-Type из списка text/plain / multipart/form-data / application/x-www-form-urlencoded, без кастомных заголовков — браузер отправляет его сразу и лишь потом решает, отдавать ли ответ коду.
Как только вы поставили Content-Type: application/json, добавили Authorization или взяли метод PUT/DELETE, браузер сначала спрашивает разрешение отдельным запросом OPTIONS — это и есть preflight.
OPTIONS /orders HTTP/1.1
Origin: http://localhost:3000
Access-Control-Request-Method: POST
Access-Control-Request-Headers: content-type, authorization
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: http://localhost:3000
Access-Control-Allow-Methods: POST, GET, OPTIONS
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Max-Age: 600
Не совпало хоть что-то — основной запрос вообще не уйдёт. Access-Control-Max-Age говорит браузеру, сколько секунд кешировать это разрешение, чтобы не слать OPTIONS перед каждым запросом.
Нюанс, который приносит очки
CORS не защищает сервер. При «простом» запросе браузер честно доставляет его на бэкенд — и заказ может создаться! — а блокирует лишь чтение ответа вашим кодом. Поэтому CORS не заменяет защиту от CSRF: для неё нужны SameSite-куки и токены. Умение это разделить сразу отличает сеньора.
И ещё: если вы шлёте куки (credentials: 'include'), сервер не имеет права отвечать Access-Control-Allow-Origin: * — нужен конкретный источник плюс Access-Control-Allow-Credentials: true.
Коды ответов, которые спрашивают
| Код | Смысл | Когда встречается на фронте |
| 200 / 201 / 204 | OK / создано / успех без тела | после POST правильнее 201, после DELETE — 204 |
| 301 / 302 | переезд навсегда / временно | 301 кешируется браузером агрессивно — ошибиться больно |
| 304 | Not Modified | ресурс не изменился, тело не передаётся |
| 400 / 422 | битый запрос / не прошла валидация | показываем ошибки в форме |
| 401 / 403 | не аутентифицирован / нет прав | 401 → на логин; 403 → «вам сюда нельзя» |
| 404 / 409 | не найдено / конфликт | 409 — например, дубликат email |
| 429 | слишком много запросов | смотрим Retry-After, делаем backoff |
| 500 / 502 / 504 | ошибка сервера / шлюза / таймаут | ретраить можно только идемпотентные запросы |
Любимая пара вопросов: 401 против 403 («ты кто?» против «я знаю, кто ты, и тебе нельзя») и почему fetch не бросает исключение на 404 и 500 — промис реджектится только на сетевой ошибке, поэтому статус надо проверять руками.
const res = await fetch('/api/orders');
if (!res.ok) { // res.ok === (status >= 200 && status < 300)
throw new Error('HTTP ' + res.status);
}
return res.json();
Кеширование: max-age, ETag и 304
Здесь ждут два уровня ответа. Первый — свежесть: пока не истёк max-age, браузер берёт файл из кеша и вообще не идёт в сеть. Второй — ревалидация: срок вышел, браузер спрашивает «а не изменилось ли?» и получает пустой 304, если файл прежний.
GET /app.js
→ 200 OK
ETag: "a1b2c3"
Cache-Control: no-cache (кешируй, но каждый раз спрашивай)
GET /app.js
If-None-Match: "a1b2c3"
→ 304 Not Modified (тело не передаётся — экономим трафик)
| Директива | Что означает |
max-age=600 | 10 минут ресурс свежий, сеть не трогаем |
no-cache | кешировать можно, но перед каждым использованием ревалидировать |
no-store | не сохранять вообще (персональные данные, ответы с токенами) |
immutable | файл никогда не изменится — не ревалидировать даже по F5 |
private / public | только браузер пользователя / можно и промежуточным кешам, CDN |
Проверим логику свежести на самодостаточном примере — это ровно то, что делает браузер, сравнивая Age с max-age.
function isFresh(cacheControl, ageSeconds) {
if (cacheControl.includes('no-store') || cacheControl.includes('no-cache')) return false;
const m = cacheControl.match(/max-age=(\d+)/);
if (!m) return false;
return ageSeconds < Number(m[1]);
}
const cases = [
['public, max-age=31536000, immutable', 100000],
['public, max-age=600', 900],
['no-cache', 5],
['no-store', 0]
];
for (const [cc, age] of cases) {
const verdict = isFresh(cc, age) ? 'берём из кеша' : 'идём на сервер';
console.log('Cache-Control: ' + cc + ' | Age: ' + age + 's -> ' + verdict);
}
Результат:
Cache-Control: public, max-age=31536000, immutable | Age: 100000s -> берём из кеша
Cache-Control: public, max-age=600 | Age: 900s -> идём на сервер
Cache-Control: no-cache | Age: 5s -> идём на сервер
Cache-Control: no-store | Age: 0s -> идём на сервер
Рабочая стратегия для SPA
Это финальный вопрос темы, и на него есть чёткий ответ из двух строк:
- Ассеты с хешем в имени (
main.9f3a2c.js) →Cache-Control: public, max-age=31536000, immutable. Меняется код — меняется имя файла, старый кеш просто перестаёт быть нужным. index.html→Cache-Control: no-cache. Он крошечный, но именно он содержит ссылки на новые хеши. Закешируете его надолго — пользователи неделю будут сидеть на старой версии.
Как это работает под капотом
Перед запросом браузер смотрит в HTTP-кеш. Если запись свежая по Cache-Control, сеть не задействуется вовсе — в DevTools это «from disk cache». Если запись протухла, браузер добавляет условные заголовки If-None-Match (по ETag) или If-Modified-Since (по Last-Modified) и делает запрос; сервер сравнивает и либо шлёт новое тело с 200, либо экономит трафик пустым 304.
CORS живёт слоем выше: сетевой стек браузера получил ответ, но прежде чем передать его в JavaScript, проверяет Access-Control-Allow-Origin. Не совпало — ответ уничтожается, промис fetch реджектится общей ошибкой без деталей. Поэтому в консоли вы видите «CORS policy», а во вкладке Network тот же запрос может быть с честным статусом 200: сервер ответил, но браузер отказался отдать ответ вашему коду. Это наблюдение — отличный ответ на вопрос «а почему в Network 200, а в коде ошибка?».
Частые ошибки на собеседовании
- «Добавлю Access-Control-Allow-Origin в заголовки fetch». Это заголовок ответа, а не запроса.
- «CORS защищает мой API». Нет: он защищает пользователя от чужого сайта, читающего ваши данные. Сервер защищают авторизация и CSRF-токены.
- Считать, что fetch кидает исключение на 500. Не кидает — проверяйте
res.ok. - Путать 401 и 403.
- Не знать, зачем прилетел OPTIONS и почему он появился ровно после добавления
Content-Type: application/json. - Ставить
max-ageна год дляindex.html— потом «у пользователей не обновляется приложение». - Забыть про
Access-Control-Allow-Credentials, когда нужны куки, и одновременно оставить*в разрешённых источниках.
Итоги-шпаргалка
- Источник = схема + хост + порт. Отличается хоть что-то — запрос кросс-доменный.
- Разрешение даёт сервер заголовком
Access-Control-Allow-Origin, а запрет исполняет браузер. Ошибка видна фронту, чинится на бэке (или проксей в деве). - Preflight
OPTIONSпоявляется на «непростых» запросах: JSON-тело, кастомные заголовки,PUT/DELETE. - CORS не защищает сервер и не заменяет CSRF-защиту.
fetchне бросает исключение на 4xx/5xx — проверяйтеres.ok.- Кеш:
max-age— свежесть без сети;ETag+If-None-Match→304— ревалидация без тела. - Хешированные ассеты —
immutableна год,index.html—no-cache.