You

Summarise this incident report in three bullet points.

Assistant

The outage began at 02:14 UTC when the queue consumer stalled. Requests backed up for nine minutes before the breaker tripped. No customer data was lost.

One chat message is a single state entity moving through its lifecycle. This post makes that machine the spine of a Svelte AI streaming interface.

Building a Svelte AI streaming interface: the message lifecycle machine

The streaming is the easy part. Here is the message-lifecycle state machine that stops a production chat from stranding a user in a broken partial state, shipped as a copy-paste Svelte 5 rune store.

The 20-line happy path, and where it breaks#

This is written for the frontend software engineer who owns an AI chat surface in Svelte. Most guides show the same short recipe. You push a message, fetch a stream, and append tokens as they arrive. It looks complete on the demo.

Chat.svelte · svelte
<!-- Chat.svelte: the 20-line happy path most tutorials stop at. -->
<script>
  let messages = $state([]);

  async function send(text) {
    messages.push({ role: 'user', content: text });
    const reply = { role: 'assistant', content: '' };
    messages.push(reply);

    const res = await fetch('/api/chat', {
      method: 'POST',
      body: JSON.stringify({ prompt: text }),
    });
    const reader = res.body.getReader();
    const decoder = new TextDecoder();
    for (;;) {
      const { value, done } = await reader.read();
      if (done) break;
      reply.content += decoder.decode(value, { stream: true });
    }
  }
</script>

However, that recipe answers exactly one question: how do tokens appear? It never asks the questions a real user forces. What happens when the reader hits stop halfway through? Where do the tokens go when the connection drops mid-stream? How does the thread survive a reload while a reply still arrives? In each case the naive code leaves a broken bubble on screen.

The failures a tutorial never handles#

Therefore the gap is not the streaming. The gap is every state around it. These are the failure modes a demo never surfaces, and the reason a state machine exists.

Model every message in a Svelte AI streaming interface as a finite-state entity#

Here is the reframe this whole post turns on. A chat message is not a string that grows. Instead it is an entity with a status, and the status moves along fixed edges. Once you name those edges, every UX behavior becomes a transition you can point at. Moreover the same model powers accessibility, persistence, and error recovery, because they all read the same status.

The six states#

First, name the vocabulary the rest of the post reuses. A message starts idle. On send it becomes pending, the optimistic bubble that appears before the server acks. On the first token it becomes streaming. From there it settles into one of three ends. The final token moves it to complete. A stop from the reader moves it to aborted. Any stream failure moves it to errored. Retry then moves an aborted or errored message back to pending, and the cycle repeats.

The lifecycle, as a state machine#

Because a state machine literally is a state diagram, the honest way to show it is a diagram, not hand-drawn boxes. This is the canonical spine. Every transition the code wires later appears here first.

The message-lifecycle state machineOne message moves along named edges. Send opens pending, the first token opens streaming, and the reply settles into complete, aborted, or errored. Retry returns an aborted or errored message to pending, and a persisted in-flight message can resume after a reload.

Notice that the three terminal states are not equal. Complete is a clean end. Aborted and errored both keep their partial content and both expose a retry edge, so neither one is a dead end. That single property, no dead ends, is what keeps a user from ever being stranded.

Drive a message through the machine#

Reading a diagram is one thing. Driving the machine is another. Below you can fire each transition yourself. Watch three things move together: the state pill on the message, the live store snapshot on the right, and the announcement line a screen reader would hear. Because they update in lockstep, the state machine, the store, and the UX are visibly one thing.

Drive the message lifecycle
  1. idle
  2. pending
  3. streaming
  4. complete
  5. aborted
  6. errored
Assistantidle

Prompt: Summarise this incident in three short sentences.

The reply appears here as it streams.

chat.svelte.ts store

status
idle
attempts
0
tokens
0
error
null
online
true

Ready. No message in flight.

Every state, and every transition#

Named transitions of the message lifecycle machine
From stateEventTo state
idlesendpending
pendingfirst tokenstreaming
pendingnetwork failerrored
streamingfinal tokencomplete
streamingstopaborted
streamingstream failerrored
abortedretrypending
erroredretrypending
aborted / erroredresume after reloadstreaming
Use the buttons to send, stop, inject an error, retry, reload, or toggle offline. The pill, the store fields, and the aria-live line all react to the same transition. The six-state gallery and the transition table below render without JavaScript.

Try the awkward orders on purpose. Stop mid-stream, then retry. Go offline, then send. Reload while a reply streams, and watch it resume instead of vanishing. Every one of those is a defined edge, so nothing lands in an undefined state.

The rune store: chat.svelte.ts#

Now turn the diagram into code. The real product of this article is one portable file. In practice it is a Svelte 5 class rune, so its fields are reactive without any store boilerplate. Because a class instance can be imported anywhere, the same machine drives every component that touches the chat.

A message as a class rune#

Start with the entity. The message carries its own status, content, error, and attempt count. Since each field uses $state, any component that reads it re-renders when it changes.

src/lib/chat.svelte.ts · ts
// src/lib/chat.svelte.ts
export type Status =
  | 'idle'
  | 'pending'
  | 'streaming'
  | 'complete'
  | 'aborted'
  | 'errored';

export class ChatMessage {
  id = crypto.randomUUID();
  role: 'user' | 'assistant';
  status = $state<Status>('idle');
  content = $state('');
  error = $state<string | null>(null);
  attempts = $state(0);

  constructor(role: 'user' | 'assistant', content = '') {
    this.role = role;
    this.content = content;
  }

  get isBusy() {
    return this.status === 'pending' || this.status === 'streaming';
  }
}

The store: a Map of messages plus transition methods#

Next, wrap the messages in a store whose methods are the transitions. The methods read exactly like the diagram edges. Furthermore the private AbortController lives here, because stop and streaming both need it.

src/lib/chat.svelte.ts · ts
// src/lib/chat.svelte.ts (continued)
export class ChatStore {
  messages = $state<ChatMessage[]>([]);
  #controller: AbortController | null = null;

  // idle -> pending -> streaming -> complete, all on one entity.
  send(text: string) {
    const user = new ChatMessage('user', text);
    user.status = 'complete';
    const reply = new ChatMessage('assistant');
    reply.status = 'pending';
    this.messages.push(user, reply);
    this.#stream(reply, text);
  }

  stop() {
    this.#controller?.abort();
  }

  async #stream(reply: ChatMessage, prompt: string) {
    this.#controller = new AbortController();
    try {
      const res = await fetch('/api/chat', {
        method: 'POST',
        body: JSON.stringify({ prompt }),
        signal: this.#controller.signal,
      });
      const reader = res.body!.getReader();
      const decoder = new TextDecoder();
      reply.status = 'streaming';
      for (;;) {
        const { value, done } = await reader.read();
        if (done) break;
        reply.content += decoder.decode(value, { stream: true });
      }
      reply.status = 'complete';
    } catch (err) {
      const aborted = err instanceof DOMException && err.name === 'AbortError';
      reply.status = aborted ? 'aborted' : 'errored';
      if (!aborted) reply.error = String(err);
    }
  }
}

That is the shared store the search intent keeps asking for. It is not a component snippet. Instead it is a plain class you import, so any surface can send, stop, or read status without prop drilling.

Why a class rune, not a writable store#

Svelte 4 readers reach for a writable store here, and that still works. However the two shapes differ, so it helps to see them side by side. The writable holds the whole chat as one value and rebuilds it on every change. The class rune mutates a reactive field in place.

chat.ts · ts
// Svelte 4: a writable store holds the whole chat as one value.
import { writable } from 'svelte/store';

export const chat = writable({ messages: [] });

export function send(text) {
  chat.update((s) => ({
    ...s,
    messages: [
      ...s.messages,
      { role: 'user', content: text, status: 'complete' },
    ],
  }));
}
// Every update rebuilds the object. Components read it with the $ prefix.

Both are valid. Yet the class rune wins for this problem, because a state machine is a natural fit for methods that mutate fields. In addition it drops the spread-and-rebuild ceremony, which matters when a message updates on every token.

Wiring the transitions#

Here is the payoff of the spine. Each UX behavior the search intent names maps to exactly one edge. Consequently you never write ad-hoc flags like isLoading that drift out of sync. Instead you move the status, and the UI follows.

Optimistic echo#

First, optimistic UI. On send, push both bubbles and set the reply to pending before the network answers. Because the entity already exists, the reader sees instant feedback and the later tokens have a home to land in.

src/lib/chat.svelte.ts · ts
send(text: string) {
  const reply = new ChatMessage('assistant');
  reply.status = 'pending';          // the idle -> pending transition
  this.messages.push(new ChatMessage('user', text), reply);
  // Both bubbles are on screen now. The server has not answered yet.
  this.#stream(reply, text);
}

Token streaming: the SvelteKit endpoint#

Next, the actual streaming mechanism, in two halves. On the server a SvelteKit endpoint returns a ReadableStream that enqueues tokens as the model produces them. On the client a reader loop appends each chunk to the same message entity. Switch between the two halves below.

src/routes/api/chat/+server.ts · ts
// src/routes/api/chat/+server.ts
// A SvelteKit endpoint that streams tokens as they arrive.
export async function POST({ request }) {
  const { prompt } = await request.json();

  const stream = new ReadableStream({
    async start(controller) {
      for await (const token of model.stream(prompt)) {
        controller.enqueue(new TextEncoder().encode(token));
      }
      controller.close();
    },
  });

  return new Response(stream, {
    headers: { 'content-type': 'text/plain; charset=utf-8' },
  });
}

The web streams API here is standard platform work, documented at the MDN ReadableStream reference. Notice that the client never creates a second bubble. Instead it mutates reply.content, so the streaming state is just the same entity growing.

Stop means abort, not ignore#

Then handle stop honestly. A stop button that only hides the spinner is a lie, because the request keeps running. Instead abort the fetch through an AbortController, and let the catch move the message to aborted.

src/lib/chat.svelte.ts · ts
stop() {
  // Stop is a real transition, not a UI trick. Abort the fetch, and the
  // reader loop throws AbortError so the catch moves us to 'aborted'.
  this.#controller?.abort();
}

// Inside #stream's catch block:
//   const aborted = err.name === 'AbortError';
//   reply.status = aborted ? 'aborted' : 'errored';
//
// The partial content stays on screen. Nothing is thrown away.

The MDN AbortController reference covers the signal contract in full. Because the partial content survives the abort, the reader keeps what already arrived and can retry from there.

Error recovery with backoff#

Next, recovery. An errored message is not a dead end, because it holds a retry edge. The retry method increments the attempt count, waits with exponential backoff, and moves the status back to pending. Consequently a flaky network heals itself without a full page reload.

src/lib/chat.svelte.ts · ts
async retry(reply: ChatMessage, prompt: string) {
  if (reply.status !== 'errored' && reply.status !== 'aborted') return;

  reply.attempts += 1;
  const wait = Math.min(1000 * 2 ** (reply.attempts - 1), 8000);
  await new Promise((r) => setTimeout(r, wait)); // exponential backoff

  reply.error = null;
  reply.status = 'pending';          // errored -> pending, the retry transition
  this.#stream(reply, prompt);
}

Resume after a reload#

Then persistence, which is the failure the happy path most obviously ignores. Persist the messages on every transition, so a reload never loses the thread. On mount, rehydrate, and mark any message caught mid-stream as errored with a retry, rather than a frozen spinner.

src/lib/chat.svelte.ts · ts
// Persist on every transition so a reload never strands the thread.
$effect(() => {
  localStorage.setItem('chat', JSON.stringify(this.messages));
});

// On mount, rehydrate. A message caught mid-stream resumes, it does not restart.
static resume(): ChatStore {
  const store = new ChatStore();
  const saved = localStorage.getItem('chat');
  if (saved) store.messages = JSON.parse(saved);

  for (const m of store.messages) {
    if (m.status === 'streaming' || m.status === 'pending') {
      m.status = 'errored';
      m.error = 'Interrupted by a reload. Retry to finish the reply.';
    }
  }
  return store;
}

In practice this is the difference between a toy and a tool. Because the status persists, a reload during a stream lands the reader on a clear, recoverable state instead of a lost reply.

Announce every transition#

Finally, accessibility, which the machine makes almost free. Since every transition changes one status, a single aria-live region can announce all of them. Therefore a screen-reader user hears the reply start, finish, stop, or fail, without any extra bookkeeping.

ChatMessage.svelte · svelte
<!-- ChatMessage.svelte: one aria-live region announces each transition. -->
<div class="message" data-status={message.status}>
  <p>{message.content}</p>
</div>

<p class="sr-only" aria-live="polite">
  {#if message.status === 'streaming'}Assistant is replying.
  {:else if message.status === 'complete'}Reply finished.
  {:else if message.status === 'aborted'}Reply stopped. Partial reply kept.
  {:else if message.status === 'errored'}Reply failed. Retry available.
  {/if}
</p>

The polite live-region pattern follows the WAI-ARIA live-regions guidance. Because the announcement reads the same status the pill reads, the visible and the audible UI can never disagree.

The state and responsibilities reference#

Keep this table beside the code. It is the artifact you cross-reference while building, because it names, for each state, the store fields, what the UI shows, what input is allowed, and where the message can go next. This is the reference a Svelte AI streaming interface is built against.

Each lifecycle state, its store fields, its UI, its allowed input, and its next transitions
StateStore fieldsWhat the UI showsAllowed inputNext states
idlestatus idle, empty contentComposer ready, quiet threadSendpending
pendingstatus pending, empty contentOptimistic bubble, spinnerStopstreaming, aborted, errored
streamingstatus streaming, growing contentTokens filling one bubbleStopcomplete, aborted, errored
completestatus complete, full contentFinished replySend nextidle (next turn)
abortedstatus aborted, partial content keptPartial reply, retry buttonRetry, Sendpending
erroredstatus errored, error setError line, retry buttonRetry, Sendpending

When not to build this#

This machine earns its keep on a real chat surface. Still, it is not free, so do not reach for it everywhere. Skip it when the interface does not stream at all, because a single request-response call has no partial state to manage. A plain loading flag is enough there.

Also skip the full machine for a one-shot completion with no stop, no retry, and no reload concern, such as an internal script or a throwaway prototype. In short, adopt the lifecycle when a user can interrupt, lose connection, or reload mid-reply. Below that bar, the six-state store is more structure than the surface needs, and a smaller Svelte AI streaming interface will do.

Where to go next#

The lifecycle machine is one layer of a product that feels fast. For the platform side of shipping Svelte on the edge, read how we approach rendering models on Cloudflare Workers, and for making that shell installable and resilient, see building a progressive web app on Cloudflare. If you are pinning language features across a team, the ECMAScript 2026 adoption guide pairs well with the class-field syntax this store leans on.

If you want a second set of hands on a Svelte AI streaming interface you are shipping, our team builds AI product interfaces and works across the frontend. You can also talk to us about the fit. Everything above is standard, documented web platform work that you own outright, with no lock-in.

Ishan Chavda

Software Engineer, Atyantik Technologies

Ishan is a software engineer at Atyantik Technologies, a software product studio that has delivered 50+ enterprise engagements across 7 countries since 2015. He works at the front of the stack, where an AI product either feels responsive or does not, and writes about the state and UX patterns that keep a streaming interface predictable.

More from Ishan ChavdaAI product interfacesHire frontend developers

Keep reading