Native Form Validation
Our signup form imported a 45KB validation library to check that email fields contained an @ symbol. The browser already did that with type="email". We removed the library, switched to native constraints with custom messages via the Constraint Validation API, and the form worked with JavaScript disabled while giving us styled error states when JS was available.
HTML constraint attributes
<form>
<label for="email">Email</label>
<input
id="email"
name="email"
type="email"
required
autocomplete="email"
/>
<label for="age">Age</label>
<input
id="age"
name="age"
type="number"
min="13"
max="120"
required
/>
<label for="website">Website</label>
<input
id="website"
name="website"
type="url"
placeholder="https://example.com"
/>
<label for="username">Username</label>
<input
id="username"
name="username"
type="text"
pattern="[a-zA-Z0-9_]{3,20}"
title="3-20 characters: letters, numbers, underscores"
required
/>
<button type="submit">Sign up</button>
</form>
Each attribute maps to a validation constraint the browser checks on submit and on blur.
Input types with built-in validation
| Type | Validates |
|---|---|
email |
Contains @ with domain |
url |
Valid URL scheme |
number |
Numeric, respects min/max/step |
tel |
No format validation (intentionally) |
date |
Valid date, respects min/max |
file |
Accept attribute filters MIME types |
Constraint Validation API
Every form control implements the ValidityState interface:
const input = document.getElementById('email');
input.addEventListener('input', () => {
const valid = input.validity.valid;
input.classList.toggle('invalid', !valid);
input.classList.toggle('valid', valid);
});
form.addEventListener('submit', (e) => {
if (!form.checkValidity()) {
e.preventDefault();
form.reportValidity(); // shows native tooltips
}
});
Key properties on input.validity:
validity.valueMissing // required field is empty
validity.typeMismatch // wrong type (email without @)
validity.patternMismatch // doesn't match pattern attribute
validity.tooShort // below minlength
validity.tooLong // above maxlength
validity.rangeUnderflow // number below min
validity.rangeOverflow // number above max
validity.customError // setCustomValidity() was called
Custom error messages
const inputs = form.querySelectorAll('input, select, textarea');
inputs.forEach((input) => {
input.addEventListener('invalid', (e) => {
e.preventDefault(); // suppress native tooltip
const messages = {
valueMissing: `${input.labels[0]?.textContent} is required`,
typeMismatch: 'Enter a valid email address',
patternMismatch: input.title || 'Invalid format',
rangeUnderflow: `Minimum value is ${input.min}`,
rangeOverflow: `Maximum value is ${input.max}`,
};
for (const [key, message] of Object.entries(messages)) {
if (input.validity[key]) {
showError(input, message);
return;
}
}
});
input.addEventListener('input', () => {
clearError(input);
});
});
Call setCustomValidity('') on input to clear custom errors and allow re-validation.
Styling valid and invalid states
input:user-valid {
border-color: #16a34a;
}
input:user-invalid {
border-color: #dc2626;
}
input:user-invalid:focus {
outline-color: #dc2626;
}
The :user-valid and :user-invalid pseudo-classes apply only after the user interacts with the field, avoiding red borders on untouched required fields at page load.
Cross-field validation
Native validation can't compare two fields. Use JavaScript for password confirmation:
const password = document.getElementById('password');
const confirm = document.getElementById('confirm-password');
function validateMatch() {
if (confirm.value && confirm.value !== password.value) {
confirm.setCustomValidity('Passwords do not match');
} else {
confirm.setCustomValidity('');
}
}
password.addEventListener('input', validateMatch);
confirm.addEventListener('input', validateMatch);
novalidate and server-side validation
Add novalidate to the form when building a fully custom validation UI:
<form novalidate>
This disables native tooltips but keeps the Constraint Validation API functional — you still call checkValidity() programmatically.
Server-side validation is non-negotiable. Client validation improves UX; it does not protect against crafted requests.
Server-side mirror
Mirror client constraints on the server — never trust the browser:
def validate_signup(data):
errors = {}
if not data.get('email') or '@' not in data['email']:
errors['email'] = 'Valid email required'
if not re.match(r'^[a-zA-Z0-9_]{3,20}$', data.get('username', '')):
errors['username'] = '3-20 alphanumeric characters'
return errors
Return field-level errors in a structured format the client can display next to each input.
Live regions for screen readers
When custom validation UI replaces native tooltips, announce errors with ARIA live regions:
<div role="alert" aria-live="polite" id="email-error"></div>
Measuring success in production
Deploy changes behind feature flags when possible so you can compare metrics between control and treatment groups. Use Real User Monitoring to capture performance data from actual devices and network conditions — lab tools alone miss the long tail of user experiences. Set up alerts for regressions: a 10% LCP increase week-over-week warrants investigation before it hits CrUX.
Document your baseline metrics before making changes. Performance work without measurement is guesswork. Share results with the team — concrete numbers ("LCP improved 800ms on mobile") build support for continued investment in web performance and reliability.
Review changes quarterly. Browser updates, new API support, and traffic pattern shifts can obsolete previous optimizations or create new opportunities. What worked in 2024 may not be the best approach in 2026.
Additional production considerations
Teams often underestimate the maintenance cost of performance optimizations. Automate what you can: CI bundle budgets, Lighthouse CI on PRs, and RUM dashboards that alert on regressions. Manual audits don't scale past a handful of pages.
Security and performance intersect more than teams expect. Third-party scripts that hurt INP also expand your attack surface. Self-hosting fonts and critical assets reduces both latency and supply-chain risk. Review every external dependency quarterly — remove what you no longer need.
Accessibility and performance share goals: semantic HTML helps screen readers and gives the browser better rendering hints. Native elements like dialog, popover, and details reduce JavaScript while improving accessibility. Prefer platform features over custom implementations when they meet your requirements.
Mobile users dominate traffic for most sites. Test on real mid-tier Android hardware, not just desktop Chrome. Simulated throttling in DevTools approximates network conditions but not CPU constraints. A fix that helps desktop may be invisible on mobile if the bottleneck is JavaScript execution, not network.
Collaborate with backend teams on TTFB and API response times. Frontend optimizations can't fix a 2-second server response. Set SLAs for API endpoints that feed critical pages and measure them in the same RUM pipeline as Core Web Vitals.
Resources
- MDN: Form validation
- Constraint Validation API
- HTML input types
- WCAG: Error identification
- Can I use :user-invalid
Frequently asked questions
When should I rely on native validation versus a JavaScript library?
Use native validation for standard constraints — required fields, email format, number ranges, URL patterns, and maxlength. It works without JavaScript, provides accessible error messages via the browser, and blocks form submission automatically. Add JavaScript validation only for cross-field rules (password confirmation), async checks (username availability), or complex business logic that HTML attributes cannot express.
How do I customize the browser's default validation messages?
Listen for the invalid event on each input and call setCustomValidity() with your message. Clear it with setCustomValidity('') on input events so the field re-validates as the user types. Never suppress native validation entirely unless you replicate all accessibility behavior in your custom UI.
Does native validation work with server-side rendering and progressive enhancement?
Yes. Native validation is the progressive enhancement baseline. Forms with required, type, pattern, and min/max attributes validate in any browser without JavaScript. Server-side validation remains mandatory — client validation is a UX improvement, not a security control. Always validate and sanitize on the server.
Hiring a senior Android / Flutter engineer?
I architect and ship production mobile software — Kotlin, Jetpack Compose, Flutter — for robotics, EV infrastructure, fintech, and real-time systems. Open to remote roles in Europe and the US.
Get in touch →