Skip to content
academia.sh

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. event is the number of exchanges written into a single stream’s body; event = 1 is 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 wrong column 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.

Start typing to search.

↑↓ Esc navigate · open · close