«Failed to fetch» в контактной форме: 10 причин и как их реально отлаживать в Chrome, Firefox и Safari

#fetch#contact-form#CORS#frontend-debugging#browser-compatibility

Сидишь, пишешь фронтенд, делаешь простую контактную форму с 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:

Значит, если у тебя сервер упал в момент обработки, пользователь мог получить несколько одинаковых писем или записей. Чтобы этого избежать, делай обработку идемпотентной:


Не забывай preventDefault() — иначе ошибки выглядят по-разному в браузерах

Знакомая ошибка — забыть event.preventDefault() в сабмите формы.

Если форма ведёт себя странно и по-разному в браузерах — сначала проверь, не забыл ли 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 — эти наблюдения помогут:


Этот разбор — не про теорию, а про то, что реально увидишь в логах и как быстро понять, где затык. Если хочешь копать глубже — посмотри исходный репозиторий Formgong, там есть полный набор кейсов с точными сообщениями из браузерных консолей.

В работе с AI coding и agentic workflows, где формы часто запускаются динамически и обрабатываются через API, такие детали очень важны. Потому что «Failed to fetch» — это не просто ошибка сети, а сигнал, что нужно заглянуть глубже и не слепо добавлять CORS-заголовки или менять UI без понимания причины.


Если хочешь — попробуй повторить тесты локально с разными кейсами, чтобы привыкнуть читать Network и Console — это навык, который всегда выручает, когда frontend ломается неочевидно.


Попробуй сам: Cursor — AI-редактор для разработчиков.


Источник: https://dev.to/formgongteam/failed-to-fetch-in-a-contact-form-10-causes-reproduced-in-chrome-firefox-and-safari-986