The bug report said "the modal saves blank data sometimes." Not always — just sometimes, and only for one field: the user's email. QA could reproduce it maybe one click in five, and only if they clicked Save fast, before the field had a chance to go red.
Here's the part that should have been impossible: the input had required on it. HTML form validation is supposed to catch an empty required field before anything goes anywhere. And yet, once in a while, it didn't — the page would flash white for a quarter second, like it had just reloaded, and the record would land in the database with an empty string where the email should be.
It had reloaded. That was the whole bug, once you knew to look for it.
The button that lived outside the form
The modal in question followed a pattern you've probably built yourself: the form fields sit in the body of the dialog, but the "Cancel" / "Save" buttons live in a shared footer component that's reused across every modal in the app — rendered as a sibling, not a child, of the <form> element.
<form id="profile-form">
<input name="email" type="email" required />
<!-- more fields -->
</form>
<!-- the footer is a separate component, outside the form -->
<div class="modal-footer">
<button type="button" id="cancel-btn">Cancel</button>
<button type="button" id="save-btn">Save</button>
</div>
You can't put a type="submit" button outside a <form> and have it submit that form just by proximity — the DOM doesn't work that way. So whoever wired this up reached for the obvious-looking fix: give the form an id, grab a reference to it, and submit it from the button's click handler.
document.getElementById("profile-form").addEventListener("submit", (e) => {
e.preventDefault();
saveProfile(new FormData(e.target)); // a fetch() call
});
document.getElementById("save-btn").addEventListener("click", () => {
document.getElementById("profile-form").submit();
});
This reads like it should work. It even looks like it works most of the time — click Save with a filled-in form, and the profile saves. The bug only shows up on the path nobody tests by hand: clicking Save on an invalid form, fast, before you've noticed the field is empty.
Before you scroll: what do you think form.submit() actually skips? It's not a trick question — the answer is in the method's own name, once you say it out loud.
.submit() doesn't click anything — it just submits
form.submit() is one of the oldest methods on the DOM, older than addEventListener itself, and it does exactly what it says: it submits the form, as raw HTTP, right now. What it does not do is simulate a user clicking a submit button — and that distinction is the entire bug.
Clicking a real <button type="submit"> triggers a sequence: the browser runs the form's constraint validation (required, pattern, type="email", minlength, all of it); if anything fails, the browser blocks the submission and shows the native "Please fill out this field" bubble; only if everything passes does it fire a cancelable submit event, which is what your addEventListener("submit", ...) handler is listening for.
form.submit() skips every step of that. No validation runs. No submit event fires. The form just submits — as a real, browser-driven navigation to its action URL, using its method — which is exactly the "page flash" QA saw. The saveProfile fetch call never ran, because the event it's attached to never fired. The browser did its own, uncontrolled submission underneath your SPA's feet.
That's not a bug in .submit(). It's the method doing its one job — bypass everything and submit — which happens to be the one job you never actually wanted.
The fix is a different method, not a different pattern
requestSubmit() exists for precisely this situation: you need to trigger a form's submission from code, and you want it to behave exactly like clicking a real submit button would — because that's literally what it does.
document.getElementById("save-btn").addEventListener("click", () => {
document.getElementById("profile-form").requestSubmit();
});
One word changed. The behavior is now correct: an empty email field blocks submission and shows the native validation bubble, a filled one runs constraint validation, passes, and fires a real submit event — which your existing e.preventDefault() handler catches, exactly as if someone had clicked a submit button that was actually inside the form.
🎮 Try it yourself
▶️ Open the interactive playground →
Runs right in your browser — poke at it and watch the concept react live.
requestSubmit() also takes an optional argument: a specific <button> to submit "as." Pass the actual button that was clicked, and the submission carries that button's name/value pair and honors its formaction / formmethod overrides — the same as a real click would, if you have more than one submit button with different behavior (a "Save" vs. "Save and publish," say).
form.requestSubmit(saveAndPublishButton);
One sharp edge here, and it's the kind that only shows up once: the button you pass has to actually be a submit button associated with that form — a descendant of it, or linked via form="profile-form". Pass a type="button" that isn't a submit button at all, and requestSubmit() throws immediately; pass a real submit button that belongs to a different form, and it throws too, just under a different error name. Either way it's a loud, synchronous failure, not a silent no-op — that's louder than .submit() ever gave you, and louder is better here: you find out in testing, not from a QA ticket three sprints later.
The part worth double-checking: is this safe to ship everywhere?
Yes, and that's worth saying plainly, because plenty of the newer form and DOM APIs aren't. requestSubmit() shipped in Chrome in 2019 and Firefox followed in 2020 — Safari was the holdout, catching up in Safari 16 in September 2022, which is the point it became Baseline: available in every evergreen browser, no flag and no polyfill required. There's no fallback section needed in this post, which is rarer than it should be.
The only real caveat is the one most people hit the opposite way: .submit() still exists, does nothing wrong by its own definition, and will keep compiling and "working" forever — nothing errors, nothing warns, your linter stays silent. The only signal you get that you reached for the wrong method is a user complaint that doesn't reproduce consistently, which is exactly the shape of bug that's expensive to track down and cheap to prevent.
🧠 Test yourself
Think it clicked? Take the 7-question quiz →
Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.
The fix here was one word. Finding it meant noticing that "the page flashed and reloaded" is .submit()'s actual, documented behavior wearing a disguise — not a flaky bug, not a race condition, just a method doing precisely what it says and nothing you asked for. Anywhere your code calls .submit() to trigger a form from outside it — a footer button, a keyboard shortcut, a wizard's "Next" step — that's requestSubmit()'s job, not .submit()'s.
Have you shipped a "phantom reload" bug like this one? What gave it away first — the validation that didn't run, or the request that never showed up in the network tab?
🚀 Want more like this? Every guide, playground, and quiz lives on bestpractic.org — open it and sign up free so the next one finds you.
Thanks for reading! Let's stay connected:
- ⭐ GitHub — follow me and star the projects: github.com/parsajiravand
- 💬 Discord — join the frontend best-practices community: discord.gg/d9KRhuAwQ
- 📸 Instagram — frontend best practices, daily: @bestpractice___
Top comments (1)
Clear walkthrough of why form.submit() skips validation and the submit event. Two small things I checked just now in Chrome 155 on a throwaway page, with the footer button outside the form:
Only tested in Chrome, and I did not run a real click on a navigating form.