Dev.to WebDev 🛠 Dev 👁 0 📖 8 min read

“Failed to fetch” in a contact form: 10 causes, reproduced in Chrome, Firefox and Safari

Your contact form calls fetch(), and the catch block gets TypeError: Failed to fetch. Most guides then list possible causes and stop there. We wanted to know what each cause looks like in practice, so we built a small te

Your contact form calls fetch(), and the catch block gets TypeError: Failed to fetch. Most guides then list possible causes and stop there. We wanted to know what each cause looks like in practice, so we built a small test bench: one page, one API on another port, and a case for every common reason a form submission fails. We ran each case in Chrome 154, Firefox 155 and WebKit 26.6 (the engine behind Safari) on 9 October 2026. Firefox and WebKit were the Playwright builds of those engines.

Three findings changed how we debug these errors:

  1. The error message tells you nothing. Every cause produced the same text in a given browser: Failed to fetch in Chrome, NetworkError when attempting to fetch resource. in Firefox, Load failed in Safari. The cause is only visible in the console or the Network tab.
  2. A CORS error does not mean the message was lost. In one case the server received the form, processed it and answered 200, and the page still showed an error.
  3. A dropped connection can send the form more than once. When the server closed the connection without answering, Chrome sent the POST twice and Firefox sent it ten times.

What the error says in each browser

The catch block received the same thing for all of the network causes below:

Browser error.name error.message
Chrome TypeError Failed to fetch
Firefox TypeError NetworkError when attempting to fetch resource.
Safari (WebKit) TypeError Load failed

So code like if (err.message === "Failed to fetch") only works in Chrome. If you want to tell users "check your connection", test err instanceof TypeError instead.

The console tells the causes apart

This is what each browser printed to the console. "Reached the server" is what our API logged.

Cause Reached the server? Chrome console Firefox console Safari console
No CORS headers, JSON body (preflight) Only the OPTIONS preflight …has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header… Cross-Origin Request Blocked… (Reason: CORS header ‘Access-Control-Allow-Origin’ missing). Status code: 204. Origin … is not allowed by Access-Control-Allow-Origin. Status code: 204
No CORS headers, FormData body (no preflight) Yes, the POST arrived and got 200 …has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present… …(Reason: CORS header ‘Access-Control-Allow-Origin’ missing). Status code: 200. …not allowed by Access-Control-Allow-Origin. Status code: 200
Preflight rejected with 401 Only the preflight Same as the first row; the 401 is not mentioned …CORS header ‘Access-Control-Allow-Origin’ missing). Status code: 401. Preflight response is not successful. Status code: 401
Server not running (connection refused) No net::ERR_CONNECTION_REFUSED Cross-Origin Request Blocked… (Reason: CORS request did not succeed). Status code: (null). Could not connect to the server.
Domain does not resolve No net::ERR_NAME_NOT_RESOLVED Same CORS-looking message as above A server with the specified hostname could not be found.
Server drops the connection Yes (see below) net::ERR_EMPTY_RESPONSE Same CORS-looking message The network connection was lost.
Page's Content Security Policy blocks the API No Refused to connect because it violates the document's Content Security Policy. Content-Security-Policy: The page’s settings blocked the loading of a resource (connect-src)… Refused to connect to … because it does not appear in the connect-src directive…
HTTPS page posting to an http:// endpoint No Mixed Content: The page at '…' was loaded over HTTPS, but requested an insecure resource '…'. This request has been blocked… Nothing reached our console log [blocked] The page at … requested insecure content from …

Things to notice:

  • Firefox reports a dead server as a CORS problem. With the API stopped, or a domain that does not exist, Firefox printed "Cross-Origin Request Blocked ‚Ķ CORS request did not succeed". People then spend an hour adding CORS headers to a server that is not running. Check the status code in that message: (null) means no response arrived at all, so this is not a CORS header problem.
  • Chrome hides the preflight status. When the preflight got a 401, Chrome said only that the CORS header was missing. Safari printed the 401 itself. If Chrome says "preflight‚Ķ doesn't pass", open the Network tab and look at the OPTIONS request's status.
  • Mixed content is easy to miss. Our test captured no console line from Firefox for it at all, only the generic error. If the page is HTTPS, check that the form's endpoint URL starts with https://.

The CORS error that still delivered the message

A fetch with a FormData body and no custom headers is a "simple" request (so is a URLSearchParams body, per the Fetch standard; we tested FormData): the browser sends it straight away, without asking the server first. In our test the POST reached the server, the server handled it and answered 200, and only then did the browser refuse to show the response to the page, because there was no Access-Control-Allow-Origin header.

For a contact form that means: the email was sent, and the visitor saw an error. Many of them will press Send again. If your inbox has duplicate messages from the same person a few seconds apart, this is the first thing to check.

A JSON body (Content-Type: application/json) is different: the browser sends an OPTIONS preflight first, the preflight fails, and the POST is never sent.

The fix is the same in both cases: the endpoint must answer with Access-Control-Allow-Origin (your site's origin, or * for a public form endpoint), and for JSON it must also answer the OPTIONS request with Access-Control-Allow-Headers: Content-Type.

A dropped connection sends the form again

We made the server close the connection as soon as the request arrived, without any response. This is what happens when a serverless function crashes mid-request or a proxy kills it. Then we counted the POST requests that reached the server for a single fetch() call, three times per browser:

Browser POSTs received per fetch()
Chrome 2, 2, 2
Firefox 10, 10, 10
Safari (WebKit) 1, 1, 1

The browser retries on its own before your code sees the error. If your server did the work (sent the email, saved the row) and then crashed before answering, Firefox can deliver the same message up to ten times. Two defences:

  • Make the handler respond before slow side effects, or make them idempotent: put a random id in the request (generated once per form fill) and ignore an id you have already seen.
  • Do not add your own automatic retry on TypeError without such an id.

Submit button without preventDefault()

A classic: the form's submit handler calls fetch() but never calls event.preventDefault(), so the browser also submits the form the normal way and reloads the page. The three browsers disagreed completely:

Browser Did the POST reach the API? Did the code see an error?
Chrome Yes No. The page navigated away and the promise never settled.
Firefox No Yes, NetworkError when attempting to fetch resource.
Safari (WebKit) Yes Yes, Load failed

So the same bug looks like "it works but the page reloads" in Chrome, "it never sends" in Firefox, and "it sends but shows an error" in Safari. If your form behaves differently in each browser, look for a missing preventDefault() first. A reload with your field values appearing in the address bar (?email=…) is the giveaway.

Not causes of "Failed to fetch"

Two things are often blamed but behave differently:

  • A 500 (or 404, or 429) from the server does not throw. fetch resolves with response.ok === false. If you only catch errors, a failing server looks like success. Always check response.ok.
  • A timeout has a different name in each browser. With AbortSignal.timeout(1500) we got TimeoutError: signal timed out in Chrome, TimeoutError: The operation timed out. in Firefox, and AbortError: Fetch is aborted in Safari. Code that checks err.name === "TimeoutError" misses Safari; check for both names.

A submit handler that survives all of the above

const form = document.querySelector("#contact");
const button = form.querySelector("button[type=submit]");
const status = form.querySelector(".status");
// One id per form fill, so a retried or repeated request can be recognised on the server.
// crypto.randomUUID() works on HTTPS pages and localhost only.
let submissionId = crypto.randomUUID();

form.addEventListener("submit", async (event) => {
  event.preventDefault(); // without this, each browser fails in its own way
  button.disabled = true; // stops double clicks while the request is in flight
  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) {
      // The server answered: show its message, not a generic network error.
      status.textContent = result.message || `The server answered ${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
      ? "The server took too long to answer. Your message may still have arrived, so check before sending again."
      : "Could not reach the server. Check your connection and try again.";
  } finally {
    button.disabled = false;
  }
});

The timeout message is careful on purpose: as the tests above show, a request that times out on the client may still have been delivered.

Checklist when you see the error

  1. Open DevTools, then the Network tab, and submit again. Is there an OPTIONS request? A red POST? No request at all?
  2. Read the console line next to it, not the catch message. Use the table above to match it to a cause.
  3. In Firefox, a "CORS request did not succeed" with Status code: (null) means the server never answered: check the URL and whether the server is up.
  4. Check the endpoint URL starts with https:// on an HTTPS page.
  5. Check the site's Content-Security-Policy header allows the endpoint in connect-src.
  6. Check the endpoint's own logs: the message may have arrived even though the page showed an error.
  7. Make sure the submit handler calls event.preventDefault().

How we tested

A local Node server played the API on one port and the page on another, so every request was cross-origin, like a static site posting to a form service. Each case ran in a fresh tab in Chrome 154.0.8037.99 (stable channel), and in Playwright's Firefox 155.0 and WebKit 26.6 builds, driven by Playwright 1.63. Nothing was sent anywhere except one DNS lookup for a .invalid domain, which never resolves. Browser extensions (ad blockers that block form endpoints) were not tested, because we could not reproduce them reliably in an automated browser.

We build Formgong, a form backend for static and AI-built sites. Its endpoint answers preflights and sends CORS headers for any origin, and it answers {"success": false, "message": …} with a real status code instead of failing silently. If you are debugging a specific service, we also wrote up the exact error messages of EmailJS, Resend, Formspree and Web3Forms.

📰 Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.