Choosing a transport
SSE, WebSocket, chunked HTTP and polling each fail differently behind a corporate proxy — and the one that works on localhost is chosen for reasons that stop applying the moment a buffering reverse proxy sits in front of it.
You are going to pick a transport in an afternoon, on localhost, where every option works. Then the chatbot gets deployed behind a CDN, or embedded in a portfolio company’s app behind a corporate reverse proxy nobody on your team administers, and exactly one of your options still behaves.
The classic symptom is worth memorising because it is so specific: the stream works perfectly in development and, in production, the entire answer appears at once after a long pause — correct content, zero streaming. Nothing errors. The network panel shows a single successful response. It looks like your code broke, and your code is fine.
The four options, and what each one actually is
Server-sent events is a plain HTTP response with content type text/event-stream that never ends. MDN describes the format precisely: messages separated by a blank line, fields event, data, id and retry, and “a colon as the first character of a line is in essence a comment, and is ignored”. That comment line is not decoration — it is how you keep an idle connection alive, which matters for a reason we get to below.
Its headline property is reconnection. MDN: “By default, if the connection between the client and server closes, the connection is restarted.” The resumption mechanism is specified in WHATWG HTML: an id: field sets the object’s last event ID, and on reconnect the browser sets Last-Event-ID in the request header list. Note what that buys and what it does not: the browser will reconnect and tell the server where it got to. Whether the server can do anything useful with that is entirely your problem.
Chunked HTTP — a POST whose response body you read incrementally — is that thing. On HTTP/1.1 the framing is Transfer-Encoding: chunked, a hop-by-hop header where each chunk is length-prefixed in hex and the terminator is a zero-length chunk. On the client you read response.body, which MDN documents as a ReadableStream you can consume with a reader loop or with for await...of, integrated with an AbortSignal.
One thing to unlearn here, because it will save you an argument. MDN carries an explicit warning: “HTTP/2 disallows all uses of the Transfer-Encoding header… Usage of the header in HTTP/2 may likely result in a specific protocol error”. Streaming over HTTP/2 happens in DATA frames. “Chunked” and “streaming” are not synonyms, and a deploy guide telling you to set Transfer-Encoding: chunked is giving you HTTP/1.1 advice.
WebSocket starts as HTTP and stops being HTTP. The client sends a GET with Upgrade: websocket and a Sec-WebSocket-Key; the server answers 101 Switching Protocols, and after that the connection is a two-way frame pipe. That buys real bidirectionality, and costs you every HTTP affordance an intermediary knows how to handle. MDN’s protocol-upgrade page also notes that “HTTP/2 explicitly disallows the use of this mechanism and header; it is specific to HTTP/1.1”.
Two honest notes about WebSocket, stated the way the source states them. MDN documents no automatic reconnection — it does not claim there is none, it simply never mentions one, and its own guide demonstrates reconnecting by constructing a new socket by hand. That is the clean contrast with SSE, where MDN explicitly says the connection is restarted. And MDN states the WebSocket interface “doesn’t support backpressure”, naming WebSocketStream as the alternative that does. Read that as one more thing the transport hands you to solve yourself, of a piece with the reconnection it also does not do.
Polling is the option everyone dismisses and it is worth keeping on the table for exactly one reason: it is the only one of the four that consists entirely of ordinary short request/response pairs, so it works through infrastructure that mangles everything else. You pay for that with latency quantised to the poll interval, and with server state — the answer has to live somewhere between polls, which means you have already built two thirds of resumability.
The intermediary is the deciding factor
Here is the mechanism behind the all-at-once symptom, from nginx’s own module documentation — nginx documenting nginx, but this is the defining reference for the behaviour, not a summary of it:
Syntax: proxy_buffering on | off;
Default: proxy_buffering on;
Context: http, server, locationRead the default again. Buffering is on. The single most common reason a streaming endpoint arrives in one lump is a default-configured reverse proxy doing exactly what it is documented to do. The same page gives the per-response escape hatch: “Buffering can also be enabled or disabled by passing ‘yes’ or ‘no’ in the ‘X-Accel-Buffering’ response header field. This capability can be disabled using the proxy_ignore_headers directive.” So X-Accel-Buffering: no is a request to the operator’s proxy that the operator is free to have already ignored.
The second nginx default that bites is the read timeout: proxy_read_timeout defaults to 60 seconds, and “the timeout is set only between two successive read operations, not for the transmission of the whole response. If the proxied server does not transmit anything within this time, the connection is closed.” A stream can run for an hour, provided it writes something at least every sixty seconds. That is what the SSE comment line is for, and it is also why a model that goes quiet for ninety seconds during a long tool call will have its connection cut by a proxy that is behaving correctly.
Where people get burned
A claim you will meet constantly and should not repeat: “you must set gzip off for SSE.” The mechanism is plausible — a compressor accumulates input before emitting a block — but nginx’s own gzip module documentation says nothing about streaming or flushing at all, and its defaults make the advice mostly moot: gzip is off, gzip_types defaults to text/html so text/event-stream is not compressed anyway, and gzip_min_length is measured from Content-Length, which a chunked stream does not have. Compression can break streaming — Vercel’s own troubleshooting page blames “compressing proxy middleware” and tells you to set Content-Encoding: none — but cite the framework, not nginx, and do not present a mechanism as a documented default.
CDNs are their own layer of this. Cloudflare — a vendor documenting its own product — exposes a Response Body Buffering setting whose default, “Standard,” “allows Cloudflare products to inspect a prefix of the response body,” against “None: strictly no buffering”. Inspecting a prefix is enough to stall your first tokens, and its own changelog warns that setting it to None “may break security functionality that requires body inspection, including the Web Application Firewall (WAF) and Bot Management”. That is the shape of every one of these decisions: the buffering is not stupidity, it is a feature somebody is paying for, and turning it off has a price that is not yours to pay.
Not every intermediary is hostile, and it is worth knowing which are not. AWS CloudFront — also a vendor documenting itself — states that it “supports only the chunked value of the Transfer-Encoding header” and returns such a response “to the client as the object is received at the edge location”. The transport reference page collects the rest of this checklist.
What the library sets, and why you should look
The Vercel AI SDK — a vendor documenting its own product, and one whose API has moved fast enough that this course prefers teaching the wire — states that its data stream protocol “uses Server-Sent Events (SSE) format for improved standardization, keep-alive through ping, reconnect capabilities, and better cache handling”. Its shipped header constant reads as a checklist of every intermediary problem in this lesson:
export const UI_MESSAGE_STREAM_HEADERS = {
'content-type': 'text/event-stream',
'cache-control': 'no-cache',
connection: 'keep-alive',
'x-vercel-ai-ui-message-stream': 'v1',
'x-accel-buffering': 'no', // disable nginx buffering
}Two things about that. It is verified from the package source, not from a docs page — the documentation names only x-vercel-ai-ui-message-stream, so if you rely on the docs alone you will not know the nginx header is being set for you. And every line of it is defensive against something in this lesson. When you write a stream endpoint by hand, this is the list.
Check your recall
Answer from memory — no scrolling back.
Retrieval check
SSE gives you automatic reconnection and Last-Event-ID for free. Why is that not the same thing as resumability?
Check your answer
Because both halves of the mechanism are client-side. The browser reopens the connection and sends the last id: it saw. The server then has to be able to answer the question “what came after that event?” — which requires the generated output to exist somewhere other than the dead connection, keyed by something stable, for long enough to be replayed.
Nothing in SSE provides that. It provides the plumbing for a resume request and a place to put the cursor. Building the thing that answers it is an infrastructure decision, and it is the subject of the refresh-survival lesson later in the course.
Hands on
Put a proxy in front of it and watch it break
Done when: ARTIFACT.md’s transport fields are filled in, including a buffering-test row that records two observed behaviours — buffered and unbuffered — that you produced yourself, not two behaviours you expected.
- Confirm the baseline you recorded earlier: what actually sits between your chatbot and the browser in production. Read the response headers rather than trusting the deploy config. If the honest answer is “nothing,” you still do the rest of this locally — the point is to have seen the failure once, deliberately, rather than for the first time in someone else’s staging environment.
- Stand up an nginx in front of the app with a default
proxy_passand nothing else configured. Do not setproxy_buffering. Hit the streaming endpoint through it withcurl -Nand record what you see: the timestamps of the first and last bytes, and whether they differ. - Now fix it twice, separately, and record both. First from the server side, by setting
X-Accel-Buffering: noon the response and changing nothing in nginx. Then from the proxy side, by settingproxy_buffering off. Confirm each one independently restores incremental delivery. - Then break the escape hatch on purpose: add
proxy_ignore_headers X-Accel-Buffering;and observe that your server-side fix stops working while your code stays identical. That is the situation you are in inside somebody else’s infrastructure, and it is the reason the transport decision is not purely yours. - Write the transport decision into
ARTIFACT.md: which one, and — this is the part that matters — the specific reason each of the other three was rejected. “SSE because it is standard” is not a reason. “Chunked POST reading SSE-format frames, because the conversation has to go in the request body andEventSourcecannot carry one” is. - Fill in the last field honestly: what this transport cannot do. Every one of the four gives something up. Bring the filled section into the chat and I will argue the other side of whichever one you picked.
What this does not cover
This lesson decides how bytes reach the browser and stops there. It says nothing about what those bytes say — and the answer is not “text.” The raw-events lesson next opens an actual provider stream and reads the named event sequence Anthropic and OpenAI emit, because the boundaries in that sequence are the ones your UI needs and the ones an SDK abstraction most reliably hides.
The proxy material here is also deliberately only half the story. This lesson uses buffering to choose a transport — it is one of the four columns you score the options on. Making a chosen stream actually survive the layers in front of it, on infrastructure you were not consulted about, is its own job, and it belongs to the surviving-the-infrastructure lesson in the production module. Expect to meet nginx and Cloudflare again there, doing a different kind of work.
Two more threads opened here are picked up later. What the browser does with a connection that dies mid-answer belongs to the retry lesson; and Last-Event-ID being plumbing rather than a solution is where the refresh-survival lesson starts, which is where the infrastructure bill for resumability comes due.
Read this next — primary source
Using server-sent eventsMDN Web Docs — free, community-maintained, non-vendor
This lesson takes the wire format and the connection-limit numbers from it. Reading the whole page adds the two things a decision table cannot carry: a complete worked server implementation, so you can see how little there is to SSE once you stop treating it as a library feature, and the full field-parsing rules — multi-line data concatenation, the comment line, what happens to a line with no colon — which is exactly the detail you need when you write the parser yourself rather than using EventSource.
Stuck, curious, or think this lesson is wrong? Ask your teaching agent. The lessons are the scaffold; the conversation is where the learning gets unstuck.