Flambe docs
Guides

Live events

Subscribe to server-sent events for import progress instead of polling.

GET /api/events/stream is a Server-Sent Events stream of changes to the caller's data. Its main use is import progress — it removes polling entirely.

event: import.updated
data: {"id":"4d1f8c2e","status":"processing"}

event: import.completed
data: {"id":"4d1f8c2e","recipe_id":"9b8a7c6d"}

Connecting

The stream is authenticated with the usual bearer header.

`EventSource` will not work in a browser

The browser's built-in EventSource cannot set request headers, so it cannot send Authorization. Use a fetch-based SSE client instead. This trips up nearly everyone the first time.

async function subscribe(onEvent: (name: string, data: unknown) => void) {
  const res = await fetch('https://api.flambe.dev/api/events/stream', {
    headers: { Authorization: `Bearer ${await getToken()}` },
  });

  if (!res.ok) throw new Error(`stream failed: ${res.status}`);

  const reader = res.body!.pipeThrough(new TextDecoderStream()).getReader();
  let buffer = '';

  while (true) {
    const { value, done } = await reader.read();
    if (done) break;
    buffer += value;

    // SSE frames are separated by a blank line.
    const frames = buffer.split('\n\n');
    buffer = frames.pop() ?? '';

    for (const frame of frames) {
      let name = 'message';
      const payload: string[] = [];

      for (const line of frame.split('\n')) {
        if (line.startsWith(':')) continue; // keep-alive comment
        if (line.startsWith('event:')) name = line.slice(6).trim();
        if (line.startsWith('data:')) payload.push(line.slice(5).trim());
      }

      if (payload.length) onEvent(name, JSON.parse(payload.join('\n')));
    }
  }
}

Keep-alives

The server sends a comment line — one beginning with : — every few seconds so intermediaries do not drop an idle connection. Skip those lines; they are not events.

Reconnecting

The stream is best-effort and carries no replay. Events that occurred while you were disconnected are gone.

So on every reconnect, delta sync to catch up, then rely on the stream for what happens next. Reconnect with backoff rather than immediately — a tight reconnect loop against a server that is having trouble makes things worse.

A 401 closes the stream with an empty body. Refresh the token and reconnect once; do not loop.

Still poll as a backstop

For a job you must not lose track of, combine both: react to import.completed, and also poll GET /api/imports/{id} on a slow interval — say every 15 seconds — so a dropped connection cannot leave a job hanging in your UI forever.

On this page