Handling Execution Timeouts in Serverless SSE Permalink to this section
Part of Edge & Serverless SSE Deployment, under Backend Stream Generation & Connection Management.
Every serverless platform ends a long invocation eventually. The difference between a stream that rolls over invisibly and one that produces a synchronised reconnect storm every fifteen minutes is about twenty lines of code.
Symptom & Developer Intent Permalink to this section
- Streams end at the same interval for every user, matching the platform’s execution limit exactly.
- The reconnect wave arrives all at once, because clients that connected together time out together.
- Users see a “reconnecting” indicator on a fixed schedule, which erodes trust in the indicator.
- Events published during the reconnect gap are missing from the client’s view.
- Metrics cannot distinguish a planned rollover from a real failure, so disconnect rates look alarming.
The intent is a handover the user never notices: closed deliberately, spread over time, resumed without a gap.
Root Cause Analysis Permalink to this section
An execution ceiling is a property of the platform, not a fault. Three things turn it into a visible problem.
Synchronised expiry. Clients that connect at the same moment — after a deploy, or when a popular page loads — reach the ceiling at the same moment. Without jitter, every one of them reconnects simultaneously, producing a load spike on a schedule.
No advance warning to the client. When the runtime kills the invocation, the connection simply closes. EventSource treats that as an error and applies whatever retry interval it last saw — the browser default of a few seconds if the server never sent one. The UI shows a failure that was not one.
A replay window too small for the gap. The reconnect gap is not just the retry interval: it includes DNS, TLS, a possible cold start, and re-subscription. If the bus retains fewer events than are published during that window, every handover loses data — and the loss is invisible unless the client detects the id discontinuity.
Step-by-Step Resolution Permalink to this section
Step 1 — Choose a handover point with real margin Permalink to this section
Close well before the ceiling so a slow final write cannot be truncated.
// Handover at ~85% of the ceiling, with an absolute floor of one minute of margin.
const CEILING_MS = Number(process.env.PLATFORM_CEILING_MS ?? 900_000);
const MARGIN_MS = Math.max(60_000, CEILING_MS * 0.15);
const HANDOVER_MS = CEILING_MS - MARGIN_MS;
Step 2 — Jitter the handover per connection Permalink to this section
This is the single most important line: it turns a synchronised wave into a smooth trickle.
// ±20% jitter so simultaneous connections do not expire simultaneously.
const jitter = 1 + (Math.random() * 0.4 - 0.2);
const handoverAt = Date.now() + HANDOVER_MS * jitter;
Step 3 — Send a retry hint at stream start, not at close Permalink to this section
The client must already know the interval before the connection ends, because it cannot read anything afterwards.
// First bytes on the wire: control the reconnect delay up front.
stream.write('retry: 2000\n\n');
Step 4 — Announce the handover, then close cleanly Permalink to this section
for await (const ev of subscription) {
stream.write(`id: ${ev.id}\nevent: ${ev.type}\ndata: ${JSON.stringify(ev.data)}\n\n`);
if (Date.now() > handoverAt) {
stream.write(
`event: handover\ndata: ${JSON.stringify({
reason: 'max_duration',
resume_from: ev.id, // the client already has this; be explicit anyway
})}\n\n`,
);
break;
}
}
stream.end();
A stream with no events would never reach the check, so run the deadline on the heartbeat too:
const heartbeat = setInterval(() => {
if (Date.now() > handoverAt) {
stream.write('event: handover\ndata: {"reason":"max_duration"}\n\n');
clearInterval(heartbeat);
stream.end();
return;
}
stream.write(': ping\n\n');
}, 15_000);
Step 5 — Keep the UI quiet for a planned close Permalink to this section
// Client: a handover is not a failure and must not surface as one.
let plannedClose = false;
es.addEventListener('handover', () => { plannedClose = true; });
es.addEventListener('error', () => {
if (plannedClose) { plannedClose = false; return; } // expected rollover
showReconnectingBanner(); // a real problem
});
es.addEventListener('resync', async () => {
applySnapshot(await (await fetch('/api/snapshot')).json());
});
Step 6 — Size the replay window to the real gap Permalink to this section
// Retain at least: retry delay + cold start + subscribe time, times the event rate,
// with generous headroom.
const GAP_SECONDS = 2 /* retry */ + 3 /* cold start */ + 1 /* subscribe */;
const EVENTS_PER_SECOND = 50;
const MIN_REPLAY = GAP_SECONDS * EVENTS_PER_SECOND * 4; // ≈1200 events
// If the requested cursor has aged out, say so rather than silently starting from now.
if (!buffer.has(cursor)) {
stream.write('event: resync\ndata: {"reason":"buffer_expired"}\n\n');
}
Validation & Monitoring Permalink to this section
# 1. Do streams end at the handover point rather than the ceiling?
for i in 1 2 3; do
start=$(date +%s)
curl -N -s "$STREAM_URL" > /dev/null
echo "stream $i lasted $(( $(date +%s) - start ))s"
done
# Expect values scattered around the handover point — the jitter should be visible.
# 2. Does the handover event arrive before the close?
curl -N -s "$STREAM_URL" | grep -m1 'event: handover'
# 3. Is the reconnect gap actually covered by the replay window?
curl -N -s -H 'Last-Event-ID: 4821' "$STREAM_URL" | head -3
# Expect 4822 next, with no gap and no resync event.
Separate planned from unplanned disconnects in metrics, or the disconnect rate is uninterpretable:
streamDuration.observe({ reason: 'max_duration' }, seconds); // planned handover
streamDuration.observe({ reason: 'client_closed' }, seconds); // the user left
streamDuration.observe({ reason: 'write_error' }, seconds); // a real failure
# Share of disconnects that were planned — should be the large majority on serverless.
sum(rate(sse_stream_duration_seconds_count{reason="max_duration"}[15m]))
/ sum(rate(sse_stream_duration_seconds_count[15m]))
# Resyncs mean the replay window is too small for the real gap.
sum(rate(sse_resync_total[15m]))
A non-zero resync rate is the clearest actionable signal here: it means users are losing data at every handover, and the fix is a larger buffer rather than anything on the client.
Production Checklist Permalink to this section
Frequently Asked Questions Permalink to this section
Why jitter the handover if every stream has the same ceiling anyway?
Because expiry time is set by connect time, and connect times cluster — after a deploy, or when a popular page loads. Without jitter those clients all expire together and reconnect together, recreating the load spike on a schedule. A ±20% spread turns the wave into a trickle at no cost.
Can I keep the connection alive past the platform ceiling somehow?
No. The ceiling terminates the invocation regardless of what the handler does. The only options are to hand over deliberately before it, or to move continuously-open streams to a runtime without one — an isolate platform or a persistent server.
How do users perceive a handover every ten to fifteen minutes?
Not at all, provided three things hold: the retry delay is short, the replay window covers the gap, and the UI does not announce the reconnect. Users notice handovers when a reconnect banner flashes on a schedule or when data goes missing — both are symptoms of one of those three being wrong.
Should the client reconnect immediately or wait?
Wait briefly — one to two seconds via the retry: field. Immediate reconnection from every client at once is exactly the thundering herd the jitter is designed to avoid, and a short delay costs the user nothing because the replay makes the gap invisible.