Replay (SSR gap)

When you combine SSR with WebSocket live updates, there’s a gap between server-side data loading and the moment the client’s WebSocket connects. Messages published during that window are lost.

The replay plugin solves this without touching the adapter core. It’s opt-in - if you don’t import it, it doesn’t exist.

How it works

  1. Server: publish through a replay buffer instead of platform.publish() directly - messages get a sequence number and are stored in a ring buffer
  2. SSR: pass the current sequence number to the client via your load() function
  3. Client: onReplay() connects, requests missed messages, and switches to live mode once caught up

Setup

Create a shared replay instance:

// src/lib/server/replay.js
import { createReplay } from 'svelte-adapter-uws/plugins/replay';

export const replay = createReplay({ size: 500 });

Use it when publishing:

// src/routes/chat/+page.server.js
import { replay } from '$lib/server/replay';

export async function load() {
  const messages = await db.getRecentMessages();
  return { messages, seq: replay.seq('chat') };
}

export const actions = {
  send: async ({ request, platform }) => {
    const form = await request.formData();
    const msg = await db.createMessage(Object.fromEntries(form));
    replay.publish(platform, 'chat', 'created', msg);
  }
};

Handle replay requests in your WebSocket handler:

// src/hooks.ws.js
import { replay } from '$lib/server/replay';

export function message(ws, { data, platform }) {
  const msg = JSON.parse(Buffer.from(data).toString());
  if (msg.type === 'replay') {
    replay.replay(ws, msg.topic, msg.since, platform, msg.reqId);
    return;
  }
}

Options

OptionDefaultDescription
size1000Max messages per topic in the ring buffer
maxTopics100Max tracked topics, LRU evicted

Server API

MethodDescription
replay.publish(platform, topic, event, data)Publish and buffer the message
replay.seq(topic)Current sequence number for a topic
replay.since(topic, seq)Buffered messages after a sequence number
replay.replay(ws, topic, sinceSeq, platform, reqId)Send missed messages to one client
replay.clear()Reset everything
replay.clearTopic(topic)Reset one topic

Client API

import { onReplay } from 'svelte-adapter-uws/plugins/replay/client';

// Works exactly like on() but bridges the SSR gap
const store = onReplay('chat', { since: data.seq });

// .scan() works the same as on().scan()
const messages = onReplay('chat', { since: data.seq }).scan([], reducer);

Each onReplay() call generates a unique request ID that is sent with the replay request and matched against the server’s responses. Multiple onReplay('chat', ...) instances on the same page each receive only their own replay stream. The server must pass msg.reqId to replay.replay() as shown above for this to work.

Client example

<!-- src/routes/chat/+page.svelte -->
<script>
  import { onReplay } from 'svelte-adapter-uws/plugins/replay/client';
  let { data } = $props();

  const messages = onReplay('chat', { since: data.seq }).scan(
    data.messages,
    (list, { event, data }) => {
      if (event === 'created') return [...list, data];
      return list;
    }
  );
</script>

{#each $messages as msg}
  <p>{msg.text}</p>
{/each}

Buffer overflow: If more than size messages were published before the client connected and the ring buffer wrapped around, the store emits a synthetic { event: 'truncated', data: null } event after the replayed messages. Check for it in your reducer to decide whether to reload from the server:

const messages = onReplay('chat', { since: data.seq }).scan(data.messages, (list, { event, data }) => {
  if (event === 'truncated') return []; // buffer overflow - reload from server
  if (event === 'created') return [...list, data];
  return list;
});

Subscribe-denied replays: Replay backends consult platform.checkSubscribe(ws, topic) before reading the buffer. Topics the wire-subscribe gate would deny emit a denied event on __replay:{topic}; treat similarly to truncated:

const messages = onReplay('chat', { since: data.seq }).scan(data.messages, (list, { event, data }) => {
  if (event === 'denied') return [];
  if (event === 'truncated') return [];
  if (event === 'created') return [...list, data];
  return list;
});

End marker shape: The end event now carries { reqId } (was null). Pass an explicit reqId to replay() for cross-replay correlation. Older clients that checked data === null should switch to an object check.

sinceSeq validation: since(topic, sinceSeq) and replay(ws, topic, sinceSeq, ...) reject negative, NaN, Infinity, fractional, and non-number sinceSeq values. Pre-validation, entry.seq > -1 was always true, so an authorized “resume from N” with a buggy host that passed in -1 or NaN would silently degrade to “dump everything in the buffer” - a backwards-compatibility shape that became a surprise for hosts that legitimately wanted no replay on a bad input. Invalid sinceSeq now produces an empty result; replay() still emits the end marker so the wire-protocol shape is preserved. Hosts that previously relied on the dump-everything behavior should pass 0 explicitly.

Limitations

  • In-memory only. The ring buffer lives in the server process. A restart loses the buffer. For most apps this is fine - the gap is typically under a second, and a page reload after a server restart gives fresh SSR data anyway.
  • Single-worker only. In clustered mode, each worker has its own buffer. If the SSR load runs on worker A and the WebSocket connects to worker B, the replay won’t have the right messages. If you need replay with clustering, stick to a single worker or use an external store.
  • Buffer overflow. If more than size messages are published to a topic before a client requests replay, the oldest are gone. Size the buffer for your expected throughput during the SSR-to-connect window (usually well under 100 messages).

Was this page helpful?