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 await runs synchronously, during dispatch.
  • The first await hands 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.

// ✅ 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

  1. Always call preventDefault() first. Place it at the very top of the handler, before any asynchronous operation.
  2. Handle errors properly. Wrap async work in try/catch, or end Promise chains with .catch(), so network failures are reported.
  3. Give user feedback. Show a loading state and a clear success or error message.
  4. 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;
}