Skip to content
academia.sh

Lesson 11 / 16

WebSocket

The upgrade handshake as a regime change: the intermediary falls into a tunnel after 101, all two hundred decisions move to the endpoints, none of the fields the frame carries yields a decision, and masking has a byte cost.

Contents

The previous lesson closed the transport envelope and counted the intermediary’s loss: one hundred sixty of the two hundred decisions moved out to the endpoints, leaving only the forty decisions derived from the host name. Despite the size of the loss, one thing had stayed in place. The connection was still shaped as request–response: the client sends a message, the server returns one, and the turn passes back to the client. Even though the intermediary could not see inside the message, it knew how many messages there were and where each one started and ended.

This lesson’s question is: if the connection’s shape changes too, what remains? WebSocket does exactly this. It starts as an ordinary HTTP request, switches to its own frame format with a single response, and from that point on the connection carries no request line, no header field, no status code. Either end can write to the other whenever it wants, over the same connection.

The Upgrade Handshake

The connection opens with a single exchange the intermediary can read. The client sends an ordinary GET request; what sets the request apart is the header fields declaring that the connection is about to be upgraded. This is called the upgrade handshake.

GET /akis HTTP/1.1
Host: station.example
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: <base64 of a sixteen-byte random value>
Sec-WebSocket-Version: 13

HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: <base64 of the hash of the key with the specification's constant>

The Sec-WebSocket-Accept value is derived from the client’s key together with a string fixed in the specification. This derivation has no confidentiality function; it only ties the response to an end that actually understood the upgrade, and it distinguishes an intermediate cache replaying an old response. The accept value’s computation and the server-side state it sets up were written in the Caching, Queues and Asynchronous Processing course, where what was measured was the state the server holds per connection. What is measured here is the decision the intermediary in between can take from the message.

The decisive line is 101 Switching Protocols. The 100 class is an interim response: the request is still in progress, no result yet. 101, instead, does not report the outcome of a process — it announces that a different protocol will now be spoken. After this line there is no HTTP on the connection.

The Intermediary’s Measure

The course’s setup is the same in this lesson too. The North Slope measurement station serves a reading to a client; between the two stands an intermediary that does not know the application and decides only from what is written in the message.

  • OP1. Forty exchanges and five decisions per exchange are measured: storable, shareable, fresh, repeatable, redirectable. The set is two hundred decisions.
  • OP2. The oracle — the truth behind the decision — is known because we produced the setup ourselves. The intermediary’s answer is compared against this truth; every exchange it has to ask about is a round debt.
  • OP3. In the open regime the intermediary reads the method, path, host name, the private flag and the validator. In the tunnel regime it reads none of them.
  • OP4. All forty exchanges are carried over a single WebSocket connection; the connection opens once and does not close.
  • OP5. The dumps and frame sizes are the setup; no connection is opened anywhere in the lesson.
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")
VISIBLE = {"open": ("method", "path", "host", "private_marker", "validator"),
           "tunnel": ()}


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(name: str) -> tuple:
    correct = wrong = unavailable = extra_round = total_bytes = 0
    for a in exchanges():
        truth, given = oracle(a), infer(message(a, VISIBLE[name]))
        missing = False
        for decision in DECISIONS:
            if decision not in given:
                unavailable, missing = unavailable + 1, True
            elif given[decision] == truth[decision]:
                correct += 1
            else:
                wrong += 1
        extra_round += 1 if missing else 0
        total_bytes += sum(len(k) + len(str(v)) + 4
                            for k, v in message(a, VISIBLE[name]).items())
    return correct, wrong, unavailable, extra_round, total_bytes


print(f"{'regime':<7s} {'correct':>7s} {'wrong':>6s} {'unavailable':>11s} "
      f"{'round debt':>10s} {'bytes':>6s}")
for name in ("open", "tunnel"):
    c, w, u, e, b = measure(name)
    print(f"{name:<7s} {c:7d} {w:6d} {u:11d} {e:10d} {b:6d}")
regime  correct  wrong unavailable round debt  bytes
open        195      5           0          0   3724
tunnel        0      0         200         40      0

Before the upgrade, the intermediary read all forty of the forty exchanges and took two hundred of the two hundred decisions on its own, with no extra round trip to the server. After the upgrade, the number of decisions left in its hands is zero, and all forty of the forty exchanges turn into a round debt.

The gap between the two rows is larger than the transport envelope’s gap. In the envelope regime the intermediary still saw the host name and could take the redirectable decision on all forty; the tunnel loses that one too. The reason is not encryption alone: after 101, no field that could feed the redirectable decision is ever visible on the connection again. The routing decision was taken once, at the moment the connection was set up, and it is frozen after that.

The Handshake Is the Last Message the Intermediary Reads

The upgrade handshake is not an exception — it is a boundary. Running the messages carried over the connection through the same decision function, one by one, shows this.

IDEMPOTENT = {"GET", "HEAD", "PUT", "DELETE"}
SAFE = {"GET", "HEAD"}


def infer(i: dict) -> dict:              # same decision function as the previous block
    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


CONNECTION = [
    ("upgrade request", {"method": "GET", "path": "/akis",
                          "host": "station.example", "upgrade": "websocket"}),
    ("101 response", {"upgrade": "websocket", "accept": "test-value"}),
    ("text frame", {"fin": 1, "opcode": 1, "masked": True, "length": 88}),
    ("close frame", {"fin": 1, "opcode": 8, "masked": True, "length": 2}),
]

print(f"{'message':<18s} {'fields':>6s} {'decisions':>9s}  decisions taken")
for name, m in CONNECTION:
    k = infer(m)
    print(f"{name:<18s} {len(m):6d} {len(k):9d}  {', '.join(k) or '-'}")
message            fields decisions  decisions taken
upgrade request         4         2  repeatable, redirectable
101 response            2         0  -
text frame              4         0  -
close frame             4         0  -

The upgrade request gives two of the five decisions: repeatable from the method field, redirectable from the host field. The remaining three need fields from the response, and that response never comes — what comes back is 101, and it carries neither the private flag nor the validator. These two decisions are the only ones the intermediary holds for the entire life of the connection.

The Frame Is Visible, Yet No Decision Follows

In the table above, the frames carry four fields and still produce zero decisions. This says the loss does not come from encryption alone. The frame’s layout is fixed in the specification, and every field it carries is about the transport, not about the message.

 0                   1                   2                   3
 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-------+-+-------------+-------------------------------+
|F|R|R|R| opcode|M|    length   |  extended payload length      |
|I|S|S|S| (4bit)|A|    (7 bit)  |  if the length field is 126   |
|N|V|V|V|       |S|             |                               |
| |1|2|3|       |K|             |                               |
+-+-+-+-+-------+-+-------------+-------------------------------+
|             masking key (32 bit, if the mask bit is 1)         |
+---------------------------------------------------------------+
|                        payload data                            |
+---------------------------------------------------------------+

Framing is the rule that splits a byte stream into message boundaries. The length field is seven bits; payloads up to 125 are written directly, a value of 126 declares that a two-byte length field follows, and 127 declares an eight-byte one. When the mask bit is set, a four-byte key is added and the payload is folded with it. The mask is mandatory in the client direction.

OPCODES = {0: "continuation", 1: "text", 2: "binary", 8: "close", 9: "ping",
           10: "pong"}


def header_size(payload: int, masked: bool) -> int:
    n = 2                                  # FIN, RSV, opcode, mask bit, length
    n += 8 if payload > 65535 else 2 if payload > 125 else 0   # extended length
    n += 4 if masked else 0                # masking key only in the client direction
    return n


print("opcodes:", ", ".join(f"{k}={v}" for k, v in OPCODES.items()))
print()
print(f"{'payload':>7s} {'direction':<15s} {'header':>7s} {'total':>7s} {'header share':>13s}")
for payload, masked in ((2, True), (93, True), (125, True), (126, True),
                        (93, False), (70000, False)):
    h = header_size(payload, masked)
    direction = "client to server" if masked else "server to client"
    print(f"{payload:7d} {direction:<15s} {h:7d} {h + payload:7d} {h / (h + payload):13.3%}")
opcodes: 0=continuation, 1=text, 2=binary, 8=close, 9=ping, 10=pong

payload direction        header   total  header share
      2 client to server       6       8       75.000%
     93 client to server       6      99        6.061%
    125 client to server       6     131        4.580%
    126 client to server       8     134        5.970%
     93 server to client       2      95        2.105%
  70000 server to client      10   70010        0.014%

In the open regime, the 3724 bytes the intermediary reads spread over forty exchanges come to 93 bytes per exchange; the frame header wraps the same payload, in the server direction, in two bytes. The gain is real and shows up clearly on small messages. But its counterpart is measured just as precisely: 93 bytes buy two hundred decisions, two bytes buy zero.

This is the course’s second claim read in reverse. There, adding a field bought no decision: three more fields, more bytes, zero new decisions. Here the same thing shows up the other way around — cutting bytes does not cost a decision either; what costs a decision is the field itself disappearing. There is no link between bytes and decisions; the link is between fields and decisions.

The One Lever Left in the Intermediary’s Hand

The intermediary cannot take decisions, but it keeps carrying the connection, and it retains exactly one authority over what it carries: closing it. Cutting an idle connection after a fixed span — the idle timeout — is the ordinary rule an intermediary reaches for to bound the number of connections it keeps open. In the open regime this rule is harmless; in request–response shape the connection is already idle between messages and can be reopened. In the tunnel, the connection itself is the carrier of state: cutting it takes with it whatever the two ends had agreed on.

The specification answers this with two control frames. The ping and pong opcodes carry no data; their only job is to move bytes over the connection and confirm that both ends are still up. They tell the intermediary nothing — as measured in the table, they produce no decision — they only reset the intermediary’s idle counter. That the intermediary’s decision count from the message stays zero while a design constraint on the connection still follows from it is this lesson’s quiet result.

One more field can be carried in the upgrade request: the Sec-WebSocket-Protocol header names the rule by which the frames will be interpreted, and the server repeats the name it chose in the response. This name is the only meaning-related mark the intermediary will see on the connection, and it too passes only once, in the opening exchange. After that, the intermediary carries a stream whose name it knows but whose content it cannot read.

Where the Decisions Move

Two hundred decisions do not vanish, they move. In the tunnel regime each one now has to be taken at an endpoint, and that means work written into the application.

Decision Whose in the open regime Whose in the tunnel What it requires at the endpoint
repeatable the intermediary the sending end a message identifier and a retry rule
storable the intermediary the receiving end an in-application copy and invalidation
shareable the intermediary the server writing the scope into the frame
fresh the intermediary the receiving end a version or counter field
redirectable the intermediary the connection setup once at opening, then fixed

The table’s right column is what an application using WebSocket has to rewrite. In HTTP, all of these decisions were defined inside the specification, and any intermediary along the path could apply them. In the frame format, the only thing defined is the transport; the fields that carry meaning are agreed between the two ends themselves.

It is also worth recording that this is not a defect. In the measurement table, the tunnel row’s wrong column is zero. The intermediary takes no wrong decision, because it takes no decision at all. The open regime’s five wrong decisions were the price of trusting a declaration; the tunnel does not pay that price either. The choice is between taking the decision cheaply on the path, or taking it fully but expensively at the endpoint.

Summary

  • The upgrade handshake is an ordinary HTTP exchange; the 101 Switching Protocols line does not report an outcome, it announces that a different protocol will now be spoken on the connection.
  • The same forty exchanges are measured with 195 correct and 5 wrong decisions and no round debt in the open regime, while in the tunnel regime all 200 of the 200 decisions are unavailable and forty round debts arise.
  • The only message the intermediary can read for the life of the connection is the upgrade request, and it gives only two of the five decisions; neither the 101 response nor any of the frames produces a decision.
  • The frame header runs from two to fourteen bytes and the fields it carries — FIN, opcode, mask, length — are about the transport; cutting bytes does not cost a decision, the field disappearing does.
  • The two hundred carried decisions turn into work for the endpoints; in return, the number of wrong decisions taken in the tunnel regime is zero.

Next Step

The tunnel moved all two hundred decisions to the endpoints because it cuts the intermediary off from the message entirely. But is changing the connection’s shape actually required for the server to send data to the client on its own? The response’s body does not have to be written in one go: it can be left open and written to piece by piece, and that stays within HTTP’s own rules. The next lesson measures this option — how many decisions the intermediary keeps when it can still read the message, and what being able to read it actually buys.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close