Edge & Serverless SSE Deployment Permalink to this section

Part of Backend Stream Generation & Connection Management.

Serverless platforms are built around a request that finishes: you are billed for the time between invocation and response, the runtime is frozen or discarded afterwards, and nothing is expected to live between invocations. An SSE stream contradicts every one of those assumptions. It is still entirely workable — edge runtimes stream well, and Lambda gained response streaming — but the constraints are real and they shape the architecture: a hard execution ceiling that must be handled as a planned disconnect, no in-process subscriber registry, and a cost model where you pay for wall-clock time a connection sits idle. This guide covers the streaming APIs on each platform, the patterns that work within the ceilings, and how to decide when a long-lived stream simply belongs on a persistent server instead.

How It Works Permalink to this section

Edge and serverless runtimes expose streaming through the WHATWG Streams API rather than through Node-style writable responses. A handler returns a Response whose body is a ReadableStream, and the platform forwards chunks as the stream enqueues them. The mental model shifts from “write to the response until you are done” to “hand back a stream object and keep feeding it from somewhere else”.

The shape every edge runtime shares Handler flow on an edge runtime: return a Response wrapping a ReadableStream, enqueue frames from an external source, and unsubscribe when the request signal aborts. The shape every edge runtime shares no in-process registry: consecutive requests land on different isolates fetch() request arrives ReadableStream controller.enqueue External source DO · bus · queue abort signal unsubscribe return immediately per event on disconnect
The mental shift is from "write until done" to "hand back a stream and feed it" — and the feed must come from outside the invocation.
// The shape every edge runtime shares: return a Response wrapping a ReadableStream.
export default {
  async fetch(request) {
    const encoder = new TextEncoder();

    const body = new ReadableStream({
      start(controller) {
        const send = (event) => {
          controller.enqueue(encoder.encode(
            `id: ${event.id}\nevent: ${event.type}\ndata: ${JSON.stringify(event.data)}\n\n`,
          ));
        };

        const heartbeat = setInterval(() => controller.enqueue(encoder.encode(': ping\n\n')), 15_000);

        // Stop feeding when the client goes away — the platform aborts the signal.
        request.signal.addEventListener('abort', () => {
          clearInterval(heartbeat);
          try { controller.close(); } catch { /* already closed */ }
        });

        subscribe(send);
      },
      cancel() {
        // Called when the consumer (the client) tears the stream down.
        unsubscribe();
      },
    });

    return new Response(body, {
      headers: {
        'Content-Type': 'text/event-stream; charset=utf-8',
        'Cache-Control': 'no-cache, no-transform',
      },
    });
  },
};

Two platform properties then dominate the design.

There is a maximum stream lifetime. Every serverless platform bounds how long an invocation may run — commonly 15 minutes on Lambda, and on edge runtimes a wall-clock or CPU-time budget that varies by product tier. The stream will be terminated when the ceiling is reached, so the only question is whether you handle it deliberately or let the client experience an unexplained drop.

There is no shared memory between invocations. The in-process subscriber registry that every single-server tutorial uses cannot exist: each open stream may be served by a different isolate, container or region. Events must arrive from outside the invocation — a durable object, a pub/sub subscription, a queue, or a polled store — which makes the Redis pub/sub fan-out pattern a hard requirement rather than a scaling option.

Server-Side Implementation Permalink to this section

Cloudflare Workers Permalink to this section

Workers stream natively and the runtime is well suited to holding connections, because an isolate handling an idle stream consumes almost no CPU. The idiomatic fan-out uses a Durable Object as the coordination point: clients connect to a Worker, the Worker subscribes to a Durable Object that owns the topic, and the object pushes events to every subscriber it holds.

// worker.js — client-facing stream, fed by a Durable Object.
export default {
  async fetch(request, env) {
    const url = new URL(request.url);
    if (url.pathname !== '/events') return new Response('Not found', { status: 404 });

    const topic = url.searchParams.get('topic') ?? 'default';
    const lastEventId = request.headers.get('Last-Event-ID') ?? url.searchParams.get('lastEventId');

    // One Durable Object instance per topic owns that topic's subscriber list.
    const id = env.TOPIC.idFromName(topic);
    const stub = env.TOPIC.get(id);

    // The DO returns a streaming Response; the Worker passes it straight through.
    return stub.fetch('https://do/subscribe', {
      headers: lastEventId ? { 'Last-Event-ID': lastEventId } : {},
    });
  },
};

// topic-object.js — the Durable Object: registry + replay buffer live here.
export class Topic {
  constructor(state) {
    this.state = state;
    this.subscribers = new Set();
    this.recent = [];                       // bounded replay buffer
  }

  async fetch(request) {
    const url = new URL(request.url);
    if (url.pathname === '/publish') return this.publish(await request.json());
    return this.subscribe(request.headers.get('Last-Event-ID'));
  }

  subscribe(lastEventId) {
    const encoder = new TextEncoder();
    const { readable, writable } = new TransformStream();
    const writer = writable.getWriter();

    const client = {
      write: (frame) => writer.write(encoder.encode(frame)).catch(() => this.drop(client)),
    };
    this.subscribers.add(client);

    // Replay anything the client missed, then go live.
    if (lastEventId) {
      const from = this.recent.findIndex((e) => e.id === lastEventId);
      if (from !== -1) {
        for (const e of this.recent.slice(from + 1)) client.write(frame(e));
      } else {
        client.write('event: resync\ndata: {"reason":"buffer_expired"}\n\n');
      }
    }

    // Alarms replace setInterval: they survive isolate eviction.
    this.state.storage.setAlarm(Date.now() + 15_000);

    return new Response(readable, {
      headers: {
        'Content-Type': 'text/event-stream; charset=utf-8',
        'Cache-Control': 'no-cache, no-transform',
      },
    });
  }

  publish(event) {
    this.recent.push(event);
    if (this.recent.length > 500) this.recent.shift();
    const f = frame(event);
    for (const c of this.subscribers) c.write(f);
    return new Response(null, { status: 204 });
  }

  async alarm() {                            // heartbeat for every subscriber
    for (const c of this.subscribers) c.write(': ping\n\n');
    if (this.subscribers.size) this.state.storage.setAlarm(Date.now() + 15_000);
  }

  drop(client) { this.subscribers.delete(client); }
}

const frame = (e) =>
  `id: ${e.id}\nevent: ${e.type ?? 'message'}\ndata: ${JSON.stringify(e.data)}\n\n`;

The alarm-based heartbeat is the important detail. A setInterval inside an isolate is not guaranteed to survive; a Durable Object alarm is durable state, so the heartbeat continues to fire even if the object is evicted and re-instantiated between events.

AWS Lambda with response streaming Permalink to this section

Lambda supports streamed responses through a function URL with RESPONSE_STREAM invoke mode. The 15-minute execution ceiling applies to the whole invocation, so the pattern is: stream normally, then close the stream deliberately before the ceiling with a retry: hint so the client reconnects to a fresh invocation.

// index.mjs — Lambda response streaming with a planned handover.
import { pipeline } from 'node:stream/promises';

const MAX_STREAM_MS = 13 * 60 * 1000;        // close 2 minutes before the 15-minute ceiling

export const handler = awslambda.streamifyResponse(async (event, responseStream) => {
  const stream = awslambda.HttpResponseStream.from(responseStream, {
    statusCode: 200,
    headers: {
      'Content-Type': 'text/event-stream; charset=utf-8',
      'Cache-Control': 'no-cache, no-transform',
    },
  });

  // Tell the client how long to wait before reconnecting after our planned close.
  stream.write('retry: 2000\n\n');

  const started = Date.now();
  const sub = await subscribeToBus(event.queryStringParameters?.topic ?? 'default');

  const heartbeat = setInterval(() => stream.write(': ping\n\n'), 15_000);

  try {
    for await (const ev of sub) {
      stream.write(`id: ${ev.id}\ndata: ${JSON.stringify(ev.data)}\n\n`);

      if (Date.now() - started > MAX_STREAM_MS) {
        // Planned handover: tell the client why, then close cleanly.
        stream.write('event: handover\ndata: {"reason":"max_duration"}\n\n');
        break;
      }
    }
  } finally {
    clearInterval(heartbeat);
    await sub.close();
    stream.end();
  }
});

Because the client reconnects with Last-Event-ID, a planned handover is invisible to the user as long as the bus retains a replay window — which is exactly the mechanism described in Idempotent Event ID Generation.

Two Lambda-specific constraints are worth stating plainly. API Gateway does not support response streaming; you need a Lambda function URL or an ALB target. And you are billed for the full wall-clock duration of the invocation at your configured memory size, whether or not any events flowed — which is the crux of the cost model below.

Deno Deploy, Vercel and Netlify edge functions Permalink to this section

All three use the same Web Streams shape as Workers. The differences are the ceilings and the coordination primitive: Deno Deploy offers Deno KV with watch semantics, while Vercel and Netlify edge functions generally require an external bus. The handler code is portable; only the subscription source changes.

// Deno Deploy: KV watch as the event source, no external bus required.
Deno.serve((req) => {
  const kv = await Deno.openKv();
  const body = new ReadableStream({
    async start(controller) {
      const enc = new TextEncoder();
      for await (const [entry] of kv.watch([['topic', 'prices']])) {
        if (req.signal.aborted) break;
        controller.enqueue(enc.encode(
          `id: ${entry.versionstamp}\ndata: ${JSON.stringify(entry.value)}\n\n`,
        ));
      }
      controller.close();
    },
  });
  return new Response(body, {
    headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache' },
  });
});

Client-Side Consumption Permalink to this section

The client must treat a planned handover as ordinary. The good news is that EventSource already does: a server that closes the response triggers a reconnect after the retry interval, carrying Last-Event-ID. The only client-side work is to avoid mistaking that for an error in the UI.

// Distinguish a planned handover from a real failure in the connection UI.
const es = new EventSource('/events?topic=prices');
let handoverExpected = false;

es.addEventListener('handover', () => {
  handoverExpected = true;             // the server told us it is closing on purpose
});

es.addEventListener('error', () => {
  if (handoverExpected) {
    handoverExpected = false;          // normal rollover — say nothing to the user
    return;
  }
  showReconnecting();                  // a genuine problem
});

es.addEventListener('resync', async () => {
  // The replay window did not cover our gap: refetch a snapshot, then resume live.
  applySnapshot(await (await fetch('/api/snapshot')).json());
});

The resync path matters more on serverless than anywhere else, because handovers are frequent by design and each one is a chance for the replay window to fall short.

Edge Cases & Network Interference Permalink to this section

  • Execution ceilings are not negotiable. Every platform terminates long invocations. Plan the handover comfortably inside the limit rather than discovering it: a stream cut at exactly the ceiling gives the client no retry: hint and produces synchronised reconnects across every stream that started at the same time.
  • CPU time versus wall-clock time. Edge runtimes typically bill and limit CPU time, not wall time, which is why an idle stream can live far longer than the CPU budget suggests. A stream doing per-event work — parsing, transforming, signing — consumes budget quickly and hits the ceiling much sooner.
  • No in-process registry. Any tutorial code holding a Set of response objects is unusable across invocations. The registry must live in a Durable Object, a shared store, or the message bus.
  • Cold starts on reconnect. Each handover may land on a cold isolate or container. The client sees this as time-to-first-event; keeping the subscription setup path lean matters more here than in a persistent server.
  • Timers do not survive eviction. setInterval inside a handler is fine while the invocation runs, but not durable across it. Use platform alarms where available.
  • Streaming through the platform’s own CDN. Some platforms front functions with a cache layer that buffers by default for certain routes. The same rules as in Proxy & CDN Configuration for SSE apply, expressed in that platform’s route config.
  • Concurrency limits count open streams. A serverless concurrency limit is a limit on simultaneous invocations, so ten thousand open streams is ten thousand concurrent invocations. On Lambda this collides with account concurrency quotas long before it collides with anything technical.
What each platform family constrains Comparison of isolate-based edge runtimes, container-based serverless and a persistent server across execution ceiling, billing basis and shared state. What each platform family constrains Ceiling Billed on Shared state Isolate edge CPU budget CPU + requests durable object Container serverless 15 min wall clock external bus Persistent server none the machine in process
The billing column decides most architectures: wall-clock billing makes a continuously-open stream expensive in a way CPU-time billing does not.

Performance & Scale Considerations Permalink to this section

The cost model is the decisive factor, and it differs by an order of magnitude between platform families.

Relative monthly cost for 10 000 continuously-open streams Bar chart of the relative monthly cost of holding ten thousand streams on wall-clock billed serverless, CPU-billed edge isolates and a single persistent virtual machine. Relative monthly cost for 10 000 continuously-open streams 10 000 streams held continuously, order-of-magnitude comparison Wall-clock serverless 300× Isolate edge One persistent VM relative monthly cost
Idle streams are nearly free where billing follows CPU and extremely expensive where it follows wall-clock time — the same code, two orders of magnitude apart.

On a Lambda-style platform you are billed for GB-seconds of wall-clock time per invocation. A stream held for ten minutes at 512 MB costs the same as ten minutes of compute, whether it delivered one event or ten thousand. Ten thousand concurrent streams held continuously is ten thousand concurrent invocations, which is both a quota problem and a bill measured in thousands of dollars a month.

On an isolate-based edge platform you are billed largely for CPU time and requests. An idle stream consumes almost no CPU, so the same ten thousand streams cost close to the request charge plus whatever the fan-out work costs. This is why edge runtimes are the serverless option that actually suits SSE.

On a persistent server, ten thousand streams is a fraction of one machine — as covered in Connection Pooling for SSE Servers — and costs whatever that machine costs, independent of how long connections stay open.

// Rough cost shape for 10 000 continuously-open streams, per month:
//   Lambda-style (wall-clock billed)     : compute for 10 000 concurrent invocations
//   Isolate edge (CPU + request billed)  : requests + a small CPU fraction per event
//   One persistent VM                    : the price of one VM, regardless of duration

The practical decision rule follows: use edge isolates when streams are numerous and mostly idle, use a persistent server when streams are numerous and busy, and use Lambda-style streaming only for short-lived streams — an AI completion, a job progress feed — where the invocation ends naturally in seconds or minutes.

Validation & Debugging Permalink to this section

Measure the two things that are specific to this environment: how long a stream actually survives, and whether reconnects are seamless.

# 1. How long does a stream really live before the platform closes it?
start=$(date +%s)
curl -N -s https://api.example.com/events > /dev/null
echo "stream ended after $(( $(date +%s) - start ))s"

# 2. Did the client get a retry hint and a handover event before the close?
curl -N -s https://api.example.com/events | grep -E '^(retry|event):' | head -5

# 3. Does resume actually work across a handover?
curl -N -s -H 'Last-Event-ID: 4821' https://api.example.com/events | head -5
# Expect ids strictly greater than 4821, with no gap.

For continuous signal, the metrics from Observability & Metrics for SSE need one addition on serverless: a counter of planned handovers, labelled by reason. If handovers dominate client-closed disconnects, your ceiling is shorter than a typical session and every user is paying reconnect cost on a schedule.

// Distinguish planned rollover from real failure in the disconnect metric.
streamDuration.observe({ reason: 'max_duration' }, seconds);   // planned handover
streamDuration.observe({ reason: 'client_closed' }, seconds);  // the user left

Production Checklist Permalink to this section

Frequently Asked Questions Permalink to this section

Can I run SSE on AWS Lambda at all?

Yes, through a function URL or ALB target with response streaming enabled — API Gateway does not support it. The constraint that shapes the design is the 15-minute execution ceiling plus wall-clock billing: it suits streams that end naturally in seconds or minutes, such as an AI completion or a job progress feed, and suits continuously-open feeds poorly because you pay for every idle minute at full memory size.

How do I fan events out when there is no shared memory between invocations?

Put the registry outside the invocation. On Cloudflare that is a Durable Object per topic, which owns both the subscriber set and the replay buffer. Elsewhere it is a pub/sub subscription per invocation, or a queue consumer. The in-process Set of responses that single-server examples use cannot work when consecutive requests land on different isolates.

What happens to open streams when I deploy a new version?

Existing invocations usually run to completion on the old version while new connections get the new one, so a deploy does not cut streams the way a server restart does — but it does mean two versions serve the same topic simultaneously. Keep the wire format backward compatible across at least one deploy, and make the planned handover the moment clients migrate to the new version.

Is an idle stream really almost free on an edge runtime?

On isolate-based platforms that bill CPU time rather than wall-clock time, yes — an idle stream costs the request plus the small amount of CPU each heartbeat and event consumes. That is the fundamental difference from container-based serverless, where the same idle stream is billed as if the process were working the whole time.

How short can the handover interval be before users notice?

With a replay window that covers the gap and a retry of one to two seconds, handovers every ten to fifteen minutes go unnoticed. Below about five minutes the reconnect cost — cold starts, replay, re-authentication — starts to dominate and shows up as periodic latency spikes in time-to-first-event. If your ceiling forces intervals that short, a persistent server is the better answer.

Deep Dives