---
title: WebSocket
source: 'https://academia.sh/en/courses/application-protocols/websocket'
course: 'Application Layer Protocols'
language: en
updated: '2026-08-17T18:07:02+00:00'
license: 'CC BY-SA 4.0'
---

# 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.

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**.

```text
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.

```python
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.

```python
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.

```text
 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.

```python
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.
