TL;DR: HTML5's required, pattern, and the Constraint Validation API give you fast, accessible client-side feedback — but they only check syntax, run entirely in a browser you don't control, and can be disabled or bypassed in seconds. Treat them as UX, not security, and validate everything again on the server.
It's an easy mistake to make, because the symptom and the fix look identical from the front end. A form with required fields won't submit until they're filled in, so it feels like the data reaching your backend has already been checked. What actually happened is narrower: the browser confirmed the string in the box matches a pattern — it said nothing about whether that string is safe to store, unique in your database, or permitted for the user currently logged in.
That boundary — between what the browser is willing to accept and what your business logic actually allows — is where the real engineering happens. Getting it right means treating every client payload as untrusted, re-validating business rules on the server, and keeping both layers in sync without copying the same regular expression into two languages. It's exactly the kind of plumbing a custom software development team gets right the first time, instead of patching it in after the first bad submission makes it to production.
The Constraint Validation API is the machinery behind HTML5's built-in form checks — the part of the spec that decides whether a field is "valid" before the form submits. It reads a handful of attributes straight off the markup:
<input type="email" required>
<input type="text" pattern="[A-Za-z]{2,30}" required>
<input type="number" min="18" max="120">
<input type="url">required checks presence, pattern checks shape against a regular expression, min/max check a numeric or date range, and the type attribute itself (email, url, number) carries an implicit format check the browser runs for you. Every one of these is a syntactic check — does the string look like the kind of thing it claims to be. None of them is a semantic check — is this a real address that receives mail, is this username actually available, does this user have permission to submit this value. MDN's guide to client-side form validation walks through the full attribute set if you need the reference.
Open DevTools, select the input, and delete the required attribute. The validation is gone — no exploit, no code change, two clicks. A user with JavaScript disabled never ran your custom validators in the first place, only whatever the browser enforces natively. And anyone talking to your API directly — a script, a second client, curl — never touches the DOM at all, so the Constraint Validation API never runs for them. Native validation is a convenience layer for people using your form the way you expected. It is not a gate.
Client-side checks are UX: they catch typos the instant they happen, before a round trip to the server, so a user fixes a malformed email address in place of waiting for a page reload. Server-side checks are trust: they're the only validation a request can't opt out of, because they run on hardware you control, after the request has already arrived. The same rule usually needs to exist in both places — once in the markup for instant feedback, once in application logic for enforcement — and the two need to stay in sync as the rule changes.
<!-- Client: instant feedback, skippable -->
<input type="email" required>
// Server: the actual gate — runs no matter what sent the request
if (!isValidEmail(req.body.email)) {
return res.status(422).json({ error: "invalid email" });
}CSS has its own hooks into the same validity state the Constraint Validation API computes, so you don't need JavaScript just to color a field red. :valid and :invalid match based on the field's current ValidityState — but both match immediately, even before the user has typed anything, which is why an empty required field looks "invalid" the moment the page loads. :user-invalid fixes that: it only matches after the user has interacted with the field and left it in an invalid state.
input:user-invalid {
border-color: #c23b3b;
}
input:valid {
border-color: #3f6b04;
}Reach for :user-invalid as the default for error styling. Reserve plain :invalid for cases where showing the state immediately is actually useful — a password-confirmation field that should flag a mismatch the moment it's typed, for example.
The browser's native validation bubble is inconsistent across browsers and assistive technology — don't rely on it alone. Tie a custom error message to its field explicitly:
<label for="email">Email</label>
<input type="email" id="email" aria-describedby="email-err" aria-invalid="true">
<p id="email-err">Enter an address in the form [email protected].</p>aria-describedby makes a screen reader announce the error text alongside the label when focus lands on the field; aria-invalid="true" flags the field's state directly. Clear both when the field becomes valid — a stale aria-invalid="true" on a field the user already fixed is its own kind of bug.
If an error message is inserted into the DOM after the page has already loaded — on blur, on submit, after an async check — a screen reader won't announce it unless something tells it to look. A live region does that:
<div aria-live="polite" id="form-errors"></div>polite waits for the current speech to finish before announcing the update, instead of interrupting mid-sentence the way assertive would. For form errors, that's almost always the right choice — a user still typing shouldn't be cut off to be told about a field they haven't reached yet.
It depends on what the check costs. A format check — does this look like an email address, is this field non-empty — is free to run on every keystroke, so validate it in real time and let the user fix it before they even reach submit. A check that needs a network round trip — is this username already taken, does this coupon code exist — is expensive and often wrong mid-typing, so defer it to blur or submit, after the user has stopped typing into that field. Validating an empty field the instant the form loads, before the user has touched it, is the most common version of this mistake — it's what :user-invalid exists to prevent at the CSS layer, and the same logic applies to any JavaScript validator you write by hand.
| Check | Client-side | Server-side |
|---|---|---|
| Format checks (email shape, number range) | Owns it — instant feedback | Re-checks it — never trusts the client |
| Required fields | Blocks submission | Rejects the request if missing |
| Business rules (uniqueness, permissions) | Can't own it — no access to the data | Owns it entirely |
| Data sanitization | Not a safety boundary | Owns it — escaping, parameterized queries |
| Security enforcement | Bypassable in seconds | The only enforcement that counts |
| User feedback speed | Owns it — no round trip | Too slow for keystroke-level feedback |
No. It's a UX layer that catches typos before submission, but it can be disabled or bypassed entirely. Pair it with server-side validation that runs regardless of what sent the request.
Yes — by removing the required attribute in DevTools, disabling JavaScript, or calling the underlying API directly without ever rendering the form.
Use pattern for simple format checks that need no custom error text. Reach for JavaScript and the Constraint Validation API when you need a custom error message, cross-field logic, or an async check.
:invalid matches immediately on load if the field is empty and required. :user-invalid only matches after the user has interacted with the field, which avoids showing an error before they've had a chance to type anything.
Inconsistently, across different browser and assistive-technology combinations. Don't rely on the native bubble alone — add an explicit aria-describedby error message tied to the field.
Yes — when you're fully replacing native validation with a custom JavaScript or Constraint Validation API flow and don't want the browser's default checks fighting your own.
Yes, via ElementInternals and attachInternals(), which let a custom element participate in form validation and submission like a native input — check browser support against your target list before relying on it. The WHATWG spec's constraints section is the authoritative reference for exactly which checks the API runs and when.