HTML minlength and maxlength: why validation gets skipped

HTML minlength and maxlength: why validation gets skipped

7 min read
Static Forms Team

minlength does not make a field required, and maxlength does not protect your server. Setting a short value with JavaScript is also a different test from typing that value into a field.

Those distinctions explain several cases where HTML length validation seems to be broken. This guide focuses on debugging them, rather than building another character counter. You'll make a local test page, check the browser's validity flags, and learn which checks belong at the receiving endpoint.

What minlength and maxlength check

Use minlength for the minimum length of a nonempty text value and maxlength for its upper limit. Add required separately when an empty field must be rejected. Both attributes take nonnegative integers; keep the minimum less than or equal to the maximum.

They apply to a textarea and to text-like inputs, including text, search, url, tel, email, and password. They do not limit the number of digits in type="number". Numeric bounds use min and max; see the separate guide to number input validation.

MDN's minlength reference describes the empty-value exception and the user-edit requirement. Its maxlength reference also explains why normal typing often stops at the limit instead of producing a visible error.

The length unit matters. These limits use UTF-16 code units, not words or necessarily the symbols a reader sees. A plain Latin letter takes one unit. The emoji ๐Ÿ˜€ takes two. A sequence that displays as one combined symbol can take more. Avoid telling users that every visible symbol consumes exactly one position.

Build a length-validation test page

Save the complete file below as length-demo.html and open it in a modern browser. It needs no packages, account, API key, or network connection. It deliberately has no delivery integration: the button checks the fields and reports a local result without sending their contents.

The short limits make boundary cases easy to try. They are demonstration values, not recommended limits for a real customer message.

HTML
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>HTML length validation lab</title>
  <style>
    * { box-sizing: border-box; }
    body {
      max-width: 42rem;
      margin: 2rem auto;
      padding: 0 1rem;
      font: 1rem/1.5 system-ui, sans-serif;
      color: #172033;
      background: #fff;
    }
    label { display: block; margin-top: 1rem; }
    input, textarea, button { font: inherit; padding: .6rem; }
    input, textarea { width: 100%; }
    textarea { min-height: 7rem; }
    button { margin-top: 1rem; }
    :focus-visible {
      outline: 3px solid #175cd3;
      outline-offset: 3px;
    }
    #result { min-height: 3rem; overflow-wrap: anywhere; }
  </style>
</head>
<body>
  <h1>Check text length locally</h1>
  <p>This lab sends nothing. Use made-up text.</p>
  <form id="lab">
    <label for="alias">Alias (optional)</label>
    <input id="alias" name="alias" type="text"
      minlength="3" maxlength="12" aria-describedby="alias-help">
    <p id="alias-help">Leave blank or enter 3 to 12 UTF-16 units.</p>

    <label for="message">Message (required)</label>
    <textarea id="message" name="message" required
      minlength="5" maxlength="20"
      aria-describedby="message-help"></textarea>
    <p id="message-help">
      Enter 5 to 20 UTF-16 units. Most ordinary letters count as one;
      some emoji count as two or more.
    </p>

    <button id="check" type="button" disabled>Check lengths</button>
    <p id="result" role="status" aria-live="polite"></p>
  </form>
  <noscript>This interactive lab needs JavaScript to report results.</noscript>
  <script>
    const form = document.querySelector("#lab");
    const button = document.querySelector("#check");
    const result = document.querySelector("#result");
    const fields = [...form.querySelectorAll("input, textarea")];

    form.addEventListener("submit", (event) => event.preventDefault());
    button.addEventListener("click", () => {
      const valid = form.reportValidity();
      const details = fields.map((field) => {
        const state = field.validity;
        return `${field.name}: length=${field.value.length}, ` +
          `missing=${state.valueMissing}, short=${state.tooShort}, ` +
          `long=${state.tooLong}`;
      });
      result.textContent =
        (valid ? "Local validation passed. " : "Check the fields. ") +
        details.join("; ") + ". Nothing was sent.";
    });
    button.disabled = false;
  </script>
</body>
</html>

There are no placeholders to replace. The script reads the actual field values and validity flags; it does not implement a second length policy. reportValidity() asks the browser to show native validation feedback. The result uses textContent, so entered markup is not interpreted as HTML.

Labels stay visible, hints are associated with their fields, and the result is a polite status region. Feedback updates when you activate the button, not on every keystroke. You can Tab to the button and activate it with Enter or Space. Native error wording and assistive-technology announcements vary by browser, so test your supported combinations before using the pattern in a production form.

Try the boundary cases by typing

Start with both fields empty and activate Check lengths. The optional alias is valid. The message fails because it is required, with missing=true; an empty value is not the same thing as a nonempty value that is too short.

Type ab in the alias and hello in the message. The alias should report short=true. Change it to abc and check again: both fields should pass.

Clear the alias. It should still pass alongside hello. Removing the optional value does not leave a minimum-length error behind.

Now try to type more than 20 ordinary letters into the message. Browsers generally stop accepting text at maxlength. Seeing long=false afterward is expected: the value never crossed the limit. Paste behavior and text-input methods deserve their own checks on the browsers you support.

Finally, enter ๐Ÿ˜€๐Ÿ˜€ in the message. Its JavaScript string length is four, so it is short of this demo's minimum of five. With ๐Ÿ˜€๐Ÿ˜€๐Ÿ˜€, the length is six and the minimum is satisfied. This is why a product requirement phrased as "five characters" needs a counting definition before you implement it.

If you want a visible running count, use the separate textarea character counter guide. Keep that display's counting model consistent with the rule it describes.

Why a scripted value can pass

A common automated test assigns a field's .value and then calls checkValidity(). That does not reproduce a user's edit. The HTML Standard's length constraints distinguish values last changed by the user from values assigned by script.

For length constraints, a script-assigned short or long value may therefore remain valid. Calling reportValidity() does not convert that assignment into a user edit. A synthetic input event is not a substitute for real typing either.

Use browser keyboard input in behavioral tests, and check valueMissing, tooShort, and tooLong separately. A required field can still fail its presence check even when a different length check is not applicable. Test assignment separately if your app prefills fields from saved data; validate that data explicitly at the server boundary.

When validation really is being bypassed

If a production form submits a short value typed by a user, inspect the submission path as well as the attributes:

  • A form with novalidate, or a submit button with formnovalidate, can bypass interactive validation.
  • Calling form.submit() bypasses constraint validation. A normal submit button or requestSubmit() follows the validation path. The requestSubmit versus submit guide covers that distinction.
  • A fetch call does not automatically validate the form it reads. Your handler must decide whether to check validity before sending.
  • A disabled or read-only control is not a reliable test case for normal editable-field validation.
  • size, CSS width, and a textarea's rows change presentation, not accepted text length.

Do not add a regex just to duplicate a length rule. Use pattern when the format itself matters, and keep it out of free-text messages unless you have a concrete reason to restrict their contents.

Put the server boundary in the right place

A browser sends field names and values, not an enforceable copy of your HTML attributes. Someone can change those attributes or send a request without loading your page. Native validation helps ordinary users fix mistakes; it is not spam protection, an authorization check, or a backend storage limit.

At an endpoint you control, validate the type and length again. Decide whether to trim whitespace, how to count text, and what payload-size limit to enforce before storing or forwarding it. A spaces-only string can satisfy a length threshold without containing a useful message. If you reject it, explain that rule in the UI rather than silently changing what the user typed.

For a hosted form backend, consult its documented request contract instead of assuming it imports your custom minlength and maxlength settings. Static Forms' quick start documents the separate setup for real submissions. This lab does not exercise that endpoint or prove email delivery. Keep credentials for private server integrations out of frontend code, and use synthetic messages during testing.

Before shipping a real form

Upload the demo to an isolated static preview if you need to compare devices. Its inline script must be allowed by that preview's Content Security Policy; otherwise the check button stays disabled. For a stricter policy, move the script and styles into allowed same-origin files rather than loosening production CSP globally.

On the actual form, test empty, short, exact-boundary, and over-limit user input. Include emoji, a pasted value, and any scripted prefill. Test keyboard access and error feedback on the browsers you support. Then verify the real submission endpoint separately, including its failure response and downstream delivery. A local "passed" message only proves the local check ran.