Call event.preventDefault() before the first await, run your asynchronous validation, then use form.requestSubmit() to start a new submission attempt. A one-use guard lets that second attempt proceed without repeating the asynchronous check.
This tutorial is for someone editing a small browser interface on a Mac. You will build a local HTML example in which a form closes a dialog only after a synthetic label check passes. The same event-handling pattern applies to ordinary forms that navigate or send a request.
Why doesn’t an asynchronous submit handler delay submission?
The browser does not await the promise returned by an event listener. The DOM event-dispatch algorithm invokes listeners and continues; the HTML submission algorithm checks whether the submit event was canceled before proceeding. Adding async to the listener does not introduce a waiting step into that lifecycle. DOM Standard: event dispatch, HTML Standard: form submission algorithm
An async function runs synchronously until its first await, then continues asynchronously. Consequently, cancellation belongs before the first await, even when the awaited promise is already fulfilled. Calling preventDefault() afterward cannot undo a submission decision that the browser has already made. MDN: async function
Cancellation ends the original attempt; it does not suspend it for later resumption. After validation succeeds, your code must explicitly request another submission. Also, preventDefault() leaves event propagation intact, so other listeners can still observe the event. MDN: Event.preventDefault()
Listen on the form’s submit event. This covers submission through a button, supported implicit submission such as pressing Enter, and calls to requestSubmit(). A button-only click handler does not cover every entry point. Native constraint validation normally runs first: an invalid required field prevents submission without firing submit. MDN: HTMLFormElement submit event
Which method preserves the form’s submission behavior?
Use requestSubmit() when the successful check should lead back into the normal form lifecycle. Calling submit() directly skips both constraint validation and the submit event. That shortcut changes the contract your asynchronous handler is supposed to preserve. MDN: HTMLFormElement.submit()
| Approach | What happens | Suitability here |
|---|---|---|
| Return a promise from the listener | Event dispatch continues without awaiting it | Does not delay the default action |
Cancel, then call submit() |
Submission bypasses native validation and submit listeners | Changes the lifecycle |
Cancel, then call requestSubmit(submitter) |
Native validation and the submit event run again | Preserves the intended submission path |
Capture event.submitter before waiting. Passing that button to requestSubmit() preserves its role, including submission overrides and its name/value contribution when applicable. If there was no submitter, call requestSubmit() without an argument. A supplied button must still belong to the form; an invalid button or a button belonging to another form causes an exception. MDN: HTMLFormElement.requestSubmit()
The new submission triggers your listener again. The example below therefore uses two separate pieces of state: pending prevents overlapping asynchronous checks, while allowNextSubmit authorizes exactly one synchronous pass through the handler. A permanent “validated” flag would be too broad because later edits could reuse an earlier result.
Build a complete local example on your Mac
Save the following complete document as async-form.html in a plain-text code editor, then open it in a current browser on your Mac. No libraries, imports, server, or installation are required. The standard lists support for requestSubmit() from Safari 16 onward. HTML Standard: form APIs
The example uses method="dialog" to make the default action easy to see locally. Successful submission closes the enclosing dialog and sets its returnValue from the submit button; it does not transmit the form controls. The opening button calls showModal() to display it. MDN: dialog element, MDN: HTMLDialogElement.showModal()
The validator is deliberately synthetic: after a short timer, it accepts labels with at least three characters after trimming, rejects the exact lowercase label taken, and throws for the exact lowercase label error. The delay illustrates asynchronous completion; it is not a performance measurement.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Asynchronous form validation</title>
</head>
<body>
<h1>Label preview</h1>
<button id="open" type="button">Open form</button>
<p id="outcome" role="status"></p>
<dialog id="editor" aria-labelledby="dialog-title">
<form id="label-form" method="dialog">
<h2 id="dialog-title">Choose a label</h2>
<label for="label">Label</label>
<input id="label" name="label" required autofocus>
<button type="submit" value="accepted">Continue</button>
<button id="cancel" type="button">Cancel</button>
<p id="status" role="status"></p>
</form>
</dialog>
<script>
const dialog = document.getElementById("editor");
const form = document.getElementById("label-form");
const input = document.getElementById("label");
const status = document.getElementById("status");
const outcome = document.getElementById("outcome");
const openButton = document.getElementById("open");
const cancelButton = document.getElementById("cancel");
let pending = false;
let allowNextSubmit = false;
let revision = 0;
async function validateLabel(value) {
await new Promise(resolve => setTimeout(resolve, 700));
const label = value.trim();
if (label === "error") {
throw new Error("Synthetic validation failure");
}
return label.length >= 3 && label !== "taken";
}
openButton.addEventListener("click", () => {
revision += 1;
dialog.returnValue = "";
status.textContent = "";
outcome.textContent = "";
dialog.showModal();
});
input.addEventListener("input", () => {
revision += 1;
});
cancelButton.addEventListener("click", () => dialog.close());
dialog.addEventListener("close", () => {
revision += 1;
outcome.textContent = "Dialog closed. Return value: " +
(dialog.returnValue || "(empty)");
});
form.addEventListener("submit", async event => {
if (allowNextSubmit) {
allowNextSubmit = false;
return;
}
event.preventDefault();
if (pending) return;
pending = true;
const submitter = event.submitter;
const value = input.value;
const checkedRevision = revision;
status.textContent = "Checking label...";
try {
const valid = await validateLabel(value);
if (!dialog.open) return;
if (revision !== checkedRevision || input.value !== value) {
status.textContent = "The label changed. Submit again.";
return;
}
if (!valid) {
status.textContent =
"Use at least three characters and avoid taken.";
return;
}
status.textContent = "Check passed. Submission requested.";
allowNextSubmit = true;
try {
if (submitter) form.requestSubmit(submitter);
else form.requestSubmit();
} finally {
allowNextSubmit = false;
}
} catch (error) {
if (dialog.open && revision === checkedRevision) {
status.textContent = "The check failed. Try again.";
}
console.error(error);
} finally {
pending = false;
}
});
</script>
</body>
</html>
The example’s browser behavior is derived from the documented lifecycle. A small Node.js EventTarget check confirmed the timing distinction: synchronous cancellation changed the dispatch result, while cancellation after await missed that result. The complete dialog example has not been executed in a Mac browser here.
How does the guard preserve ordering and handle changes?
On the first submission, the handler cancels immediately and captures both the label and the initiating button. While the check is pending, another submit event is also canceled, but it does not start another validation. This keeps a rapid series of submissions from producing overlapping checks.
When the check resolves, the handler verifies that the dialog is still open and that the label has not changed. The revision counter detects user edits even if the user changes the text and then restores it. The value comparison also catches a direct value change that leaves different text in the field.
A passing result sets allowNextSubmit immediately before calling requestSubmit(). That method runs the native validation and submit-event path again. When the listener is reentered, it consumes the permission and returns without canceling. The browser can then perform the default dialog-closing action. This ordering follows the standard’s validation-before-submit-event sequence. HTML Standard: form submission algorithm
The inner finally clears the permission even if no submit event occurs or the method throws. The outer finally clears pending after rejection, an exception, a stale result, or a successful retry. An unsuccessful attempt therefore leaves the form available for another check.
Closing the dialog also increments the revision. A result from an earlier opening cannot authorize a later opening. For this standalone document, these checks cover the available interaction paths. If you add more fields or scripts that update them, extend the snapshot and revision handling to every value the validator depends on.
Test the success, rejection, and interruption paths
Run these checks in the local browser example. The expected results follow from the supplied code; they are not claimed browser test results.
| Action | Expected result |
|---|---|
Leave the label empty and choose Continue |
Native required-field validation blocks submission before the async handler runs |
Enter ab and choose Continue |
The dialog stays open and displays the rejection message after the check |
Enter taken |
The synthetic rule rejects it |
Enter sample |
The dialog remains open during checking, then closes with return value accepted |
Enter error |
The failure message appears, and another submission remains possible |
| Submit a label, then edit it during checking | The result is discarded and the form asks for another submission |
| Submit repeatedly while checking | Only the first attempt starts an asynchronous check |
Choose Cancel while checking |
The dialog closes without waiting for the pending result |
Also try submitting with Enter while the input has focus. Implicit submission depends on the form structure and platform conventions; the reason for handling submit is to cover whichever supported interaction actually triggers form submission. MDN: HTMLFormElement submit event
What changes when the form sends a normal request?
For an existing request-sending form, retain its real action, method, field names, button values, and submission overrides. Replace the synthetic validator with your actual asynchronous check. The dialog opening and closing code is specific to this demonstration; the cancel–validate–request sequence belongs in the form’s submission handler.
Keep fields available for the final submission. Disabling controls throughout the wait requires care: disabled fields are excluded when the browser constructs the submission entry list. The example instead leaves editing available and rejects stale validation results. HTML Standard: constructing the entry list
If other submit listeners exist, they will observe both the canceled first attempt and the later attempt. Coordinate them so they do not perform the same work twice or change validated values during the authorized pass. requestSubmit() also does not guarantee completion: another listener may cancel it, and native constraints can block it. Its return value is undefined, not a completion promise. MDN: HTMLFormElement.requestSubmit()
The key contract is precise: cancel synchronously, validate a defined set of values, discard obsolete results, and authorize one new native submission attempt. That gives asynchronous validation an explicit place in the workflow while preserving the browser’s form lifecycle.
Sources
DOM Standard — WHATWG
HTML Standard: The form element — WHATWG
async function — MDN Web Docs
Event: preventDefault() method — MDN Web Docs
HTMLFormElement: submit event — MDN Web Docs
HTMLFormElement: submit() method — MDN Web Docs
HTMLFormElement: requestSubmit() method — MDN Web Docs
The Dialog element — MDN Web Docs
HTMLDialogElement: showModal() method — MDN Web Docs