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 Island Form Submission Timeline Three stacked lanes over a shared time axis. The server-stream lane delivers the shell, the form markup chunk, and deferred data. The hydration lane shows the island bundle executing and use:enhance attaching. The action lane shows the POST to the form action. Below the axis, three cards mark the failure windows: an unenhanced submit before hydration, a rewritten action attribute, and store drift after the response. Submission lifecycle — three lanes, one clock SERVER STREAM ISLAND HYDRATION FORM ACTION shell HTML form markup chunk deferred data bundle + use:enhance enhanced from here on POST → form action 303 + reload 0 ms 200 ms 400 ms 600 ms A · submit before enhance 0–200 ms: the browser does a full page POST — plan for it, don't fight it B · action attribute rewritten a client router rewrites action= and the named action is lost C · store drift after response sibling islands keep the pre-POST value until the store is invalidated

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));

What the Enhancer Must Preserve Four native behaviours listed with the enhanced equivalent that must replace them: the browser's own validation, the redirect after post, the disabled state during submission, and the error rendering. Each row notes what breaks if the enhancer forgets it, which is the most common source of a form that works without JavaScript and fails with it. NATIVE BEHAVIOUR THE ENHANCER MUST constraint validation blocks submit check validity before sending, and focus the first invalid field redirect after a successful post follow the redirect, or update the URL so back does not resubmit the page is inert while posting signal pending state and guard against a second submit server errors render in the response · the enhancer must place them in the same slot

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

Four States to Test Before Shipping a Form A two-by-two matrix. Rows are JavaScript enabled or disabled; columns are a fast and a slow response. Each cell states the expected behaviour: native post, native post with a browser spinner, enhanced inline update, and enhanced pending state with a guarded second submit. The matrix makes the point that the disabled-JavaScript row is not optional. Test all four, not just the happy one FAST RESPONSE SLOW RESPONSE (3 s) JS off native post, new page renders errors appear in the same slot browser shows its own progress double submit must be idempotent JS on inline update, no navigation focus stays where the visitor left it pending state visible within 150 ms second submit suppressed, not queued

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.


Frequently Asked Questions

Why does use:enhance fire before my island has fully hydrated?

When streaming SSR delivers the form markup before the island's JS bundle executes, use:enhance may attach to a DOM node whose event handler has not yet run through onMount. Guard the enhance callback with an isMounted flag set inside onMount to guarantee the handler executes only after the island is fully initialised.

Why does my form POST to the wrong route after a streaming flush?

Client-side routing middleware can rewrite the form's action attribute during a streaming flush before hydration completes. Pin the action value on the server-rendered markup and disable SvelteKit's client-side router for that form endpoint by adding data-sveltekit-reload to the form element.

How do I prevent Svelte store subscriptions from leaking across island unmounts?

Return the unsubscribe function from onMount: const unsub = formState.subscribe(handler); return () => unsub(). For derived stores, Svelte unsubscribes automatically when the last subscriber is removed, but writable subscriptions established imperatively must always be cleaned up manually.

← Back to SvelteKit Component Islands