Lesson 12 / 16
Server-Sent Events
What staying HTTP leaves the intermediary: the five decisions the head message carries, the decision point thinning out as event count rises, the wrong readings that a once-taken decision produces when applied to the whole body, and how long a buffering intermediary delays events.
Contents
The previous lesson counted the tunnel’s price: after the upgrade the intermediary loses all two hundred decisions, and all forty of the forty exchanges turn into a round debt. But changing the connection’s shape is not actually required for the server to send data to the client on its own. Nothing anywhere requires an HTTP response’s body to be written in one go: the body can be left open and written to piece by piece.
Server-Sent Events (SSE) ties this option to a fixed form. A single response to a single GET request is left open, and the server writes events to it in sequence. This lesson’s measure is: because the stream stays HTTP, the intermediary can still read the message — so how many decisions are left in its hands, and what does it pay in return for being able to read it?
The Stream Response
The request is ordinary. So is the response; the only things that set it apart are the media type and the fact that the body never ends.
GET /akis/olcum HTTP/1.1
Host: station.example
Accept: text/event-stream
HTTP/1.1 200 OK
Content-Type: text/event-stream
Cache-Control: no-store
Transfer-Encoding: chunked
event: olcum
id: 17
data: {"istasyon": "kuzey-yamac", "deger": 3}
: a comment line produces no event
event: olcum
id: 18
data: {"istasyon": "kuzey-yamac", "deger": 5}
The body is line-based: field lines build an event, a blank line closes it, and a line starting
with a colon is a comment. Because the length cannot be known in advance, the body ends with
chunked transfer or with the connection closing. The detail of the field format, reconnection
based on the id field, and the cost the server side pays per connection were measured in the
Caching, Queues and Asynchronous Processing course. What is measured here is the decision the
intermediary in between can take from this message.
The Decision Point
- OP6. The forty exchanges carried over a single connection in the previous lesson are carried
here in a single stream’s body.
eventis the number of exchanges written into a single stream’s body;event = 1is the request–response shape where every exchange opens its own request. - OP7. The intermediary takes its decision from the stream’s head message. Events written into the body do not carry their own request line or header fields; they are invisible to the intermediary.
- OP8. The
scope wrongcolumn is a second reading: it counts how many decisions come out wrong when the decision taken from the head message is applied to every event in the body. - OP9. The last row is the tunnel regime, where no field of the message can be read at all; it is included here for comparison with the previous lesson’s measurement.
SEED = 20260809 SAFE = {"GET", "HEAD"} IDEMPOTENT = {"GET", "HEAD", "PUT", "DELETE"} METHOD_POOL = ["GET", "GET", "GET", "HEAD", "POST", "PUT", "DELETE"] PATH_POOL = ["/olcum/kuzey", "/olcum/yamac", "/ozet", "/kayit", "/oturum"] DECISIONS = ("storable", "shareable", "fresh", "repeatable", "redirectable") OPEN = ("method", "path", "host", "private_marker", "validator") def generator(seed: int): d = seed % 2147483646 + 1 def r(n: int) -> int: nonlocal d d = (d * 48271) % 2147483647 return d % n return r def exchanges(count: int = 40) -> list[dict]: r, out = generator(SEED), [] for i in range(count): method, path = METHOD_POOL[r(7)], PATH_POOL[r(5)] private = path == "/oturum" or r(5) == 0 stale = r(3) == 0 out.append({"no": i + 1, "method": method, "path": path, "private": private, "stale": stale, "private_marker": private and r(4) != 0, "tag_missed": stale and r(6) == 0}) return out def oracle(a: dict) -> dict: return {"storable": a["method"] in SAFE and not a["private"], "shareable": not a["private"], "fresh": not a["stale"], "repeatable": a["method"] in IDEMPOTENT, "redirectable": True} def message(a: dict, visible: tuple) -> dict: full = {"method": a["method"], "path": a["path"], "host": "station.example", "private_marker": a["private_marker"], "validator": a["stale"] and not a["tag_missed"]} return {k: v for k, v in full.items() if k in visible} def infer(i: dict) -> dict: k = {} if "method" in i: k["repeatable"] = i["method"] in IDEMPOTENT if "private_marker" in i: k["shareable"] = not i["private_marker"] if "method" in i: k["storable"] = i["method"] in SAFE and not i["private_marker"] if "validator" in i: k["fresh"] = not i["validator"] if "host" in i: k["redirectable"] = True return k def measure_stream(event: int, visible: tuple = OPEN) -> tuple: out = exchanges() points = correct = wrong = unavailable = extra_round = total_bytes = scope_wrong = 0 for i, a in enumerate(out): m = message(out[i - i % event], visible) # fields of the message that opens the stream head, truth = infer(m), oracle(a) scope_wrong += sum(head[k] != truth[k] for k in head) given = head if i % event == 0 else {} # decision taken only at stream start correct += sum(given[k] == truth[k] for k in given) wrong += sum(given[k] != truth[k] for k in given) unavailable += len(DECISIONS) - len(given) extra_round += 1 if len(given) < len(DECISIONS) else 0 if i % event == 0: points += 1 total_bytes += sum(len(k) + len(str(v)) + 4 for k, v in m.items()) return points, correct, wrong, unavailable, extra_round, total_bytes, scope_wrong fmt = "{:>5s} {:>6d} {:>6d} {:>7d} {:>11d} {:>4d} {:>6d} {:>14d}" print(f"{'event':>5s} {'points':>6s} {'correct':>6s} {'wrong':>7s} " f"{'unavailable':>11s} {'round':>4s} {'bytes':>6s} {'scope wrong':>14s}") for event in (1, 2, 4, 8, 20, 40): print(fmt.format(str(event), *measure_stream(event))) print(fmt.format("-", *measure_stream(40, visible=()))) out = exchanges() head = infer(message(out[0], OPEN)) print() print("under event=40 scope, wrong per decision (across 40 events):") for k in DECISIONS: w = sum(head[k] != oracle(a)[k] for a in out) print(f" {k:<13s} value at stream start {str(head[k]):<5s} wrong {w:2d}/40")
event points correct wrong unavailable round bytes scope wrong
1 40 195 5 0 0 3724 5
2 20 97 3 100 20 1847 37
4 10 47 3 150 30 929 45
8 5 23 2 175 35 461 57
20 2 9 1 190 38 189 71
40 1 4 1 195 39 92 73
- 1 0 0 200 40 0 0
under event=40 scope, wrong per decision (across 40 events):
storable value at stream start False wrong 13/40
shareable value at stream start True wrong 17/40
fresh value at stream start True wrong 12/40
repeatable value at stream start False wrong 31/40
redirectable value at stream start True wrong 0/40
The first row is the request–response shape, and it is exactly the open regime the course measures: 195 correct, 5 wrong, no unavailable decision, no round debt, 3724 bytes. The last row is the previous lesson’s tunnel regime: 0 decisions, 200 unavailable, 40 round debts.
The rows in between show what a stream does. When forty events are written into a single body, the intermediary stays at one decision point and has five decisions in hand. That is five more than the tunnel’s zero, and the gap comes from exactly one place: the response that opens the stream is a readable HTTP response. In the previous lesson the 101 response gave zero decisions because it carried neither the private flag nor the validator; here the response is an ordinary 200 and it feeds all five of the five decisions.
The drop in bytes comes from the same place. What brings 3724 bytes down to 92 is that thirty-nine of the forty messages’ header fields are never sent at all. The field is gone, and so is the decision — this runs in the same direction as the previous lesson’s result. Bytes and decisions drop together, because the field is what carries both.
The Scope of a Decision
The decision point thinning out is not the whole loss. An intermediary does not forget the decision it took at the start of the stream — it applies it to the whole body. If it counted the response cacheable, it caches the whole body; if it counted it fresh, it treats the whole body as fresh.
The scope wrong column measures this. At event = 1 the head message and the event are the same
thing, and the column gives the same five wrong decisions. At event = 40 a single decision set is
applied to forty events, and 73 of the two hundred decisions come out wrong. The decision
point has not gone from one to forty; the decision’s scope has spread from one to forty.
The breakdown says where the 73 comes from. redirectable is never wrong, because its value
is the same on every exchange; a widening scope does not break a decision that does not change.
repeatable is wrong 31 times: the head message is a POST and its decision came out False,
yet most of the events in the body belong to safe methods. The remaining three — storable 13,
shareable 17, fresh 12 — come from personalization and staleness changing from event to
event. The rule is this: a widening scope only breaks decisions that change from exchange to
exchange.
This is the stream’s quietest cost. Measured at the decision point, the intermediary has only one wrong decision, and that is fewer than the open regime’s five. Measured across the same stream’s scope, the wrong count rises from five to 73. Given the set’s resolution of 1/200 = 0.0050, this gap sits well above the measurable band, and it does not come from a reading error — it comes from how long the decision is treated as valid. This is the reason a stream response is closed off from caching.
Reconnection Is a Decision Point
The table’s event column answers one more question. A stream never stays up forever; the
connection breaks, the client reconnects. Reconnecting is a new request, and a new request is
a new decision point. A stream that breaks once every twenty events is the table’s event = 20
row: two decision points, ten decisions. A stream that breaks every eight events gives five points
and twenty-five decisions. What the intermediary sees is inversely proportional to how stable the
stream is.
One consequence of this is routing. The redirectable decision is taken once, at the start of
the stream, and frozen for the rest of it. In request–response shape the intermediary can route
each of the forty exchanges separately; when an upstream resource does not answer, it sends the
next request somewhere else. A single stream has no such flexibility: the choice is made once, and
the only way to change it is to break the stream. Breaking it also produces a new decision point,
so the intermediary can only fix its routing once the client reconnects.
The field that makes breaking cheap — the client attaching the last event id it saw to the new request — produces no decision at all in this table. The intermediary reads that field but has nothing to derive from it; the field is a contract between the two ends. This is another instance of the course’s fourth reading: a field existing does not mean the intermediary can derive a decision from it.
Buffering
Because the intermediary can read the message, it also sees the body, and it makes a choice about what it sees: relay the bytes immediately, or hold them and send in a batch. Batching is an ordinary efficiency rule; on a body that never ends, it delays events.
- OP10. The intermediary relays no byte from the body until the buffer reaches the threshold. A threshold of 0 means no buffering. Event texts are produced in the specification’s line format; the values are the setup.
def event_text(no: int, value: int) -> str: """The specification's line format: field lines and the blank line that closes the event.""" return (f"event: olcum\nid: {no}\n" f'data: {{"istasyon": "kuzey-yamac", "deger": {value}}}\n\n') EVENTS = [event_text(i + 1, i * 3 % 97) for i in range(40)] def buffer(threshold: int) -> tuple: buffered, group, waits = 0, [], [] for i, m in enumerate(EVENTS): buffered += len(m) group.append(i) if buffered >= threshold: waits.extend(i - j for j in group) # how many events each event waited buffered, group = 0, [] average = sum(waits) / len(waits) if waits else 0.0 return len(waits), max(waits, default=0), average, len(group) print(f"event count {len(EVENTS)}, event size " f"{min(len(m) for m in EVENTS)}-{max(len(m) for m in EVENTS)} bytes, " f"body {sum(len(m) for m in EVENTS)} bytes") print() print(f"{'threshold':>9s} {'delivered':>9s} {'max wait':>15s} " f"{'average wait':>17s} {'held at end':>14s}") for threshold in (0, 128, 256, 1024, 4096): delivered, worst, average, held = buffer(threshold) w = str(worst) if delivered else "-" a = f"{average:.2f}" if delivered else "-" print(f"{threshold:9d} {delivered:9d} {w:>15s} {a:>17s} {held:14d}")
event count 40, event size 66-68 bytes, body 2704 bytes
threshold delivered max wait average wait held at end
0 40 0 0.00 0
128 40 1 0.50 0
256 40 3 1.50 0
1024 32 15 7.50 8
4096 0 - - 40
Two readings apply. In the first three rows, delay grows with the threshold: a 256-byte buffer delays events by 1.5 events on average. The last row is a different thing. The stream produces 2704 bytes of total body across forty events, and a 4096-byte threshold is larger than the whole body; the intermediary relays no byte at all, and until the stream closes it has never existed for the client.
The critical point is that the intermediary is not doing anything wrong. Batching a response is
correct behavior for a body that ends. What it does not know is that this body will not end, and
the only field that tells it so is the media type. An intermediary that does not recognize the
text/event-stream value has no other marker at hand: the body length is not declared, the method
is GET, the status code is 200, and none of these says “this body will not end.” The precaution the
server side takes with a heartbeat line closes the same gap from the other end, and it was measured
within that course.
One Direction
The stream is one-way: server to client. When the client has something to tell the server, it cannot write into the stream — it opens an ordinary request instead. This is a detail that does not look like a loss in the measurement table, but its consequence is direct: every message coming from the client is a decision point again. On the stream side the decision point has dropped to one; every request the client writes to the server is a readable exchange like the table’s first row, and it brings its own decision point back.
The tunnel does not even have this. Every two-way message there is in the same frame format, and none of them produces a decision. The gap also shows what the choice is made on: if two-way communication is genuinely needed, the tunnel; if only server-side streaming is needed, SSE. The cost of direction is measurable in decisions.
Summary
- Server-Sent Events stays inside HTTP: the request is an ordinary GET, the response is an ordinary 200, and the body is left open and written to with events.
- When forty events are carried in a single stream, the intermediary’s decision point drops to one and it keeps five decisions in hand; in the tunnel regime this count is zero, and the gap comes from the response that opens the stream being readable.
- The decision taken from the head message is applied to the whole body; the wrong count measured at the decision point is 1, while the wrong count measured across the scope is 73 of two hundred decisions.
- Bytes drop from 3724 to 92 while decisions drop from 200 to 5; what carries both is the field.
- A buffering intermediary delays events by 1.5 events on average at a 256-byte threshold; against a 2704-byte body, a 4096-byte threshold delivers no event at all, and the only field that says the body will not end is the media type.
Next Step
Both the stream and the tunnel work by keeping the connection open: one never finishes the body, the other changes the protocol. Both extend the connection’s lifetime to free the server’s hand. But how far can one get without doing either — without adding a single new field to the protocol — just by asking more often? The next lesson measures this: short polling, long polling and server streaming are set side by side over the same event set, and what divides them is not the event, it is the decision.
To keep your progress and take notes, Log in
My notes
Log in to take notes.