<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Formgong</title>
    <description>The latest articles on DEV Community by Formgong (@formgongteam).</description>
    <link>https://dev.to/formgongteam</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4173434%2Fbd47bcc5-1a47-4d4b-83ce-7574a3869354.png</url>
      <title>DEV Community: Formgong</title>
      <link>https://dev.to/formgongteam</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/formgongteam"/>
    <language>en</language>
    <item>
      <title>We sent a wrong key to 7 form backends. Three of them told the form it worked.</title>
      <dc:creator>Formgong</dc:creator>
      <pubDate>Fri, 09 Oct 2026 14:09:01 +0000</pubDate>
      <link>https://dev.to/formgongteam/we-sent-a-wrong-key-to-7-form-backends-three-of-them-told-the-form-it-worked-llh</link>
      <guid>https://dev.to/formgongteam/we-sent-a-wrong-key-to-7-form-backends-three-of-them-told-the-form-it-worked-llh</guid>
      <description>&lt;p&gt;&lt;em&gt;Disclosure first: we build Formgong, one of the seven services below. That is why we wrote down the tests and the pass rules before sending a single request, and why every answer below is quoted as the service returned it.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;A contact form on a static or AI-built site usually ends like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;endpoint&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;Accept&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;showThankYou&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So the form is only as honest as the form backend's answer. If the backend replies &lt;code&gt;200 OK&lt;/code&gt; to a key that does not exist, the visitor sees "Thank you", the owner gets nothing, and nobody notices until a customer asks why they never got a reply. In an earlier test of 105 AI-built sites, &lt;a href="https://formgong.com/blog/contact-forms-that-send-nothing" rel="noopener noreferrer"&gt;27 forms showed success and sent nothing&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;We sent the same broken requests to seven form backends on 9 October 2026 to see which ones would catch the mistake.&lt;/p&gt;

&lt;h2&gt;
  
  
  The tests
&lt;/h2&gt;

&lt;p&gt;Each service got made-up keys only, so nothing was delivered anywhere:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Placeholder key:&lt;/strong&gt; the sample key from the service's own docs, the one tutorials and AI builders paste and people forget to replace.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Unknown key:&lt;/strong&gt; a random key of the right shape.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Missing key:&lt;/strong&gt; no key field at all (only for services where the key is a field, not part of the URL).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Browser JSON post:&lt;/strong&gt; can a page on another site read the answer (CORS)?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Server post:&lt;/strong&gt; the unknown key again, from a server instead of a browser.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Tests 1–4 ran in a normal Chrome 154 tab on &lt;code&gt;https://example.com&lt;/code&gt;, with &lt;code&gt;fetch&lt;/code&gt;, JSON and &lt;code&gt;Accept: application/json&lt;/code&gt;. Test 5 ran with curl. The rule for tests 1–3 was simple: a wrong key must not get a 2xx status. For test 5, the answer had to be about the key, not a blanket refusal of server calls.&lt;/p&gt;

&lt;h2&gt;
  
  
  Results
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Service&lt;/th&gt;
&lt;th&gt;Placeholder key&lt;/th&gt;
&lt;th&gt;Unknown key&lt;/th&gt;
&lt;th&gt;Missing key&lt;/th&gt;
&lt;th&gt;Browser (CORS)&lt;/th&gt;
&lt;th&gt;Server post&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Formspree&lt;/td&gt;
&lt;td&gt;✅ 404&lt;/td&gt;
&lt;td&gt;✅ 404&lt;/td&gt;
&lt;td&gt;n/a&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅ 404&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Web3Forms&lt;/td&gt;
&lt;td&gt;❌ &lt;strong&gt;200, &lt;code&gt;success: true&lt;/code&gt;&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;✅ 400&lt;/td&gt;
&lt;td&gt;✅ 400&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;❌ &lt;strong&gt;403 for every server call&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Forminit (formerly Getform)&lt;/td&gt;
&lt;td&gt;❌ &lt;strong&gt;200&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;❌ &lt;strong&gt;200&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;n/a&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅ (200, but about the form)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Basin&lt;/td&gt;
&lt;td&gt;✅ 400&lt;/td&gt;
&lt;td&gt;✅ 400&lt;/td&gt;
&lt;td&gt;n/a&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅ 400&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;FormSubmit&lt;/td&gt;
&lt;td&gt;n/a*&lt;/td&gt;
&lt;td&gt;❌ &lt;strong&gt;200&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;n/a&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;❌ &lt;strong&gt;200, unrelated message&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Formcarry&lt;/td&gt;
&lt;td&gt;✅ 500&lt;/td&gt;
&lt;td&gt;✅ 500&lt;/td&gt;
&lt;td&gt;n/a&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅ 500&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Formgong&lt;/td&gt;
&lt;td&gt;✅ 404&lt;/td&gt;
&lt;td&gt;✅ 404&lt;/td&gt;
&lt;td&gt;✅ 400&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅ 404&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;* FormSubmit's placeholder is &lt;code&gt;your@email.com&lt;/code&gt;, a real domain, and a first submission makes FormSubmit email that address. We did not send mail to a stranger.&lt;/p&gt;

&lt;h2&gt;
  
  
  Would your form show “Thank you” for a wrong key?
&lt;/h2&gt;

&lt;p&gt;This is the part that matters for real code. Two common success checks, applied to what each service actually returned for a wrong key:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Service&lt;/th&gt;
&lt;th&gt;&lt;code&gt;if (response.ok)&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;&lt;code&gt;if (data.success)&lt;/code&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Formspree&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Web3Forms, placeholder key&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Yes&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Yes&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Web3Forms, unknown key&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Forminit&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Yes&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Basin&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;FormSubmit&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Yes&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Yes&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Formcarry&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Formgong&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;With Web3Forms' sample key, FormSubmit with any wrong address, and Forminit with a status check, a misconfigured form looks like a working one.&lt;/p&gt;

&lt;h2&gt;
  
  
  What each service answered
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Web3Forms: success for the sample key, and no server calls
&lt;/h3&gt;

&lt;p&gt;With &lt;code&gt;YOUR_ACCESS_KEY_HERE&lt;/code&gt;, the sample key from Web3Forms' installation docs, the answer was:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"success"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"message"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"It works. Please insert your actual form_id or access_key to receive emails."&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;HTTP 200 and &lt;code&gt;success: true&lt;/code&gt;. The message asks you to insert a real key, but the code above never shows the message to anyone. A random key was handled well: &lt;code&gt;400&lt;/code&gt; and "Invalid Form ID/Access Key! Please double check for extra space!".&lt;/p&gt;

&lt;p&gt;From curl, the same request got &lt;code&gt;403&lt;/code&gt;: "This method is not allowed. Use our API in client side or contact support with server IP address (Pro plan is required)". Web3Forms' docs say so too: "Server side usage requires paid plan + server IP whitelisting". If your form posts through a Next.js route, a Server Action or a Supabase Edge Function, it does not work on the free plan.&lt;/p&gt;

&lt;h3&gt;
  
  
  Forminit (formerly Getform): 200 and “This form has been deleted”
&lt;/h3&gt;

&lt;p&gt;Getform.io now redirects to Forminit. For both the placeholder and a random form ID, Forminit answered HTTP &lt;strong&gt;200&lt;/strong&gt; with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"success"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"error"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"FORM_NOT_FOUND"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"code"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;404&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"message"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"This form has been deleted and is no longer available. Please contact support if you believe this is an error."&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The body is honest (&lt;code&gt;success: false&lt;/code&gt;, &lt;code&gt;FORM_NOT_FOUND&lt;/code&gt;), but the HTTP status is 200, so &lt;code&gt;response.ok&lt;/code&gt; is &lt;code&gt;true&lt;/code&gt;. And the message says the form was deleted, for an ID that never existed, which sends you to support instead of to your typo.&lt;/p&gt;

&lt;h3&gt;
  
  
  FormSubmit: 200, and &lt;code&gt;"success": "false"&lt;/code&gt; as a string
&lt;/h3&gt;

&lt;p&gt;For a random form address, FormSubmit answered HTTP &lt;strong&gt;200&lt;/strong&gt; with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"success"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"false"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"message"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Email address 5f208483772f23807fae5d4f589e6af4 is not formatted correctly."&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;"false"&lt;/code&gt; in quotes is a non-empty string, and a non-empty string is truthy in JavaScript, so &lt;code&gt;if (data.success)&lt;/code&gt; passes. From curl, the answer was a different 200: "Make sure you open this page through a web server, FormSubmit will not work in pages browsed as HTML files." That message is about local HTML files, not about the request we sent.&lt;/p&gt;

&lt;h3&gt;
  
  
  Formcarry: right message, wrong status class
&lt;/h3&gt;

&lt;p&gt;Formcarry rejected every wrong ID, which is what counts, with "Form is invalid… Make sure you are using the correct form ID". The status was &lt;code&gt;500&lt;/code&gt; though, and the body said &lt;code&gt;"code": 401&lt;/code&gt;. A 500 tells monitoring tools and retry logic that Formcarry's server broke, when the mistake was in the request.&lt;/p&gt;

&lt;h3&gt;
  
  
  Formspree and Basin: honest
&lt;/h3&gt;

&lt;p&gt;Formspree answered &lt;code&gt;404 Form not found&lt;/code&gt; everywhere, including from a server. Basin answered &lt;code&gt;400&lt;/code&gt; with a clear explanation everywhere. Neither will make a broken form look like a working one.&lt;/p&gt;

&lt;h3&gt;
  
  
  Formgong
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;404&lt;/code&gt; with &lt;code&gt;"code": "unknown_access_key"&lt;/code&gt; for the placeholder and for a random key, &lt;code&gt;400&lt;/code&gt; with &lt;code&gt;"code": "missing_access_key"&lt;/code&gt; when the key was missing, the same answers from a server, and the message in the visitor's browser language (our test tab was set to Ukrainian, so that is what came back).&lt;/p&gt;

&lt;h2&gt;
  
  
  Free plans, prices and where data lives
&lt;/h2&gt;

&lt;p&gt;Taken from each service's own pricing and privacy pages on 9 October 2026:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Service&lt;/th&gt;
&lt;th&gt;Free submissions / month&lt;/th&gt;
&lt;th&gt;Cheapest paid plan&lt;/th&gt;
&lt;th&gt;Data stored&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Formgong&lt;/td&gt;
&lt;td&gt;300&lt;/td&gt;
&lt;td&gt;$9 / month ($86 billed yearly)&lt;/td&gt;
&lt;td&gt;EU&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Web3Forms&lt;/td&gt;
&lt;td&gt;250&lt;/td&gt;
&lt;td&gt;priced by location (we were shown Turkish lira)&lt;/td&gt;
&lt;td&gt;US&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Forminit&lt;/td&gt;
&lt;td&gt;100 (1 form)&lt;/td&gt;
&lt;td&gt;$19 / month&lt;/td&gt;
&lt;td&gt;EU (AWS)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Formspree&lt;/td&gt;
&lt;td&gt;50&lt;/td&gt;
&lt;td&gt;$15 / month ($10 billed yearly)&lt;/td&gt;
&lt;td&gt;US&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Basin&lt;/td&gt;
&lt;td&gt;50 (1 form)&lt;/td&gt;
&lt;td&gt;$15 / month ($12.50 billed yearly)&lt;/td&gt;
&lt;td&gt;Canada, with files in the US&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Formcarry&lt;/td&gt;
&lt;td&gt;50 (1 form)&lt;/td&gt;
&lt;td&gt;$6 / month ($5 billed yearly)&lt;/td&gt;
&lt;td&gt;EU&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;FormSubmit&lt;/td&gt;
&lt;td&gt;"unlimited"&lt;/td&gt;
&lt;td&gt;not stated&lt;/td&gt;
&lt;td&gt;not stated&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  What to take from this
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Whatever you use, test the wrong key once.&lt;/strong&gt; Put a wrong key in your form, submit, and check that the page shows an error. It takes a minute and it catches the most common way a form silently stops working.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check both the status and the body,&lt;/strong&gt; and compare &lt;code&gt;success&lt;/code&gt; with &lt;code&gt;=== true&lt;/code&gt;, not truthiness:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="k"&gt;catch&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({}));&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;success&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;showThankYou&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="nf"&gt;showError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="s2"&gt;`Error &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;If you post from a server&lt;/strong&gt;, rule out services that block server calls on the free plan.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Among the seven, Formspree, Basin and Formgong never let a wrong key look like success. Of those three, Formgong has the largest free plan (300 submissions a month against 50), the lowest paid price ($9 against $15) and keeps data in the EU. You can &lt;a href="https://formgong.com" rel="noopener noreferrer"&gt;try it&lt;/a&gt; with the same wrong-key test above.&lt;/p&gt;

&lt;h2&gt;
  
  
  How we tested
&lt;/h2&gt;

&lt;p&gt;One Chrome 154 tab on &lt;code&gt;https://example.com&lt;/code&gt; sent tests 1–4 with &lt;code&gt;fetch&lt;/code&gt;, six seconds apart to stay under per-IP limits. curl 8.7.1 sent test 5. Each service got its own random key per run; nothing was delivered. An earlier run from an automated (Playwright) Chrome got a Cloudflare bot challenge from Web3Forms instead of its API, so we discarded that run's Web3Forms row and re-ran every service from a normal browser; the other six services gave the same status codes and errors in both runs. The pass rules were fixed before the first request and not changed after.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>html</category>
      <category>forms</category>
    </item>
    <item>
      <title>Nodemailer + Gmail errors, reproduced: 535-5.7.8, “Missing credentials for PLAIN”, wrong version number, and serverless ports</title>
      <dc:creator>Formgong</dc:creator>
      <pubDate>Fri, 09 Oct 2026 12:49:39 +0000</pubDate>
      <link>https://dev.to/formgongteam/nodemailer-gmail-errors-reproduced-535-578-missing-credentials-for-plain-wrong-version-2fp7</link>
      <guid>https://dev.to/formgongteam/nodemailer-gmail-errors-reproduced-535-578-missing-credentials-for-plain-wrong-version-2fp7</guid>
      <description>&lt;p&gt;A contact form that sends through Gmail with Nodemailer usually fails in one of a handful of ways, and the error text often points in the wrong direction. We reproduced each failure against the real &lt;code&gt;smtp.gmail.com&lt;/code&gt; on 9 October 2026, with Nodemailer 10.0.16 on Node and a made-up Gmail address, so every attempt failed before a message could be accepted. Then we ran the same code on Cloudflare Workers.&lt;/p&gt;

&lt;p&gt;What we learned in short:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;Missing credentials for "PLAIN"&lt;/code&gt; never reached Google.&lt;/strong&gt; Nodemailer raises it itself when the password is empty, which almost always means an environment variable is not set where the code runs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Gmail gives the same 535 for a wrong password and for an account that does not exist.&lt;/strong&gt; The error cannot tell you which one you have.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;secure&lt;/code&gt; must match the port.&lt;/strong&gt; The wrong combination fails either instantly with an OpenSSL message or after a long silent wait.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;On Cloudflare Workers none of Gmail's ports worked&lt;/strong&gt;, even though the same code reached Gmail from &lt;code&gt;wrangler dev&lt;/code&gt; on a laptop.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Every error at a glance
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Setup&lt;/th&gt;
&lt;th&gt;&lt;code&gt;err.code&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;&lt;code&gt;err.command&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;Message&lt;/th&gt;
&lt;th&gt;Time to fail&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Wrong password (port 465 or 587, or &lt;code&gt;service: "gmail"&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;EAUTH&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;AUTH PLAIN&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Invalid login: 535-5.7.8 Username and Password not accepted. For more information, go to 535 5.7.8 https://support.google.com/mail/?p=BadCredentials …&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0.4–0.6 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Password empty or &lt;code&gt;undefined&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;EAUTH&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;API&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Missing credentials for "PLAIN"&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0.4 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;No &lt;code&gt;auth&lt;/code&gt; at all&lt;/td&gt;
&lt;td&gt;&lt;code&gt;EENVELOPE&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;MAIL FROM&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Mail command failed: 530-5.7.0 Authentication Required. …&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0.6 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;secure: true&lt;/code&gt; on port 587&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ESOCKET&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;CONN&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;…SSL routines:tls_validate_record_header:wrong version number…&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0.1 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;secure: false&lt;/code&gt; on port 465&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ETIMEDOUT&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;CONN&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Greeting never received&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;as long as &lt;code&gt;greetingTimeout&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Host typo &lt;code&gt;smtp.gmial.com&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ESOCKET&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;CONN&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;connect ECONNREFUSED 51.79.68.169:465&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1.3 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Normal password on an account with 2-Step Verification&lt;/td&gt;
&lt;td&gt;&lt;code&gt;EAUTH&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;AUTH PLAIN&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;534-5.7.9 Application-specific password required&lt;/code&gt; (from user reports; we could not reproduce it without a real account)&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Log &lt;code&gt;err.code&lt;/code&gt;, &lt;code&gt;err.command&lt;/code&gt; and &lt;code&gt;err.responseCode&lt;/code&gt; next to the message. Together they say which step failed: &lt;code&gt;API&lt;/code&gt; means Nodemailer stopped before talking to anyone, &lt;code&gt;CONN&lt;/code&gt; means the connection or TLS failed, &lt;code&gt;AUTH PLAIN&lt;/code&gt; means Google rejected the login, and &lt;code&gt;MAIL FROM&lt;/code&gt; means Google rejected the message itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  535-5.7.8 “Username and Password not accepted”
&lt;/h2&gt;

&lt;p&gt;This is the one everybody hits. We got it on port 465 with &lt;code&gt;secure: true&lt;/code&gt;, on port 587 with &lt;code&gt;secure: false&lt;/code&gt;, and with the &lt;code&gt;service: "gmail"&lt;/code&gt; shortcut. All three are correct transport settings, and all three reached the login step.&lt;/p&gt;

&lt;p&gt;Two things worth knowing:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;It does not tell you whether the account exists.&lt;/strong&gt; Our address was made up and Gmail still said "Username and Password not accepted", exactly as it does for a real account with a wrong password. So check the &lt;code&gt;user&lt;/code&gt; value too: it must be the full address, such as &lt;code&gt;you@gmail.com&lt;/code&gt;, and must be the same account the app password was created in.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your normal Google password is the wrong password.&lt;/strong&gt; Gmail no longer accepts the account password from apps like Nodemailer. You need an app password:

&lt;ul&gt;
&lt;li&gt;Turn on 2-Step Verification for the account.&lt;/li&gt;
&lt;li&gt;Create an app password at &lt;a href="https://myaccount.google.com/apppasswords" rel="noopener noreferrer"&gt;myaccount.google.com/apppasswords&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;Put the 16 characters it shows into &lt;code&gt;auth.pass&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Google's help page says app passwords work only with 2-Step Verification on. It also lists three cases where the option is missing: 2-Step Verification set up only with security keys, a work or school account, or Advanced Protection. In those cases use OAuth2 (Nodemailer supports it) or a sending service instead.&lt;/p&gt;

&lt;p&gt;If the app password still fails, check how it reached your code. Google shows it in groups of four with spaces; paste it without them to rule that out. A stray quote or a trailing newline in a &lt;code&gt;.env&lt;/code&gt; file breaks it too. (We could not test which of these Gmail tolerates: with a made-up account every login fails the same way.)&lt;/p&gt;

&lt;h2&gt;
  
  
  “Missing credentials for "PLAIN"”: Gmail was never asked
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Error: Missing credentials for "PLAIN"
  code: 'EAUTH', command: 'API'
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;command: 'API'&lt;/code&gt; is the giveaway: Nodemailer threw this before it sent anything to Google. We got it by passing &lt;code&gt;pass: undefined&lt;/code&gt;, which is what &lt;code&gt;process.env.GMAIL_APP_PASSWORD&lt;/code&gt; is when the variable does not exist in that environment.&lt;/p&gt;

&lt;p&gt;Typical reasons:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The variable is in your local &lt;code&gt;.env&lt;/code&gt;, but not in the hosting dashboard (Vercel, Netlify, Supabase secrets).&lt;/li&gt;
&lt;li&gt;It is set for Preview but not for Production, or the other way round.&lt;/li&gt;
&lt;li&gt;It was added after the last deploy, and the running deployment does not have it yet. Redeploy.&lt;/li&gt;
&lt;li&gt;The code that sends mail runs in the browser, where server variables do not exist. Sending mail must happen on the server: anything in browser code, including an app password, is visible to every visitor.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  530-5.7.0 “Authentication Required”
&lt;/h2&gt;

&lt;p&gt;Leave out &lt;code&gt;auth&lt;/code&gt; entirely and Gmail lets you connect, then refuses the message at &lt;code&gt;MAIL FROM&lt;/code&gt; with &lt;code&gt;530-5.7.0 Authentication Required&lt;/code&gt;. The error code is &lt;code&gt;EENVELOPE&lt;/code&gt;, which sounds like a problem with your addresses, but it is the same missing login. Usually &lt;code&gt;auth&lt;/code&gt; was spread from an object that turned out empty.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;code&gt;secure&lt;/code&gt; and port: two very different failures
&lt;/h2&gt;

&lt;p&gt;Gmail listens on 465 for TLS from the first byte and on 587 for plain text upgraded with STARTTLS. Nodemailer's &lt;code&gt;secure: true&lt;/code&gt; means the first; &lt;code&gt;secure: false&lt;/code&gt; means the second.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;secure: true&lt;/code&gt; on 587&lt;/strong&gt; fails at once with an OpenSSL error: &lt;code&gt;SSL routines:tls_validate_record_header:wrong version number&lt;/code&gt;. Nothing in it says "port", which is why this one sends people searching for OpenSSL bugs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;secure: false&lt;/code&gt; on 465&lt;/strong&gt; fails slowly. The server waits for a TLS handshake, the client waits for a plain-text greeting, and nobody speaks. We set &lt;code&gt;greetingTimeout&lt;/code&gt; to 10 seconds and got &lt;code&gt;Greeting never received&lt;/code&gt; after 10.06 seconds. With Nodemailer's default timeouts the wait is longer, which on a serverless platform can mean the function hits its own time limit first and you see a platform timeout instead of an SMTP error.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The two working combinations:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Either: TLS from the start&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;host&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;smtp.gmail.com&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;port&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;465&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;secure&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;pass&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="c1"&gt;// Or: STARTTLS&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;host&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;smtp.gmail.com&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;587&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;secure&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;pass&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  A typo in the host goes to someone else's server
&lt;/h2&gt;

&lt;p&gt;We tried &lt;code&gt;smtp.gmial.com&lt;/code&gt;. It resolved, to an address at the hosting company OVH, not to Google, and the connection was refused on ports 25, 465 and 587. So today you get &lt;code&gt;ECONNREFUSED&lt;/code&gt;. But a typo domain that resolves can start accepting connections at any time, and then your Gmail address and app password go to whoever runs it. If you see &lt;code&gt;ECONNREFUSED&lt;/code&gt; with an IP address you do not recognise, check the spelling of &lt;code&gt;host&lt;/code&gt; before anything else, and rotate the app password if a typo was ever deployed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Serverless: it works locally, then fails after deploy
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Cloudflare Workers
&lt;/h3&gt;

&lt;p&gt;We deployed a Worker (with &lt;code&gt;nodejs_compat&lt;/code&gt;) that tries the same Gmail login on each port, and called it twice. Both runs gave the same answers:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Port&lt;/th&gt;
&lt;th&gt;Result on Cloudflare Workers&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;465&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;ESOCKET&lt;/code&gt;: &lt;code&gt;proxy request failed, cannot connect to the specified address&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;587&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;ESOCKET&lt;/code&gt;: &lt;code&gt;TLS Handshake Failed.&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;25&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;ESOCKET&lt;/code&gt;: &lt;code&gt;Connections to port 25 are prohibited&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;None of them reached the login step. The same Worker run locally with &lt;code&gt;wrangler dev&lt;/code&gt; behaved differently: port 587 and port 25 reached Gmail and got the normal 535. So a Worker can pass every local test and still fail on every port once deployed.&lt;/p&gt;

&lt;h3&gt;
  
  
  Supabase Edge Functions (Lovable, Bolt)
&lt;/h3&gt;

&lt;p&gt;Supabase's limits page says: "Outgoing connections to ports &lt;code&gt;25&lt;/code&gt; and &lt;code&gt;587&lt;/code&gt; are not allowed." That leaves 465 for Gmail. We did not run Nodemailer in an Edge Function for this article, so treat this as the documented rule rather than a tested result.&lt;/p&gt;

&lt;h3&gt;
  
  
  Vercel and Netlify functions
&lt;/h3&gt;

&lt;p&gt;We did not test these. If you see a function timeout with no SMTP error, check the &lt;code&gt;secure&lt;/code&gt; and port pair above first, because the slow failure is long enough to hit a function limit.&lt;/p&gt;

&lt;h2&gt;
  
  
  A transport that tells you what went wrong
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;nodemailer&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;nodemailer&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;GMAIL_USER&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pass&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;GMAIL_APP_PASSWORD&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;pass&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Fail with a clear message instead of Nodemailer's 'Missing credentials for "PLAIN"'.&lt;/span&gt;
  &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;GMAIL_USER or GMAIL_APP_PASSWORD is not set in this environment&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;transporter&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;nodemailer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createTransport&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;host&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;smtp.gmail.com&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;465&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;secure&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// must be true for 465 and false for 587&lt;/span&gt;
  &lt;span class="na"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;pass&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;connectionTimeout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;greetingTimeout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// fail fast instead of hitting the platform's time limit&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;sendContactMessage&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;message&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;transporter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sendMail&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;from&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// Gmail sends from the account you logged in with&lt;/span&gt;
      &lt;span class="na"&gt;replyTo&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// so Reply goes to the visitor&lt;/span&gt;
      &lt;span class="na"&gt;to&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;subject&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`New message from &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// These three fields identify the failing step; the message alone often misleads.&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;mail failed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;code&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;command&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;responseCode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;responseCode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Checklist
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;command: 'API'&lt;/code&gt; with "Missing credentials": the password variable is not set where the code runs.&lt;/li&gt;
&lt;li&gt;535-5.7.8: use an app password, not your Google password, and the full address as &lt;code&gt;user&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;534-5.7.9: same fix; the account has 2-Step Verification and you sent the normal password.&lt;/li&gt;
&lt;li&gt;"wrong version number": you set &lt;code&gt;secure: true&lt;/code&gt; on port 587.&lt;/li&gt;
&lt;li&gt;"Greeting never received" or a function timeout: you set &lt;code&gt;secure: false&lt;/code&gt; on port 465.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ECONNREFUSED&lt;/code&gt; to an unfamiliar IP: check the spelling of &lt;code&gt;host&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Works locally, fails deployed on Cloudflare Workers or Supabase: the platform blocks the port. Use an HTTP email API or a form backend instead of SMTP.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  How we tested
&lt;/h2&gt;

&lt;p&gt;Node with Nodemailer 10.0.16, one connection per case to &lt;code&gt;smtp.gmail.com&lt;/code&gt;, a made-up address (&lt;code&gt;formgong.repro.nobody.4417@gmail.com&lt;/code&gt;) and made-up passwords, so Gmail rejected every attempt and nothing was sent. The Cloudflare Workers results come from a Worker deployed for the test and removed afterwards; each port was tried twice. Google's app password rules are quoted from &lt;a href="https://support.google.com/mail/answer/185833" rel="noopener noreferrer"&gt;Google's help page&lt;/a&gt;, the Supabase rule from &lt;a href="https://supabase.com/docs/guides/functions/limits" rel="noopener noreferrer"&gt;Supabase's Edge Function limits&lt;/a&gt;, and the 534-5.7.9 text from user reports such as &lt;a href="https://wpmailsmtp.com/docs/error-534-5-7-9-application-specific-password-required/" rel="noopener noreferrer"&gt;WP Mail SMTP's write-up&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;We build &lt;a href="https://formgong.com" rel="noopener noreferrer"&gt;Formgong&lt;/a&gt;, a form backend: your form posts to it over HTTPS and it sends the notification, so there is no SMTP login, app password or blocked port to deal with. If your form fails in the browser before it ever reaches the server, our &lt;a href="https://dev.to/formgongteam/failed-to-fetch-in-a-contact-form-10-causes-reproduced-in-chrome-firefox-and-safari-986"&gt;“Failed to fetch” write-up&lt;/a&gt; covers that side.&lt;/p&gt;

</description>
      <category>node</category>
      <category>javascript</category>
      <category>email</category>
      <category>webdev</category>
    </item>
    <item>
      <title>“Failed to fetch” in a contact form: 10 causes, reproduced in Chrome, Firefox and Safari</title>
      <dc:creator>Formgong</dc:creator>
      <pubDate>Fri, 09 Oct 2026 12:28:24 +0000</pubDate>
      <link>https://dev.to/formgongteam/failed-to-fetch-in-a-contact-form-10-causes-reproduced-in-chrome-firefox-and-safari-986</link>
      <guid>https://dev.to/formgongteam/failed-to-fetch-in-a-contact-form-10-causes-reproduced-in-chrome-firefox-and-safari-986</guid>
      <description>&lt;p&gt;Your contact form calls &lt;code&gt;fetch()&lt;/code&gt;, and the catch block gets &lt;code&gt;TypeError: Failed to fetch&lt;/code&gt;. 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.&lt;/p&gt;

&lt;p&gt;Three findings changed how we debug these errors:&lt;/p&gt;

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

&lt;h2&gt;
  
  
  What the error says in each browser
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;catch&lt;/code&gt; block received the same thing for all of the network causes below:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Browser&lt;/th&gt;
&lt;th&gt;&lt;code&gt;error.name&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;&lt;code&gt;error.message&lt;/code&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Chrome&lt;/td&gt;
&lt;td&gt;&lt;code&gt;TypeError&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Failed to fetch&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Firefox&lt;/td&gt;
&lt;td&gt;&lt;code&gt;TypeError&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;NetworkError when attempting to fetch resource.&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Safari (WebKit)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;TypeError&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Load failed&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

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

&lt;h2&gt;
  
  
  The console tells the causes apart
&lt;/h2&gt;

&lt;p&gt;This is what each browser printed to the console. "Reached the server" is what our API logged.&lt;/p&gt;

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

&lt;p&gt;Things to notice:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Firefox reports a dead server as a CORS problem.&lt;/strong&gt; 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: &lt;code&gt;(null)&lt;/code&gt; means no response arrived at all, so this is not a CORS header problem.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Chrome hides the preflight status.&lt;/strong&gt; 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 &lt;code&gt;OPTIONS&lt;/code&gt; request's status.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mixed content is easy to miss.&lt;/strong&gt; 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 &lt;code&gt;https://&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The CORS error that still delivered the message
&lt;/h2&gt;

&lt;p&gt;A &lt;code&gt;fetch&lt;/code&gt; with a &lt;code&gt;FormData&lt;/code&gt; body and no custom headers is a "simple" request (so is a &lt;code&gt;URLSearchParams&lt;/code&gt; body, per the Fetch standard; we tested &lt;code&gt;FormData&lt;/code&gt;): 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 &lt;code&gt;Access-Control-Allow-Origin&lt;/code&gt; header.&lt;/p&gt;

&lt;p&gt;For a contact form that means: &lt;strong&gt;the email was sent, and the visitor saw an error.&lt;/strong&gt; 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.&lt;/p&gt;

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

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

&lt;h2&gt;
  
  
  A dropped connection sends the form again
&lt;/h2&gt;

&lt;p&gt;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 &lt;code&gt;fetch()&lt;/code&gt; call, three times per browser:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Browser&lt;/th&gt;
&lt;th&gt;POSTs received per &lt;code&gt;fetch()&lt;/code&gt;
&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Chrome&lt;/td&gt;
&lt;td&gt;2, 2, 2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Firefox&lt;/td&gt;
&lt;td&gt;10, 10, 10&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Safari (WebKit)&lt;/td&gt;
&lt;td&gt;1, 1, 1&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;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:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;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.&lt;/li&gt;
&lt;li&gt;Do not add your own automatic retry on &lt;code&gt;TypeError&lt;/code&gt; without such an id.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Submit button without &lt;code&gt;preventDefault()&lt;/code&gt;
&lt;/h2&gt;

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

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Browser&lt;/th&gt;
&lt;th&gt;Did the POST reach the API?&lt;/th&gt;
&lt;th&gt;Did the code see an error?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Chrome&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No. The page navigated away and the promise never settled.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Firefox&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes, &lt;code&gt;NetworkError when attempting to fetch resource.&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Safari (WebKit)&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes, &lt;code&gt;Load failed&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;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 &lt;code&gt;preventDefault()&lt;/code&gt; first. A reload with your field values appearing in the address bar (&lt;code&gt;?email=…&lt;/code&gt;) is the giveaway.&lt;/p&gt;

&lt;h2&gt;
  
  
  Not causes of "Failed to fetch"
&lt;/h2&gt;

&lt;p&gt;Two things are often blamed but behave differently:&lt;/p&gt;

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

&lt;h2&gt;
  
  
  A submit handler that survives all of the above
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;form&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;#contact&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;button&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;form&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;button[type=submit]&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;form&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;.status&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;// One id per form fill, so a retried or repeated request can be recognised on the server.&lt;/span&gt;
&lt;span class="c1"&gt;// crypto.randomUUID() works on HTTPS pages and localhost only.&lt;/span&gt;
&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;submissionId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;randomUUID&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="nx"&gt;form&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;submit&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;preventDefault&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// without this, each browser fails in its own way&lt;/span&gt;
  &lt;span class="nx"&gt;button&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;disabled&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// stops double clicks while the request is in flight&lt;/span&gt;
  &lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;textContent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Sending…&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;FormData&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;form&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;submission_id&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;submissionId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;form&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;action&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;Accept&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;AbortSignal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;15000&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="k"&gt;catch&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({}));&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;success&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="c1"&gt;// The server answered: show its message, not a generic network error.&lt;/span&gt;
      &lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;textContent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="s2"&gt;`The server answered &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;. Please try again.`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;textContent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Thank you, your message was sent.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;form&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reset&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nx"&gt;submissionId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;randomUUID&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;timedOut&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;TimeoutError&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;AbortError&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;textContent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;timedOut&lt;/span&gt;
      &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;The server took too long to answer. Your message may still have arrived, so check before sending again.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
      &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Could not reach the server. Check your connection and try again.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;finally&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;button&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;disabled&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checklist when you see the error
&lt;/h2&gt;

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

&lt;h2&gt;
  
  
  How we tested
&lt;/h2&gt;

&lt;p&gt;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 &lt;code&gt;.invalid&lt;/code&gt; 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.&lt;/p&gt;




&lt;p&gt;We build &lt;a href="https://formgong.com" rel="noopener noreferrer"&gt;Formgong&lt;/a&gt;, a form backend for static and AI-built sites. Its endpoint answers preflights and sends CORS headers for any origin, and it answers &lt;code&gt;{"success": false, "message": …}&lt;/code&gt; 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 &lt;a href="https://formgong.com/blog/emailjs-errors" rel="noopener noreferrer"&gt;EmailJS&lt;/a&gt;, &lt;a href="https://formgong.com/blog/resend-errors" rel="noopener noreferrer"&gt;Resend&lt;/a&gt;, &lt;a href="https://formgong.com/blog/formspree-errors" rel="noopener noreferrer"&gt;Formspree&lt;/a&gt; and &lt;a href="https://formgong.com/blog/web3forms-errors" rel="noopener noreferrer"&gt;Web3Forms&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>webdev</category>
      <category>cors</category>
      <category>debugging</category>
    </item>
  </channel>
</rss>
