Skip to main content
All developer posts
Svelte

How to make web forms survive an outage

Give each form submission an ID, save it in the browser, and retry until the server answers. Then let a unique index turn duplicates into harmless replays.

By the HotelierKit team · 15 min read
Retry loop code beside an enquiry form saved on the device while the connection is down

A guest fills in your enquiry form, presses Send, and at that moment your server drops off the network for a minute. The page loaded fine, because it came from the edge cache, but the POST request has nowhere to go. Without extra work, that enquiry is gone, and nobody knows it existed.

In this tutorial, you'll build a form that holds on to the message until the server takes it. The browser gives each submission an ID, saves it locally, and retries with backoff, even across a page reload. On the server, Laravel uses that ID so a message that arrives twice is stored once.

Prerequisites

You'll need:

  • A Laravel app with an Enquiry model and a JSON endpoint at POST /api/hotels/{hotel}/enquiries
  • A Svelte 5 front end that uses runes
  • Working knowledge of TypeScript, fetch and Laravel migrations

The examples assume one app serves several hotels, so each enquiry belongs to a Hotel. If yours serves one, drop that part.

How the pieces fit

Here's the flow you're building:

  1. When the guest presses Send, the browser creates a UUID for the submission and saves the message in localStorage.
  2. It sends the message. On a network error, a timeout or a server error, it waits and tries again.
  3. On success, or on a real rejection such as a validation error, it deletes the saved copy.
  4. If the guest closes the tab first, the next page load finds the saved copy and sends it.
  5. The server stores at most one row per ID and answers a repeat with the same success response.

The UUID is an idempotency key: a value that lets the server recognize the same request when it arrives more than once. That's what makes retrying safe.

Step 1: Save the submission before you send it

Start with a small storage module. It keeps a list of pending submissions, which we'll call the outbox, under one localStorage key.

// outbox.ts
export type OutboxEntry = {
  key: string;
  hotel: string;
  fields: Record<string, string>;
  createdAt: number;
};

const STORAGE_KEY = 'enquiry-outbox';
const MAX_AGE_MS = 24 * 60 * 60 * 1000;

function write(entries: OutboxEntry[]): boolean {
  try {
    localStorage.setItem(STORAGE_KEY, JSON.stringify(entries));
    return true;
  } catch {
    return false;
  }
}

export function listOutbox(): OutboxEntry[] {
  try {
    const raw = localStorage.getItem(STORAGE_KEY);
    const entries: OutboxEntry[] = raw ? JSON.parse(raw) : [];
    const cutoff = Date.now() - MAX_AGE_MS;
    return entries.filter((entry) => entry.createdAt > cutoff);
  } catch {
    return [];
  }
}

export function addToOutbox(entry: OutboxEntry): boolean {
  const others = listOutbox().filter((e) => e.key !== entry.key);
  return write([...others, entry]);
}

export function takeFromOutbox(key: string): boolean {
  const entries = listOutbox();
  const rest = entries.filter((entry) => entry.key !== key);
  return rest.length < entries.length && write(rest);
}

Every storage call sits inside try/catch. localStorage can throw, for example when the visitor blocks site data or storage is full. A form that crashes on save is worse than one that can't save. addToOutbox returns whether the write worked, and the UI will use that to decide what it can honestly promise.

listOutbox also drops anything older than a day, so a laptop opened next week won't send a stale enquiry.

Create the ID once, when the guest first presses Send, with crypto.randomUUID(). It only works in secure contexts, which means HTTPS or localhost. Then save the message right away, before the first request goes out. If the tab closes during a slow first attempt, the saved copy is all you have.

Step 2: Decide which failures are worth retrying

Not every failure deserves a retry. Resending a 422 just sends the same invalid email address again. Roughly:

  • Network error or timeout: fetch rejects. Retry.
  • 5xx from your app: something broke, maybe briefly. Retry.
  • 4xx from your app: the server read the request and said no. Show the error.

Note the words "from your app". When your origin is down, whatever sits in front of it answers instead: a CDN, a load balancer or a reverse proxy. Their error pages can carry almost any status code. If you trust the status alone, a proxy's 404 or 403 looks like a permanent rejection, and you'd throw away a message that would have gone through a minute later.

So our classifier asks one question first: did our app send this? The app sets an X-App-Handled header on its responses. If the header is missing, the app never saw the request, so the answer is retry. Pick a header your proxy layer doesn't set itself.

// classify.ts
export const APP_HEADER = 'X-App-Handled';

export type Verdict = 'accepted' | 'rejected' | 'retry';

export function classify(res: Response): Verdict {
  if (!res.headers.has(APP_HEADER)) {
    return 'retry';
  }

  if (res.ok) {
    return 'accepted';
  }

  return res.status >= 500 ? 'retry' : 'rejected';
}

On the Laravel side, a small middleware adds the header:

<?php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;

class MarkAppHandled
{
    public function handle(Request $request, Closure $next): Response
    {
        $response = $next($request);

        $response->headers->set('X-App-Handled', '1');

        return $response;
    }
}

Register it as global middleware with $middleware->prepend(MarkAppHandled::class) in bootstrap/app.php. Laravel renders exceptions, including validation errors, before the response travels back out through middleware, so those responses get the header too. If your endpoint lives on another origin, also list the header in Access-Control-Expose-Headers, or the browser hides it and every error looks retryable.

Step 3: Send one attempt with a timeout

// send.ts
import { classify } from './classify';
import type { OutboxEntry } from './outbox';

export type FieldErrors = Record<string, string[]>;

export type Attempt =
  | { verdict: 'accepted' }
  | { verdict: 'rejected'; errors: FieldErrors }
  | { verdict: 'retry' };

const ATTEMPT_TIMEOUT_MS = 30_000;

export async function sendOnce(entry: OutboxEntry): Promise<Attempt> {
  let res: Response;

  try {
    res = await fetch(`/api/hotels/${entry.hotel}/enquiries`, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        Accept: 'application/json',
      },
      body: JSON.stringify({
        ...entry.fields,
        submission_key: entry.key,
      }),
      signal: AbortSignal.timeout(ATTEMPT_TIMEOUT_MS),
    });
  } catch {
    return { verdict: 'retry' };
  }

  const verdict = classify(res);
  if (verdict !== 'rejected') {
    return { verdict };
  }

  const body = await res.json().catch(() => ({}));
  const fallback = { form: ['Please contact the hotel directly.'] };
  return { verdict, errors: body.errors ?? fallback };
}

AbortSignal.timeout() gives up on a request that hangs. fetch then rejects, and the catch treats it like any other network error. This example allows 30 seconds per attempt. The Accept header tells Laravel to answer validation failures with a JSON 422 instead of a redirect. Any other rejection without field errors gets a general message, so the guest is never left with a silent form.

Step 4: Retry with backoff

Backoff means waiting longer between attempts, so a server that's recovering isn't flooded the moment it comes back. Our form waits 5, 15, 30 and 60 seconds, then tries once a minute, and gives up 15 minutes after the first attempt.

// backoff.ts
import { sendOnce, type Attempt } from './send';
import type { OutboxEntry } from './outbox';

export type Settled =
  | Exclude<Attempt, { verdict: 'retry' }>
  | { verdict: 'paused' };
export type DelayFor = (step: number, elapsed: number) => number | null;

const FORM_STEPS_MS = [5_000, 15_000, 30_000, 60_000];
const FORM_GIVE_UP_MS = 15 * 60_000;

export const formSchedule: DelayFor = (step, elapsed) => {
  if (elapsed >= FORM_GIVE_UP_MS) return null;
  return FORM_STEPS_MS[step] ?? 60_000;
};

function sleep(ms: number, signal: AbortSignal): Promise<void> {
  return new Promise((resolve) => {
    const done = () => {
      clearTimeout(timer);
      signal.removeEventListener('abort', done);
      resolve();
    };
    const timer = setTimeout(done, ms);
    signal.addEventListener('abort', done);
    if (signal.aborted) done();
  });
}

export async function sendWithBackoff(
  entry: OutboxEntry,
  delayFor: DelayFor,
  signal: AbortSignal,
  onRetry?: () => void,
): Promise<Settled | null> {
  const startedAt = Date.now();
  for (let attempt = 0; !signal.aborted; attempt++) {
    const result = await sendOnce(entry);
    if (result.verdict !== 'retry') return result;
    const wait = delayFor(attempt, Date.now() - startedAt);
    if (wait === null) return { verdict: 'paused' };
    onRetry?.();
    await sleep(wait, signal);
  }
  return null;
}

The loop takes the delay function as an argument, so the resume flow in step 6 can pass a shorter schedule. It also takes an AbortSignal, so a component that unmounts stops the loop cleanly, even mid-sleep. In that case the loop returns null and the entry stays saved for the next page load. If thousands of clients could retry at the same moment, add some random jitter to each delay so they don't all come back together.

Step 5: Show the guest what's happening

Now wire it into a Svelte 5 component. One stage value drives the UI: editing, sending, retrying, paused (gave up for now) and delivered.

<!-- EnquiryForm.svelte -->
<script lang="ts">
  import { onDestroy } from 'svelte';
  import { addToOutbox, takeFromOutbox } from './outbox';
  import type { OutboxEntry } from './outbox';
  import { formSchedule, sendWithBackoff } from './backoff';
  import type { FieldErrors } from './send';

  type Stage =
    | 'editing'
    | 'sending'
    | 'retrying'
    | 'paused'
    | 'delivered';

  let { hotel }: { hotel: string } = $props();

  let fields = $state({ name: '', email: '', message: '' });
  let stage = $state<Stage>('editing');
  let stored = $state(true);
  let errors = $state<FieldErrors>({});
  let entry: OutboxEntry | null = null;

  const controller = new AbortController();
  onDestroy(() => controller.abort());

  const statusText = $derived.by(() => {
    switch (stage) {
      case 'sending':
        return 'Sending your message.';
      case 'retrying':
        return stored
          ? 'We are having trouble reaching the hotel. Your ' +
              'message is saved, and we will keep trying.'
          : 'We are having trouble reaching the hotel. Please ' +
              'keep this page open while we keep trying.';
      case 'paused':
        return stored
          ? 'We could not send your message yet. It is saved, ' +
              'and we will try again on your next visit.'
          : 'We could not send your message. Please try again.';
      case 'delivered':
        return 'Thank you. Your message has been sent.';
      default:
        return '';
    }
  });

  function startEntry(): OutboxEntry {
    const fresh = {
      key: crypto.randomUUID(),
      hotel,
      fields: $state.snapshot(fields),
      createdAt: Date.now(),
    };
    stored = addToOutbox(fresh);
    return fresh;
  }

  async function submit(event: SubmitEvent) {
    event.preventDefault();
    const current = (entry ??= startEntry());
    errors = {};
    stage = 'sending';

    const result = await sendWithBackoff(
      current,
      formSchedule,
      controller.signal,
      () => {
        stage = 'retrying';
      },
    );

    if (!result) return;
    if (result.verdict === 'paused') {
      stage = 'paused';
      return;
    }

    takeFromOutbox(current.key);
    entry = null;

    if (result.verdict === 'rejected') {
      errors = result.errors;
      stage = 'editing';
    } else {
      stage = 'delivered';
    }
  }
</script>

{#snippet fieldError(name: string)}
  {#if errors[name]}
    <p class="field-error">{errors[name][0]}</p>
  {/if}
{/snippet}

<form onsubmit={submit}>
  <fieldset disabled={stage !== 'editing'}>
    <label>
      Name
      <input name="name" bind:value={fields.name} required />
    </label>
    {@render fieldError('name')}

    <label>
      Email
      <input
        name="email"
        type="email"
        bind:value={fields.email}
        required
      />
    </label>
    {@render fieldError('email')}

    <label>
      Message
      <textarea
        name="message"
        bind:value={fields.message}
        required
      ></textarea>
    </label>
    {@render fieldError('message')}
  </fieldset>

  {@render fieldError('form')}

  {#if stage === 'editing' || stage === 'paused'}
    <button type="submit">
      {stage === 'paused' ? 'Try again' : 'Send enquiry'}
    </button>
  {/if}

  <p role="status">{statusText}</p>
</form>

A few things to notice:

  • stage and stored are $state, and statusText is $derived from them, so the message always matches reality. If the save failed, the copy stops claiming the message is stored and asks the guest to keep the page open.
  • The role="status" region is always in the page, and only its content changes. Screen readers reliably announce changes inside a live region that already exists. A region that appears together with its text is easy to miss.
  • A rejection removes the saved copy, shows the server's errors, and leaves the guest's text in the form to fix.
  • The ID survives a pause. When the guest presses "Try again", the same entry and the same ID go out, so the server can match it.

Our production form adds a fallback. After a minute of retrying, it shows a link to email the hotel directly, with the message already filled in. If the browser couldn't save the message, that link appears as soon as retrying starts.

Step 6: Resume after a reload

When a page loads, check for pending submissions. Only load the resume code if there's something to resume, so ordinary visitors don't download it.

// main.ts
import { listOutbox } from './outbox';

async function resumePending(): Promise<void> {
  if (listOutbox().length === 0) return;
  const { openResumeBar } = await import('./resume-bar');
  openResumeBar();
}

void resumePending();

// resume-bar.ts
import { mount } from 'svelte';
import ResumeBar from './ResumeBar.svelte';

export function openResumeBar(): void {
  const target = document.createElement('div');
  document.body.append(target);
  mount(ResumeBar, { target });
}

openResumeBar mounts a small status bar with Svelte's mount() function. The bar calls drainOutbox and turns each update into a short message.

// drain.ts
import { listOutbox, takeFromOutbox } from './outbox';
import type { OutboxEntry } from './outbox';
import { sendWithBackoff, type DelayFor } from './backoff';

export type DrainUpdate = {
  entry: OutboxEntry;
  state: 'sending' | 'accepted' | 'rejected' | 'deferred';
};

const RESUME_STEPS_MS = [5_000, 15_000, 30_000];

export const resumeSchedule: DelayFor = (step) =>
  RESUME_STEPS_MS[step] ?? null;

export async function drainOutbox(
  report: (update: DrainUpdate) => void,
  signal: AbortSignal,
): Promise<void> {
  const queue = listOutbox().sort(
    (a, b) => a.createdAt - b.createdAt,
  );

  for (const entry of queue) {
    report({ entry, state: 'sending' });
    const result = await sendWithBackoff(
      entry,
      resumeSchedule,
      signal,
    );

    if (!result) return;
    if (result.verdict === 'paused') {
      report({ entry, state: 'deferred' });
      return;
    }

    takeFromOutbox(entry.key);
    report({ entry, state: result.verdict });
  }
}

This reuses the sendWithBackoff loop with a shorter schedule: 5, 15 and 30 seconds, then stop. The guest is browsing now, not waiting on a form, so there's no reason to keep going for 15 minutes. Messages go oldest first. If one still can't get through, the bar says it will try again on the next visit and leaves the rest of the queue for then.

A rejection needs more care here than on the form. The original form is gone and the entry has left storage, so the bar shows the guest their message in a read-only box they can copy. That's why each update carries the whole entry.

When the form component itself unmounts with a message still pending, our form opens the resume bar straight away instead of waiting for the next page load.

Step 7: Deduplicate on the server

The browser may now send the same enquiry several times. The database makes sure only one copy lands. Add the column, a unique index on the pair of hotel and key, and a timestamp you'll use for notifications:

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::table('enquiries', function (Blueprint $table) {
            $table->uuid('submission_key')->nullable();
            $table->timestamp('hotel_alerted_at')->nullable();

            $table->unique(['hotel_id', 'submission_key']);
        });
    }

    public function down(): void
    {
        Schema::table('enquiries', function (Blueprint $table) {
            $table->dropUnique(['hotel_id', 'submission_key']);
            $table->dropColumn(['submission_key', 'hotel_alerted_at']);
        });
    }
};

The Laravel docs cover compound indexes under Creating indexes. Scoping the index to the hotel means one hotel's keys never collide with, or match, another's. Then the controller:

use App\Models\Enquiry;
use App\Models\Hotel;
use App\Services\EnquiryAlerts;
use Illuminate\Database\UniqueConstraintViolationException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Str;

class EnquiryController extends Controller
{
    public function __construct(private EnquiryAlerts $alerts) {}

    public function store(Request $request, Hotel $hotel): JsonResponse
    {
        $key = $request->input('submission_key');
        $key = Str::isUuid($key) ? $key : null;

        if ($existing = $this->findByKey($hotel, $key)) {
            return $this->replay($existing);
        }

        abort_unless($hotel->enquiries_open, 403);

        $fields = $request->validate([
            'name' => 'required|string',
            'email' => 'required|email',
            'message' => 'required|string',
        ]);

        try {
            $enquiry = $hotel->enquiries()->create([
                ...$fields,
                'submission_key' => $key,
            ]);
        } catch (UniqueConstraintViolationException $e) {
            return $this->replay(
                $this->findByKey($hotel, $key) ?? throw $e,
            );
        }

        $this->alerts->send($enquiry);
        $enquiry->touch('hotel_alerted_at');

        return $this->received($enquiry);
    }
}

$this->alerts stands in for however your app tells the hotel about a new enquiry. A key that isn't a valid UUID is treated as no key, so it can't break the lookup.

Answer a duplicate as a replay

A replay is a repeat of a request the server already handled. The browser can't tell whether its last attempt failed before or after the row was saved, so a duplicate isn't an error. It means "you already have this". Answer it with the same success response as the first time, and the retry loop ends happily.

private function findByKey(Hotel $hotel, ?string $key): ?Enquiry
{
    if ($key === null) {
        return null;
    }

    return $hotel->enquiries()
        ->firstWhere('submission_key', $key);
}

private function replay(Enquiry $enquiry): JsonResponse
{
    $this->alertIfMissed($enquiry);

    return $this->received($enquiry);
}

private function received(Enquiry $enquiry): JsonResponse
{
    return response()->json(['id' => $enquiry->id], 201);
}

Resend a notification that was lost

There's one more failure mode. The request saves the row, then dies before it notifies the hotel. The guest's retry finds the row and gets a success, but nobody at Lagoon Retreat knows the enquiry exists. hotel_alerted_at fixes that: the store path sets it only after the notification goes out, so a null means it never did.

private function alertIfMissed(Enquiry $enquiry): void
{
    $claimed = Enquiry::query()
        ->whereKey($enquiry->getKey())
        ->whereNull('hotel_alerted_at')
        ->where('created_at', '<', now()->subMinute())
        ->update(['hotel_alerted_at' => now()]);

    if ($claimed === 0) {
        return;
    }

    try {
        $this->alerts->send($enquiry);
    } catch (\Throwable $e) {
        Enquiry::query()
            ->whereKey($enquiry->getKey())
            ->update(['hotel_alerted_at' => null]);

        report($e);
    }
}

Two details stop this from double-sending. First, it waits until the row is older than the longest a request can run. A replay can arrive while the original request is still working: saved, but not yet notified. Set that wait comfortably above your request timeout. Second, it claims the row with a conditional update. The database checks hotel_alerted_at IS NULL and writes in one statement, so when two replays race, only one gets 1 back. If sending fails, the claim is released for the next attempt, and the guest still gets their success response.

This is at-least-once delivery. If a request dies after sending but before stamping, the hotel may hear about the enquiry twice. That beats not hearing at all.

Gotchas

Stamp the header on every response

If one of your app's responses goes out without the header, the classifier decides it came from the proxy and retries it. A genuine rejection then gets retried for 15 minutes. We register the middleware globally and first, so even responses built by other middleware, such as a maintenance-mode 503 or a 405, carry it.

A slow request looks like a frozen form

When the origin is reachable but stuck, a single attempt can sit for its full timeout with nothing on screen. Our form switches to the "trouble reaching" message after 10 seconds, before the attempt has failed, so the guest knows the form is still working.

Tabs share storage

localStorage is shared by every tab on the same site. If the guest opens a second tab while the first is still retrying, the second tab's resume flow may send the message first and delete the entry. Two rules keep this sane. A tab writes its entry once, when it creates it, and never writes it back later: a missing entry means another tab sent it. And only the tab whose takeFromOutbox returned true counts the success, for example as an analytics conversion. The server's unique index absorbs the duplicate POST.

Look for the replay before you validate

If the hotel edits or closes the form between the first attempt and a retry, validating the retry again could reject a message you already stored. The controller checks for an existing row before it checks enquiries_open, or validates the fields. That's also why it calls $request->validate() inside the method. A form request class would validate before your controller code runs.

Check first, then catch the duplicate anyway

The original slow request and a retry can arrive together. Both pass the lookup, one insert wins, and the other hits the unique index. Catch UniqueConstraintViolationException, look the row up again, and answer as a replay. Keep the column nullable too: in most SQL databases, nulls don't collide in a unique index, so requests without an ID still insert.

Replays can't be your only recovery

A request that saved the row and then died usually gets retried within seconds. That retry lands inside the one-minute wait, gets its success and stops, and the notification still hasn't gone. So we also run a scheduled command every few minutes that applies the same claim-and-send to rows left unnotified for more than a couple of minutes.

Wrapping up

A one-minute outage shouldn't cost a hotel an enquiry. Give each submission an ID, save it before you send it, retry only what's worth retrying, and let a unique index turn duplicates into replays. HotelierKit runs this pattern on the hotel websites it hosts, and none of it needs anything more exotic than localStorage and a unique index.

Built with these techniques

The hotel website platform behind these posts.

HotelierKit designs, hosts and runs websites for independent hotels. See how it works in a 30-minute demo.