Function URL response streaming looks great in a happy-path curl demo and then dies in production when a mobile client stalls mid-transfer. The unfair advantage is treating the writable HTTP stream as a real backpressure surface: pause producers, bound buffers, and fail closed before you OOM the execution environment or burn the full timeout on a half-dead TCP session.
⚡ TL;DR: Use
awslambda.streamifyResponsewith explicit highWaterMark discipline; neverBuffer.concatthe whole body; honordrain/cork/ abort signals; set Function URL idle timeouts that match your producer; emit CloudWatch metrics for stalled bytes and aborted streams. Pair with Lambda Function URLs Streaming and Lambda Plus Bedrock streaming.
Why streaming fails under stall
Lambda still bills wall-clock while the client is slow. If your handler keeps generating into an unbounded in-memory queue, you pay twice: memory + timeout. Backpressure means the Node.js Writable must force the producer to wait when write() returns false.
// handler.ts — Node 20 Function URL streaming with backpressure
import { Readable } from "node:stream";
import { pipeline } from "node:stream/promises";
declare const awslambda: {
streamifyResponse: (
fn: (event: unknown, responseStream: NodeJS.WritableStream, context: unknown) => Promise<void>
) => unknown;
HttpResponseStream: {
from: (stream: NodeJS.WritableStream, meta: { statusCode: number; headers: Record<string, string> }) => NodeJS.WritableStream;
};
};
export const handler = awslambda.streamifyResponse(async (event, responseStream, _ctx) => {
const httpStream = awslambda.HttpResponseStream.from(responseStream, {
statusCode: 200,
headers: {
"Content-Type": "application/octet-stream",
"Cache-Control": "no-store",
},
});
// ✅ Bound the writable buffer — default is often too generous for 128–512 MB functions
(httpStream as any).writableHighWaterMark = 64 * 1024;
const abort = new AbortController();
httpStream.on("error", () => abort.abort());
httpStream.on("close", () => abort.abort());
const source = createProducer(event, abort.signal); // async generator → Readable
try {
await pipeline(Readable.from(source), httpStream);
} catch (err) {
// Client stall / reset — do not retry the whole payload in-process
console.error(JSON.stringify({ msg: "stream_aborted", err: String(err) }));
}
});
async function* createProducer(event: unknown, signal: AbortSignal) {
for await (const chunk of fetchUpstreamChunks(event)) {
if (signal.aborted) return; // ✅ stop producing
// ❌ Never: chunks.push(chunk) into a growing array
yield chunk;
}
}
Detect stalls before timeout
Instrument bytes written vs wall time. If throughput drops below a floor for N seconds, destroy the stream and let the client reconnect with Range / cursor semantics.
function wrapWithStallGuard(stream: NodeJS.WritableStream, opts: { minBps: number; windowMs: number }) {
let written = 0;
let windowStart = Date.now();
const timer = setInterval(() => {
const elapsed = Date.now() - windowStart;
const bps = (written * 1000) / Math.max(elapsed, 1);
if (bps < opts.minBps) {
stream.destroy(new Error(`stall_below_${opts.minBps}_bps`));
}
written = 0;
windowStart = Date.now();
}, opts.windowMs);
const origWrite = stream.write.bind(stream);
(stream as any).write = (chunk: any, ...rest: any[]) => {
written += Buffer.isBuffer(chunk) ? chunk.length : Buffer.byteLength(String(chunk));
return origWrite(chunk, ...rest);
};
stream.on("close", () => clearInterval(timer));
return stream;
}
| Symptom | Likely cause | Fix |
|---|---|---|
| OOM mid-stream | Unbounded buffering | pipeline + highWaterMark |
| Full timeout, 0 bytes late | Client stalled, producer kept running | abort signal + stall guard |
| Spiky memory | Buffer.concat of all chunks |
yield/write incrementally |
| Duplicate work on retry | No resume cursor | idempotent Range / cursor tokens |
Function URL and client contract
Document that clients must read promptly. For browsers, prefer fetch + ReadableStream cancel on navigation. For mobile, set read timeouts shorter than the Lambda timeout so the server can reclaim concurrency.
# ✅ Probe backpressure locally (illustrative)
curl -N --limit-rate 1k "https://xxx.lambda-url.region.on.aws/export" -o /dev/null
# Watch CloudWatch: Duration rises, Max Memory used stays flat if backpressure works
❌ Setting Lambda timeout to 15 minutes “just in case” without stall detection — you warehouse dead sockets.
Closing checklist
✅ Dos
– ✅ Use streamifyResponse + pipeline, not manual res.end(bigBuffer)
– ✅ Propagate abort on error/close
– ✅ Metric stalled streams and aborted transfers
– ✅ Design resume tokens for large exports
– ✅ Keep writableHighWaterMark small relative to function memory
❌ Don’ts
– ❌ Don’t buffer the entire payload to “simplify” headers
– ❌ Don’t ignore write() === false
– ❌ Don’t rely on API Gateway for multi-MB streams (use Function URLs)
– ❌ Don’t leave producers running after client disconnect
Related reading
- Lambda Function URLs Streaming Response
- Lambda Plus Bedrock: Stream Tokens Without API Gateway Caps
- Lambda Timeouts, Retries, and DLQs
- Lambda Warm Pools for Agent Tools
Last updated on September 11, 2026
Discover more from CheatCoders
Subscribe to get the latest posts sent to your email.

Pingback: Lambda Ephemeral Storage: Safe /tmp Strategies for Large Artifacts - CheatCoders