Lambda Response Streaming: Backpressure When Clients Stall Mid-Transfer

Lambda Response Streaming: Backpressure When Clients Stall Mid-Transfer

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.streamifyResponse with explicit highWaterMark discipline; never Buffer.concat the whole body; honor drain / 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

Last updated on September 11, 2026


Discover more from CheatCoders

Subscribe to get the latest posts sent to your email.

1 Comment

Leave a Reply