---
title: 'Long and Short Polling'
source: 'https://academia.sh/en/courses/application-protocols/long-and-short-polling'
course: 'Application Layer Protocols'
language: en
updated: '2026-08-17T18:07:01+00:00'
license: 'CC BY-SA 4.0'
---

# Long and Short Polling

Real-time behavior faked by buying rounds: polling adding not a single new field to the protocol, the round cost per decision staying unchanged across four approaches, and the sum of spent rounds and round debt staying fixed.

The previous two lessons measured two ways of keeping the connection **open.** The tunnel changed
the protocol, the stream never finished the body; both extended the connection's lifetime so the
server could speak on its own. This lesson's question is: without doing either, without adding a
single new field to the protocol, just by **asking more often**, how far can one get?

**Polling** is the client sending requests at regular intervals to learn the state. In **short
polling** the server answers right away; if it has nothing new to give, it says so. In **long
polling** the server delays the response; it holds the request until either a change happens or
the hold time runs out. This lesson's measure is what each pays in **rounds per decision.** One
caution: the `ping` control frame from the previous lesson and the polling in this lesson are
separate things — one is a frame that confirms the connection is up, the other is a round the
application takes to learn the state.

## Same Request, Different Timing

The difference between the two polling forms **has no counterpart in the message.**

```text
# short polling — the server answers right away
GET /olcum/kuzey HTTP/1.1
Host: station.example
If-None-Match: "s-118"

HTTP/1.1 304 Not Modified
ETag: "s-118"

# long polling — the request line and headers are the same, the response is delayed
GET /olcum/kuzey HTTP/1.1
Host: station.example
If-None-Match: "s-118"

HTTP/1.1 200 OK
ETag: "s-119"
Content-Type: application/json

{"istasyon": "kuzey-yamac", "deger": 5}
```

The two requests are identical. There is no new method, no new header field, no new status code.
The conditional-request field is not polling's invention either; it is HTTP's own field, built in
this course's HTTP family topic. The whole difference is **when the server writes**, and time is
not something written in the message.

The cost polling puts on the server — request count, empty-response ratio, held connections — was
measured in the Caching, Queues and Asynchronous Processing course. **What divides here is the
decision, not the event, and what is measured is the intermediary's account in between.**

## The Measurement

- **OP11.** The station's state is watched over forty ticks. Whether the state changes on a given
  tick is the setup's oracle; the state changes in twelve of the forty ticks.
- **OP12.** A **round** is one round trip in which the client sends a request and gets a response.
  An empty response does not make a round cheaper.
- **OP13.** Short polling asks once every `interval` ticks. Long polling holds until a change
  happens; if the `hold` tick runs out, it gives an empty response and the client reconnects.
- **OP14.** Server streaming and the tunnel spend a single round; after that there is no new
  message left for the intermediary to read.
- **OP15.** Every five unavailable decisions is one **round debt**: if the intermediary wants that
  exchange's decision, it has to go back to the server.

```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")
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 short(interval: int, out: list[dict]) -> list[int]:
    """Asked once every `interval` ticks; the response returns the state at that tick."""
    return list(range(interval - 1, len(out), interval))


def long(hold: int, out: list[dict]) -> list[int]:
    """The server holds until a change happens; a `hold` tick fills up with an empty response."""
    rounds, t = [], 0
    while t < len(out):
        end = min(t + hold - 1, len(out) - 1)
        for u in range(t, end + 1):
            if out[u]["stale"]:
                end = u
                break
        rounds.append(end)
        t = end + 1
    return rounds


def measure(rounds: list[int], visible: tuple = OPEN) -> tuple:
    out = exchanges()
    correct = wrong = total_bytes = 0
    fields = set()
    for t in rounds:
        m = message(out[t], visible)
        given, truth = infer(m), oracle(out[t])
        correct += sum(given[k] == truth[k] for k in given)
        wrong += sum(given[k] != truth[k] for k in given)
        total_bytes += sum(len(k) + len(str(v)) + 4 for k, v in m.items())
        fields |= set(m)
    taken = correct + wrong
    ratio = len(rounds) / taken if taken else float("nan")
    return (len(rounds), len(fields), taken, correct, wrong,
            200 - taken, ratio, total_bytes)


OUT = exchanges()
print(f"tick {len(OUT)}, decision set {len(OUT) * len(DECISIONS)}, "
      f"changes {sum(a['stale'] for a in OUT)}")
print()
fmt = "{:<18s} {:>4d} {:>5d} {:>7d} {:>6d} {:>7d} {:>11d} {:>10.3f} {:>6d}"
print(f"{'approach':<18s} {'round':>4s} {'fields':>5s} {'taken':>7s} "
      f"{'correct':>6s} {'wrong':>7s} {'unavailable':>11s} {'round/decision':>10s} "
      f"{'bytes':>6s}")
APPROACHES = [("short polling (1)", short(1, OUT), OPEN),
              ("short polling (4)", short(4, OUT), OPEN),
              ("long polling (5)", long(5, OUT), OPEN),
              ("server stream", [0], OPEN),
              ("tunnel", [0], ())]
for name, rounds, visible in APPROACHES[:-1]:
    print(fmt.format(name, *measure(rounds, visible)))
n, fields, taken, c, w, e, ratio, b = measure([0], visible=())
print(f"{'tunnel':<18s} {n:4d} {fields:5d} {taken:7d} {c:6d} {w:7d} {e:11d} "
      f"{'-':>10s} {b:6d}")

print()
print(f"{'approach':<18s} {'spent round':>12s} {'round debt':>9s} "
      f"{'total':>6s}")
for name, rounds, visible in APPROACHES:
    m = measure(rounds, visible)
    spent, debt = m[0], m[5] // len(DECISIONS)
    print(f"{name:<18s} {spent:12d} {debt:9d} {spent + debt:6d}")
```

```
tick 40, decision set 200, changes 12

approach           round fields   taken correct   wrong unavailable round/decision  bytes
short polling (1)    40     5     200    195       5           0      0.200   3724
short polling (4)    10     5      50     49       1         150      0.200    946
long polling (5)     15     5      75     72       3         125      0.200   1392
server stream         1     5       5      4       1         195      0.200     92
tunnel                1     0       0      0       0         200          -      0

approach            spent round round debt  total
short polling (1)            40         0     40
short polling (4)            10        30     40
long polling (5)             15        25     40
server stream                 1        39     40
tunnel                        1        40     41
```

The first row is the course's open regime itself: forty rounds, 195 correct, 5 wrong, no
unavailable decision, 3724 bytes. Pushing the interval to four cuts the round count to ten and
drops decisions taken to fifty. Long polling spends twelve rounds on the twelve changes and three
more on the hold time running out: fifteen total.

## The Round's Constant

The `fields` column is **five** in all four HTTP approaches. Polling adds no field to the
protocol — not the short form, not the long form. Server streaming adds none either. The only
thing that gets added is **rounds**, and how many of them there are is not written in the message.

The `round/decision` column is the direct result: **0.200** in all four approaches. This number is
1/5, and it is no coincidence. A readable exchange carries five fields, five fields give five
decisions; a round therefore always buys exactly five decisions. Changing polling's frequency
cannot **move** this ratio, because both its numerator and its denominator come from the same
field set.

This produces the course's second claim in its third form. Adding a field bought no decision;
hiding a field took a decision away; now it turns out that **adding a round does not change the
per-decision cost either.** Polling fakes real-time behavior while taking nothing from the
protocol; it only does the same operation more often.

The tunnel row sits outside this table and no ratio can be computed for it: when the field count
is zero, so is the denominator. If a round buys zero decisions, buying rounds has no meaning
either.

## Round Accounting

The second table splits the same forty exchanges into two kinds of round. **Spent round** is a
round trip the client actually made. **Round debt** is a round trip the intermediary will have to
make back to the server for decisions it could not take. In all four HTTP approaches the total is
**forty** and it does not change.

This is the plainest form of the course's axis. The decisions on forty exchanges have to come
from somewhere: either the client pays the round up front and the decision arrives in the
message, or the round is written as debt and the decision is not taken. Short polling drives the
debt to zero and pays all forty rounds up front. Server streaming pays one round and leaves
thirty-nine in debt. Every approach in between is a point on this same line.

The tunnel row gives forty-one. The extra round is the upgrade handshake itself: one round is
spent and no decision comes back for it. All forty of the forty exchanges stay in debt. The price
of hiding sits exactly in the one row that spills past the table.

## What the Held Request Corresponds to at the Intermediary

Long polling has a side effect for the intermediary. While the server is holding the request, no
byte flows over the connection; what the intermediary sees is a request whose response is
delayed. Nothing in the intermediary's hands says this wait is **deliberate** — the request line
is an ordinary GET, and none of the headers say "I'm going to keep you waiting." The idle timeout
from the previous lesson, meanwhile, is an intermediary's ordinary rule, and it cuts the held
request without knowing it was meant to be held.

- **OP16.** The intermediary cuts a connection with no byte flowing on it once it passes the idle
  timeout threshold; the client reopens the cut round. The table's `idle cutoff` column gives this
  threshold in ticks.

```python
SEED = 20260809
PATH_POOL = ["/olcum/kuzey", "/olcum/yamac", "/ozet", "/kayit", "/oturum"]


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 changes(count: int = 40) -> list[bool]:
    """Only the `stale` flag is taken from the previous block's generator; call order is preserved."""
    r, out = generator(SEED), []
    for _ in range(count):
        r(7)                                # method selection
        path = PATH_POOL[r(5)]
        private = path == "/oturum" or r(5) == 0
        stale = r(3) == 0
        if private:
            r(4)                            # private flag
        if stale:
            r(6)                            # validator resolution
        out.append(stale)
    return out


def long_rounds(hold: int, changed: list[bool]) -> list[tuple]:
    """Each round is a (start, end) tick pair; the end is either a change or the hold limit."""
    rounds, t = [], 0
    while t < len(changed):
        end = min(t + hold - 1, len(changed) - 1)
        for u in range(t, end + 1):
            if changed[u]:
                end = u
                break
        rounds.append((t, end))
        t = end + 1
    return rounds


ROUNDS = long_rounds(5, changes())
DURATIONS = [e - b + 1 for b, e in ROUNDS]
print(f"long polling (hold 5): {len(ROUNDS)} rounds, durations {DURATIONS}")
print()
print(f"{'idle cutoff':>11s} {'cut rounds':>11s} {'total rounds':>13s} "
      f"{'decisions taken':>16s} {'round/decision':>15s}")
for threshold in (1, 2, 3, 5, 10):
    cut = sum(1 for d in DURATIONS if d > threshold)
    total = len(ROUNDS) + cut
    print(f"{threshold:11d} {cut:11d} {total:13d} {total * 5:16d} "
          f"{total / (total * 5):15.3f}")
```

```
long polling (hold 5): 15 rounds, durations [2, 5, 5, 3, 5, 1, 1, 2, 1, 3, 4, 4, 1, 1, 2]

idle cutoff  cut rounds  total rounds  decisions taken  round/decision
          1          10            25              125           0.200
          2           7            22              110           0.200
          3           5            20              100           0.200
          5           0            15               75           0.200
         10           0            15               75           0.200
```

The `decisions taken` column is the round count multiplied by five; the previous measurement
already showed that every readable exchange gives exactly five decisions. A one-tick idle cutoff
cuts ten of the fifteen rounds and drives the total up to twenty-five. The round long polling
gained is thereby taken back: a design that would get by on fifteen rounds spends twenty-five
because of the intermediary's threshold. Pulling the hold time below the threshold fixes it — at a
five-tick threshold no round gets cut — but that means the application is choosing **its own
design according to a setting on the path.**

The `round/decision` column stops at 0.200 here too. A cut round is a readable exchange as well,
and it too gives five decisions. Cutting does not change the **ratio** of decisions the
intermediary takes; it only raises the round the client pays.

## What a Round Buys

It is also worth saying what a round buys where it does not buy a decision: **freshness.** In
short polling, when the interval is one tick, the client learns every change on the very tick it
happens. At a four-tick interval it learns, in the worst case, three ticks late. Long polling
closes this delay without spending a round — because the server waits for the change, the response
is written at the exact moment of the change — and gives the freshness of forty rounds for fifteen.

This is the real distinction among the three approaches, and it never shows up in the decision
table. The decision table is the intermediary's account; freshness is the client's account. A
protocol choice weighs both at once: how many decisions the intermediary can take, and how much
new information the client gets for how many rounds. The measurement band's lower bound holds here
too; on a set of two hundred decisions the smallest measurable difference is 1/200 = 0.0050, and
every difference in the table sits well above it.

## Summary

- Short and long polling's requests are identical to each other; the difference is when the
  server writes the response, and time is not a field written in the message.
- In all four HTTP approaches the number of fields the intermediary reads is five; polling adds
  not a single new field to the protocol.
- The round cost per decision is 0.200 in all four approaches, because a readable exchange always
  gives exactly five decisions; changing the frequency cannot move this ratio.
- The sum of spent rounds and round debt is forty in all four HTTP approaches: short polling
  40 + 0, long polling 15 + 25, server stream 1 + 39.
- The tunnel pushes the total to forty-one; the extra round is the upgrade handshake, for which no
  decision comes back.
- What a round buys is not a decision but freshness, and freshness never shows up in the
  intermediary's table.

## Next Step

All three approaches sat between the **two ends** of a single application, and all three used the
same transport envelope: the same host name, the same connection, the same five fields. The
intermediary's account could therefore be kept in a single ledger — which round belonged to which
exchange was always known. Some protocols, though, do not even use a single connection. The next
lesson measures a protocol family whose messages spread across more than one connection, and asks:
when the intermediary cannot match the round it sees to the round it cannot see, what is left in
its ledger?
