JavaScript Async Form Submission: Understanding await and preventDefault
Why an async submit handler sometimes lets the form submit anyway, and the patterns that reliably stop it.
Voices come from this device. Listening happens in your browser; nothing is sent anywhere.
In short
The browser decides whether to submit a form as soon as the synchronous part of your submit handler returns, and the first await ends that part. Call event.preventDefault() before any await, never rely on onsubmit="return asyncFn()" (a Promise is truthy), then do the async work and submit programmatically.
The problem
When working with modern JavaScript, developers often hit a frustrating issue: their async form handler doesn't seem to stop the form from submitting, and the page reloads before the server response arrives. This happens when preventDefault() runs too late, or when the handler's return value is what is supposed to cancel the submit.
Common mistake patterns
The two versions developers usually write:
// ❌ PROBLEMATIC: preventDefault() runs after the handler has yielded
form.addEventListener('submit', async (e) => {
const response = await fetch('/api/validate', {
method: 'POST',
body: new FormData(form)
});
const result = await response.json();
if (!result.success) {
e.preventDefault(); // Too late: the form has already been submitted.
}
});
<!-- ❌ PROBLEMATIC: an async function always returns a Promise, and a Promise is truthy -->
<form onsubmit="return validateForm()">
The issue is subtle but critical: the browser does not wait for your async function. It checks whether the default action was cancelled as soon as the synchronous part of the handler returns.
Understanding the root cause
To understand why this happens, look at how async functions interact with event dispatch:
- Async functions always return Promises.
- Event dispatch is synchronous and ignores the returned Promise.
- Code up to the first
awaitruns synchronously, during dispatch. - The first
awaithands control back to the browser, which then performs the default action (submitting the form) unless it was already prevented.
Key insight
preventDefault() itself works fine inside an async handler, if it is called before the first await. Anything after an await runs in a later task, when the submission has already started. And return false (or returning a Promise) from an async function cancels nothing.
The solution
There are several correct ways to handle async form submissions. Here are the most effective ones.
Solution 1: prevent first, then run the async work (recommended)
// ✅ CORRECT: preventDefault() runs synchronously, before anything yields
form.addEventListener('submit', (e) => {
e.preventDefault();
// Run the async part in an async IIFE (Immediately Invoked Function Expression)
(async () => {
try {
const response = await fetch('/api/submit', {
method: 'POST',
body: new FormData(form)
});
const result = await response.json();
if (result.success) {
console.log('Form submitted successfully!');
} else {
console.error('Submission failed:', result.error);
}
} catch (error) {
console.error('Network error:', error);
}
})();
});
An async handler that calls e.preventDefault() on its very first line is equally correct. Keeping the handler itself non-async makes the rule impossible to break by accident when someone later adds an await above it.
Solution 2: use .then() instead of async/await
// ✅ ALTERNATIVE: use Promises directly
form.addEventListener('submit', (e) => {
e.preventDefault();
fetch('/api/submit', {
method: 'POST',
body: new FormData(form)
})
.then(response => response.json())
.then(result => {
if (result.success) {
console.log('Form submitted successfully!');
} else {
console.error('Submission failed:', result.error);
}
})
.catch(error => {
console.error('Network error:', error);
});
});
Solution 3: validate asynchronously, then submit for real
// ✅ Keep the native form post, but only after the server says the data is valid
form.addEventListener('submit', async (e) => {
e.preventDefault(); // 1. always first
const response = await fetch('/api/validate', { method: 'POST', body: new FormData(form) });
const { valid } = await response.json();
if (valid) form.submit(); // 2. submit() does not fire 'submit' again
});
Why these solutions work
In every version preventDefault() runs before the handler yields, so the browser sees a cancelled event. The asynchronous work then happens on your terms, and nothing is submitted until you decide to.
Live demonstration
Try submitting this form to see correct async handling in action. The page never reloads:
Async form submission demo
Best practices
- Always call
preventDefault()first. Place it at the very top of the handler, before any asynchronous operation. - Handle errors properly. Wrap async work in
try/catch, or end Promise chains with.catch(), so network failures are reported. - Give user feedback. Show a loading state and a clear success or error message.
- Consider resetting the form. After a successful submission, reset it or redirect to a confirmation page.
Complete working example
A production-ready version of async form handling:
// ✅ PRODUCTION-READY FORM HANDLER
document.getElementById('myForm').addEventListener('submit', function (e) {
// Prevent the default form submission (before anything async)
e.preventDefault();
const form = e.target;
const submitButton = form.querySelector('button[type="submit"]');
const originalText = submitButton.textContent;
// Show loading state
submitButton.disabled = true;
submitButton.textContent = 'Submitting...';
(async () => {
try {
const response = await fetch(form.action, {
method: 'POST',
body: new FormData(form),
headers: { 'X-Requested-With': 'XMLHttpRequest' }
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const result = await response.json();
if (result.success) {
showMessage('Form submitted successfully!', 'success');
form.reset();
} else {
showMessage(`Error: ${result.message}`, 'error');
}
} catch (error) {
console.error('Submission error:', error);
showMessage('Network error. Please try again.', 'error');
} finally {
submitButton.disabled = false;
submitButton.textContent = originalText;
}
})();
});
function showMessage(message, type) {
const messageDiv = document.getElementById('form-status');
messageDiv.textContent = message;
messageDiv.className = `message ${type}`;
messageDiv.hidden = false;
}
Was this useful?
Anonymous. A clap or a critique is the only feedback this blog records; pick again to take it back.
Frequently asked questions
Does preventDefault() work inside an async event handler?
Yes, as long as it runs before the first await. Code up to the first await runs synchronously during event dispatch; anything after it runs once the browser has already performed the default action.
Why does onsubmit="return validate()" not stop the form when validate is async?
An async function always returns a Promise, and a Promise is truthy, so the inline handler effectively returns true and the form submits.
How do I validate on the server before letting a form submit?
Call event.preventDefault() first, await your validation, then call form.submit() (or send the data with fetch) only when it passes.