HTML form submit: what the browser sends, and how to take over

Pressing a submit button collects every named field into one list and sends it to the form's action address. Once you know the rules for that list, most "form submit not working" problems explain themselves.

When an HTML form is submitted, the browser collects every field that has a name, turns the names and values into a list of pairs, and sends that list to the address in the form's action.

With method="get" the list goes into the URL. With method="post" it goes into the request body. Then the browser loads the reply as a new page.

Try it below. Change the fields, then press one of the buttons, or press Enter in the name field.

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>What a form sends</title>
<style>
  body { margin: 0; padding: 16px; font: 15px/1.4 system-ui, sans-serif; background: #f4f5f7; color: #1d2330; }
  form, .out { background: #fff; border-radius: 12px; padding: 14px 16px; box-shadow: 0 2px 10px rgba(0,0,0,.08); }
  label { display: block; margin-bottom: 10px; font-size: 13px; color: #4b5563; }
  label input, label select { display: block; width: 100%; box-sizing: border-box; margin-top: 3px; padding: 7px 9px; font: inherit; color: #1d2330; border: 1px solid #cfd4dc; border-radius: 7px; }
  label.check { display: flex; gap: 8px; align-items: center; }
  label.check input { width: auto; margin: 0; }
  .buttons { display: flex; gap: 8px; }
  button { padding: 8px 14px; font: inherit; border: 0; border-radius: 7px; background: #e5e7eb; cursor: pointer; }
  button[value="publish"] { background: #2563eb; color: #fff; }
  .out { margin-top: 12px; font-size: 13px; }
  .out b { display: block; margin-top: 8px; color: #6b7280; font-weight: 600; }
  .out b:first-child { margin-top: 0; }
  code { display: block; font: 13px/1.45 ui-monospace, Consolas, monospace; word-break: break-all; }
  .sent { color: #0f5132; } .gone { color: #9a3412; }
</style>
</head>
<body>
<form id="form" action="/signup" method="get">
  <label>Name <input name="name" value="Ana Kim"></label>
  <label>Email (this input has no name) <input type="email" value="ana@example.com"></label>
  <label>Plan
    <select name="plan"><option>free</option><option>pro</option></select>
  </label>
  <label>Coupon (disabled) <input name="coupon" value="SAVE10" disabled></label>
  <label class="check"><input type="checkbox" name="news" value="yes"> Send me the newsletter</label>
  <div class="buttons">
    <button name="action" value="save">Save draft</button>
    <button name="action" value="publish">Publish</button>
  </div>
</form>

<div class="out" id="out"></div>

<script>
  const form = document.getElementById('form');
  const out = document.getElementById('out');

  function show(submitter) {
    // The same list the browser would send. The second argument adds the clicked button.
    const data = new FormData(form, submitter);
    const query = new URLSearchParams(data).toString();
    const pairs = [...data].map(([k, v]) => `<code class="sent">${k} = ${v}</code>`).join('');
    // Anything the form holds that did not make it into the list
    const gone = [...form.elements]
      .filter((el) => el.tagName !== 'BUTTON' && !data.has(el.name))
      .map((el) => `<code class="gone">${el.name || '(no name) ' + el.value}${el.disabled ? ' - disabled' : ''}${el.type === 'checkbox' && !el.checked ? ' - unchecked' : ''}</code>`)
      .join('');
    out.innerHTML =
      `<b>Sent as ${submitter ? 'pressed: ' + submitter.textContent : 'no button pressed yet'}</b>${pairs}` +
      `<b>Left out</b>${gone}` +
      `<b>method="get" puts it in the URL</b><code>${form.getAttribute('action')}?${query}</code>` +
      `<b>method="post" puts the same text in the body</b><code>${query}</code>`;
  }

  form.addEventListener('submit', (e) => {
    e.preventDefault();   // stay on this page instead of loading /signup
    show(e.submitter);    // the button that was pressed, or the default one on Enter
  });
  form.addEventListener('input', () => show(null));
  show(null);
</script>
</body>
</html>
The panel shows exactly what the form would send. The email input has no name, so it never appears.

The email is on screen and filled in, but it is missing from every line of output. The coupon is missing too. Neither is a bug. They are the rules of form submission, and they are covered one by one below.

action and method: where the data goes

Two attributes on <form> decide the destination. action is the address that receives the data.

Leave it out and the form sends to the current page, which is why a bare form seems to reload itself. HTML form without an action covers that case in detail.

method decides where the pairs travel. The pairs themselves are the same text either way.

The same two fields, sent with GET and with POST. Only the place of the text changes.
The same two fields, sent with GET and with POST. Only the place of the text changes.
method="get" (the default) method="post"
Where the data goes After ? in the URL In the request body
Visible in address bar and history Yes No
Can be bookmarked or shared Yes No
File uploads No, only the file name Yes, with enctype="multipart/form-data"
Typical use Search boxes, filters Sign-ups, orders, messages

In the encoded text, spaces become + and pairs are joined with &. A server reads it back into names and values.

Which fields are sent, and which are not

The browser does not send "the form". It walks through the form's controls and keeps only some of them. The demo above shows each rule in action.

Six kinds of field and whether each one ends up in the submitted data.
Six kinds of field and whether each one ends up in the submitted data.
  • No name, no data. The name is the key. An id does not count.
  • disabled fields are skipped. If you need a value sent but not edited, use readonly instead, or a hidden field.
  • Unchecked checkboxes send nothing. Not false, not an empty value. The key is simply absent, so the server must treat "missing" as "off".
  • Only the pressed button counts. A submit button with a name adds its own pair, but only when it is the one that sent the form. That is how one form can have Save draft and Publish.

The submit event, preventDefault and FormData

Right before sending, the browser fires a submit event on the form. That is the hook for taking over. The pattern has five steps:

  1. Give every field a name.
  2. Listen for submit on the form, not click on the button, so Enter is covered too.
  3. Call event.preventDefault() so the browser does not load the action address.
  4. Read the fields with new FormData(form).
  5. Send the data yourself with fetch, then update the page.
form.addEventListener('submit', async (event) => {
  event.preventDefault();                 // stay on this page
  const data = new FormData(form);        // same pairs the browser would send
  const res = await fetch(form.action, { method: 'POST', body: data });
  // show a message based on res.ok
});

A FormData body is sent as multipart/form-data. If the server expects the classic encoded text, pass new URLSearchParams(data) instead.

event.submitter tells you which button sent the form. new FormData(form) leaves the buttons out, so pass the submitter as a second argument, new FormData(form, event.submitter), to get exactly what the browser would have sent. The first demo does that.

requestSubmit() vs submit()

Sometimes code has to submit the form, for example after a step in a wizard. There are two methods for that, and they behave very differently.

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>submit() vs requestSubmit()</title>
<style>
  body { margin: 0; padding: 16px; font: 15px/1.4 system-ui, sans-serif; background: #f4f5f7; color: #1d2330; }
  form { background: #fff; border-radius: 12px; padding: 14px 16px; box-shadow: 0 2px 10px rgba(0,0,0,.08); }
  label { display: block; font-size: 13px; color: #4b5563; }
  input { display: block; width: 100%; box-sizing: border-box; margin-top: 3px; padding: 7px 9px; font: inherit; border: 1px solid #cfd4dc; border-radius: 7px; }
  .buttons { display: flex; flex-wrap: wrap; gap: 8px; margin: 12px 0; }
  .buttons button { padding: 8px 12px; font: 600 13px ui-monospace, Consolas, monospace; border: 0; border-radius: 7px; cursor: pointer; background: #1d4ed8; color: #fff; }
  .buttons button.old { background: #9a3412; }
  .buttons button.plain { background: #e5e7eb; color: #1d2330; }
  ol { margin: 0; padding: 10px 14px 10px 32px; min-height: 90px; max-height: 190px; overflow: auto; background: #fff; border-radius: 12px; font: 13px/1.6 ui-monospace, Consolas, monospace; box-shadow: 0 2px 10px rgba(0,0,0,.08); }
  .ok { color: #0f5132; } .bad { color: #9a3412; }
  iframe { display: none; }
</style>
</head>
<body>
<!-- target="sink": when submit() really sends, the hidden frame loads instead of this page -->
<form id="form" action="about:blank" target="sink">
  <label>Email (required) <input type="email" name="email" required placeholder="Leave empty, then try each button"></label>
</form>
<iframe name="sink" title="receives the submission"></iframe>

<div class="buttons">
  <button type="button" id="req">form.requestSubmit()</button>
  <button type="button" id="old" class="old">form.submit()</button>
  <button type="button" id="clear" class="plain">Clear log</button>
</div>
<ol id="log"></ol>

<script>
  const form = document.getElementById('form');
  const log = document.getElementById('log');
  let fired;

  function note(text, cls) {
    const li = document.createElement('li');
    li.textContent = text;
    li.className = cls;
    log.append(li);
  }

  form.addEventListener('invalid', () => { fired = true; note('invalid event: validation stopped it', 'bad'); }, true);
  form.addEventListener('submit', (e) => {
    fired = true;
    e.preventDefault();
    note('submit event fired, preventDefault() kept us here', 'ok');
  });

  document.getElementById('req').addEventListener('click', () => {
    fired = false;
    note('requestSubmit() called');
    form.requestSubmit();  // validates, then fires submit, like clicking a submit button
  });

  document.getElementById('old').addEventListener('click', () => {
    fired = false;
    note('submit() called');
    form.submit();         // no validation, no submit event: it sends right away
    if (!fired) note('no validation, no submit event, sent as is', 'bad');
  });

  document.getElementById('clear').addEventListener('click', () => { log.textContent = ''; });
</script>
</body>
</html>
Leave the email empty and try both. requestSubmit() stops at validation; submit() sends anyway.
requestSubmit() runs the same three steps as a click. submit() jumps straight to sending.
requestSubmit() runs the same three steps as a click. submit() jumps straight to sending.
form.requestSubmit() form.submit()
Checks required, type, pattern Yes No
Fires the submit event Yes No
Can be cancelled with preventDefault() Yes No
Tells your handler which button Pass it: requestSubmit(button) Not applicable

Use requestSubmit() unless you deliberately want to bypass your own checks. The built-in checks themselves are covered in HTML form validation.

Enter, and buttons that submit by accident

Pressing Enter in a single-line field submits the form. The browser treats it as a click on the form's first submit button, so that button becomes event.submitter. If the first button is disabled, Enter does nothing. A <textarea> is different: Enter adds a new line.

A <button> inside a form is a submit button unless it says otherwise. The default type is submit. A Show password or Add row button written as plain <button> will send the form every time it is clicked. Write it like this:

<button type="button" id="toggle">Show password</button>

A finished example: a contact form

This one puts the pieces together. Each field is checked when you leave it, the button is disabled while sending so a double click cannot send twice, and a thank-you replaces the form. Nothing leaves the page; a timer stands in for the network.

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Contact form</title>
<style>
  body { margin: 0; padding: 16px; font: 15px/1.45 system-ui, sans-serif; background: #f4f5f7; color: #1d2330; }
  .card { max-width: 440px; margin: 0 auto; background: #fff; border-radius: 14px; padding: 18px 20px; box-shadow: 0 4px 18px rgba(0,0,0,.08); }
  h2 { margin: 0 0 12px; font-size: 19px; }
  .field { margin-bottom: 12px; }
  label { display: block; font-size: 13px; font-weight: 600; margin-bottom: 4px; }
  input, textarea { width: 100%; box-sizing: border-box; padding: 9px 10px; font: inherit; border: 1px solid #cfd4dc; border-radius: 8px; }
  textarea { min-height: 90px; resize: vertical; }
  .field.bad input, .field.bad textarea { border-color: #c2410c; background: #fff7f3; }
  .err { min-height: 18px; margin-top: 3px; font-size: 13px; color: #c2410c; }
  button { width: 100%; padding: 11px; font: 600 15px system-ui, sans-serif; color: #fff; background: #2563eb; border: 0; border-radius: 8px; cursor: pointer; }
  button:disabled { background: #93a8d8; cursor: wait; }
  .done { padding: 24px 6px; text-align: center; }
  .done b { display: block; font-size: 18px; color: #0f5132; margin-bottom: 6px; }
</style>
</head>
<body>
<div class="card">
  <!-- novalidate: we show our own messages under each field instead of the browser bubbles -->
  <form id="form" action="/contact" method="post" novalidate>
    <h2>Contact us</h2>
    <div class="field">
      <label for="name">Name</label>
      <input id="name" name="name" required autocomplete="name">
      <div class="err" aria-live="polite"></div>
    </div>
    <div class="field">
      <label for="email">Email</label>
      <input id="email" name="email" type="email" required autocomplete="email">
      <div class="err" aria-live="polite"></div>
    </div>
    <div class="field">
      <label for="message">Message</label>
      <textarea id="message" name="message" required minlength="10"></textarea>
      <div class="err" aria-live="polite"></div>
    </div>
    <button id="send">Send message</button>
  </form>
  <div class="done" id="done" hidden>
    <b>Thanks, your message is in.</b>
    <span id="echo"></span><br><br>
    <a href="#" id="again">Send another</a>
  </div>
</div>

<script>
  const form = document.getElementById('form');
  const send = document.getElementById('send');

  // Put the browser's own check result under the field, in plain words
  function check(el) {
    const box = el.closest('.field');
    let msg = '';
    if (el.validity.valueMissing) msg = 'Please fill this in.';
    else if (el.validity.typeMismatch) msg = 'That does not look like an email address.';
    else if (el.validity.tooShort) msg = `At least ${el.minLength} characters, please.`;
    box.classList.toggle('bad', msg !== '');
    box.querySelector('.err').textContent = msg;
    return msg === '';
  }

  // Check a field when the user leaves it, and again while they fix it
  form.querySelectorAll('input, textarea').forEach((el) => {
    el.addEventListener('blur', () => check(el));
    el.addEventListener('input', () => { if (el.closest('.bad')) check(el); });
  });

  form.addEventListener('submit', async (e) => {
    e.preventDefault();
    const fields = [...form.querySelectorAll('input, textarea')];
    const bad = fields.filter((el) => !check(el));
    if (bad.length) { bad[0].focus(); return; }

    const data = new FormData(form);
    send.disabled = true;               // stops a second click sending twice
    send.textContent = 'Sending...';
    // A real page would send here: await fetch(form.action, { method: 'POST', body: data });
    await new Promise((r) => setTimeout(r, 1200));

    document.getElementById('echo').textContent = `We will reply to ${data.get('email')}.`;
    form.hidden = true;
    document.getElementById('done').hidden = false;
  });

  document.getElementById('again').addEventListener('click', (e) => {
    e.preventDefault();
    form.reset();
    send.disabled = false;
    send.textContent = 'Send message';
    form.hidden = false;
    document.getElementById('done').hidden = true;
  });
</script>
</body>
</html>
Try sending it empty, then with a bad email, then filled in properly.
  • novalidate on the form turns off the browser's message bubbles, so the messages under each field are the only ones. The validity object still reports what is wrong.
  • Focus the first bad field so a keyboard or screen reader user lands where the fix is needed.
  • Disable the button, not the fields. Disabled fields are left out of FormData, so disabling them before reading the data would empty it.

For where the answers can actually go when there is no server of your own, see HTML form to email.

When it does not work

What you see Cause Fix
The page reloads and your JavaScript result flashes and vanishes The form really submitted and loaded a new page Call event.preventDefault() in the submit handler
A field is missing from the data It has no name, or it is disabled Add a name; use readonly instead of disabled
A checkbox is missing when unchecked Unchecked boxes are never sent Treat a missing key as "off" on the server
A button meant for something else sends the form <button> defaults to type="submit" Add type="button"
The submit handler never runs and required is ignored Code calls form.submit() Call form.requestSubmit()
Fields after a certain point are not sent, or the inner form does nothing A <form> nested inside another <form> Use one form, or put the second outside and link fields with the form attribute
A click handler on the button misses Enter Listening to click on the button Listen to submit on the form

Nested forms are not allowed in HTML. The parser ignores the inner <form> tag, so its fields join the outer form, and the inner </form> can end the outer form early.

When two forms have to sit next to each other in the layout, keep them separate and point a field at the right one:

<form id="search" action="/search"></form>
<input name="q" form="search">
<button form="search">Search</button>

A form is easier to review by using it than by reading its code. A screenshot cannot be typed into, and an .html attachment with a form is often blocked by mail filters or opens as plain text on a phone.

To send the working version, paste the page into a NOS document and choose Create share link. HTML to link walks through it.

The page renders as written and its scripts run, so the people you send it to can fill in the fields and see your checks react. If you change the code later, the same link shows the new version.

Questions people ask

What is the difference between form.submit() and form.requestSubmit()?

requestSubmit() behaves like clicking a submit button: it runs the built-in validation, fires the submit event, and only then sends. submit() skips both steps and sends immediately, so your submit listener never runs.

Why is one of my fields missing from the submitted data?

The browser only sends fields that have a name attribute and are not disabled. Checkboxes and radio buttons are sent only when checked. An id is not enough; the name becomes the key in the data.

How do I stop a form from reloading the page?

Listen for the submit event on the form and call event.preventDefault() at the start of the handler. Then read the fields with new FormData(form) and send them with fetch if needed.

Does pressing Enter submit a form?

Yes, in a single-line field such as a text or email input. If the form has a submit button, Enter acts as a click on the first one. Without a submit button, Enter submits only when the form has just one such field. Enter in a textarea adds a new line instead.

How do I know which button submitted the form?

Read event.submitter in the submit handler. It is the button that was clicked, or the first submit button when Enter was pressed. It is null when the code called requestSubmit() without an argument.

Keep reading