TT Lab
Get started
Learn Learning paths Courses

SSE — How the Server Speaks First

When Does the First Byte Arrive

Continue in TT Lab

Goal

You will build the problem you actually hit with SSE — the code is correct, but everything appears on screen at once — yourself, and confirm it by timing.

Rules

Start the server

cd /root/work/sse
uvicorn app:app --host 127.0.0.1 --port 8000 > /tmp/uv.log 2>&1 &
curl -N -s localhost:8000/stream

If you leave out -N, curl buffers on its own side, so even if the server streams fine, it looks as if everything came out at once. The first thing to suspect when diagnosing is the measuring tool itself.

Measure the first byte

curl -N -s -o /dev/null -w '%{time_starttransfer}
' localhost:8000/stream

Steps

  1. /stream — 3 events at 0.3-second intervals
  2. Wire format → 02-wire.txt
  3. id: · retry:
  4. Resume with Last-Event-ID → 04-resume.txt
  5. /idle — comment heartbeat
  6. Reproduce /buffered → 06-timing.txt
  7. /long + finally → closed.log
  8. Wrap-up → 08-notes.md

Notes

If you do not end an event with a blank line (`

`), the client thinks it is not finished and keeps waiting. This is the number one cause of "nothing arrives."

Build a response that never ends

In /root/work/sse/app.py, create a FastAPI app and make GET /stream send 3 events as text/event-stream at 0.3-second intervals.

Run mkdir -p /root/work/sse. The app name must be app. StreamingResponse(gen(), media_type="text/event-stream") is enough (sse-starlette is also installed). Inside the generator, do await asyncio.sleep(0.3). To check, use curl -N localhost:8000/stream — -N turns off curl's buffering.

Match the wire format

Attach event: token and data: to each event, and end each event with a blank line. Save exactly what you received as 02-wire.txt.

One event is event: token\ndata: 안녕\n\n (the data value is the Korean word for "hello"). Forgetting the final blank line is the number one cause of "nothing arrives." To save: curl -N -s localhost:8000/stream > 02-wire.txt.

Give it an id and a retry

Attach an id: to each event, starting from 1, and send a retry: once at the very start of the stream.

The id is a value the browser remembers and sends back in the Last-Event-ID header on reconnection. retry: 3000 is the reconnection wait time in milliseconds.

Resume from what was missed

If the request has a Last-Event-ID header, make it send from the id after that one. Increase the total to 5 events, and save the result received with Last-Event-ID: 3 as 04-resume.txt.

Use request.headers.get("last-event-id") (look it up in lowercase). If it is absent, start from 1; if present, start from that value + 1. To check: curl -N -s -H 'Last-Event-ID: 3' localhost:8000/stream > 04-resume.txt — only ids 4 and 5 should appear. This is why SSE is cheaper to operate than WebSocket.

Keep an idle connection alive

Create GET /idle and make it send only comment heartbeats, with no events, 5 times at 0.2-second intervals.

: ping\n\n — a line starting with a colon is a comment, so the client doesn't treat it as an event. Only bytes flow, which gets past the load balancer's idle timeout (usually 60 seconds).

Reproduce buffering

Create GET /buffered. Wait the same amount as /stream (5 times, 0.3 seconds each), but build everything first and return it all at once. Measure the first-byte arrival time on both paths and save it as 06-timing.txt.

Instead of a generator, build a list and return it with Response(...). To measure, compare curl -N -s -o /dev/null -w '%{time_starttransfer}\n' localhost:8000/stream with the same command on /buffered. Streaming comes out under 0.3 seconds, and the buffered one around 1.5 seconds — the total time is the same, but the first byte differs. This is the true nature of the symptom "the code is correct, but everything appears on screen at once."

Notice the disconnect

Create GET /long so that it streams for a long time, and put a finally in the generator so that when the connection drops, it writes one line to /root/work/sse/closed.log.

Even when the client disconnects, the generator keeps running until it notices. If you don't put cleanup code in try: ... finally:, zombie tasks pile up on the server every time a tab is closed. To check: curl -N -s --max-time 1 localhost:8000/long > /dev/null; cat closed.log.

When SSE and when WebSocket

In 08-notes.md, write at least three lines: what the two numbers in step 6 mean, what Last-Event-ID does for you, and one case where you should pick WebSocket.

The text must contain 버퍼링, Last-Event-ID, and WebSocket (the first is the Korean word for "buffering"). You only need to remember one criterion — what gets recovered automatically when the connection drops.