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.
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:
- In DevTools → Elements, right-click the
<form>element. - Choose Break on → Attribute modifications.
- Reload and let the page stream. If the debugger pauses on an
actionattribute 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.
Related
- SvelteKit Component Islands — architecture overview and deferred hydration patterns that set the context for everything on this page.
- Event Delegation in Partially Hydrated Apps — covers the broader cross-island event model that underpins the store-based sync pattern in Step 4.
- Optimistic Updates Without Full Hydration — framework-agnostic treatment of optimistic UI rollback that complements the
use:enhancepattern above.
← Back to SvelteKit Component Islands