Some validation is genuinely expensive โ parsing a 2,000-row pasted spreadsheet against a schema, checking uniqueness across every row of a repeatable group, verifying a large JSON configuration โ and running it on the main thread freezes typing for as long as it takes.
Performance and scale for large forms covers reducing render cost. This page handles the other half: compute that cannot be made cheap. A Web Worker runs it in parallel so the input stays responsive, but introduces asynchrony, serialisation cost and stale results โ all of which the form must handle deliberately.
Context and prerequisites
Move validation to a worker only when profiling shows it matters. A useful threshold: if a validation pass regularly takes more than about 16 ms (one frame) on a throttled mid-range device, it will make typing or scrolling stutter. Typical candidates:
- Whole-form schema validation of large nested data (hundreds of rows).
- Cross-row rules: duplicates, totals, overlapping date ranges across a list.
- Parsing and validating pasted or uploaded CSV, JSON or XML before import.
- Expensive checks such as password strength estimation with large dictionaries.
Keep cheap per-field checks โ required, format, length โ on the main thread. They give instant feedback, and a worker round trip (message, clone, schedule, reply) costs more than they do.
The core pattern: a typed worker with run ids and supersession
// validate.worker.ts
import { RowsSchema } from "./schema"; // the same schema the server uses
type Req = { id: number; rows: unknown[] };
type Res = { id: number; issues: { path: (string | number)[]; message: string }[] };
self.onmessage = (e: MessageEvent<Req>) => {
const { id, rows } = e.data;
const result = RowsSchema.safeParse(rows);
const issues = result.success ? [] : result.error.issues.map((i) => ({ path: i.path, message: i.message }));
(self as unknown as Worker).postMessage({ id, issues } satisfies Res);
};
// worker-validator.ts (main thread)
type Issue = { path: (string | number)[]; message: string };
export function createWorkerValidator() {
const worker = new Worker(new URL("./validate.worker.ts", import.meta.url), { type: "module" });
let latest = 0;
const pending = new Map<number, (issues: Issue[] | null) => void>();
worker.onmessage = (e: MessageEvent<{ id: number; issues: Issue[] }>) => {
const resolve = pending.get(e.data.id);
pending.delete(e.data.id);
// A result for a superseded run resolves to null: the caller ignores it.
resolve?.(e.data.id === latest ? e.data.issues : null);
};
return {
validate(rows: unknown[]): Promise<Issue[] | null> {
const id = ++latest;
// Resolve every older pending run as superseded right away, so callers
// awaiting them can stop waiting (a worker cannot abort a sync parse).
for (const [oldId, r] of pending) { r(null); pending.delete(oldId); }
return new Promise((resolve) => {
pending.set(id, resolve);
worker.postMessage({ id, rows }); // structured clone of rows
});
},
destroy() {
worker.terminate(); // also frees the worker's memory
for (const r of pending.values()) r(null);
pending.clear();
},
};
}
Bundlers such as Vite and webpack 5 recognise new Worker(new URL(..., import.meta.url)) and emit the worker as its own chunk, including its imports โ so the worker can import the same schema module as the main thread without duplication in your source.
Step-by-step walkthrough
- Profile first. Confirm with the Performance panel that validation, not rendering, is the long task โ the procedure is in profiling form re-renders in DevTools.
- Split validation into cheap and heavy. Per-field format checks stay on the main thread and run per keystroke; heavy whole-form or cross-row checks go to the worker and run debounced.
- Give every request an id. The worker echoes it; the main thread only applies the result for the latest id. This is the worker equivalent of cancelling stale async validation.
- Resolve superseded runs as
null. A synchronous parse inside a worker cannot be interrupted, but the caller does not need to wait for it. For very long runs,terminate()the worker and start a fresh one instead. - Return plain issue objects. Error instances and schema-library classes do not survive structured cloning intact; map to
{ path, message }in the worker. - Show a pending state for the heavy checks. While the worker runs, mark affected rows as โcheckingโ rather than showing stale results.
- Terminate on unmount. A leaked worker keeps its memory and any large data it received.
Failure modes and edge cases
1. Cloning cost exceeds the savings
postMessage structured-clones its payload on the main thread. Sending a 5 MB object on every keystroke can cost more than validating it. Send only what changed โ the edited row and its index โ and keep the full dataset in the workerโs own memory, or transfer an ArrayBuffer with the transfer list for raw data.
2. Different code paths for client and server
If the worker imports a copy of your schema rather than the shared module, the two drift. Import the same module; the bundler handles it. See sharing one Zod schema between client and server.
3. Content Security Policy
A strict worker-src or script-src policy can block workers created from blob URLs, which some bundler configurations emit in development. Use real module URLs and allow worker-src 'self'.
4. Server rendering
Workers do not exist during SSR. Create the validator in an effect or on mount, never at module scope in a file imported by server code.
5. Submit must wait for the heavy check
On submit, run the heavy check again and await it โ if the debounced run has not finished, the form must not submit with stale worker results. Disable nothing; show โCheckingโฆโ on the submit button with aria-busy while waiting.
Verification checklist
Frequently Asked Questions
Can I use Comlink instead of a hand-written protocol?
Yes. Comlink wraps postMessage so worker functions look like async calls, which removes the id bookkeeping for the request itself. You still need supersession: track the latest call on the main thread and ignore older results, because Comlink does not cancel them.
Would requestIdleCallback or scheduler.yield be enough?
If the work can be split into small chunks, yielding between chunks with scheduler.yield() or setTimeout keeps the main thread responsive without a worker. A single monolithic schema parse cannot be chunked, which is when a worker is the simpler answer.
How many workers should a form use?
One per form is almost always enough; heavy checks are debounced, so there is rarely more than one run in flight. Share one worker across forms on the same page if memory matters, keyed by a form id in each message.