Handling Form Submissions Across SvelteKit Islands

Form submissions inside SvelteKit component islands break in subtle ways when streaming SSR delivers HTML in progressive chunks. The form’s markup arrives before the island’s JavaScript bundle executes, so use:enhance may attach to a node whose lifecycle hooks have not yet fired, action attributes can be rewritten by client-side routing middleware mid-flush, and cross-island validation state drifts out of sync. The result is silent submission drops, unexpected route mismatches, and INP regressions that appear only under real network conditions.

Prerequisites


Submission lifecycle under streaming SSR

Before stepping through individual fixes, it helps to see where the failure points sit in the timeline. The diagram below maps the streaming chunk sequence against the hydration boundary and the actions dispatch.

SvelteKit streaming SSR form submission lifecycle Timeline showing HTML chunk delivery, island hydration, and form action dispatch, with three annotated failure zones. SERVER STREAM ISLAND HYDRATION FORM ACTION 0 ms 200 ms 400 ms 600 ms Shell HTML Form markup chunk Deferred data JS bundle + onMount POST → action handler A: enhance races B: action rewritten C: store drift A: enhance race B: action rewrite C: store drift

Zone A is the most common: the form chunk arrives before the island’s JS bundle, so use:enhance fires against a partially initialised component. Zone B happens when routing middleware rewrites action during a streaming flush. Zone C appears in multi-island pages where a validation store from Island A is consumed by Island B before Island A has mounted.


Step 1 — Audit hydration timestamps against chunk arrival

Goal: establish whether use:enhance is attaching before onMount completes.

Add the following snippet to the island’s <script> block. It uses PerformanceObserver to log the gap between the form chunk hitting the parser and the island’s onMount firing.


<script>
  import { onMount } from 'svelte';
  import { enhance } from '$app/forms';

  // Track whether onMount has completed before any submission attempt.
  // use:enhance can fire a submit event before this flag is true on slow connections.
  let isMounted = false;

  onMount(() => {
    // Mark the exact moment this island becomes interactive
    performance.mark('contact-form-hydrated');
    isMounted = true;
  });

  function handleSubmit({ formData, cancel }) {
    // Guard: if the island is not yet mounted, abort the submission
    // rather than dispatching an action against a partial DOM state.
    if (!isMounted) {
      cancel();
      console.warn('[Island] Submission cancelled: island not yet hydrated');
      return;
    }
    // ... rest of optimistic logic (see Step 3)
  }
</script>

<form method="POST" use:enhance={handleSubmit}>
  <slot />
</form>

Expected output: In Chrome DevTools → Performance → Timings, the contact-form-hydrated mark appears after the streaming chunk is parsed. If the mark fires after a user’s first submit attempt (visible as a submit event in the Event Log), Zone A is confirmed.

To measure the gap programmatically in dev:

// Paste into DevTools console after page load
new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (entry.name === 'contact-form-hydrated') {
      console.log(`Island hydrated at ${entry.startTime.toFixed(1)} ms`);
    }
  }
}).observe({ type: 'mark', buffered: true });

Step 2 — Trace action dispatch failures in +page.server.ts

Goal: confirm that POST reaches the correct action and that FormData arrives intact.

Add explicit console instrumentation inside your action. SvelteKit does not log action payloads by default; you must opt in.

// src/routes/contact/+page.server.ts
import type { Actions } from './$types';
import { fail } from '@sveltejs/kit';

export const actions: Actions = {
  default: async ({ request }) => {
    const formData = await request.formData();

    // Log every field so you can catch serialisation drops caused by
    // streaming desync or missing hidden inputs on the client DOM.
    console.table(Object.fromEntries(formData.entries()));

    const email = formData.get('email');
    if (!email || typeof email !== 'string') {
      // fail() returns ActionResult type 'failure' — use:enhance can inspect this
      return fail(422, { message: 'Email is required', fields: { email: '' } });
    }

    // process...
    return { success: true };
  }
};

Run npm run dev and submit the form under Network throttle (Fast 3G). Watch the terminal: if console.table prints with missing fields, the form DOM was partially constructed when the submission fired. If the terminal shows nothing, the POST hit a different route — a Zone B rewrite.

To detect a Zone B route rewrite, set a DOM breakpoint:

  1. In DevTools → Elements, right-click the <form> element.
  2. Choose Break on → Attribute modifications.
  3. Reload and let the page stream. If the debugger pauses on an action attribute change during streaming, client-side routing middleware is rewriting it.

Fix for Zone B: add data-sveltekit-reload to the form element. This tells SvelteKit’s router to treat the form as a plain HTML form, bypassing client-side interception:


<form method="POST" data-sveltekit-reload use:enhance={handleSubmit}>
  <slot />
</form>

Step 3 — Implement use:enhance with optimistic rollback

Goal: submit without blocking the main thread; roll back UI state on ActionResult failure.

Optimistic updates let the UI respond instantly while the server processes the request. The critical correctness requirement is that a failure reverses all state mutations applied during the optimistic phase, which requires snapshotting values before mutating.


<script>
  import { onMount } from 'svelte';
  import { enhance } from '$app/forms';

  // Hold the last-known server-confirmed state for rollback
  let confirmedValues = { email: '', message: '' };
  let pendingValues = { ...confirmedValues };
  let isMounted = false;
  let submitting = false;

  onMount(() => {
    performance.mark('contact-form-hydrated');
    isMounted = true;
  });

  function handleSubmit({ formData, cancel }) {
    // Abort if the island hydration guard has not fired yet
    if (!isMounted) { cancel(); return; }

    const payload = Object.fromEntries(formData.entries());

    // Snapshot confirmed state so we can roll back on failure
    const snapshot = { ...confirmedValues };

    // Optimistic: apply new values to the UI immediately (non-blocking)
    pendingValues = { ...confirmedValues, ...payload };
    submitting = true;

    // Measure time-to-update to keep within the 50 ms INP budget
    const t0 = performance.now();

    return async ({ result, update }) => {
      const elapsed = performance.now() - t0;
      if (elapsed > 50) {
        console.warn(`[Island] enhance handler took ${elapsed.toFixed(1)} ms — exceeds INP budget`);
      }

      if (result.type === 'failure' || result.type === 'error') {
        // Roll back to last confirmed state on any server-reported failure
        pendingValues = snapshot;
        submitting = false;
        return;
      }

      // Commit the optimistic state to confirmed on success
      confirmedValues = { ...pendingValues };
      submitting = false;

      // update() re-runs the load function and re-renders the page section
      await update();
    };
  }
</script>

<form method="POST" use:enhance={handleSubmit} aria-busy={submitting}>
  <input name="email" type="email" value={pendingValues.email} />
  <textarea name="message">{pendingValues.message}</textarea>
  <button type="submit" disabled={submitting}>
    {submitting ? 'Sending…' : 'Send'}
  </button>
</form>

For multi-step forms, avoid serialising the entire FormData on every step. Track only changed fields to reduce POST payload size:

// Compute field delta: only send changed values to the action
const delta = Object.entries(payload).filter(
  ([key, val]) => val !== confirmedValues[key]
);
const deltaFormData = new FormData();
delta.forEach(([k, v]) => deltaFormData.set(k, v));

Step 4 — Synchronise cross-island validation state

Goal: prevent Zone C drift where Island B’s submit button enables before Island A’s validation store reports isValid.

Use a shared Svelte store as a lightweight message bus between islands on the same page. Avoid direct DOM queries across island boundaries — they break when islands mount in different orders under slow streaming.

// src/lib/stores/formState.ts
import { writable, derived } from 'svelte/store';

export interface FieldState {
  value: string;
  error: string | null;
}

// One entry per named field; islands write their own slice
export const fieldStates = writable<Record<string, FieldState>>({});

// Derived store: true only when every field has no error
// Island B subscribes to this; Island A writes fieldStates
export const isFormValid = derived(
  fieldStates,
  ($fields) => Object.values($fields).every((f) => f.error === null)
);

Island A writes its validation outcome:


<script>
  import { onMount, onDestroy } from 'svelte';
  import { fieldStates } from '$lib/stores/formState';

  let unsubscribe: () => void;

  onMount(() => {
    // Initialise this island's slice of the shared store
    fieldStates.update((s) => ({ ...s, email: { value: '', error: null } }));
  });

  function validateEmail(value: string) {
    const error = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)
      ? null
      : 'Enter a valid email address';
    // Write only this island's slice — do not overwrite other islands' state
    fieldStates.update((s) => ({ ...s, email: { value, error } }));
  }

  onDestroy(() => {
    // Clean up this island's slice to prevent orphaned store state
    fieldStates.update(({ email: _, ...rest }) => rest);
  });
</script>

<input
  name="email"
  type="email"
  on:input={(e) => validateEmail(e.currentTarget.value)}
/>

Island B gates its submit button on the derived store:


<script>
  import { isFormValid } from '$lib/stores/formState';
  // $isFormValid is false until every island has written a valid slice
</script>

<button type="submit" disabled={!$isFormValid}>Submit</button>

Use requestIdleCallback if the validation schema is expensive (e.g. Zod parsing a large object), to avoid blocking a scroll or animation frame:

// Schedule CPU-intensive schema validation off the critical path
requestIdleCallback(
  () => {
    const result = mySchema.safeParse(formValues);
    fieldStates.update((s) => ({
      ...s,
      email: { value: formValues.email, error: result.success ? null : result.error.message }
    }));
  },
  { timeout: 1000 } // fall through after 1 s even if the main thread is busy
);

Verification

After applying Steps 1–4, confirm each failure zone is closed:

Zone A (enhance race): Open DevTools → Performance, record a page load under Fast 3G throttle. Confirm the contact-form-hydrated mark fires before any submit event in the Event Log. In the terminal, the [Island] Submission cancelled warning should never appear during normal use.

Zone B (action rewrite): In DevTools → Network, filter by Fetch/XHR and submit the form. The POST URL must match your +page.server.ts route exactly. If data-sveltekit-reload is set, you will see a full-page navigation in the Network panel instead of a fetch — that is correct behaviour for forms where client-side routing is disabled.

Zone C (store drift): Add a temporary console.log inside the isFormValid derived store subscription. Confirm it emits true only after all islands have written their field slices. Remove the log before shipping.

INP: Wrap your handleSubmit body in performance.now() timings and verify the optimistic update path completes in under 50 ms on a mid-range Android device. The submission latency target (network included) should fall in the 300–450 ms range with delta payloads enabled.

Metric Unoptimised Target Primary lever
INP on submit 350–600 ms < 150 ms Guard + non-blocking optimistic update
Form submission latency 800–1200 ms 300–450 ms Delta FormData; skip unchanged fields
Time to interactive 2.1 s < 1.2 s Lazy-load validation schema; stream form HTML first
Island memory footprint 12–18 MB < 9 MB onDestroy store cleanup; sessionStorage validation cache

Troubleshooting

`use:enhance` fires but the terminal shows no action log

Root cause: The POST is hitting a different route. Client-side routing has rewritten the action attribute during a streaming flush (Zone B). Fix: add data-sveltekit-reload to the form element, or pin the action attribute to an absolute path: action="/contact?/default".

Submit button in Island B stays disabled even after Island A validates

Root cause: Island A’s onMount has not fired yet when Island B renders, so the shared fieldStates store has no entry for the email field, and isFormValid remains false. Fix: initialise all field slices with their default valid/invalid state in the page’s load function or a layout store so both islands start from a consistent baseline rather than an empty object.

Heap snapshot shows accumulating `Writable` instances across navigations

Root cause: The fieldStates.update() call in Island A’s onMount adds a slice, but onDestroy is not removing it — or the component is being re-created without being destroyed (e.g. inside a {#key} block that receives new keys without unmounting). Fix: ensure onDestroy removes the island’s slice from fieldStates, and confirm the {#key} block has a stable expression that does not regenerate on every render cycle.


← Back to SvelteKit Component Islands