projects·playbook

SSE (Server-Sent Events) for AI streaming UX

The pattern

When generating or fetching data from AI (or a slow paginated upstream), stream the progress to the browser via SSE rather than making the user wait for a single big response. Users see cards/progress/chunks as they're produced.

Projects exemplifying this

  • amboras/api/generate opens an SSE stream. Progress events + generated StoreConfig + Shopify sync status flow to the browser as each step completes. Same pattern reused for the AI editor (/api/stores/[id]/edit).
  • synapse (repo flavor, Follower Intel) — /api/followers/all streams each paginated batch of ~20 followers from RapidAPI as an SSE event. Cards appear in the grid in real time as data arrives.

Why SSE over alternatives

  • vs WebSockets: SSE is one-way server→client, which is exactly what progress/generation streaming needs. Simpler protocol, works over plain HTTP, and — the load-bearing reason in both projects here — works with Vercel's serverless streaming response model without needing a persistent connection.
  • vs polling: no wasted requests, real-time updates, smaller overhead.
  • vs one big response: user doesn't wait for the slowest thing. Perceived latency is "first event" not "last event."

Implementation shape (consistent across both)

POST /api/...  (SSE response)
  event: progress   → { step, message }
  event: <typed>    → { ...payload }   // e.g., "followers", "storeConfig", "shopifyProduct"
  event: done       → { total, final }
  event: error      → { message }      // rare — usually surface gracefully

The frontend opens an EventSource, listens for events, and updates UI incrementally. Errors inside the stream are sent as event: error rather than HTTP status codes (the outer HTTP status is already 200 once the stream opens).

When this playbook misleads

  • Don't SSE for small/fast responses — overhead isn't justified.
  • Don't SSE for two-way interaction — use WebSockets.
  • Don't forget the reconnection story — EventSource auto-reconnects but your backend needs to handle "resume from cursor" if that matters.

Related