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.
What does the browser check for me, where does that stop being enough, and what am I taking on when I switch it off?
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.
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.
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.
- 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
:invalidstyling 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, thendaniel; thedanresponse arrives last and marks a valid name as taken (Out-of-Order Responses).
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,badInputandcustomError, plus avalidsummary. - Constraints come from attributes:
required, thetype's own rule,pattern,min/max/step,minlength/maxlength.maxlengthis special — it prevents entry rather than reporting a violation, so it never shows a message. checkValidity()returns a boolean and fires aninvalidevent on each failing control.reportValidity()does the same and additionally displays the browser's message bubble on the first failure. Theinvalidevent is your hook for custom presentation.setCustomValidity(message)setscustomErrorand 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-rangeand:user-valid/:user-invalidreflect this state live.:user-invalidis 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).
novalidateon the form, orformnovalidateon 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-invalidselectors, 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
patternregular 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".
1const form = document.querySelector<HTMLFormElement>('#signup')!2 3function messageFor(el: HTMLInputElement): string {4 const v = el.validity5 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 wording10}11 12form.addEventListener(13 'invalid',14 (e) => {15 const el = e.target as HTMLInputElement16 e.preventDefault() // suppress the native bubble only17 showError(el, messageFor(el)) // your DOM, your styling, your a11y wiring18 },19 true, // capture: `invalid` does not bubble20)21 22form.addEventListener('submit', (e) => {23 if (!form.checkValidity()) { // fires `invalid` per failing control24 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.
| Trigger | Symptom | Cause | Response |
|---|---|---|---|
| Message wording and language | A German page shows English messages, or wording that contradicts the product voice | Messages come from the browser UI locale, not the document | Suppress the bubble, render your own text — but keep the underlying constraints (Internationalization). |
| Bubble styling and position | Message cannot match the design and vanishes on its own | It is browser chrome, outside the DOM | Render 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 interaction | Use :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 expressed | Constraints are per control by design | setCustomValidity on the dependent field, recomputed whenever either field changes. |
| Server-only rules | "That username is taken" cannot be known client-side | The client does not have the data | Async check for feedback plus setCustomValidity, and accept that the server's answer at submit is the authoritative one (Validation Errors: Feedback, Not Verdicts). |
| Custom controls | A div-based combobox or rich text field has no validity state at all | Only listed form controls participate | Back it with a real hidden-but-associated control, or own validation for that control explicitly (Accessible Component Patterns). |
| Only one error shown at a time | User fixes one field, submits, and learns about the next | Interactive validation reports the first failure and stops | Run 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).
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-invalidrather than:invalidso an untouched empty required field is not marked as an error the moment the page renders. - Validate on
blurfor format, and oninputonly 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
invalidevent, callpreventDefault()on it to suppress the native bubble, and render your own message fromvalidationMessageor from your own map keyed off the validity flags. - Use
setCustomValidityfor cross-field and server-returned rules so everything flows through one state model — includingform.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.
requiredon the element sets the accessible required state without any ARIA.aria-requiredis 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-invalidplusaria-describedbyis 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
novalidatewith 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
:invalidwithout:user-invalid. Every required field is red on load, which trains users to ignore red. - A
patternthat 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.
disabledon 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.
- 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
setCustomValidityafter 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).
- 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
curlwith 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).
- "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
novalidatediscards. - "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.
- "
patternis 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.
- "
maxlengthwill 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
- In the console:
input.validityfor the flag set,input.validationMessagefor 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).
- 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.
- 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.
setCustomValiditycentralises 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, theinvalidevent 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-invalidreached 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.
- — 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.