Exact problem: a SvelteKit form runs validation or mutates ARIA attributes synchronously during the hydration window, causing Svelte to detect a server/client DOM divergence and log a hydration mismatch warning β€” which also corrupts aria-invalid state and triggers visible UI flicker before the user has touched anything.

Context and prerequisites

This page drills into one specific failure mode inside the broader topic of hydration sync for SSR forms. Before reading on, you should be familiar with how SvelteKit’s $page.form object carries server action results back to the client. You should also understand Svelte store integration for forms because the fix wraps that same store pattern with a lifecycle gate.

The diagram below shows the two timelines β€” server render and client hydration β€” and the narrow window where premature validation causes the mismatch:

Svelte hydration mismatch window Two parallel horizontal timelines. The top lane (Server) shows HTML render completing before the client connects. The bottom lane (Client) shows script parse, hydration checksum, onMount, and then tick() completing β€” the gap between hydration checksum and tick() is shaded as the mismatch danger zone. Server Client HTML render + serialize $page.form JS parse Hydration checksum ⚠ mismatch zone onMount tick() βœ“ safe to validate HTML delivered time β†’

Core pattern: the hydration gate

The single implementation below addresses the entire mismatch class. Every non-obvious line carries an inline comment.

<script lang="ts">
  import { onMount, tick } from 'svelte';
  import { writable, derived, type Readable } from 'svelte/store';
  import { beforeNavigate } from '$app/navigation';

  // ── Types ──────────────────────────────────────────────────────────────────
  interface FormData {
    email: string;
    username: string;
    [key: string]: string;
  }

  type FieldErrors = Record<string, string>;

  // ── Props ──────────────────────────────────────────────────────────────────
  export let form: Partial<FormData> = {};

  // ── Stores ─────────────────────────────────────────────────────────────────
  // Wrap the server-supplied prop in a store so derived() can subscribe to it.
  // A plain prop cannot be used inside derived() β€” stores are required.
  const formStore = writable<Partial<FormData>>(form);
  $: formStore.set(form); // mirror future server-action updates reactively

  // The hydration gate. Stays false until Svelte finishes reconciling the DOM.
  // NEVER set this true synchronously β€” doing so re-introduces the mismatch.
  const isHydrated = writable(false);

  const validationErrors = writable<FieldErrors>({});

  // Derived validity bypasses all schema checks while the gate is closed.
  // This is what prevents aria-invalid from being set during the danger zone.
  const isValid: Readable<boolean> = derived(
    [formStore, validationErrors, isHydrated],
    ([$form, $errors, $hydrated]) => {
      if (!$hydrated) return true; // treat as valid during hydration β€” no attribute mutations
      return Object.keys($errors).length === 0;
    }
  );

  // ── Lifecycle ───────────────────────────────────────────────────────────────
  onMount(async () => {
    // tick() yields to the microtask queue, giving Svelte one full pass to
    // reconcile server HTML with the initial client vdom. Only after that
    // completes is it safe to mutate DOM-visible reactive state.
    await tick();
    isHydrated.set(true);

    // Return cleanup: runs on component destroy (navigation away or unmount).
    return () => {
      isHydrated.set(false);       // reset so the gate closes if component remounts
      validationErrors.set({});    // clear stale errors β€” they don't belong to the next route
    };
  });

  // Reset the gate on SvelteKit client-side navigation to prevent stale state
  // from a previous route leaking into the next one during the transition.
  beforeNavigate(() => {
    isHydrated.set(false);
    validationErrors.set({});
  });

  // ── Validation ──────────────────────────────────────────────────────────────
  // AbortController is used here to cancel any async uniqueness checks if the
  // user types again before the previous request resolves. Without this, a
  // slow response can overwrite a newer, correct validation result.
  let abortController: AbortController | null = null;

  async function validateField(name: string, value: string): Promise<void> {
    // Hard guard: never run validation during the hydration window.
    // $isHydrated reads the current store value synchronously via the $ prefix.
    if (!$isHydrated) return;

    // Cancel any in-flight async validation for this field before starting a new one.
    abortController?.abort();
    abortController = new AbortController(); // each call gets a fresh token
    const signal = abortController.signal;   // pass signal into fetch() to cancel

    try {
      const result = runSchemaValidation($formStore);

      // If the signal aborted while runSchemaValidation was executing, discard the result.
      if (signal.aborted) return;

      validationErrors.update(errors => ({
        ...errors,
        [name]: result[name] ?? '',
      }));
    } catch (err) {
      // AbortError is expected and benign β€” swallow it silently.
      if (err instanceof Error && err.name !== 'AbortError') {
        console.error('[form] validation error:', err);
      }
    }
  }

  function handleInput(event: Event): void {
    const target = event.target as HTMLInputElement;
    const { name, value } = target;
    formStore.update(f => ({ ...f, [name]: value }));
    void validateField(name, value);
  }

  function handleSubmit(event: SubmitEvent): void {
    event.preventDefault();
    // Full-form validation and submission logic goes here.
  }

  // Stub β€” replace with your actual schema library (Zod, Valibot, etc.)
  function runSchemaValidation(data: Partial<FormData>): FieldErrors {
    const errors: FieldErrors = {};
    if (!data.email?.includes('@')) errors.email = 'Enter a valid email address.';
    if (!data.username || data.username.length < 3) errors.username = 'Username must be at least 3 characters.';
    return errors;
  }
</script>

<form on:submit={handleSubmit} novalidate>
  <div class="field">
    <label for="email">Email</label>
    <input
      id="email"
      name="email"
      type="email"
      value={$formStore.email ?? ''}
      on:input={handleInput}
      aria-invalid={$validationErrors.email ? 'true' : 'false'}
      aria-describedby={$validationErrors.email ? 'email-error' : undefined}
    />
    {#if $validationErrors.email && $isHydrated}
      <!-- role="alert" triggers screen-reader announcement on insertion -->
      <span id="email-error" role="alert" class="error-text">
        {$validationErrors.email}
      </span>
    {/if}
  </div>

  <div class="field">
    <label for="username">Username</label>
    <input
      id="username"
      name="username"
      type="text"
      value={$formStore.username ?? ''}
      on:input={handleInput}
      aria-invalid={$validationErrors.username ? 'true' : 'false'}
      aria-describedby={$validationErrors.username ? 'username-error' : undefined}
    />
    {#if $validationErrors.username && $isHydrated}
      <span id="username-error" role="alert" class="error-text">
        {$validationErrors.username}
      </span>
    {/if}
  </div>

  <button type="submit">Submit</button>
</form>

Step-by-step walkthrough

  1. Wrap the prop in a store (lines formStore = writable(form) and $: formStore.set(form)). A component prop cannot be passed directly to derived(). Wrapping it lets the derived validity store subscribe and react when the server sends a new $page.form payload after a form action round-trip.

  2. Create isHydrated as a writable(false) store. This is the gate. All reactive expressions that would mutate DOM-visible attributes read this store via derived(), so they evaluate to safe defaults while the gate is closed.

  3. Open the gate inside onMount after await tick(). onMount runs after the component first renders on the client. tick() yields until Svelte finishes its DOM reconciliation pass. Setting isHydrated to true before that pass completes is the most common mistake β€” it re-introduces the very mismatch you are trying to prevent.

  4. Return a cleanup function from onMount. Svelte calls the returned function when the component is destroyed. Resetting isHydrated and validationErrors ensures that if this component is ever re-mounted (e.g., in a Svelte 5 {#snippet} context), it starts clean rather than inheriting stale state from a previous mount.

  5. Register beforeNavigate to reset both stores. SvelteKit’s client-side router does not destroy and recreate components on every navigation. Without this, the gate remains open from the previous route and the next route’s hydration is unprotected.

  6. Gate validateField with if (!$isHydrated) return. This is the inline guard β€” a second line of defence in case the derived store’s value has not yet propagated when an input event fires very early.

  7. Use AbortController to cancel in-flight async validation. Each call to validateField aborts the previous controller and creates a fresh one. The signal is checked after any async operation to discard stale results β€” this is the pattern described in implementing async email availability checks.

Failure modes and edge cases

1. Setting isHydrated = true without await tick()

If you call isHydrated.set(true) synchronously inside onMount, the derived store re-evaluates before Svelte reconciles the DOM. The aria-invalid attributes are now set based on client state while the server HTML still has different attribute values β€” Svelte detects the mismatch and logs the warning.

Fix: always await tick() before opening the gate.

2. Browser autofill fires before onMount

Browsers can autofill input values immediately after parsing the HTML, which can trigger input events before onMount runs. The inline if (!$isHydrated) return guard in validateField handles this β€” the event fires, the guard exits early, and no validation runs until the gate opens.

<!-- Explicit autocomplete attributes reduce autofill-timing surprises -->
<input name="email" type="email" autocomplete="email" ... />

3. Stale closure over $isHydrated in a debounce wrapper

If you debounce handleInput and capture $isHydrated in the debounce closure at call time, the captured value may be false even though the gate has since opened by the time the debounced function actually runs.

// WRONG β€” $isHydrated captured at call time (before gate opens)
const debouncedValidate = debounce((name: string, value: string) => {
  if (!$isHydrated) return; // always false if debounce fires early
}, 300);

Fix: read the store value inside the debounced callback, not at the point of call. With Svelte’s $ auto-subscription, $isHydrated inside a <script> block is always the current value β€” but only inside the reactive Svelte context. If you extract the debounce to a plain .ts module, use get(isHydrated) from svelte/store instead.

4. beforeNavigate not firing on hard navigation

beforeNavigate only fires for SvelteKit’s client-side router transitions. A full page reload bypasses it. This is fine β€” a hard reload re-runs the full SSR cycle, so there is no stale state to reset. Do not add a beforeunload listener as a workaround; it causes problems with browser back/forward cache.

5. Design system wrapper components that forward ARIA props

If you use a component library where <Input> wraps a native <input>, confirm the wrapper forwards aria-invalid and aria-describedby directly to the underlying element. Wrappers that cache ARIA props internally may delay propagation, making the gate ineffective for those attributes.

Verification checklist

  • Open DevTools console on initial page load β€” zero β€œHydration mismatch” warnings appear
  • aria-invalid is false on all inputs immediately after load before the user types anything
  • After the first input event, aria-invalid toggles correctly and role="alert" errors are announced by VoiceOver / NVDA
  • Simulate 3G in Chrome DevTools β€” no validation errors flash during page load
  • Navigate away and back using SvelteKit’s client router β€” confirm no stale error state on return
  • Run axe-core post-hydration β€” zero violations for aria-live regions or invalid attribute combinations
  • In CI, assert console.warn is never called with the substring Hydration mismatch during form load (Playwright: page.on('console', ...))
  • Rapid input typing does not accumulate orphaned validation errors from aborted async requests

FAQ

Why does onMount validation trigger hydration warnings even when the DOM looks correct?

Svelte computes a checksum of the server-rendered HTML before the client mounts. If onMount mutates any attribute β€” class, data-*, aria-* β€” synchronously, the checksum comparison is still running when the mutation lands, and Svelte flags a divergence. The visual output might look identical to you, but the internal tree comparison has already failed. Awaiting tick() defers the mutation until after the comparison clears.

Does the hydration gate delay validation noticeably for users?

No. tick() resolves in a single microtask, typically under 2 ms on any modern device. The gate is invisible to users β€” it only covers the window between the initial HTML parse and the component mount, before the user could realistically have interacted with the form.

How do I test for hydration mismatches in CI?

In Playwright, attach a console event listener before navigating to the page and collect all warnings. After the page load completes, assert that none of the collected messages contain 'Hydration mismatch'. Pair this with an axe-core scan to assert aria-invalid is false on every input at load time β€” that combination catches both the mismatch and its accessibility side-effect.

Does this pattern work with Svelte 5 runes?

Yes. Replace writable with $state and derived with $derived. Keep the same onMount / tick() fence β€” the hydration lifecycle has not changed in Svelte 5, only the reactivity primitives. The beforeNavigate call and AbortController pattern carry over unchanged.


Related

← Hydration Sync for SSR Forms