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.

A 60 ms validation pass on and off the main thread On the main thread, validation runs from 0 to 60 milliseconds and keystrokes arriving at 20, 35 and 50 milliseconds wait until it finishes, so the user sees the characters appear late in a burst. With a worker, the main thread handles each keystroke immediately; the worker runs validation in parallel and posts results at 64 milliseconds, and a superseded run is discarded. Main thread only validate: typing blocked catch-up Main thread + worker keystrokes painted Worker validate in parallel 0ms 20ms 40ms 60ms 80ms 100ms The worker does not make validation faster; it stops validation from delaying what the user sees.

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

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. Return plain issue objects. Error instances and schema-library classes do not survive structured cloning intact; map to { path, message } in the worker.
  6. Show a pending state for the heavy checks. While the worker runs, mark affected rows as โ€œcheckingโ€ rather than showing stale results.
  7. Terminate on unmount. A leaked worker keeps its memory and any large data it received.
What runs where The main thread runs cheap per-field checks such as required, format and length on every keystroke, and renders all results. The worker runs heavy checks such as whole-form schema parses of large data, cross-row duplicates and totals, and import parsing, debounced, returning plain issue objects tagged with a run id. Main thread required, format, length per field. Runs every keystroke; instant feedback. Renders every result, including worker issues. Worker Whole-form parse of large data. Cross-row duplicates, totals, overlaps. Debounced; returns { id, issues }.

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.

Is a worker worth it? Required and format checks cost microseconds and should stay on the main thread. A schema parse of a small form costs well under a millisecond and should stay. A schema parse of hundreds of rows can cost tens of milliseconds and is a good worker candidate. Cross-row duplicate and total checks over large lists are good candidates. CSV import parsing is a strong candidate. Remote availability checks are asynchronous network calls and gain nothing from a worker. Validation Typical cost Worker? required / format / length microseconds no schema parse, small form under 1 ms no schema parse, hundreds of rows tens of ms yes cross-row duplicates and totals grows with rows yes CSV or JSON import parse tens to hundreds of ms yes remote availability check network-bound no gain

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.


Related

โ† Performance and Scale for Large Forms