Learn · JavaScript
Submit an HTML form with fetch()
Intercepting a form's submit event and sending it with fetch() takes five lines. Making it behave when the server says no, the network stalls or someone double-clicks takes a few more. This is the version worth copying, followed by the reasoning behind each line.
Start from a form that works without JavaScript
Give the form a real action and method. If the script fails to load, the browser still submits it the classic way and the server answers with a redirect. The script is an enhancement on top, not a requirement.
html
<form id="contact" action="https://forms.formward.eu/f/<FORM_ID>" method="post">
<label for="email">Email</label>
<input id="email" name="email" type="email" required>
<label for="message">Message</label>
<textarea id="message" name="message" required></textarea>
<button>Send</button>
<p id="status" role="status"></p>
</form>The submit handler
The browser runs constraint validation before the submit event fires, so required and type="email" are already checked when this code runs.
js
const form = document.querySelector("#contact");
const status = document.querySelector("#status");
let pending = null;
form.addEventListener("submit", async (event) => {
event.preventDefault();
if (pending) return; // already sending
const button = event.submitter;
const body = new FormData(form, button); // build BEFORE disabling the button
pending = new AbortController();
if (button) button.disabled = true;
status.textContent = "Sending...";
try {
const res = await fetch(form.action, {
method: "POST",
body, // no Content-Type: fetch sets it
headers: { Accept: "application/json" },
signal: AbortSignal.any([pending.signal, AbortSignal.timeout(15000)]),
});
const data = await res.json().catch(() => ({}));
if (res.ok) {
form.reset();
status.textContent = "Thanks, your message was sent.";
} else {
showError(res, data);
}
} catch (err) {
status.textContent = err.name === "TimeoutError"
? "No answer from the server. Your message may still have arrived."
: "Network error. Check your connection and try again.";
} finally {
pending = null;
if (button) button.disabled = false;
}
});Why FormData and not JSON
Passing a FormData object as the body makes fetch set Content-Type: multipart/form-data with a generated boundary. File inputs come along for free, and multipart/form-data is one of the three content types that keep a cross-origin request "simple", so the browser sends it without a CORS preflight.
JSON needs Content-Type: application/json, which is not on that list: every cross-origin JSON POST is preceded by an OPTIONS preflight, and files have to be handled separately. Send JSON when the API requires it; for a form, FormData is less code and one round trip fewer. The FormData article covers conversions, and CORS and forms covers the preflight rules.
Never set Content-Type by hand when the body is FormData. A hand-written multipart/form-data header has no boundary parameter, and the server cannot split the parts.
fetch() does not throw on 4xx and 5xx
The promise only rejects on network failure, an invalid URL or an abort. A 422 or a 500 resolves normally, so check res.ok (true for 200 to 299) before treating the response as success. The Accept: application/json header asks the server for a JSON answer instead of an HTML page; Accept is a CORS-safelisted header, so it does not trigger a preflight.
When the server reports which field failed, put the message on that field with the constraint validation API. The browser then shows it in its native bubble and blocks the next submit until the user edits the field:
js
function showError(res, data) {
if (res.status === 422 && data.field) {
const field = form.elements.namedItem(data.field);
if (field instanceof HTMLInputElement || field instanceof HTMLTextAreaElement) {
field.setCustomValidity("Please check this field (" + (data.code ?? data.error) + ").");
field.reportValidity();
field.addEventListener("input", () => field.setCustomValidity(""), { once: true });
status.textContent = "";
return;
}
}
if (res.status === 429) {
const wait = res.headers.get("Retry-After") ?? "60";
status.textContent = "Too many attempts. Try again in " + wait + " seconds.";
return;
}
status.textContent = data.error ?? "Something went wrong (HTTP " + res.status + ").";
}Clearing the custom message on the next input matters: as long as it is non-empty, the field stays invalid and the form refuses to submit.
Double submits and timeouts
The pending guard and the disabled button stop a second click from sending a second request. Build the FormData before disabling the button: disabled controls are left out of the entry list, so disabling first silently drops the submitter's name=value pair.
AbortSignal.timeout() rejects with a TimeoutError after the given time, and AbortSignal.any() combines it with your own controller so you can also cancel on navigation. Both are Baseline 2024; on older browsers drop the signal line rather than ship a polyfill for a contact form.
A timeout does not mean the server did not get the data. POST is not idempotent, so an automatic retry can create a duplicate. Tell the user what happened instead of retrying behind their back.
Keep the no-JavaScript path honest
Test the form with JavaScript disabled once. The classic submission should land on a real thank-you page via a redirect, and a validation error should show a readable page rather than raw JSON. The server tells the two apart by the Accept header, which is why the script sends it.
How this works with Formward
Formward answers a request that sends Accept: application/json with JSON: { ok: true, id, files } and HTTP 200 on success, { ok: false, error } with a 4xx otherwise. A 422 from a hosted form's field rules includes field and code, which is what the handler above reads; a 429 carries Retry-After. Classic submissions get a redirect or an HTML page, never raw JSON.
Submissions caught by the honeypot get the same success response as real ones, so a client cannot tell them apart. Status codes and bodies are listed in the AJAX docs and responses docs.
Sources
Point a form at an EU endpoint
Formward receives the POST, filters spam and stores submissions in Sweden. No server to run.