SSE on AWS Lambda Response Streaming Permalink to this section

Part of Edge & Serverless SSE Deployment, under Backend Stream Generation & Connection Management.

Lambda can stream, but only through the right front door and only within an execution ceiling that ends every stream eventually. Getting it right means choosing the invoke path deliberately and treating the ceiling as a planned handover rather than an outage.

Symptom & Developer Intent Permalink to this section

  • The function returns everything at once when the handler finishes, instead of streaming.
  • Behind API Gateway the response is buffered no matter what the handler does.
  • Streams end abruptly at fifteen minutes with no notice to the client.
  • The bill is far higher than expected for a feature that transfers very little data.
  • Concurrency limits are hit at a client count that seems small.

The intent is a Lambda-hosted stream that delivers incrementally, hands over cleanly before the ceiling, and costs what you expected.

Root Cause Analysis Permalink to this section

Three Lambda-specific behaviours explain all five symptoms.

Which front door supports streaming Table of Lambda invoke paths with whether each supports response streaming and what it does to an SSE response. Which front door supports streaming Streams? Result API Gateway no buffered to the end Function URL, BUFFERED no buffered to the end Function URL, RESPONSE_STREAM yes incremental ALB target yes incremental
No handler change makes API Gateway stream. The invoke path is the decision, and it is made before any code is written.

Buffering is the default. A standard handler returns a value that Lambda serialises after the handler resolves. Streaming requires the streamifyResponse wrapper and a writable stream — without it, everything written is accumulated and delivered at the end.

API Gateway does not support response streaming. Regardless of how the function is written, an integration through API Gateway buffers the whole response. Streaming requires a Lambda function URL with RESPONSE_STREAM invoke mode, or an ALB target. This is a platform constraint, not a configuration mistake.

Billing is wall-clock, not work. A Lambda invocation is billed for its full duration at the configured memory size. A stream open for ten minutes costs ten minutes of compute even if it delivered three events, and every concurrently open stream is a concurrent invocation counting against the account limit. That is what makes continuously-open feeds expensive here in a way they are not on an isolate runtime or a persistent server.

The fifteen-minute maximum then caps every stream. Handled deliberately it is invisible; handled by accident it appears as a synchronised mass disconnection of every stream that started at the same time.

Step-by-Step Resolution Permalink to this section

Where the handover sits inside the ceiling Timeline of a Lambda invocation showing the stream opening, the planned handover at thirteen minutes and the hard ceiling at fifteen. Where the handover sits inside the ceiling time → Stream opens retry: 2000 written 0 min Handover event deliberate close 13 min Client reconnects Last-Event-ID resume 13 min Hard ceiling never reached 15 min
Two minutes of margin covers a slow final write and a client that is mid-reconnect; closing at the ceiling itself gives the client no retry hint at all.

Step 1 — Write the handler with streamifyResponse Permalink to this section

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

  // Control the reconnect delay before anything else is written.
  stream.write('retry: 2000\n\n');

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

  try {
    const sub = await subscribe(event.queryStringParameters?.topic ?? 'default',
                               event.headers?.['last-event-id']);
    for await (const ev of sub) {
      stream.write(`id: ${ev.id}\nevent: ${ev.type}\ndata: ${JSON.stringify(ev.data)}\n\n`);
    }
  } finally {
    clearInterval(heartbeat);
    stream.end();
  }
});

Step 2 — Create a function URL with streaming invoke mode Permalink to this section

aws lambda create-function-url-config \
  --function-name sse-stream \
  --auth-type AWS_IAM \
  --invoke-mode RESPONSE_STREAM

# Confirm the mode actually applied — this is the setting people forget.
aws lambda get-function-url-config --function-name sse-stream \
  --query 'InvokeMode'
# "RESPONSE_STREAM"

In Terraform:

resource "aws_lambda_function_url" "sse" {
  function_name      = aws_lambda_function.sse.function_name
  authorization_type = "AWS_IAM"
  invoke_mode        = "RESPONSE_STREAM"   # BUFFERED is the default
}

Step 3 — Hand over before the ceiling instead of being cut by it Permalink to this section

const CEILING_MS = 15 * 60 * 1000;
const HANDOVER_MS = 13 * 60 * 1000;        // two minutes of margin

const started = Date.now();

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

  if (Date.now() - started > HANDOVER_MS) {
    // Tell the client this close is deliberate so the UI stays quiet.
    stream.write('event: handover\ndata: {"reason":"max_duration"}\n\n');
    break;
  }
}

Because the client reconnects with Last-Event-ID, a handover is seamless as long as the bus retains a replay window covering the reconnect gap.

Step 4 — Set the function timeout below the platform maximum Permalink to this section

aws lambda update-function-configuration \
  --function-name sse-stream \
  --timeout 870 \
  --memory-size 512

A function timeout slightly under the handover point guarantees the invocation ends even if the handover logic is skipped for any reason.

Step 5 — Check concurrency against expected stream count Permalink to this section

# Each open stream is one concurrent invocation.
aws service-quotas get-service-quota \
  --service-code lambda --quota-code L-B99A9384 \
  --query 'Quota.Value'

# Reserve capacity so streams cannot starve the rest of the account.
aws lambda put-function-concurrency \
  --function-name sse-stream --reserved-concurrent-executions 2000

Two thousand reserved executions means at most two thousand simultaneously open streams — a hard user cap that must be sized deliberately.

Validation & Monitoring Permalink to this section

Cost of one continuously-open stream for a month Bar chart of the relative monthly cost of holding one stream open on Lambda at different memory sizes against an isolate runtime. Cost of one continuously-open stream for a month one stream held continuously for 730 hours Lambda 1024 MB 200× Lambda 512 MB 100× Lambda 128 MB 25× Isolate edge relative monthly cost per stream
You pay wall-clock time at the configured memory size whether or not events flow, which is what makes memory sizing a first-order cost decision here.
# 1. Is the response actually streamed, or delivered at the end?
curl -N -s --aws-sigv4 "aws:amz:$AWS_REGION:lambda" \
  --user "$AWS_ACCESS_KEY_ID:$AWS_SECRET_ACCESS_KEY" \
  "$FUNCTION_URL" | while read -r line; do printf '%s %s\n' "$(date +%s)" "$line"; done | head -10
# Timestamps must climb; identical timestamps mean buffering.

# 2. Does the handover happen before the ceiling?
start=$(date +%s); curl -N -s "$FUNCTION_URL" > /dev/null; echo "ended after $(( $(date +%s) - start ))s"
# Expect roughly the handover interval, not 900.

# 3. Does resume work across the handover?
curl -N -s -H 'Last-Event-ID: 4821' "$FUNCTION_URL" | head -3

The CloudWatch metrics that matter are ConcurrentExecutions — which for this function equals open streams — and Duration, whose distribution should cluster at the handover interval rather than at the timeout. A cluster at exactly the timeout means handovers are not firing and streams are being killed.

Cost is the metric people discover last and should watch first:

Monthly cost ≈ concurrent streams × 730 h × 3600 s × GB-seconds price × memory GB

Run that number with your real memory size before launch. For continuously-open feeds at any scale it usually argues for an isolate runtime or a persistent server, as compared in Edge & Serverless SSE Deployment.

Production Checklist Permalink to this section

Frequently Asked Questions Permalink to this section

Why is my Lambda response still buffered even with streamifyResponse?

Almost certainly the invoke path. API Gateway buffers responses whatever the handler does, and a function URL created without --invoke-mode RESPONSE_STREAM defaults to BUFFERED. Check the URL config first — the handler code is usually fine.

Is Lambda a reasonable place to run SSE at all?

For streams that end naturally in seconds or minutes — an AI completion, a job progress feed, an export — it is a good fit, because the invocation ends when the work does. For continuously-open feeds it is the most expensive option available, since you pay wall-clock time per open stream and each one occupies a concurrency slot.

What happens at the fifteen-minute limit if I do nothing?

The invocation is terminated and the connection closes without a retry: hint or an explanation. Every client that connected at roughly the same time reconnects at roughly the same moment, producing a synchronised burst against the next set of invocations. Handing over deliberately with a jittered retry avoids both problems.

Can I use provisioned concurrency to avoid cold starts on reconnect?

Yes, and it helps time-to-first-event after each handover — but it adds a fixed hourly charge on top of the per-invocation cost, which makes an already expensive design more so. If cold starts on frequent handovers are the problem, the underlying issue is usually that the execution ceiling is too short for the workload.