Сидишь, пишешь фронтенд, делаешь простую контактную форму с fetch — и в какой-то момент ловишь в catch ошибку TypeError: Failed to fetch. В гайдах обычно перечисляют кучу причин и на этом всё. Но что реально происходит, как понять, где именно затык и что с ним делать — это редко показывают.
В этой статье расскажу, что выяснили ребята из Formgong: они сделали тестовый стенд с одной страницей и API на другом порту, чтобы воспроизвести 10 типичных причин, почему fetch в форме выдаёт такую ошибку. Тестировали в свежих версиях Chrome, Firefox и WebKit (Safari) — и получили много неожиданных инсайтов, которые могут сэкономить тебе часы дебага.
Ошибка «Failed to fetch» — это вообще ни о чём
Первое, что удивило — текст ошибки в catch-блоке одинаков для всех причин внутри одного браузера. В Chrome — просто Failed to fetch, в Firefox — NetworkError when attempting to fetch resource., в Safari — Load failed. Но это ничего не говорит о настоящей проблеме.
Настоящая причина всегда видна либо в консоли браузера, либо в Network tab. При этом, если ты просто проверяешь err.message === "Failed to fetch", это работает только в Chrome, и ты легко пропустишь другие случаи. Правильнее проверять err instanceof TypeError, если хочешь показать пользователю «проверьте подключение».
CORS — не всегда виноватый в ошибках fetch
Все знают, что CORS — частая причина проблем с fetch, но тут есть важный нюанс. Если у тебя в запросе Content-Type: application/json, браузер сначала отправляет preflight OPTIONS-запрос. Если он падает, POST вообще не уйдёт.
Но если ты отправляешь форму с FormData без кастомных заголовков — это «simple request» и preflight не происходит. В реальном тесте POST дошёл до сервера, сервер обработал запрос и вернул 200, но браузер всё равно показал ошибку из-за отсутствия заголовка Access-Control-Allow-Origin. То есть письмо отправилось, но пользователь увидел ошибку — и нажал «Отправить» ещё раз.
Результат — дубликаты писем в твоём ящике. Этот кейс особенно важен, если у тебя контактная форма, которая работает через fetch.
Когда сервер не отвечает — браузеры ведут себя по-разному
Если сервер просто не запущен, или домен не резолвится, ошибка выглядит как невозможность соединиться (net::ERR_CONNECTION_REFUSED или net::ERR_NAME_NOT_RESOLVED). Но Firefox выдаёт это как «CORS request did not succeed» с Status code: (null). Люди часто тратят часы на добавление CORS-заголовков к несуществующему серверу.
Chrome и Safari показывают более явные ошибки, а в Firefox — будь внимателен к статусу (null) — это значит, что ответа вообще не было.
Сервер закрыл соединение, а браузеры повторяют запросы
Самый неожиданный кейс — если сервер принял запрос, начал обрабатывать, но закрыл соединение до ответа. Такое бывает при крашах serverless-функций или если прокси убивает соединение.
Тест показал, что при одном вызове fetch:
- Chrome отправляет POST два раза
- Firefox — аж 10 раз
- Safari — один раз
Значит, если у тебя сервер упал в момент обработки, пользователь мог получить несколько одинаковых писем или записей. Чтобы этого избежать, делай обработку идемпотентной:
- Генерируй уникальный
submission_idна клиенте и передавай на сервер - Сервер игнорирует повторные requests с одним id
- Или отвечай клиенту до выполнения тяжелых операций (отправка почты, запись в базу)
Не забывай preventDefault() — иначе ошибки выглядят по-разному в браузерах
Знакомая ошибка — забыть event.preventDefault() в сабмите формы.
- В Chrome POST уходит, но страница перезагружается и promise fetch никогда не резолвится
- В Firefox POST не уходит, а в catch приходит NetworkError
- В Safari POST уходит, но fetch падает с Load failed
Если форма ведёт себя странно и по-разному в браузерах — сначала проверь, не забыл ли preventDefault. Перезагрузка страницы с параметрами в адресной строке — классический признак.
Короткий пример с обработчиком формы, который учитывает все эти нюансы
const form = document.querySelector("#contact");
const button = form.querySelector("button[type=submit]");
const status = form.querySelector(".status");
let submissionId = crypto.randomUUID();
form.addEventListener("submit", async (event) => {
event.preventDefault(); // без этого всё пойдет не так
button.disabled = true;
status.textContent = "Sending…";
const data = new FormData(form);
data.set("submission_id", submissionId);
try {
const response = await fetch(form.action, {
method: "POST",
headers: { Accept: "application/json" },
body: data,
signal: AbortSignal.timeout(15000),
});
const result = await response.json().catch(() => ({}));
if (!response.ok || result.success === false) {
status.textContent = result.message || `Server responded ${response.status}. Please try again.`;
return;
}
status.textContent = "Thank you, your message was sent.";
form.reset();
submissionId = crypto.randomUUID();
} catch (err) {
const timedOut = err.name === "TimeoutError" || err.name === "AbortError";
status.textContent = timedOut
? "Server took too long to respond. Your message might have arrived, check before resending."
: "Could not reach the server. Check your connection and try again.";
} finally {
button.disabled = false;
}
});
Кому и когда это поможет
Если ты делаешь форму с fetch, особенно с JSON, или с FormData на кросс-доменный API — эти наблюдения помогут:
- Не гоняйся слепо за ошибкой из catch, заглядывай в Network и консоль
- Помни, что CORS — не всегда виноват, иногда запрос до сервера дошёл и сервер ответил — но браузер не дал тебе данные
- Если сервер падает и закрывает соединение, будь готов к тому, что браузер повторит запрос несколько раз
- Всегда делай обработку идемпотентной через уникальные id
- Никогда не забывай
preventDefault()на форме - Проверь, что endpoint по HTTPS, если страница сама по HTTPS, иначе запрос заблочат как mixed content
- Для таймаутов учитывай разное поведение в браузерах — проверяй и
TimeoutError, иAbortError
Этот разбор — не про теорию, а про то, что реально увидишь в логах и как быстро понять, где затык. Если хочешь копать глубже — посмотри исходный репозиторий Formgong, там есть полный набор кейсов с точными сообщениями из браузерных консолей.
В работе с AI coding и agentic workflows, где формы часто запускаются динамически и обрабатываются через API, такие детали очень важны. Потому что «Failed to fetch» — это не просто ошибка сети, а сигнал, что нужно заглянуть глубже и не слепо добавлять CORS-заголовки или менять UI без понимания причины.
Если хочешь — попробуй повторить тесты локально с разными кейсами, чтобы привыкнуть читать Network и Console — это навык, который всегда выручает, когда frontend ломается неочевидно.
Попробуй сам: Cursor — AI-редактор для разработчиков.