Skip to content
academia.sh

Lesson 05 / 16

HTTP Versions

The same fields are measured across three framing regimes: the text line takes 3724 bytes and 251 blocking units, the binary frame that keeps context gives the same outcome on all 200 decisions for 440 bytes, and in the binary frame with no context 189 decisions move to the endpoints.

Contents

A cookie is a header field, and it is resent on every request within its scope. So is the host name, so is the method, so are the preferences the client declares. In a forty-exchange session, the same strings cross the wire forty times, written out from scratch each time.

This repetition is the result of a design decision: an HTTP message is built from text lines, and a text line is self-sufficient — the parser reads it, finds the colon, separates name from value. The difference between versions is this lesson’s subject, and it is not in meaning but in framing: which fields travel does not change, how they get packed onto the wire does. Our question — what does changing the packing buy the intermediary in between, and what does it cost?

Same Meaning, Different Framing

HTTP/1.1 writes a message line by line. The request line carries the method and path, each following line is a header field, and a blank line announces that the body starts. HTTP/2 and HTTP/3 put the same fields into binary frames: every frame has a type, a length, a stream identifier, and flags; header fields are compressed with a separate encoding.

# teaching dump, not run

# text-line framing
GET /olcum/kuzey HTTP/1.1
Host: station.example
Cookie: oturum=k7
Accept: text/csv
Connection: keep-alive

# binary framing, same fields
FRAME type=HEADERS stream=7 flags=END_HEADERS
  :method     -> index
  :path       -> literal  /olcum/kuzey
  :authority  -> index
  cookie      -> index
  accept      -> index
FRAME type=DATA stream=7 flags=END_STREAM

The two forms say the same thing. Method, path, host, and the two preference fields appear in both; :method and :path are the counterparts of the text-form request line. Three things change: fields travel in frames, not lines, frames carry a stream identifier, and header fields can be sent by index.

The stream identifier makes multiplexing possible: more than one exchange can advance interleaved over a single connection, because every frame carries which stream it belongs to. Text-line framing has no such field; requests on one connection can only proceed one after another, and this holds for a persistent connection too — the connection stays open, but order is preserved.

HTTP/3 changes not the framing but the transport. The frame layout stays similar; the transport layer underneath offers independent streams instead of a single ordered byte stream. Why this matters is the next section’s subject.

Head-of-Line Blocking in Two Layers

Head-of-line blocking is what happens when the item at the front of a queue cannot advance, so everything behind it waits too. HTTP has two separate forms of this, and confusing them makes the difference across versions invisible.

The application-layer form: if requests on one connection are sequential, a slow response holds up every exchange behind it. The slowness does not come from the network — the server is assembling that response from more than one record. Multiplexing removes this form.

The transport-layer form: if the transport layer delivers in order, a lost segment holds up everything that follows it, regardless of which stream it belongs to, because the transport layer does not know about streams. Multiplexing does not remove this form; it only moves it down from the layer above. What removes it is the transport itself becoming stream-aware.

This distinction is a direct consequence of the byte-stream abstraction established in the Network Models and Protocols course: once an ordered, lossless stream is promised, keeping that promise requires a queue that waits. The introductory intuition from How the Internet Works ties to a number here.

Blocking carries a separate meaning for the intermediary, which does not only read a message but forwards it — that too is a queue. If application order is preserved, the intermediary cannot send a response it holds before its turn comes, and it holds up everything behind it while waiting for one response. What multiplexing removes is not the server’s queue but the intermediary’s.

Header Compression and the Intermediary’s Context

Binary framing sends header fields by index: a name–value table is kept for the connection’s duration, and when a pair recurs, what is sent is not its name and value but its position in the table. The gain is large, since fields like the host name and the method almost never change.

The price: an index is meaningless to whoever does not keep the table. An intermediary present since the connection began builds the same table and resolves every field. One that joined later, or forwards frames without decoding them, can read only the fields sent literally.

  • HA46 — The same forty exchanges, five fields, and oracle are used; only how the fields are written on the wire changes.
  • HA47 — In text-line framing, every field is written out from scratch in every message: name, value, separator.
  • HA48 — In binary framing, a name–value pair is written literally the first time it appears and enters the table; later occurrences send a 1-byte index.
  • HA49 — An intermediary that keeps the table resolves every field. One that does not reads only fields sent literally; an indexed field does not exist for it.
  • HA50 — The exchange slow on the application side is /ozet; the server assembles that response from more than one record.
  • HA51 — The exchange that loses data in transport is chosen by a separate generator, with a single modulus.
  • HA52 — A blocking unit is a count, not a duration: an exchange waits one unit behind every blocker ahead of it in the same queue.
  • HA53 — In parallel connections, exchanges are distributed to connections in turn, and each connection has its own table.
  • HA54 — The measurement is not a network measurement; the byte count is a count of the message’s content.

The Measurement

"""HTTP versions: what framing buys the intermediary, and what it costs.

Part 1 - the same fields across three framing regimes: decisions and bytes.
Part 2 - head-of-line blocking and multiplexing: blocked exchanges.
"""
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")
FIELDS = ("method", "path", "host", "private_marker", "validator")


def generator(seed):
    d = seed % 2147483646 + 1

    def r(n):
        nonlocal d
        d = (d * 48271) % 2147483647
        return d % n
    return r


def exchanges(count=40):
    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({"num": 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):
    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):
    return {"method": a["method"], "path": a["path"], "host": "station.example",
            "private_marker": a["private_marker"],
            "validator": a["stale"] and not a["tag_missed"]}


def infer(i):
    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 frame(batch, connections=1, compress=True):
    """Turns each message into a (bytes, literal-fields) pair.
    A name-value pair already seen on the same connection is sent as an
    index: it costs 1 byte, and an intermediary with no table cannot decode it."""
    table, out = [set() for _ in range(connections)], []
    for i, a in enumerate(batch):
        t, b, literal = table[i % connections], 0, set()
        for k, v in message(a).items():
            if not compress or (k, v) not in t:
                b += len(k) + len(str(v)) + 4
                literal.add(k)
                t.add((k, v))
            else:
                b += 1
        out.append((b, literal))
    return out


def measure(batch, frames, context):
    d = y = e = t = b = 0
    for a, (byte_cost, literal) in zip(batch, frames):
        visible = set(FIELDS) if context else literal
        truth = oracle(a)
        given = infer({k: v for k, v in message(a).items() if k in visible})
        missing = False
        for decision in DECISIONS:
            if decision not in given:
                e += 1
                missing = True
            elif given[decision] == truth[decision]:
                d += 1
            else:
                y += 1
        t += 1 if missing else 0
        b += byte_cost
    return d, y, e, t, b


def blocking_units(batch, blocked, connections=1):
    """An exchange waits one unit behind every blocker ahead of it in the
    same queue."""
    prior, total = [0] * connections, 0
    for i, blk in enumerate(blocked):
        total += prior[i % connections]
        prior[i % connections] += 1 if blk else 0
    return total


batch = exchanges()
loss_gen = generator(SEED + 1)
SLOW = [a["path"] == "/ozet" for a in batch]
LOST = [loss_gen(5) == 0 for _ in batch]
BOTH = [s or l for s, l in zip(SLOW, LOST)]
print(f"exchanges {len(batch)} | decisions {len(batch) * len(DECISIONS)} | "
      f"assembled response {sum(SLOW)} | lost in transport {sum(LOST)}")
print()
print("framing regime            correct  wrong  unavailable  extra round  bytes")
for name, frames, context in (
        ("text line", frame(batch, 1, False), True),
        ("binary, context kept", frame(batch), True),
        ("binary, no context", frame(batch), False)):
    d, y, e, t, b = measure(batch, frames, context)
    print(f"  {name:23s} {d:5d} {y:7d} {e:11d} {t:13d} {b:6d}")
print()
print("framing regime            app order  transport order  blocked exchanges")
for name, app_order, transport_order in (("text line, single connection", True, True),
                                          ("binary, single transport stream", False, True),
                                          ("binary, separate transport streams", False, False)):
    blocked = BOTH if app_order and transport_order else LOST if transport_order else [False] * len(batch)
    print(f"  {name:33s} {'yes' if app_order else 'no':>9s}"
          f" {'yes' if transport_order else 'no':>16s} {blocking_units(batch, blocked):18d}")
print()
print("parallel connections  blocked exchanges  literal fields   bytes")
for n in (1, 2, 6):
    c = frame(batch, n)
    print(f"  {n:19d} {blocking_units(batch, BOTH, n):19d}"
          f" {sum(len(s) for _, s in c):15d} {sum(b for b, _ in c):6d}")
exchanges 40 | decisions 200 | assembled response 9 | lost in transport 7

framing regime            correct  wrong  unavailable  extra round  bytes
  text line                 195       5           0             0   3724
  binary, context kept      195       5           0             0    440
  binary, no context         10       1         189            39    440

framing regime            app order  transport order  blocked exchanges
  text line, single connection            yes              yes                251
  binary, single transport stream          no              yes                 83
  binary, separate transport streams        no               no                  0

parallel connections  blocked exchanges  literal fields   bytes
                    1                 251              15    440
                    2                 121              29    661
                    6                  36              74   1397

The Gain: Same Decision, an Eighth of the Bytes

The first table’s first two rows should be read side by side. Text-line framing gives 195 correct, 5 wrong, 0 unavailable, and spends 3724 bytes. Binary framing that keeps context gives the same three numbers and spends 440 bytes. The decision table does not change; bytes drop 88.2%.

This is the shared setup’s second claim read backwards. Adding a field did not buy a decision; now it turns out that making a field cheaper does not cost one either. As long as the intermediary can resolve a field, its byte cost is unrelated to the decision. Two hundred field occurrences pass through the forty exchanges, and only 15 are a distinct name–value pair; the remaining 185 repeat the same fifteen pairs. This is exactly where compression gains ground.

The second table gives the second gain. Text-line framing on a single connection produces 251 blocking units in total: nine assembled responses and seven losses each hold up every exchange behind them. Once multiplexing removes application order, blocking drops to 83 — all of it now from the transport layer. Once the transport separates streams too, it is 0.

The order of the three rows matters: multiplexing does not zero out blocking, it drops it to a third; what zeros it out is the transport changing. The rest of the problem was sitting one layer below the one framing solved.

The Loss: An Unresolvable Field Is Ignored

The third row is this lesson’s real finding. Bytes stay the same 440, but the decision table collapses: 10 correct, 1 wrong, 189 unavailable, 39 extra round trips.

The reason: across forty messages, only 15 fields are written literally; the remaining 185 are sent by index. An intermediary with no table cannot read those 185 fields, and it cannot draw a decision from a field it cannot read. 189 of the two hundred decisions move from the middle to the endpoints — more even than the 160 the envelope regime carried. The fields sit there in the message; they do not exist for the intermediary.

The remaining eleven decisions come from the first exchange and the first few occurrences where the table gets built. The single wrong decision sits there too: the first exchange is POST /kayit and is personalized, but the server did not set the flag. The intermediary can read the field in that message, so it decides, and decides wrong. The pattern does not change — a readable field can be wrong; an unreadable one is only missing.

The rule that follows: compression is a visibility decision. An intermediary present from the connection’s start loses nothing; one that joins later, or forwards frames without decoding them, loses almost everything. Text-line framing had no such split, because every message was self-sufficient.

Parallel Connections: Another Price for the Same Problem

The third table measures the pre-multiplexing fix: opening more than one connection to the same host. Blocking genuinely drops — from 251 to 121 on two connections, to 36 on six.

The price sits in the two columns to the right. Every connection has its own header table; as exchanges split across connections, the same name–value pairs get written literally again on each one. The count of literal fields rises from 15 to 29, then to 74; bytes rise from 440 to 661, then to 1397. Blocking dropping to a seventh on six connections is paid for by bytes tripling.

This is where multiplexing’s difference shows: on one connection, it both pushes blocking down to the transport layer and keeps the table in one piece. Parallel connections trade one cost for the other instead of removing either.

Summary

  • The difference between versions is not in meaning but in framing: the same fields are written as a text line or as a binary frame with a stream identifier.
  • Binary framing that keeps context gives the same 195 correct and 5 wrong decisions for 440 bytes instead of 3724; only 15 of the 200 field occurrences across forty exchanges are a distinct name–value pair.
  • Head-of-line blocking has two forms; multiplexing removes the application-layer one and drops blocking from 251 to 83, and only the transport gaining stream awareness drops the transport-layer form to 0.
  • An intermediary with no context reads only fields written literally: 189 decisions move to the endpoints, a debt of 39 round trips is created, and bytes do not change at all.
  • Parallel connections drop blocking to 36 but split the table; literal fields rise to 74, bytes to 1397.

Next Step

Framing determines whether the intermediary can read a field. But even when it can read one, there is a decision that comes not from the field itself but from comparing two values: does the copy on hand say the same thing as the message that just arrived? The next lesson measures that comparison. Which field tells the intermediary whether its stored copy is still valid, what that field’s resolution is, and what the intermediary says when the resolution falls short.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close