FormsGENERALBROWSER-SPECIFICSPEC-EVOLVING

Native Validation and Its Limits

The Constraint Validation API gives you checks, states and messages for free — then runs out at styling, wording and timing. Replacing it means re-implementing what it did well.

The intent, the obvious build, and why it breaks

Every lesson starts where the work starts: someone wanted an outcome, and the first implementation that comes to mind has a problem.

The question

What does the browser check for me, where does that stop being enough, and what am I taking on when I switch it off?

The user intent

A person filling in a form wants to be told, at a useful moment, that something they typed will not be accepted — and told clearly enough to fix it without guessing.

The obvious build

Native validation looks unpolished and the messages are not in our voice, so put novalidate on the form, validate everything in JavaScript on every keystroke, and render our own messages.

Why it breaks

Validating on every keystroke tells the user their email address is invalid while they are still typing the second character of it. The message is technically true and practically hostile.

How it breaks in a real browser
  • Validating on every keystroke tells the user their email address is invalid while they are still typing the second character of it. The message is technically true and practically hostile.
  • The bespoke email regex rejects addresses that are valid — plus-addressing, new top-level domains, non-ASCII local parts — and the users affected cannot work around it (Internationalization).
  • The :invalid styling that came free now needs an equivalent, and the naive equivalent styles every empty required field red on first render, before the user has done anything wrong.
  • The first invalid field is no longer focused and scrolled into view on failed submit, so on a long form the user sees a submit that appears to do nothing while the error sits offscreen (Focus Management).
  • The rules drift from the server's. The client accepts a 60-character display name and the server rejects at 50, so the user gets a generic failure after submitting rather than a specific one before (Validation Errors: Feedback, Not Verdicts).
  • Async checks — "is this username taken" — race. The user types dan, then daniel; the dan response arrives last and marks a valid name as taken (Out-of-Order Responses).
IntentEventStateUI LogicDOM WorkNetworkLayout / PaintPixelsFeedback

What is actually happening

In the browser, not in the framework.

  • Every listed control has a validity state: an object with flags such as valueMissing, typeMismatch, patternMismatch, tooLong, tooShort, rangeUnderflow, rangeOverflow, stepMismatch, badInput and customError, plus a valid summary.
  • Constraints come from attributes: required, the type's own rule, pattern, min/max/step, minlength/maxlength. maxlength is special — it prevents entry rather than reporting a violation, so it never shows a message.
  • checkValidity() returns a boolean and fires an invalid event on each failing control. reportValidity() does the same and additionally displays the browser's message bubble on the first failure. The invalid event is your hook for custom presentation.
  • setCustomValidity(message) sets customError and makes the control invalid with your wording; setCustomValidity('') clears it. This is the supported way to inject an application rule — including a server-returned one — into the native machinery.
  • The CSS pseudo-classes :required, :optional, :valid, :invalid, :in-range, :out-of-range and :user-valid/:user-invalid reflect this state live. :user-invalid is the important one: it applies only after the user has interacted with the field, which is exactly the timing hand-written validation gets wrong.
  • Interactive validation runs automatically as part of the submit algorithm (Native Forms First). novalidate on the form, or formnovalidate on a specific submit button, skips it — the latter being how a "Save draft" button legitimately bypasses checks meant for a final submit.

What this makes the browser do

And which of it is avoidable.

  • Evaluating each control's constraints — a parse and a comparison per control, run at submit and on value change. Negligible even for large forms.
  • Matching :valid / :invalid / :user-invalid selectors, which invalidates style for the affected elements when the state flips (Style Invalidation).
  • Rendering the native message bubble, which is browser chrome rather than page content: it is not in the DOM, cannot be styled, and disappears on its own schedule.
  • Compiling and running pattern regular expressions. Worth noting only because a catastrophically backtracking pattern is main-thread work you supplied (Long Tasks).
  • Custom validation replaces almost none of this with cheaper work — it replaces it with your work, running at times you now have to choose.

Keep the machinery, replace the presentation

The productive move is almost never all-or-nothing. Keep the constraint attributes so the validity state, the pseudo-classes and the accessible semantics all keep working, then intercept the invalid event to render the message yourself.

This is roughly twenty lines, and it is the difference between "we do our own validation" meaning "we present it our way" and it meaning "we rebuilt the state model too".

Custom presentation on top of the native state model
1const form = document.querySelector<HTMLFormElement>('#signup')!
2
3function messageFor(el: HTMLInputElement): string {
4 const v = el.validity
5 if (v.valueMissing) return `${el.labels?.[0]?.textContent ?? 'This field'} is required.'
6 if (v.typeMismatch && el.type === 'email') return 'Enter an email address, like name@example.com.'
7 if (v.tooShort) return `Use at least ${el.minLength} characters.`
8 if (v.patternMismatch) return el.dataset.hint ?? 'That format is not accepted.'
9 return el.validationMessage // fall back to the browser's wording
10}
11
12form.addEventListener(
13 'invalid',
14 (e) => {
15 const el = e.target as HTMLInputElement
16 e.preventDefault() // suppress the native bubble only
17 showError(el, messageFor(el)) // your DOM, your styling, your a11y wiring
18 },
19 true, // capture: `invalid` does not bubble
20)
21
22form.addEventListener('submit', (e) => {
23 if (!form.checkValidity()) { // fires `invalid` per failing control
24 e.preventDefault()
25 form.querySelector<HTMLInputElement>(':invalid')?.focus()
26 }
27})

Two details do real work here. invalid does not bubble, so the listener must be registered in the capture phase to catch it from the form. And checkValidity() rather than reportValidity() means your handler renders the message instead of the browser.

Where native validation genuinely runs out

Being honest about the limits is what makes the recommendation credible. Some of them are cosmetic and some are structural, and only the structural ones justify taking over.

Notice that none of the structural limits are fixed by novalidate. They are fixed by adding something the platform does not have, which is a different decision from removing something it does.

Limit, consequence, and the honest response
TriggerSymptomCauseResponse
Message wording and languageA German page shows English messages, or wording that contradicts the product voiceMessages come from the browser UI locale, not the documentSuppress the bubble, render your own text — but keep the underlying constraints (Internationalization).
Bubble styling and positionMessage cannot match the design and vanishes on its ownIt is browser chrome, outside the DOMRender an associated message element instead; this is the strongest cosmetic reason to intervene (Errors People Can Actually Perceive).
Timing:invalid marks untouched required fields as errors on load:invalid reflects state, not interactionUse :user-invalid, or a touched class if you must support older engines.
Cross-field rules"Passwords must match" or "end date after start date" cannot be expressedConstraints are per control by designsetCustomValidity on the dependent field, recomputed whenever either field changes.
Server-only rules"That username is taken" cannot be known client-sideThe client does not have the dataAsync check for feedback plus setCustomValidity, and accept that the server's answer at submit is the authoritative one (Validation Errors: Feedback, Not Verdicts).
Custom controlsA div-based combobox or rich text field has no validity state at allOnly listed form controls participateBack it with a real hidden-but-associated control, or own validation for that control explicitly (Accessible Component Patterns).
Only one error shown at a timeUser fixes one field, submits, and learns about the nextInteractive validation reports the first failure and stopsRun checkValidity() over all controls and render every message plus a summary at the top of the form.

The client is a courtesy; the server decides

This is the load-bearing sentence of the lesson. Client-side validation exists so the user finds out quickly and locally that something will not work. It is not, and cannot be, a guarantee about what the server receives, because the client is fully under the user's control and the request does not have to come from a browser at all (What the Frontend Is Responsible For in Auth).

The practical consequence is a division of labour, not a duplication of code. The client optimises for feedback latency and wording; the server owns correctness, authorization and every rule whose violation would matter. Where they overlap, they should agree — and where they disagree, the server wins by construction.

This also settles what to do with server-returned validation errors: they are not a fallback that only fires when the client missed something. They are the authoritative answer, and the UI needs a first-class path for rendering them per field, which means the API needs to return them per field (The Error Model: Structure Over Apology).

  • The client can skip validation entirely and the server must still be correct.
  • The server can never skip validation, no matter how thorough the client is.
  • Where the two disagree, the client should be the looser one — a client stricter than the server blocks legitimate input with no recourse.
  • Server errors need to arrive keyed by field to be renderable inline; a single string forces a generic banner (Validation Errors: Feedback, Not Verdicts).
Two validations, one authority
fails: explain locallypassesbypasses the client entirelyrejectsacceptsrender per fieldUser inputcurl / script / replayed requestConstraint validation (fast, local, optional)HTTP requestServer validation (authoritative)Authorization + business rulesField-keyed error responsePersisted stateInline message + focus
UserLLMAgentToolDataDecisionHumanGuardrail

How to build it

Most important first.

  • Keep the constraint attributes even when you render your own messages. They provide the validity state, the pseudo-classes, the accessible required/invalid semantics, and a working baseline before hydration.
  • Style with :user-invalid rather than :invalid so an untouched empty required field is not marked as an error the moment the page renders.
  • Validate on blur for format, and on input only to clear an error that is already showing. Errors that appear while typing and vanish while typing are the most disliked pattern in forms.
  • Listen for the invalid event, call preventDefault() on it to suppress the native bubble, and render your own message from validationMessage or from your own map keyed off the validity flags.
  • Use setCustomValidity for cross-field and server-returned rules so everything flows through one state model — including form.checkValidity() — rather than two parallel ones.
  • Mirror the server's rules, and derive them from a shared schema if you can. Where they must differ, make the client's rules the looser of the two so the server is never the first to say no about something the client silently allowed (The Three Validations).
  • On failed submit, move focus to the first invalid control and announce a summary. This is the part the browser does for you and the part rebuilt forms most often lose (Errors People Can Actually Perceive).
  • For async checks, cancel the in-flight request on each new keystroke and ignore responses for values that are no longer current (Cancelling a Request Nobody Is Waiting For).

Keyboard, focus, semantics, announcement

A required field on every lesson in this domain, not a section added when there is room.

  • required on the element sets the accessible required state without any ARIA. aria-required is only for controls that cannot carry the real attribute.
  • A failing native validation focuses the offending control and announces its message. Suppressing the bubble means you inherit that responsibility, and aria-invalid plus aria-describedby is how you meet it (Errors People Can Actually Perceive).
  • The native bubble is browser chrome, so it is not reachable by pointer, not persistent, and not readable on demand — one of the genuine reasons to replace it rather than a matter of taste.
  • Errors announced only through a colour change reach nobody using a screen reader and are missed by users with colour vision deficiency; text and semantics are what carry meaning (Contrast, Colour and Motion).
  • Do not disable submit as the error signal. Users who navigate by keyboard or by scanning the control list may never encounter the disabled control that would have explained the state.

What can go wrong

Failure modes
  • novalidate with no replacement. It happens when the form was styled before it was wired, and the result is a form with no client-side checks at all that nobody notices until the server error rate moves.
  • Styling :invalid without :user-invalid. Every required field is red on load, which trains users to ignore red.
  • A pattern that anchors implicitly. The attribute matches the whole value, unlike most regex usage, so a pattern written with ^...$ habits behaves as expected while a partial-match pattern does not — and the difference is silent.
  • Custom validity set and never cleared. A field stays invalid after the user fixes it, and submit refuses forever with a message about a value that is no longer there.
  • disabled on the submit button until the form is valid. It gives the user nothing to press and nothing to explain why, and a disabled button is skipped by keyboard navigation. A live submit that reports errors is better in every respect.
  • Trusting type="email" as an identity check. Syntactic validity says nothing about deliverability; only a confirmation message does (Parse, Validate, Authorize, Process).
  • Async validation with no request ordering discipline, producing an error message that belongs to a value the user typed three keystrokes ago.
What can arrive out of order
  • Async availability checks: responses for older values arriving after newer ones, marking a valid value invalid. Ignore any response whose request value is not the current one (Five Components, One Request).
  • Autofill writing several fields between a validation run and its own re-render, so a cross-field rule is evaluated against a form state that no longer exists.
  • A server-returned validation error applied via setCustomValidity after the user has already edited the field, pinning an error to a value the server never saw.
  • Two submits from a fast double-press: the second passes validation while the first is still in flight (Submission: Method, Encoding and Doing It Once).
Security
  • Client-side validation is a courtesy to the user, never a control. It exists to give fast, local feedback; it provides no guarantee whatsoever about what reaches the server (What the Frontend Is Responsible For in Auth).
  • Everything on the client is editable: attributes can be removed in devtools, JavaScript can be broken with a breakpoint, and the request can be replayed from curl with no browser involved at all.
  • The server must therefore validate independently — types, ranges, formats, ownership, authorization and business rules — and treat every field as adversarial input (Transport Validation, Business Validation).
  • Fields the client never sent are still fields an attacker can send. Server-side allow-listing of accepted fields is what prevents a request from setting attributes the form never exposed (Mass Assignment and Over-Posting).
  • Error messages returned to the client should say what is wrong with the input without revealing whether an account exists, what the internal rule is, or how the check is implemented (The Error Model: Structure Over Apology).
Misreads
  • "Native validation is not good enough, so turn it off." The presentation is not good enough. The state model, the semantics, the timing pseudo-classes and the pre-hydration coverage are excellent, and they are what novalidate discards.
  • "Client validation makes the server's validation redundant." It is the other way round. The server's validation is the only one that exists as far as correctness is concerned; the client's exists for speed of feedback.
  • "pattern is a security control." It is a hint that the browser evaluates and the user can delete.
  • "If the client says it is valid, the server can trust it." A request never has to come from your client at all.
  • "Disabling submit until valid is good UX." It removes the user's ability to ask why, and it removes a tab stop.
  • "maxlength will report an error." It silently prevents further input. A user pasting a longer value sees it truncated with no explanation, which is why a visible counter usually belongs alongside it.

Measuring it, and what changes in the field

How you would see this
  • In the console: input.validity for the flag set, input.validationMessage for the browser's wording, form.checkValidity() for the aggregate — all readable at any moment without instrumentation.
  • The server's validation error rate broken down by field. A field the client accepts and the server rejects is a rule that has drifted, and it is measurable rather than a matter of opinion (Validation Errors: Feedback, Not Verdicts).
  • Field-level abandonment analytics: a field where users repeatedly correct and retry is usually a validation timing or wording problem, not a user problem.
  • A keyboard-only pass through a deliberately failed submit. Where does focus go, what is announced, and can you find the error without looking at the screen (Accessibility Testing).
Slow device, slow network, large data, old tab
  • Before hydration, native validation is the only validation there is. On a slow device or a slow network, that window is long enough for a real user to submit inside it (Hydration).
  • Message wording and bubble positioning differ across browsers and follow the browser's UI language rather than the page's, so a page in one language can show validation messages in another.
  • On a long form on a small screen, the first invalid control may be far offscreen, which makes focus-and-scroll behaviour on failed submit much more important than it appears on a desktop test.
  • On a slow network, async validation results arrive after the user has moved on, so the ordering discipline is not a theoretical concern — it is the normal case.
What this costs
  • Custom messages and presentation cost you the browser's built-in localisation, its focus-and-scroll behaviour and its pre-hydration coverage. Keeping the constraint attributes and only replacing the presentation is the compromise that keeps most of the value.
  • Sharing a schema between client and server removes drift but couples the two deployments, and a client running an older schema will disagree with the server it is talking to (Long-Lived Clients and Version Skew).
  • Validating on blur is kinder than validating on keystroke and slower to surface a problem. There is no timing that is right for every field; format rules suit blur, and length counters suit live updates.
  • setCustomValidity centralises state at the cost of imperative DOM calls, which sits awkwardly in declarative frameworks and needs a careful effect to keep in sync.

Where this applies

Frontend advice ages badly and fragments across engines. These labels say what each claim is specific to, and where a different browser, device or framework would differ.

  • GENERALThe Constraint Validation API — validity flags, checkValidity, reportValidity, setCustomValidity, the invalid event and the CSS pseudo-classes — is specified and implemented across current engines.
  • BROWSER-SPECIFICMessage wording, bubble styling, positioning and dismissal timing are entirely the browser's: Chrome, Firefox and Safari word the same failure differently and localise to the browser UI language rather than the document language, so you cannot rely on any specific string.
  • SPEC-EVOLVING:user-valid / :user-invalid reached broad support considerably later than :valid / :invalid, so codebases that predate it often carry a hand-rolled "touched" flag; check current support before removing the fallback rather than assuming either way.

Where the depth lives

This domain teaches the browser-side mechanism and hands the rest off.

Domains that do not exist yet
  • Software Design — "parse, do not validate" is the same idea seen from the type system: a value that has passed a check should change type so the check cannot be forgotten downstream.