Streaming Responses From an AWS Lambda Function URL
10 min read · updated August 11, 2026
A Lambda behind a function URL buffers its whole response by default, which is fatal for anything that generates text token by token: the user waits for the entire generation, then sees it all at once. Response streaming fixes that, and it is one configuration field plus one handler wrapper.
Two invoke modes, two payload ceilings
A function URL has an InvokeMode with two values, documented by AWS on its invoking a response streaming enabled function page:
BUFFERED— the default. Lambda uses theInvokeoperation, the result is available when the payload is complete, and the maximum payload is 6 MB.RESPONSE_STREAM— Lambda usesInvokeWithResponseStream, payload chunks go out as they are produced, and the maximum response payload is 200 MB.
Changing it is one CLI call, or one property in CloudFormation:
aws lambda update-function-url-config \ --function-name my-streaming-function \ --invoke-mode RESPONSE_STREAM
MyFunctionUrl:
Type: AWS::Lambda::Url
Properties:
AuthType: AWS_IAM
InvokeMode: RESPONSE_STREAMOne constraint decides the language for you. AWS supports response streaming on Node.js managed runtimes; for other languages including Python you need a custom runtime with a custom Runtime API integration, or the Lambda Web Adapter. If your model-calling code is Python and you want streaming through a function URL, that is a real decision to make before you start, not a detail.
Writing the handler
Wrap the handler in awslambda.streamifyResponse(). The awslambda global is provided by the Node.js runtime and needs no import. The wrapped function receives (event, responseStream, context), where responseStream is a Node writable stream.
- Write the simplest thing that proves the mechanism, so that when the real version misbehaves you know whether the problem is streaming or your model client.
export const handler = awslambda.streamifyResponse( async (event, responseStream, _context) => { responseStream.write("first "); await new Promise((r) => setTimeout(r, 1000)); responseStream.write("second "); responseStream.end(); } ); - Replace the timer with the real source. AWS recommends
pipeline()over repeatedwrite()wherever you have a readable source, because it applies backpressure — without it a fast producer can overwhelm the writable stream.import { pipeline } from "node:stream/promises"; import { Readable } from "node:stream"; export const handler = awslambda.streamifyResponse( async (event, responseStream, _context) => { const upstream = await fetch(MODEL_URL, { method: "POST", headers: { "content-type": "application/json", authorization: AUTH }, body: JSON.stringify({ ...JSON.parse(event.body), stream: true }), }); await pipeline(Readable.fromWeb(upstream.body), responseStream); } ); - End the stream.
pipeline()does this for you; if you are writing manually, callresponseStream.end()before the handler returns. AWS notes that from Node.js 24 the runtime no longer waits for unresolved promises after the handler returns or the stream ends, so anything asynchronous must be awaited inside the handler. - Set the function timeout to cover the longest generation you will allow. The tutorial function AWS publishes uses
--timeout 10to stream three responses a second apart; a real generation needs considerably more, and the consequence of getting it wrong is in fixing “Task timed out after N seconds”.
Status codes and headers
A streaming handler has no return value, so there is nowhere to put a status code or headers. AWS provides awslambda.HttpResponseStream.from(responseStream, metadata) for this, and its own tutorial reassigns the variable so the unwrapped stream cannot be used by mistake:
export const handler = awslambda.streamifyResponse(
async (event, responseStream, _context) => {
const metadata = {
statusCode: 200,
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
},
};
responseStream = awslambda.HttpResponseStream.from(responseStream, metadata);
responseStream.write("data: hello\n\n");
responseStream.end();
await responseStream.finished();
}
);The metadata must be set before the first write. This is the usual reason a streaming endpoint returns the right body with the wrong content type: the first chunk went out, the response head was already committed, and the later HttpResponseStream.from call had nothing to do.
Reading the stream on the client
The client has to be told not to buffer, and most default tooling buffers. With curl that is --no-buffer, which is what makes the difference between watching text arrive and seeing it appear at the end:
curl --request POST "https://<id>.lambda-url.eu-west-1.on.aws/" \
--user "$AWS_ACCESS_KEY_ID" \
--aws-sigv4 "aws:amz:eu-west-1:lambda" \
--data '{"prompt":"write a haiku"}' \
--no-bufferIn a browser, read the response body as a stream rather than awaiting .text(), which defeats the whole exercise:
const res = await fetch(url, { method: "POST", body: JSON.stringify(input) });
const reader = res.body.getReader();
const decoder = new TextDecoder();
for (;;) {
const { done, value } = await reader.read();
if (done) break;
process.stdout.write(decoder.decode(value, { stream: true }));
}AWS also warns that the Lambda console always shows responses as buffered, so testing there will tell you nothing about whether streaming works. Test through the URL.
The limits that bite in production
- The 6 MB bandwidth cliff. AWS documents the streaming rate for the first 6 MB of the response as uncapped, after which the remainder is capped at 2 MBps. For token-by-token text this never applies — 6 MB is a very long generation. For anything streaming binary or a large document it applies sharply, and the symptom is a response that starts fast and then crawls.
- You pay for the whole duration regardless. AWS states that streamed responses are not interrupted or stopped when the client connection breaks, and that customers are billed for the full function duration. A user who closes the tab mid-generation does not stop the function, or the upstream model call it is paying for. If that matters, watch for the abort signal in your own code and end early — nothing will do it for you.
- Function URLs do not stream inside a VPC. AWS is explicit: function URLs do not support response streaming within a VPC environment. The documented alternative is to call
InvokeWithResponseStreamthrough the SDK via an interface VPC endpoint for Lambda. This is a design constraint, not a setting, and it is worth knowing before the architecture is drawn. - Function URL auth is IAM or nothing.
AWS_IAMmeans every caller signs with SigV4;NONEmeans the URL is public. There is no middle option, so a browser client generally needs something in front — which is where API Gateway streaming becomes the comparison to make.