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 / 204OK / создано / успех без телапосле POST правильнее 201, после DELETE — 204
301 / 302переезд навсегда / временно301 кешируется браузером агрессивно — ошибиться больно
304Not 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=60010 минут ресурс свежий, сеть не трогаем
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.htmlCache-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-Match304 — ревалидация без тела.
  • Хешированные ассеты — immutable на год, index.htmlno-cache.
Проверьте себя
1. В браузере CORS-ошибка при POST-запросе с Content-Type: application/json. Что нужно сделать, чтобы запрос заработал в продакшене?
AДобавить заголовок Access-Control-Allow-Origin в объект headers у fetch
BНастроить сервер так, чтобы он отвечал на preflight OPTIONS и слал Access-Control-Allow-Origin с нужным источником
CОтключить в fetch режим cors, поставив mode: 'no-cors'
DПопросить пользователей установить расширение, снимающее CORS
2. Какая стратегия кеширования корректна для SPA с хешированными именами файлов?
AВсё, включая index.html, отдавать с max-age=31536000, immutable
BВсё отдавать с no-store, чтобы пользователь всегда видел свежую версию
CАссеты с хешем — max-age=31536000, immutable; index.html — no-cache
DАссеты — no-cache, index.html — immutable на год