<?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>What happens to a form submission after the free limit runs out</title>
      <dc:creator>Formgong</dc:creator>
      <pubDate>Thu, 29 Oct 2026 17:24:50 +0000</pubDate>
      <link>https://dev.to/formgongteam/what-happens-to-a-form-submission-after-the-free-limit-runs-out-15cn</link>
      <guid>https://dev.to/formgongteam/what-happens-to-a-form-submission-after-the-free-limit-runs-out-15cn</guid>
      <description>&lt;p&gt;Say your contact form has been fine for months. Then someone's bot finds it, or you have one good week, and the free plan runs out halfway through the month. The next customer who fills in the form might still get your "Thank you" page, and you might never get their message. Nothing on your side looks broken, so there's no reason to go and check.&lt;/p&gt;

&lt;p&gt;On pricing pages, free form backends look almost the same: 50 a month here, 250 there. The part that differs is what happens to the message that arrives after that, and most vendors answer it in one FAQ line, if at all.&lt;/p&gt;

&lt;p&gt;We're Formgong and we make one of the 14 services in this list, so treat our row with the suspicion you'd give any vendor writing about competitors. On 16 October 2026 we read every vendor's own pricing, docs and help pages for a bigger comparison, and on 29 October we fetched the same pages again with curl to see what had moved. We didn't run real accounts past the limit, so this is what each vendor says it does, quoted where the wording matters. There are no prices here; those change more often than anything else on these pages.&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 limit&lt;/th&gt;
&lt;th&gt;A submission over the limit&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;50 a month&lt;/td&gt;
&lt;td&gt;Saved to an Over Limit folder without notification emails; you reprocess it once you have room&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Formcarry&lt;/td&gt;
&lt;td&gt;50 a month, spam counts&lt;/td&gt;
&lt;td&gt;Held until the 1st of next month (up to 1,900) while the visitor sees the normal thank-you page; past that the endpoint answers 401&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Formspark&lt;/td&gt;
&lt;td&gt;250 in total, not per month&lt;/td&gt;
&lt;td&gt;A small buffer is held under Overflow and released when you buy a bundle; past that, discarded. JSON callers get 402&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Formgong&lt;/td&gt;
&lt;td&gt;1,000 a month&lt;/td&gt;
&lt;td&gt;Still delivered on credit, up to 5,000 extra, until the 7th of next month; if unpaid, stored but not delivered until you pay, then 429&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Forminit&lt;/td&gt;
&lt;td&gt;100 a month&lt;/td&gt;
&lt;td&gt;The form pauses; submissions are rejected, not stored and cannot be recovered&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Static Forms&lt;/td&gt;
&lt;td&gt;250 every 30 days&lt;/td&gt;
&lt;td&gt;Rejected with an error, neither delivered nor stored; warning emails at 80% and 90%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Splitforms&lt;/td&gt;
&lt;td&gt;500 in total, not per month&lt;/td&gt;
&lt;td&gt;HTTP 429, not stored&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;FormKeep&lt;/td&gt;
&lt;td&gt;50 a month per form, spam counts&lt;/td&gt;
&lt;td&gt;Ignored and lost&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Netlify Forms&lt;/td&gt;
&lt;td&gt;Unlimited on credit-based plans&lt;/td&gt;
&lt;td&gt;If the project goes over its plan limits, the whole project is paused&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Jotform&lt;/td&gt;
&lt;td&gt;100 a month&lt;/td&gt;
&lt;td&gt;Forms are disabled and show "This Form Has Exceeded Its Allocated Quota"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Basin&lt;/td&gt;
&lt;td&gt;50 a month&lt;/td&gt;
&lt;td&gt;Its own pages disagree&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;FormBackend&lt;/td&gt;
&lt;td&gt;50 a month&lt;/td&gt;
&lt;td&gt;"Not dropped without warning"; held or rejected is not stated&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Web3Forms&lt;/td&gt;
&lt;td&gt;250 a month&lt;/td&gt;
&lt;td&gt;Not stated in the docs; the pricing page refused our reader (403)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;FormSubmit&lt;/td&gt;
&lt;td&gt;No published limit&lt;/td&gt;
&lt;td&gt;Nothing published to go over; the site was down when we checked on 29 October&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvnlntdw9fpj570ys8qzj.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvnlntdw9fpj570ys8qzj.png" alt="Where a message goes after the free limit: 4 services throw it away, 3 hold it, 3 do something else, 4 don't say" width="800" height="384"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Four of them throw the message away
&lt;/h2&gt;

&lt;p&gt;Forminit, Static Forms, Splitforms and FormKeep don't keep anything that comes in over the limit. Forminit says it most plainly: submissions sent while a form is paused "are not stored or queued and cannot be recovered". Static Forms and Splitforms say much the same and send back an error status (Splitforms uses 429). FormKeep's pricing page says extra submissions are ignored, and an article on its support site says they will be lost.&lt;/p&gt;

&lt;p&gt;There's one more thing about FormKeep, and Formcarry too. Both count spam toward the free 50. A bot that finds your form on the 3rd can spend the whole month for you.&lt;/p&gt;

&lt;p&gt;Of all the bad outcomes, an error status is the one you can do something about, because your page sees it. If your form posts with &lt;code&gt;fetch&lt;/code&gt;, look at the status and give the person another way to reach you:&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;res&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="na"&gt;body&lt;/span&gt;&lt;span class="p"&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="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;res&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="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;showThanks&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// 429 from Splitforms, 402 from Formspark, 401 from Formcarry past its hold&lt;/span&gt;
  &lt;span class="nf"&gt;showError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;This didn't go through. Please email us at hello@example.com.&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With a plain HTML form and no JavaScript, the visitor lands on whatever error page the backend renders, and you have no say in what it says.&lt;/p&gt;

&lt;h2&gt;
  
  
  The ones that keep it
&lt;/h2&gt;

&lt;p&gt;Formspree, Formcarry and Formspark hold on to over-limit submissions, so nothing disappears right away. Where they keep it is the problem. Formspree puts it in an Over Limit folder "without sending notification emails or running plugins". In fairness, Formspree also emails you when that starts and warns you at 50%, 75% and 90%, which is more than most do. Formcarry sets the extra submissions aside until the first day of the next month, and meanwhile the visitor sees the normal thank-you page. If you upgrade before then, they move into your form.&lt;/p&gt;

&lt;p&gt;From the visitor's side a held message and a delivered one look the same. Someone asks for a quote on the 12th, sees "thank you" and waits. If you read it on the 1st, they've likely gone with someone else.&lt;/p&gt;

&lt;p&gt;Formspark and Splitforms work differently in another way. Their free allowance, 250 and 500, is a total and doesn't come back next month. For a portfolio form that gets a message every few weeks that's plenty. A small shop will use it up.&lt;/p&gt;

&lt;p&gt;Then there's us. When a Formgong account goes over, we keep delivering on credit until the 7th of the next month, up to 5,000 extra. That works once in any 12 months. If you go over again later in the year you get 20% on top, and after that new submissions wait until you upgrade or the month turns over. We took this from our code, not from a FAQ, but please check it like you'd check anyone else's claim.&lt;/p&gt;

&lt;h2&gt;
  
  
  When the limit stops more than the form
&lt;/h2&gt;

&lt;p&gt;Netlify is a strange case. On its credit-based plans the forms are free and unlimited, so you'd think there's nothing to hit. But the plan has credit limits of its own, and Netlify's billing FAQ says that projects which go over them "will be paused and will no longer receive web requests, form submissions, etc." until the next billing cycle, unless you upgrade or switch on auto recharge. So when that happens, your whole site is down, and the form with it.&lt;/p&gt;

&lt;p&gt;Jotform goes the other way and is loud about it. It disables the form, and anyone who opens it sees "This Form Has Exceeded Its Allocated Quota". You've lost the form until the month resets, sure. Nobody walks away thinking their message got through, though.&lt;/p&gt;

&lt;h2&gt;
  
  
  When the vendor's own pages don't answer
&lt;/h2&gt;

&lt;p&gt;We couldn't pin down four of them.&lt;/p&gt;

&lt;p&gt;Basin contradicts itself. The FAQ in its docs says free-plan submissions "may be restricted until the next month", while the pricing page says "We won't cut you off". Both still said that when we looked again on the 29th. FormBackend only promises that submissions "aren't dropped without warning", which doesn't tell you whether they're held or refused. With Web3Forms we got nothing: the docs don't cover it and the pricing page sent curl a 403 on both days. FormSubmit says it has no limit, and on the 29th we couldn't even re-read that, because the site was returning a Cloudflare 521.&lt;/p&gt;

&lt;p&gt;If you're on one of these, ask their support, or watch the dashboard the first month you get close.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to check on your own form
&lt;/h2&gt;

&lt;p&gt;First, find your backend in the table and see where its warning emails go. Plenty of us signed up with some address we stopped reading years ago. If spam counts on your plan, turn on the spam filter or a captcha now, not after a bot has eaten the month. And send your form one request it should refuse (a wrong key is the easiest) and look at the page a visitor would get. It takes two minutes.&lt;/p&gt;

&lt;p&gt;We wrote up the same 14 services in more detail, with how each answered a wrong key and where it keeps your data, in our &lt;a href="https://formgong.com/en/best-form-backend/" rel="noopener noreferrer"&gt;form backend comparison&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Has a form limit ever cost you a lead? Or did you find a pile of old messages in an over-limit folder one day? Tell us in the comments which backend it was and how you found out. If a vendor behaves differently from what its page says, we'd like to hear that as well, so we can fix the table.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>forms</category>
      <category>beginners</category>
    </item>
    <item>
      <title>Our moderation model answers in 0.3 s or in 19 s, nothing in between. Hedged requests took us from 15/30 to 26/30</title>
      <dc:creator>Formgong</dc:creator>
      <pubDate>Thu, 22 Oct 2026 13:56:14 +0000</pubDate>
      <link>https://dev.to/formgongteam/our-moderation-model-answers-in-03-s-or-in-19-s-nothing-in-between-hedged-requests-took-us-from-15k</link>
      <guid>https://dev.to/formgongteam/our-moderation-model-answers-in-03-s-or-in-19-s-nothing-in-between-hedged-requests-took-us-from-15k</guid>
      <description>&lt;p&gt;We run a small AI help chat for websites, and every message in it passes a safety check before anything else happens. A visitor's question is classified by Llama Guard 3 (the 8B model, on Cloudflare Workers AI) before it reaches retrieval and the chat model, and the model's answer is classified again, together with the question, before it is shown and stored. Only an explicit "unsafe" verdict refuses the turn.&lt;/p&gt;

&lt;p&gt;The chat streams its answers, so the check sits on the critical path. We gave it a budget of 3 seconds. Longer than that and the visitor is staring at a silent chat window wondering if it broke.&lt;/p&gt;

&lt;p&gt;Then we measured the latency, on 2026-10-19, with 30 calls. It was bimodal in a way we hadn't seen from an inference API before: about half the calls came back in 0.1 to 0.3 seconds, and the rest took anywhere from 3 to 19 seconds. Almost nothing in between. A warm path and a cold path, presumably, though Workers AI doesn't say.&lt;/p&gt;

&lt;p&gt;With that distribution, a single call against a 3-second timeout answered 15 times out of 30. A coin flip, which for a safety check is embarrassing: half of all turns went through unchecked purely because the classifier drew the slow path.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix is from a 2013 paper
&lt;/h2&gt;

&lt;p&gt;Raising the timeout doesn't help. The slow path runs up to 19 seconds, so a timeout long enough to catch it makes the chat unusable, and any timeout short enough for the chat keeps losing the slow half.&lt;/p&gt;

&lt;p&gt;What does help is noticing that the two paths are independent draws. If one call lands on the slow path with probability ~0.5, two calls both land on it with probability ~0.25. This is the "hedged request" from Dean and Barroso's The Tail at Scale: when a reply is slow, send the same request again and take whichever answer arrives first.&lt;/p&gt;

&lt;p&gt;Ours starts a copy of the same classification every second while no call has answered, inside the same 3-second budget:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;GUARD_TIMEOUT_MS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;GUARD_HEDGE_MS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="nx"&gt;_000&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;call&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;ai&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;GUARD_MODEL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;max_tokens&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;temperature&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&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;copies&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;length&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ceil&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;GUARD_TIMEOUT_MS&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;GUARD_HEDGE_MS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
  &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;reject&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;timers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;call&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;reject&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;GUARD_HEDGE_MS&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&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;out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;race&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
  &lt;span class="nf"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;copies&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;reject&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;timers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;reject&lt;/span&gt;&lt;span class="p"&gt;(&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="s2"&gt;`timeout &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;GUARD_TIMEOUT_MS&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; ms`&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt; &lt;span class="nx"&gt;GUARD_TIMEOUT_MS&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;So at most three calls race: the original, a copy at 1 s, a copy at 2 s. When the original draws the fast path (half the time), it answers in 0.3 s and no copy ever starts, because the timers get cleared in a &lt;code&gt;finally&lt;/code&gt;. The copies only cost money on the turns that were about to fail anyway.&lt;/p&gt;

&lt;p&gt;Same 30-call measurement, same 3-second budget, with hedging: 26 answers out of 30.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two details that matter more than the race
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Don't hedge errors.&lt;/strong&gt; The first version of this raced everything, and that's wrong. A hedge is a bet that the same request will be fast the second time. That's true for a slow call, and false for a quota error or an outage, where the retry burns money to fail identically. So an error from any call ends the whole check at once; only silence gets hedged. In the code above that falls out of &lt;code&gt;Promise.race&lt;/code&gt; naturally, since a rejection wins the race the same way a result does.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Decide what a missing verdict means before you ship.&lt;/strong&gt; Hedging got us from 15 to 26 out of 30, not to 30. The remaining failures time out, and something has to happen with that turn. We fail open: the failure is logged and the turn continues unchecked, because for a help chat on a shop's website, a chat that goes mute whenever a classifier flakes is worse than one unchecked message. A moderation pipeline for user-generated content that stays up forever would reasonably make the opposite choice and fail closed. Either way, pick it deliberately; the default you don't choose is still a choice.&lt;/p&gt;

&lt;p&gt;The measured numbers, for easy comparison, all on the same day with 30 calls each: fast path 0.1–0.3 s for about half the calls, slow path 3–19 s for the rest; one call inside 3 s: 15 of 30; hedged copies each second inside 3 s: 26 of 30.&lt;/p&gt;

&lt;p&gt;This runs in production in &lt;a href="https://formgong.com/?ref=devto" rel="noopener noreferrer"&gt;Formgong&lt;/a&gt;'s help chat, where the same check also writes the category of anything flagged into the message log. If you've dealt with bimodal inference latency on Workers AI or elsewhere, we'd like to hear what distribution you saw and what you did about it, and if you try the chat and something about it annoys you, tell us in the comments or at &lt;a href="mailto:support@formgong.com"&gt;support@formgong.com&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>ai</category>
      <category>cloudflare</category>
      <category>javascript</category>
    </item>
    <item>
      <title>A Playwright test that catches a contact form pretending to send</title>
      <dc:creator>Formgong</dc:creator>
      <pubDate>Mon, 19 Oct 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/formgongteam/a-playwright-test-that-catches-a-contact-form-pretending-to-send-524h</link>
      <guid>https://dev.to/formgongteam/a-playwright-test-that-catches-a-contact-form-pretending-to-send-524h</guid>
      <description>&lt;p&gt;A contact form that shows "Message sent!" and sends nothing looks exactly like one that works. Unit tests won't catch it, because there is nothing to unit test: the handler does exactly what it was written to do. You notice when a customer mentions they wrote to you weeks ago.&lt;/p&gt;

&lt;p&gt;This isn't rare. We tested 105 contact forms on live sites built with AI tools like Lovable and Bolt, and 27 of them showed a success message without sending anything (&lt;a href="https://formgong.com/en/blog/contact-forms-that-send-nothing/" rel="noopener noreferrer"&gt;the study and its data&lt;/a&gt;). The handler behind most of them looked roughly like this (a reconstruction of the pattern, not a copy of any one site):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;handleSubmit&lt;/span&gt; &lt;span class="o"&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;e&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;React&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;FormEvent&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;e&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="nf"&gt;setIsSubmitting&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="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="nf"&gt;toast&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Message sent!&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;We'll get back to you soon.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="nf"&gt;setFormData&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;""&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="dl"&gt;""&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="nf"&gt;setIsSubmitting&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="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There's a loading state, a delay, a nice toast and a reset. The one thing missing is a request.&lt;/p&gt;

&lt;p&gt;Below are two Playwright tests that catch this, and the traps we fell into while building the browser harness for that study. The tests are much smaller than the harness, but the traps apply to both.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test 1: the typed text must leave the page
&lt;/h2&gt;

&lt;p&gt;The idea is to fill the form with a value nobody would type by accident, then look for that value in the outgoing requests. If no request carries it, the form didn't send what the visitor wrote, whatever the page says afterwards.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;test&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;expect&lt;/span&gt; &lt;span class="p"&gt;}&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;@playwright/test&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// A value no real visitor types, so it is easy to find in any URL or body.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;MARK&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;formcheck-7f3a91&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nf"&gt;test&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 form sends what the visitor typed&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;page&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="kd"&gt;const&lt;/span&gt; &lt;span class="na"&gt;carrying&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&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;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;route&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;**/*&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;route&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;route&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;request&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;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;postData&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&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;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;url&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;MARK&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;MARK&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;carrying&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&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="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;method&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="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;url&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="c1"&gt;// Answer it here, so CI never sends a real email.&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;route&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fulfill&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;contentType&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;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;success&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="p"&gt;});&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;route&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;();&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;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;goto&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="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getByLabel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Name&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;fill&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Form check&lt;/span&gt;&lt;span class="dl"&gt;"&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;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getByLabel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Email&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;fill&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="nx"&gt;MARK&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;@example.com`&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;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getByLabel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Message&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;fill&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Automated check &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;MARK&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="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getByRole&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&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;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sr"&gt;/send/i&lt;/span&gt; &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;click&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;expect&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;poll&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;carrying&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;5000&lt;/span&gt; &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;toBeGreaterThan&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&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 marker uses only letters, digits and a hyphen on purpose. They survive URL encoding and form encoding unchanged, so the same &lt;code&gt;includes&lt;/code&gt; check works for JSON, &lt;code&gt;FormData&lt;/code&gt; and &lt;code&gt;application/x-www-form-urlencoded&lt;/code&gt; bodies. If you put the marker only in the email field, remember that &lt;code&gt;@&lt;/code&gt; becomes &lt;code&gt;%40&lt;/code&gt; in an encoded body, which is why the message field carries it too.&lt;/p&gt;

&lt;p&gt;The fake response with &lt;code&gt;success: true&lt;/code&gt; is a convenience. If your form talks to a real backend with a test mode, let that request through instead and assert on what the backend stored.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test 2: no "thank you" when the backend fails
&lt;/h2&gt;

&lt;p&gt;The first test proves something leaves the page. The second one proves the page doesn't celebrate too early. Make every POST fail and check that no success text appears.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;no success message when the backend fails&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;page&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;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;route&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;**/*&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="nx"&gt;route&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="nx"&gt;route&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;request&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;method&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&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="nx"&gt;route&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fulfill&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;500&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="dl"&gt;""&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
      &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;route&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;continue&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="na"&gt;dialogs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;dialog&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;d&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;dialogs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;message&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;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dismiss&lt;/span&gt;&lt;span class="p"&gt;();&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;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;goto&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="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getByLabel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Name&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;fill&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Form check&lt;/span&gt;&lt;span class="dl"&gt;"&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;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getByLabel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Email&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;fill&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;formcheck@example.com&lt;/span&gt;&lt;span class="dl"&gt;"&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;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getByLabel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Message&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;fill&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Automated check&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;before&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;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;locator&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;body&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;innerText&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;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getByRole&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&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;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sr"&gt;/send/i&lt;/span&gt; &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;click&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="c1"&gt;// Sample for a few seconds: some toasts disappear after two or three.&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;seen&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nb"&gt;Set&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&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;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&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;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;waitForTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;250&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;now&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;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;locator&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;body&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;innerText&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;for &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;line&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;now&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&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;t&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;line&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&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;t&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;before&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="nx"&gt;seen&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;appeared&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;seen&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;dialogs&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;appeared&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;not&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toMatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/thank|sent|success|received|get back to you/i&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;Comparing against the text that was on the page before the click matters. Plenty of contact pages already say "We'll get back to you within a day" in their static copy, and a plain &lt;code&gt;getByText&lt;/code&gt; would fail on that.&lt;/p&gt;

&lt;h2&gt;
  
  
  The traps
&lt;/h2&gt;

&lt;p&gt;Our harness ran against hundreds of sites we didn't control, so it hit cases a test for your own site may never see. Still, each of these cost us a re-run, and a couple of them show up on ordinary sites.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Analytics requests.&lt;/strong&gt; Clicking Send often fires Google Analytics, Tag Manager, Clarity, Hotjar or PostHog. If your test counts "any request after the click" as sending, every fake form passes. Looking for the marker instead of counting requests avoids this.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Alert dialogs.&lt;/strong&gt; Some forms say thank you with &lt;code&gt;alert()&lt;/code&gt;. Playwright dismisses dialogs automatically if you don't handle them, so the success message never reaches your assertions. That's why the second test listens for &lt;code&gt;dialog&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Toasts that vanish.&lt;/strong&gt; We had forms that showed "Message sent!" for two or three seconds. Reading the page once after a fixed wait missed them. Sampling every 250 milliseconds caught them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Honeypots.&lt;/strong&gt; A hidden field that only bots fill in. Some handlers show a fake success when it has a value, so a test that fills every input looks like a bot and gets a fake "thank you". Fill fields the way a person would, by label, and you won't touch the honeypot.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Hidden selects.&lt;/strong&gt; shadcn/Radix select menus render a hidden native &lt;code&gt;&amp;lt;select&amp;gt;&lt;/code&gt; that the form validates against. When our harness stopped filling hidden fields to avoid honeypots, these forms started refusing to submit. On your own site, select the option through the visible menu, or call &lt;code&gt;selectOption&lt;/code&gt; on the native element with &lt;code&gt;{ force: true }&lt;/code&gt;, since Playwright won't act on a hidden element otherwise.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;mailto:&lt;/code&gt; without a request.&lt;/strong&gt; A form that does &lt;code&gt;location.href = "mailto:..."&lt;/code&gt; opens the visitor's mail app and never makes a network request. Test 1 will fail on it, and that's arguably right: the site gets nothing unless the visitor actually sends that email.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this fits
&lt;/h2&gt;

&lt;p&gt;Put both tests in the same CI job as the rest of your end-to-end suite and run them on every deploy. They take a few seconds. If you generate pages with an AI tool, they're worth even more, because a regenerated component can quietly swap a working handler for the pattern above.&lt;/p&gt;

&lt;p&gt;For the backend side, the rule is the same one the second test enforces: show success only after the service that stores or delivers the message confirms it. We build &lt;a href="https://formgong.com/?ref=devto" rel="noopener noreferrer"&gt;Formgong&lt;/a&gt;, a form backend that answers with &lt;code&gt;success: true&lt;/code&gt; only once a submission is stored, which is what the first test's fake response imitates.&lt;/p&gt;

&lt;p&gt;Do you test your contact form in CI, or do you find out from customers? We'd like to hear how other teams handle it, and if one of these tests breaks on your stack, tell us where.&lt;/p&gt;

</description>
      <category>playwright</category>
      <category>testing</category>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>I checked 9 copy-paste HTML contact forms from the top search results. 4 send nothing</title>
      <dc:creator>Formgong</dc:creator>
      <pubDate>Sat, 17 Oct 2026 15:55:36 +0000</pubDate>
      <link>https://dev.to/formgongteam/i-checked-9-copy-paste-html-contact-forms-from-the-top-search-results-4-send-nothing-2li2</link>
      <guid>https://dev.to/formgongteam/i-checked-9-copy-paste-html-contact-forms-from-the-top-search-results-4-send-nothing-2li2</guid>
      <description>&lt;p&gt;&lt;em&gt;Disclosure: I build Formgong, a form backend. Three of the pages below belong to companies like mine, and I say which.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;If you search "html contact form code", you get pages with a form you can copy. I wanted to know how many of those forms would actually deliver a message if you pasted them into a site today.&lt;/p&gt;

&lt;p&gt;On 13 October 2026 I took the top 10 pages for four searches ("html contact form code", "html contact form", "contact form html css", "simple contact form html"), merged by rank, skipping pages with no copyable code (a Scribd PDF, a Medium post with the code only as screenshots, and a page that now redirects to a form-builder tool). One page was down ("Site unavailable"), so 9 were judged. I wrote the checks down before opening the first page, and I only read the code. Nothing was submitted anywhere.&lt;/p&gt;

&lt;h2&gt;
  
  
  The checks
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Does it send?&lt;/strong&gt; The form has an &lt;code&gt;action&lt;/code&gt; pointing at something that exists, or the page gives the backend code. &lt;code&gt;action="#"&lt;/code&gt;, no action, or JavaScript that only shows "Thanks" count as no.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Labels.&lt;/strong&gt; Every field has a &lt;code&gt;&amp;lt;label&amp;gt;&lt;/code&gt; tied to it. A placeholder alone doesn't count.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;type="email"&lt;/code&gt;&lt;/strong&gt; on the email field.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;required&lt;/code&gt;&lt;/strong&gt; on name, email and message.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;autocomplete&lt;/code&gt;&lt;/strong&gt; on name and email, so phones and password managers can fill them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Any spam protection&lt;/strong&gt; in the snippet: a honeypot, a captcha, a server check.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;If JavaScript sends it:&lt;/strong&gt; does it show an error when the request fails?&lt;/li&gt;
&lt;/ol&gt;

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

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkppjmjnzjgmsaaim3sdj.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkppjmjnzjgmsaaim3sdj.png" alt="Results table: 9 contact form snippets against 6 checks; 5 of 9 send, 0 of 9 set autocomplete or a spam check" width="800" height="398"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Page&lt;/th&gt;
&lt;th&gt;Owner&lt;/th&gt;
&lt;th&gt;Sends&lt;/th&gt;
&lt;th&gt;Labels&lt;/th&gt;
&lt;th&gt;type=email&lt;/th&gt;
&lt;th&gt;required&lt;/th&gt;
&lt;th&gt;autocomplete&lt;/th&gt;
&lt;th&gt;Spam&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Formspree library&lt;/td&gt;
&lt;td&gt;form backend&lt;/td&gt;
&lt;td&gt;yes, after signup&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;no&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;no&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;no&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;not in snippet&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Medium (Cwrcode)&lt;/td&gt;
&lt;td&gt;independent&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;no&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;no&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;no&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;no&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;no&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GeeksforGeeks&lt;/td&gt;
&lt;td&gt;independent&lt;/td&gt;
&lt;td&gt;yes (PHP)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;no&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;no&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;no&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;no&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;TemplateMonster Help (2011)&lt;/td&gt;
&lt;td&gt;independent&lt;/td&gt;
&lt;td&gt;yes (PHP)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;no&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;no&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;no&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;no&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;no&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;freeCodeCamp&lt;/td&gt;
&lt;td&gt;independent&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;no&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;no&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;yes*&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;no&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;no&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;TutorialsPoint&lt;/td&gt;
&lt;td&gt;independent&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;no&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;no&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;no&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;a Lovable-built blog&lt;/td&gt;
&lt;td&gt;independent&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;no&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;no&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;no&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Web3Forms&lt;/td&gt;
&lt;td&gt;form backend&lt;/td&gt;
&lt;td&gt;yes, after signup&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;no&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;no&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;not in snippet&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Formcarry blog&lt;/td&gt;
&lt;td&gt;form backend&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;no&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;no&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;no&lt;/strong&gt; (PHP version)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The totals:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;5 of 9 send anywhere.&lt;/strong&gt; Three of those five are form-backend companies, which is fair: sending is what they sell.&lt;/li&gt;
&lt;li&gt;Of the &lt;strong&gt;6 independent pages, 2 send&lt;/strong&gt;, and one of the two can't be submitted as shown (see TemplateMonster below). So &lt;strong&gt;1 of 6&lt;/strong&gt; gives code that delivers a message as pasted.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;3 of the 4&lt;/strong&gt; forms that don't send never tell the reader.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;0 of 9&lt;/strong&gt; set &lt;code&gt;autocomplete&lt;/code&gt;. &lt;strong&gt;0 of 9&lt;/strong&gt; have spam protection in the code itself. The three backend companies say they filter on their side; I didn't test that.&lt;/li&gt;
&lt;li&gt;The only snippet that submits with JavaScript shows "Thank you" without making a request at all.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Four things worth seeing in the code
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The fields have no names.&lt;/strong&gt; The Medium post's form has inputs for name, email and phone, and none of them has a &lt;code&gt;name&lt;/code&gt; attribute. Browsers only send fields that have a name. Even with a working backend behind it, the message would arrive with the country code and the text, and nothing that tells you who wrote it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;input&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"text"&lt;/span&gt; &lt;span class="na"&gt;size=&lt;/span&gt;&lt;span class="s"&gt;"33"&lt;/span&gt; &lt;span class="na"&gt;placeholder=&lt;/span&gt;&lt;span class="s"&gt;"Enter Your Name"&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"firstname"&lt;/span&gt; &lt;span class="na"&gt;autofocus&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;input&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"email"&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"eid"&lt;/span&gt; &lt;span class="na"&gt;placeholder=&lt;/span&gt;&lt;span class="s"&gt;"Enter Your Email"&lt;/span&gt; &lt;span class="na"&gt;size=&lt;/span&gt;&lt;span class="s"&gt;"33"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The Send button disappears.&lt;/strong&gt; The TemplateMonster article from 2011 shows its textarea with a &lt;code&gt;&amp;lt;br&amp;gt;&lt;/code&gt; after it and no closing tag. A browser keeps reading everything after an unclosed &lt;code&gt;&amp;lt;textarea&amp;gt;&lt;/code&gt; as its text, including the button. Parsed in Chrome, the form has zero submit buttons. Its PHP also builds the &lt;code&gt;From&lt;/code&gt; and &lt;code&gt;Reply-To&lt;/code&gt; headers from a variable the script never defines.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;An empty message passes &lt;code&gt;required&lt;/code&gt;.&lt;/strong&gt; freeCodeCamp's form posts to &lt;code&gt;action="#"&lt;/code&gt;, so it sends nothing, and the tutorial doesn't mention that. The labels point at ids the inputs don't have. And the textarea ships with a space inside:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;textarea&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"message"&lt;/span&gt; &lt;span class="na"&gt;cols=&lt;/span&gt;&lt;span class="s"&gt;"30"&lt;/span&gt; &lt;span class="na"&gt;rows=&lt;/span&gt;&lt;span class="s"&gt;"10"&lt;/span&gt; &lt;span class="na"&gt;placeholder=&lt;/span&gt;&lt;span class="s"&gt;"Enter your message here ..."&lt;/span&gt; &lt;span class="na"&gt;required&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nt"&gt;&amp;lt;/textarea&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That space is the field's value, so the browser treats it as filled. In Chrome, &lt;code&gt;validity.valueMissing&lt;/code&gt; is &lt;code&gt;false&lt;/code&gt; for a message nobody typed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Copy it as-is and you CC a stranger.&lt;/strong&gt; GeeksforGeeks is the one independent page whose code would deliver, on a PHP host where &lt;code&gt;mail()&lt;/code&gt; works. Its PHP adds a fixed header:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="s2"&gt;"CC: somebodyelse@example.com"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Paste it unchanged and every message also goes to example.com.&lt;/p&gt;

&lt;p&gt;One more, from a backend company's own page: Formcarry's PHP example checks the name and email with patterns that reject &lt;code&gt;o.brien+site@gmail.com&lt;/code&gt;, &lt;code&gt;ann@studio.agency&lt;/code&gt;, &lt;code&gt;José García&lt;/code&gt; and &lt;code&gt;Олена Коваль&lt;/code&gt;. I tested the two patterns with Python's &lt;code&gt;re&lt;/code&gt;, not PHP, because PHP isn't installed on my machine.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a contact form that works needs
&lt;/h2&gt;

&lt;p&gt;This is the checklist I'd use before pasting any snippet:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;form&lt;/span&gt; &lt;span class="na"&gt;action=&lt;/span&gt;&lt;span class="s"&gt;"https://your-endpoint.example/submit"&lt;/span&gt; &lt;span class="na"&gt;method=&lt;/span&gt;&lt;span class="s"&gt;"POST"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;label&amp;gt;&lt;/span&gt;Name &lt;span class="nt"&gt;&amp;lt;input&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"name"&lt;/span&gt; &lt;span class="na"&gt;required&lt;/span&gt; &lt;span class="na"&gt;autocomplete=&lt;/span&gt;&lt;span class="s"&gt;"name"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/label&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;label&amp;gt;&lt;/span&gt;Email &lt;span class="nt"&gt;&amp;lt;input&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"email"&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"email"&lt;/span&gt; &lt;span class="na"&gt;required&lt;/span&gt; &lt;span class="na"&gt;autocomplete=&lt;/span&gt;&lt;span class="s"&gt;"email"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/label&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;label&amp;gt;&lt;/span&gt;Message &lt;span class="nt"&gt;&amp;lt;textarea&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"message"&lt;/span&gt; &lt;span class="na"&gt;required&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/textarea&amp;gt;&amp;lt;/label&amp;gt;&lt;/span&gt;
  &lt;span class="c"&gt;&amp;lt;!-- honeypot: hidden from people, bots fill it, the server drops those --&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;hidden&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;label&amp;gt;&lt;/span&gt;Leave empty &lt;span class="nt"&gt;&amp;lt;input&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"botcheck"&lt;/span&gt; &lt;span class="na"&gt;tabindex=&lt;/span&gt;&lt;span class="s"&gt;"-1"&lt;/span&gt; &lt;span class="na"&gt;autocomplete=&lt;/span&gt;&lt;span class="s"&gt;"off"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/label&amp;gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;button&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"submit"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Send&lt;span class="nt"&gt;&amp;lt;/button&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/form&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;An &lt;code&gt;action&lt;/code&gt; that points at something that really sends. Then send yourself one test from the live page and check the inbox.&lt;/li&gt;
&lt;li&gt;A &lt;code&gt;name&lt;/code&gt; on every field. Without it the value never leaves the browser.&lt;/li&gt;
&lt;li&gt;Labels wrapped around or tied to each field, &lt;code&gt;type="email"&lt;/code&gt;, &lt;code&gt;required&lt;/code&gt;, and &lt;code&gt;autocomplete&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;No whitespace between &lt;code&gt;&amp;lt;textarea&amp;gt;&lt;/code&gt; and &lt;code&gt;&amp;lt;/textarea&amp;gt;&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Some spam check, even a honeypot.&lt;/li&gt;
&lt;li&gt;If you send with &lt;code&gt;fetch&lt;/code&gt;, show an error when the request fails. A "Thanks" that appears no matter what is worse than no form.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We put these rules into the free &lt;a href="https://formgong.com/en/tools/form-generator/?ref=devto" rel="noopener noreferrer"&gt;form generator&lt;/a&gt; on our site. It writes the same form as HTML, Tailwind, Bootstrap, React or Vue. It posts to Formgong, so it needs a key (there's a free plan). If you have your own backend, copy the markup and change the &lt;code&gt;action&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;To check a form that is already live, paste the page address into our &lt;a href="https://formgong.com/en/tools/form-checker/?ref=devto" rel="noopener noreferrer"&gt;form checker&lt;/a&gt;. It reads the page's HTML and reports a missing action, fields without names or labels, a lost submit button and a whitespace-filled &lt;code&gt;required&lt;/code&gt; textarea. It works with any form backend and never submits anything.&lt;/p&gt;

&lt;p&gt;The method, the full results and the failing line from every snippet are in a public repo: &lt;a href="https://github.com/formgong/html-snippet-study" rel="noopener noreferrer"&gt;formgong/html-snippet-study&lt;/a&gt;. If you want a page I didn't quote, ask in the comments.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tell me what I missed
&lt;/h2&gt;

&lt;p&gt;Which tutorial did you copy your first contact form from, and did it work? If you know a snippet that passes all seven checks, post it below. I'll add it to the table.&lt;/p&gt;

</description>
      <category>html</category>
      <category>webdev</category>
      <category>beginners</category>
      <category>forms</category>
    </item>
    <item>
      <title>CSP form-action also blocks the redirect after a form POST, and Chrome's error names the wrong URL</title>
      <dc:creator>Formgong</dc:creator>
      <pubDate>Fri, 16 Oct 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/formgongteam/csp-form-action-also-blocks-the-redirect-after-a-form-post-and-chromes-error-names-the-wrong-url-2l4a</link>
      <guid>https://dev.to/formgongteam/csp-form-action-also-blocks-the-redirect-after-a-form-post-and-chromes-error-names-the-wrong-url-2l4a</guid>
      <description>&lt;p&gt;&lt;em&gt;Disclosure: I build Formgong, a form backend. We hit this bug on our own review page.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;We were building a plain HTML page where customers leave a review: stars, a name, a text box, a submit button, no&lt;br&gt;
JavaScript. The page has a Content-Security-Policy with &lt;code&gt;form-action 'self'&lt;/code&gt;. The form posts to &lt;code&gt;/submit&lt;/code&gt;, and the&lt;br&gt;
server answers with a 303 back to a thank-you page.&lt;/p&gt;

&lt;p&gt;In local development the redirect pointed at a different port. We pressed Send and nothing happened. The page didn't&lt;br&gt;
move, the button didn't react, and Chrome's console said that sending form data to our own &lt;code&gt;/submit&lt;/code&gt; violated&lt;br&gt;
&lt;code&gt;form-action 'self'&lt;/code&gt; and that the request had been blocked.&lt;/p&gt;

&lt;p&gt;So we looked for the bug in the form's action URL. There wasn't one: &lt;code&gt;/submit&lt;/code&gt; was on the page's own origin, which&lt;br&gt;
&lt;code&gt;form-action 'self'&lt;/code&gt; allows. What the browser had refused was the redirect that came after it, and the message didn't&lt;br&gt;
say so.&lt;/p&gt;

&lt;p&gt;We then tested this properly on 10 October 2026: seven cases, three browser engines, with the expected result for every&lt;br&gt;
case written down before the first run.&lt;/p&gt;
&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;Three origins on one machine, so that "same origin" and "other origin" are real: A (the page), B (a form endpoint), C&lt;br&gt;
(somewhere else). The endpoint counts every POST it receives, so we know whether the data arrived regardless of what&lt;br&gt;
the page shows.&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;// one tiny server per origin: A = :4601, B = :4602, C = :4603 (from run.cjs)&lt;/span&gt;
&lt;span class="nx"&gt;http&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createServer&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;url&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;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;origin&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;method&lt;/span&gt; &lt;span class="o"&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="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pathname&lt;/span&gt; &lt;span class="o"&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="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;posts&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="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;                                  &lt;span class="c1"&gt;// the data arrived&lt;/span&gt;
    &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;resume&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;to&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;searchParams&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;to&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;code&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;searchParams&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;code&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="mi"&gt;303&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;to&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;writeHead&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&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;content-type&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;text/html&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;end&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;p id=done&amp;gt;received&amp;lt;/p&amp;gt;&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="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;writeHead&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="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;location&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;code&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;307&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="nf"&gt;origin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;to&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;/submit`&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="nf"&gt;origin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;to&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;/done`&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;end&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="c1"&gt;// GET /case?id=N serves the form with that case's Content-Security-Policy header&lt;/span&gt;
&lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;listen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;PORTS&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;127.0.0.1&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="c"&gt;&amp;lt;!-- the page, served with e.g.  Content-Security-Policy: form-action 'self' --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;form&lt;/span&gt; &lt;span class="na"&gt;method=&lt;/span&gt;&lt;span class="s"&gt;"POST"&lt;/span&gt; &lt;span class="na"&gt;action=&lt;/span&gt;&lt;span class="s"&gt;"http://127.0.0.1:4601/submit?to=B&amp;amp;code=303"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;input&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"message"&lt;/span&gt; &lt;span class="na"&gt;value=&lt;/span&gt;&lt;span class="s"&gt;"hello"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;button&amp;gt;&lt;/span&gt;Send&lt;span class="nt"&gt;&amp;lt;/button&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/form&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each case ran in a fresh browser context: Google Chrome 154.0.8037.99, and Playwright's builds of Firefox 155.0 and&lt;br&gt;
WebKit 26.6 (Safari's engine), all driven by Playwright. We clicked Send, waited 2.5 seconds, and recorded the final URL,&lt;br&gt;
the POSTs each origin received, the console, and the &lt;code&gt;securitypolicyviolation&lt;/code&gt; event.&lt;/p&gt;

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

&lt;p&gt;"Stays" means the page didn't navigate. The numbers are POSTs the server actually received.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;#&lt;/th&gt;
&lt;th&gt;Page CSP&lt;/th&gt;
&lt;th&gt;Form posts to&lt;/th&gt;
&lt;th&gt;Server answers&lt;/th&gt;
&lt;th&gt;Chrome 154&lt;/th&gt;
&lt;th&gt;Firefox 155&lt;/th&gt;
&lt;th&gt;WebKit 26.6&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;&lt;code&gt;form-action 'self'&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;A&lt;/td&gt;
&lt;td&gt;303 → A&lt;/td&gt;
&lt;td&gt;navigates, A got 1&lt;/td&gt;
&lt;td&gt;navigates, A got 1&lt;/td&gt;
&lt;td&gt;navigates, A got 1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;&lt;code&gt;form-action 'self'&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;A&lt;/td&gt;
&lt;td&gt;303 → B&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;stays, A got 1&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;navigates, A got 1&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;stays, A got 1&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;&lt;code&gt;form-action 'self'&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;B&lt;/td&gt;
&lt;td&gt;(never asked)&lt;/td&gt;
&lt;td&gt;stays, 0&lt;/td&gt;
&lt;td&gt;stays, 0&lt;/td&gt;
&lt;td&gt;stays, 0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;&lt;code&gt;form-action 'self' B&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;B&lt;/td&gt;
&lt;td&gt;303 → A&lt;/td&gt;
&lt;td&gt;navigates, B got 1&lt;/td&gt;
&lt;td&gt;navigates, B got 1&lt;/td&gt;
&lt;td&gt;navigates, B got 1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;&lt;code&gt;form-action 'self' B&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;B&lt;/td&gt;
&lt;td&gt;303 → C&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;stays, B got 1&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;navigates, B got 1&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;stays, B got 1&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;default-src 'self'&lt;/code&gt; only&lt;/td&gt;
&lt;td&gt;B&lt;/td&gt;
&lt;td&gt;303 → C&lt;/td&gt;
&lt;td&gt;navigates, B got 1&lt;/td&gt;
&lt;td&gt;navigates, B got 1&lt;/td&gt;
&lt;td&gt;navigates, B got 1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;&lt;code&gt;form-action 'self'&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;A&lt;/td&gt;
&lt;td&gt;307 → B&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;stays, A 1, B 0&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;navigates, A 1, &lt;strong&gt;B 1&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;stays, A 1, B 0&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;We ran it twice. In the first run Chrome's case 3 timed out, because Playwright waited for a navigation that a blocked&lt;br&gt;
form never starts. We counted that case as void rather than as a result and reran everything. The 20 cases both runs&lt;br&gt;
share came out the same.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three things that will cost you an afternoon
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvkyaob3u6s8jpymea1uv.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvkyaob3u6s8jpymea1uv.png" alt="Case 2 step by step. Chrome 154 and WebKit 26.6: the form posts to A, the server stores the message and answers 303 to B, the browser blocks B under form-action and the page stays on the form; Chrome's console names the action URL. Firefox 155: same POST and 303, but it follows the redirect without a form-action check" width="800" height="287"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. The data arrives, and the page acts as if it didn't.&lt;/strong&gt; In cases 2 and 5, Chrome and WebKit send the POST, the&lt;br&gt;
server stores it, and the browser then refuses to follow the redirect. The visitor sees the same form with the same text&lt;br&gt;
in it and no error. If they press Send again, that's a second POST, and you store the submission twice.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Chrome's message points at the wrong URL.&lt;/strong&gt; For case 2, Chrome prints:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Sending form data to '&lt;a href="http://127.0.0.1:4601/submit?to=B&amp;amp;code=303" rel="noopener noreferrer"&gt;http://127.0.0.1:4601/submit?to=B&amp;amp;code=303&lt;/a&gt;' violates the following Content Security Policy&lt;br&gt;
directive: "form-action 'self'". The request has been blocked.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That is word for word what it prints for case 3, where nothing was sent (only the URL differs). The URL it names is the&lt;br&gt;
form's action, which is allowed. The &lt;code&gt;securitypolicyviolation&lt;/code&gt; event does the same: its &lt;code&gt;blockedURI&lt;/code&gt; is the action URL&lt;br&gt;
in both Chrome and WebKit. Only WebKit's console names the real culprit:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Refused to load &lt;a href="http://127.0.0.1:4602/done" rel="noopener noreferrer"&gt;http://127.0.0.1:4602/done&lt;/a&gt; because it does not appear in the form-action directive of the Content&lt;br&gt;
Security Policy.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;3. Firefox didn't check the redirect in any of our cases.&lt;/strong&gt; Firefox 155 blocked the direct case (3), but in cases 2, 5&lt;br&gt;
and 7 it followed the redirect as if &lt;code&gt;form-action&lt;/code&gt; weren't there. With a 307 it went further and re-sent the POST body&lt;br&gt;
to the other origin (case 7, B got 1). If you test in Firefox, the form works. Your Chrome and Safari visitors then get&lt;br&gt;
the frozen page.&lt;/p&gt;

&lt;p&gt;One more thing to know, since it shows up in case 6: &lt;code&gt;form-action&lt;/code&gt; does not fall back to &lt;code&gt;default-src&lt;/code&gt;. A policy with&lt;br&gt;
only &lt;code&gt;default-src 'self'&lt;/code&gt; lets a form post and redirect anywhere, in all three engines. MDN's &lt;code&gt;form-action&lt;/code&gt; page says&lt;br&gt;
the same: no &lt;code&gt;default-src&lt;/code&gt; fallback, and "Not setting this allows anything."&lt;/p&gt;

&lt;p&gt;The spec hasn't settled this either. Whether &lt;code&gt;form-action&lt;/code&gt; should apply to redirects is&lt;br&gt;
issue #8 in the w3c/webappsec-csp repo, "CSP: form-action and redirects", filed in 2015 and still open when I checked&lt;br&gt;
on 13 October 2026. MDN's page warns that implementations are inconsistent.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fixing it
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;form-action&lt;/code&gt; has to allow every URL the submission passes through: the action and every redirect after it. In practice:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Redirect back to the page's own origin.&lt;/strong&gt; If the endpoint is yours, send the visitor to a thank-you URL on the same
origin as the form. That is what we did: the form posts to &lt;code&gt;/submit&lt;/code&gt; and returns to the same review page with &lt;code&gt;?sent=1&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;List the redirect target.&lt;/strong&gt; If the endpoint sends visitors to another domain (a payment page, a hosted thank-you
page), that origin goes into &lt;code&gt;form-action&lt;/code&gt; too, not just the endpoint's.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Or don't navigate at all.&lt;/strong&gt; Submit with &lt;code&gt;fetch()&lt;/code&gt; and show the result on the page. &lt;code&gt;form-action&lt;/code&gt; doesn't apply to
&lt;code&gt;fetch()&lt;/code&gt;; &lt;code&gt;connect-src&lt;/code&gt; does, and since the page never navigates, &lt;code&gt;form-action&lt;/code&gt; never sees a redirect.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Check the result in Chrome or Safari, not Firefox. Watch the server's log as well as the browser's console: if the server&lt;br&gt;
received the POST and the page didn't move, the redirect is the problem, whatever the console says.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;We found this while building a review page for &lt;a href="https://formgong.com" rel="noopener noreferrer"&gt;Formgong&lt;/a&gt;, a form backend for static and&lt;br&gt;
AI-built sites. The test plan was written before the run, and we kept the raw results of both runs, every console line&lt;br&gt;
included.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Your turn
&lt;/h2&gt;

&lt;p&gt;Have you seen this in another browser, or with a different redirect code? Tell me in the comments and I'll add it to the table. And if you try Formgong for your own forms, I'd really like to hear what's missing or confusing.&lt;/p&gt;

</description>
      <category>security</category>
      <category>webdev</category>
      <category>html</category>
      <category>javascript</category>
    </item>
    <item>
      <title>A WordPress contact form block that does not use your host's mail</title>
      <dc:creator>Formgong</dc:creator>
      <pubDate>Wed, 14 Oct 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/formgongteam/a-wordpress-contact-form-block-that-does-not-use-your-hosts-mail-1fmg</link>
      <guid>https://dev.to/formgongteam/a-wordpress-contact-form-block-that-does-not-use-your-hosts-mail-1fmg</guid>
      <description>&lt;p&gt;&lt;em&gt;Disclosure: I build Formgong. Our WordPress plugin went live in the official directory on 13 October 2026, so part of this post is about it. Everything I say about the other plugins comes from their own pages and source code, checked the same day.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The search "wordpress contact form block" mixes two different blocks.&lt;/p&gt;

&lt;p&gt;On WordPress.com, the block you get from the inserter is Jetpack's Form block. The WordPress.com support page (last reviewed on 14 August 2026) says to search for "form" and pick a layout: Contact, RSVP, Registration, Appointment, Feedback, or Multistep. Responses are collected under Jetpack → Forms. If you don't see the block, the same page says you may be on a WordPress.org site, and you need to install the free Jetpack plugin.&lt;/p&gt;

&lt;p&gt;That's a good answer when you want the replies inside WordPress.&lt;/p&gt;

&lt;p&gt;It's a bad answer when the thing that fails is your host's mail.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the notification email comes from
&lt;/h2&gt;

&lt;p&gt;On a self-hosted site, every block I looked at ends in the same function. I checked each one on 13 October 2026:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Jetpack Forms.&lt;/strong&gt; In the Jetpack repo on GitHub (trunk), &lt;code&gt;class-feedback-email-renderer.php&lt;/code&gt; wraps core &lt;code&gt;\wp_mail()&lt;/code&gt; to send the notification.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Contact Form Block&lt;/strong&gt; (Jordy Meow, version 1.1.1). In the plugin's &lt;code&gt;classes/core.php&lt;/code&gt;, the default recipient is &lt;code&gt;get_option( 'admin_email' )&lt;/code&gt;, and the message goes out with &lt;code&gt;wp_mail( $form['to'], ... )&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Flinkform&lt;/strong&gt; (version 1.15.0). Its directory page says notifications are "Sent through your site's standard WordPress mail (wp_mail)".&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So if &lt;code&gt;wp_mail()&lt;/code&gt; on your host doesn't deliver, swapping one of these blocks for another changes nothing. You get a new form that fails the same way. Jetpack and Flinkform keep a copy of each submission in WordPress, so the lead isn't lost, but nobody gets an email about it.&lt;/p&gt;

&lt;p&gt;A thank-you line under the button is not proof. Send one test from the live page and read the inbox.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two directory plugins that show up for the same search
&lt;/h2&gt;

&lt;p&gt;I did not install these. The facts below are from their directory pages and the WordPress.org plugin API on 13 October 2026.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Contact Form Block&lt;/strong&gt;, version 1.1.1, emails the site admin by default. Its page says a honeypot and a signed timestamp are on by default, and the 1.1.1 changelog (dated 2026/10/10) says posts sent directly to the site or in under 3 seconds are rejected. It says it loads no JS or CSS files unless you turn on reCAPTCHA. The API lists 600 active installations.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Flinkform&lt;/strong&gt;, version 1.15.0, stores submissions in its own database table and emails you through &lt;code&gt;wp_mail&lt;/code&gt;. It offers conditional logic and multi-step forms in the free plugin, and says it ships under 15 KB of gzipped frontend JavaScript. That size is their claim; I didn't measure it. The API lists 20 active installations.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If your host's mail works, either is a reasonable pick. If you need conditional rules that you write yourself, Flinkform has them for free. Pair it with an SMTP plugin if your host's mail is the weak spot.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the Formgong block does instead
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkitwrjzt9lct2xx03hou.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkitwrjzt9lct2xx03hou.png" alt="A Formgong contact form on a published WordPress page with the Storefront theme: name, email, phone and message fields with underline inputs and a dark Send button" width="640" height="591"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The Aula template on a published page, Storefront theme.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Formgong is a third option, and a narrow one: no form builder, and no submissions stored in WordPress.&lt;/p&gt;

&lt;p&gt;The plugin adds a block called &lt;strong&gt;Formgong form&lt;/strong&gt; (&lt;code&gt;formgong/form&lt;/code&gt;) and a shortcode. The block has a template picker in the sidebar. The shortcode looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[formgong]
[formgong template="aula"]
[formgong product="1"]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The form is a plain HTML form. The visitor's browser posts it straight to &lt;code&gt;https://formgong.com/submit&lt;/code&gt;, and Formgong sends the notification. The plugin's PHP never calls &lt;code&gt;wp_mail()&lt;/code&gt;. I grepped the 1.0.1 source to be sure. The only request the plugin makes from your server is the admin's "Send a test submission" button, and that goes through &lt;code&gt;wp_safe_remote_post()&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Stripped down, the default form renders like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;form&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"formgong-form formgong-skin-plain"&lt;/span&gt; &lt;span class="na"&gt;action=&lt;/span&gt;&lt;span class="s"&gt;"https://formgong.com/submit"&lt;/span&gt; &lt;span class="na"&gt;method=&lt;/span&gt;&lt;span class="s"&gt;"post"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;h2&amp;gt;&lt;/span&gt;Contact us&lt;span class="nt"&gt;&amp;lt;/h2&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;input&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"hidden"&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"access_key"&lt;/span&gt; &lt;span class="na"&gt;value=&lt;/span&gt;&lt;span class="s"&gt;"YOUR-ACCESS-KEY"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;input&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"hidden"&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"_lang"&lt;/span&gt; &lt;span class="na"&gt;value=&lt;/span&gt;&lt;span class="s"&gt;"en"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;input&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"hidden"&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"_page_url"&lt;/span&gt; &lt;span class="na"&gt;value=&lt;/span&gt;&lt;span class="s"&gt;"https://example.com/contact/"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;label&amp;gt;&lt;/span&gt;Name &lt;span class="nt"&gt;&amp;lt;input&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"text"&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"name"&lt;/span&gt; &lt;span class="na"&gt;autocomplete=&lt;/span&gt;&lt;span class="s"&gt;"name"&lt;/span&gt; &lt;span class="na"&gt;required&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/label&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;label&amp;gt;&lt;/span&gt;Email &lt;span class="nt"&gt;&amp;lt;input&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"email"&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"email"&lt;/span&gt; &lt;span class="na"&gt;autocomplete=&lt;/span&gt;&lt;span class="s"&gt;"email"&lt;/span&gt; &lt;span class="na"&gt;required&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/label&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;label&amp;gt;&lt;/span&gt;Message &lt;span class="nt"&gt;&amp;lt;textarea&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"message"&lt;/span&gt; &lt;span class="na"&gt;required&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/textarea&amp;gt;&amp;lt;/label&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"formgong-trap"&lt;/span&gt; &lt;span class="na"&gt;aria-hidden=&lt;/span&gt;&lt;span class="s"&gt;"true"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;label&amp;gt;&lt;/span&gt;Leave this field empty&lt;span class="nt"&gt;&amp;lt;input&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"text"&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"botcheck"&lt;/span&gt; &lt;span class="na"&gt;tabindex=&lt;/span&gt;&lt;span class="s"&gt;"-1"&lt;/span&gt; &lt;span class="na"&gt;autocomplete=&lt;/span&gt;&lt;span class="s"&gt;"off"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/label&amp;gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;button&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"submit"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Send&lt;span class="nt"&gt;&amp;lt;/button&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/form&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;About that markup:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The access key is public.&lt;/strong&gt; Anyone can submit with it, and it can't open the inbox. You paste it under Settings → Formgong.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;botcheck&lt;/code&gt; is the spam honeypot.&lt;/strong&gt; It is hidden from visitors and should stay empty.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;_page_url&lt;/code&gt; is scheme, host and path only.&lt;/strong&gt; The plugin drops the query string on purpose, because on a WooCommerce order page it can hold the order key. With the default referrer policy, a cross-origin post often carries only the site's origin as the referrer, so this field is what records the real page.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The bundled catalog has 55 templates: 22 website templates, 7 starter layouts, 1 NPS survey and 25 industry forms (clinic appointment, insurance quote, returns, RSVP and so on). The slug &lt;code&gt;plain&lt;/code&gt; forces the built-in contact form above. Labels follow the site language in 12 languages, and anything else falls back to English.&lt;/p&gt;

&lt;h2&gt;
  
  
  The test button tells you when the key is wrong
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyzrp0cb0jy2bq9jxns4a.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyzrp0cb0jy2bq9jxns4a.png" alt="The Settings, Formgong screen in WordPress admin with fields for the endpoint, access key, form language, heading, button text, template and redirects" width="799" height="562"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Settings → Formgong: the key, the template and the redirects.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The "Send a test submission" button posts a fixed message from &lt;code&gt;plugin-test@example.com&lt;/code&gt; and shows Formgong's answer as an admin notice. A typo in the key doesn't pass quietly. I sent an unknown key to the live endpoint on 13 October 2026, and it answered HTTP 404 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="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"code"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"unknown_access_key"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"message"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"That access key does not exist."&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 plugin shows that as "Formgong did not accept the test (unknown_access_key): That access key does not exist." The test does not finish the connect wizard in the Formgong dashboard. A real submission from the public page does that.&lt;/p&gt;

&lt;h2&gt;
  
  
  WooCommerce
&lt;/h2&gt;

&lt;p&gt;On a store, the same plugin can add two optional forms:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a question on the product page, sent with the product name, SKU and URL;&lt;/li&gt;
&lt;li&gt;a question on the customer's order page, sent with the order number. It only shows to a user who passes &lt;code&gt;current_user_can( 'view_order', $order_id )&lt;/code&gt;, and not on the confirmation page right after checkout.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Checkout, payments and WooCommerce's order emails stay in WooCommerce. The plugin doesn't touch them.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the free plan covers, and what's missing
&lt;/h2&gt;

&lt;p&gt;From our plan limits in code and the live pricing page, on 13 October 2026:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;300 submissions a month, unlimited forms.&lt;/li&gt;
&lt;li&gt;Every submission emailed to one address. Telegram is instant on every plan.&lt;/li&gt;
&lt;li&gt;One webhook per form, 30-day retention.&lt;/li&gt;
&lt;li&gt;No auto-reply to the visitor on Free.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The site says the data stays in the EU, outside your WordPress database.&lt;/p&gt;

&lt;p&gt;Now the limits. The plugin's forms have no file fields. Its forms don't show Cloudflare Turnstile either, so if Turnstile is on for that form in the Formgong dashboard, every submission is rejected with &lt;code&gt;turnstile_missing&lt;/code&gt;. Turn it off for that form. The honeypot and Formgong's own spam checks still run. Some industry templates show a field only after a certain answer (the clinic form asks for a child's name only when the visit is for a child), but you can't write your own rules in WordPress, and there's no card payment. If you need those, stay with a builder.&lt;/p&gt;

&lt;h2&gt;
  
  
  Install
&lt;/h2&gt;

&lt;p&gt;In WordPress, open Plugins → Add New, search for "Formgong", click Install Now, then Activate. The directory page is &lt;a href="https://wordpress.org/plugins/formgong/" rel="noopener noreferrer"&gt;https://wordpress.org/plugins/formgong/&lt;/a&gt;. It went live on 13 October 2026, so it is brand new.&lt;/p&gt;

&lt;p&gt;If your site can't reach the directory, download &lt;a href="https://formgong.com/downloads/formgong-wordpress.zip" rel="noopener noreferrer"&gt;https://formgong.com/downloads/formgong-wordpress.zip&lt;/a&gt; and upload it under Plugins → Add New → Upload Plugin.&lt;/p&gt;

&lt;p&gt;Then open Settings → Formgong, paste the key, click Send a test submission, and add the block. Setup notes, including the full template list: &lt;a href="https://formgong.com/en/docs/wordpress/?ref=devto" rel="noopener noreferrer"&gt;https://formgong.com/en/docs/wordpress/?ref=devto&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If the form you already have is Contact Form 7 and it works, don't rebuild it just to reach Telegram. Send the form you have to a webhook instead. The CF7 to Webhook plugin can post it to a Formgong inbound URL, and Free allows three of those per form.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tell me what's missing
&lt;/h2&gt;

&lt;p&gt;If you run a WordPress site or build them for clients: what annoys you most about contact forms today, and what would make you switch to a plugin like this? Write it in the comments. I read every one, and the next version is shaped by them.&lt;/p&gt;

</description>
      <category>wordpress</category>
      <category>webdev</category>
      <category>php</category>
      <category>showdev</category>
    </item>
    <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;&lt;strong&gt;Update, 16 October 2026:&lt;/strong&gt; we ran the same tests again on 13 form backends and added what each free plan does with a message that arrives after the monthly limit. The full side-by-side table is here: &lt;a href="https://formgong.com/en/best-form-backend/" rel="noopener noreferrer"&gt;Best form backend services, tested side by side&lt;/a&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>
