Chapter 7: Polling and Hybrid Delivery Strategies

Learning Objectives

Pre-Quiz: Polling Fundamentals

A team runs 10,000 clients that poll a chat API every second, but conversations are usually quiet. They switch from short polling to long polling. What is the main reason this reduces cost?

Long polling compresses each HTTP response, so less bandwidth is used per request.
The server holds each request open until data arrives, eliminating most empty responses that short polling generates while idle.
Long polling caches results on the client, so identical requests are never re-sent.
Long polling upgrades the connection to a WebSocket, removing HTTP overhead entirely.

A poller restarts after a crash and re-fetches "the latest 50 messages" every run with no cursor. Which failure is it structurally exposed to?

It can both miss messages that scrolled past the window and re-process messages it already handled.
It will always miss messages but can never duplicate them.
It will always duplicate messages but can never miss them.
Nothing — fetching the latest batch is inherently exactly-once.

Your poller receives a 429 with a Retry-After: 5 header. Why is honoring that header preferred over immediately applying your own exponential backoff?

Ignoring the header is an HTTP protocol violation that will get the connection dropped.
Exponential backoff cannot be used at all once a 429 has been returned.
The server knows its own recovery capacity, so its stated wait is both correct and the fastest path back to being served.
Retry-After guarantees the next request will never be rate-limited again.

1. Polling Fundamentals

Key Points

Polling is the act of a client repeatedly asking a server whether state has changed, rather than waiting for the server to announce it. It needs nothing special: any HTTP client, behind any proxy or firewall, can make a request and read a response.

Short Polling vs Long Polling

Short polling sends a request at fixed intervals; if nothing is new, the server responds immediately with an empty result. Its defining property is a trade-off between latency and efficiency: a shorter interval means faster updates but more wasted empty requests; latency is bounded by the interval, so a 10-second poll means a new message waits up to 10 seconds. Ten thousand clients polling every second produce 10,000 requests per second even when nothing is happening.

Long polling flips the waiting around: the client sends a request the server holds open until new data arrives or a timeout elapses, then the client immediately re-requests. This gives near-instant delivery and dramatically fewer empty responses, at the cost of one held connection per waiting client. The canonical example is Amazon SQS, where a WaitTimeSeconds of up to 20s can yield about a 95% cost reduction versus short polling for a lightly-loaded queue.

Analogy — the mailbox and the doorbell. Short polling is walking to your mailbox on a fixed schedule; most trips it is empty. Long polling is waiting at the box until the carrier actually arrives — you get the letter instantly, but you are tied up standing there, and 10,000 people all waiting at once is a lot of people standing around.

Figure 7.3: Short polling vs long polling request timing.

sequenceDiagram participant C as Client participant S as Server Note over C,S: Short polling (fixed interval, returns immediately) C->>S: GET /messages (poll 1) S-->>C: 200 empty (nothing new) C->>S: GET /messages (poll 2) S-->>C: 200 empty (nothing new) Note right of C: message arrives at server here C->>S: GET /messages (poll 3) S-->>C: 200 [msg] (waited up to 1 interval) Note over C,S: Long polling (server holds request open) C->>S: GET /messages (held open) Note right of S: server waits... Note right of S: message arrives, respond at once S-->>C: 200 [msg] (near-instant delivery) C->>S: GET /messages (immediately re-request) Note right of S: no data; hold until timeout S-->>C: 200 empty (timeout elapsed)

Cursors, Since-Tokens, and Avoiding Missed or Duplicate Messages

A poll that just asks "give me the latest messages" is broken in two directions: it misses messages that rolled past while it was busy, and it re-fetches messages it already handled. The fix is a cursor — a persisted marker of the last event you successfully processed. You send it with each request, the server returns only events newer than it, and after processing a batch you advance the cursor to the highest position seen and persist it.

The cursor is your primary deduplication mechanism, but it is not sufficient alone. Most message APIs offer at-least-once delivery, so the message at the boundary (whose ID equals your cursor) can legitimately be replayed. If you persist the cursor after processing but crash before persisting, that message is handled again on restart — which is why a separate ID-keyed dedup check is required.

Analogy — the bookmark. A cursor is a bookmark you move forward before closing the book. Lose it and you either start over at page one or guess where you were — and guessing is exactly how messages get missed or read twice.

Respecting Rate Limits and Retry-After Headers

A rate limit caps how many requests you may make in a window; exceed it and you get HTTP 429 Too Many Requests. A polling loop is a request-generating machine, so it is precisely the client that trips limits. The single most important rule: honor the Retry-After header when present — the server knows its own capacity, so its stated wait is both correct and the fastest path back to being served.

When there is no Retry-After, compute your own wait with exponential backoff (1s, 2s, 4s, 8s...) plus jitter — a small random offset. Jitter prevents the thundering herd: without it, a thousand clients that failed in one shared outage retry in perfect lockstep and hammer the recovering server in synchronized waves. Idempotency also matters: GETs are safe to retry freely, but POSTs need connection-error-only retries or an Idempotency-Key.

Visual animation — coming soon

Post-Quiz: Polling Fundamentals

A team runs 10,000 clients that poll a chat API every second, but conversations are usually quiet. They switch from short polling to long polling. What is the main reason this reduces cost?

Long polling compresses each HTTP response, so less bandwidth is used per request.
The server holds each request open until data arrives, eliminating most empty responses that short polling generates while idle.
Long polling caches results on the client, so identical requests are never re-sent.
Long polling upgrades the connection to a WebSocket, removing HTTP overhead entirely.

A poller restarts after a crash and re-fetches "the latest 50 messages" every run with no cursor. Which failure is it structurally exposed to?

It can both miss messages that scrolled past the window and re-process messages it already handled.
It will always miss messages but can never duplicate them.
It will always duplicate messages but can never miss them.
Nothing — fetching the latest batch is inherently exactly-once.

Your poller receives a 429 with a Retry-After: 5 header. Why is honoring that header preferred over immediately applying your own exponential backoff?

Ignoring the header is an HTTP protocol violation that will get the connection dropped.
Exponential backoff cannot be used at all once a 429 has been returned.
The server knows its own recovery capacity, so its stated wait is both correct and the fastest path back to being served.
Retry-After guarantees the next request will never be rate-limited again.
Pre-Quiz: When Polling Is the Right Tool

A new channel integration is a small SaaS ticketing tool that exposes a clean REST API but offers no webhooks and no streaming endpoint. Why is polling the correct choice here rather than a compromise?

Polling is always cheaper than any push mechanism regardless of the provider.
No amount of architecture on your side can create a push channel the provider does not offer, so pulling is the only way to learn about new events.
REST APIs are technically incapable of supporting webhooks.
Polling automatically converts the REST API into a WebSocket under the hood.

Your webhook endpoint was down for two minutes during a deploy. Why does a cursored poll recover the missed events while the webhooks could not?

Webhooks store failed deliveries forever, so the poll just reads them from the same queue.
A poll asks "give me everything since my cursor," so the provider hands over the whole backlog without you needing to know which events you missed.
The deploy automatically replays every dropped webhook once the endpoint returns.
Polling runs on a separate network that is immune to deploy downtime.

An integration must run from a locked-down environment with no public IP and no inbound ports open. Why does polling fit while webhooks do not?

Polling is a plain outbound HTTP request, whereas webhooks require a publicly reachable inbound endpoint the provider can POST to.
Webhooks cannot use TLS, so firewalls always block them.
Polling opens an inbound port automatically, satisfying the provider.
Webhooks require more bandwidth than firewalls typically allow.

2. When Polling Is the Right Tool

Key Points

Polling has a reputation as the fallback of last resort, but there are whole classes of problems where it is the correct choice, not the compromise.

APIs Without Push Support

The most decisive reason to poll is that the upstream API gives you no choice. Many services expose only request/response HTTP with no webhooks and no streaming endpoint. If the platform cannot push, no architectural sophistication conjures a push channel into existence — you ask, or you learn nothing. You do not need the provider to register a callback URL, you do not need a publicly reachable endpoint, and you do not need a persistent connection.

Batch Reconciliation and Catch-Up After Downtime

Polling's "ask for everything since X" model is a superpower when recovering after your own system was unavailable. Push is fire-and-forget, so a delivery that fails against a dead endpoint may never be retried. A poll simply asks for everything since the last cursor, and the provider hands over the whole backlog. This is catch-up — a single cursored poll reconciles your state with the provider's — and it is also the engine of nightly or hourly batch reconciliation.

Simplicity and Firewall-Friendliness

Polling is a plain outbound HTTP request, so it works anywhere an HTTP client works — behind corporate proxies, inside restrictive firewalls, from a machine with no public IP. Webhooks require the reverse: a publicly reachable, TLS-terminated endpoint to secure. WebSockets require holding a persistent connection through hostile middleboxes. For a locked-down environment or a proof-of-concept, "just poll" gets you working today and can be debugged with curl.

Post-Quiz: When Polling Is the Right Tool

A new channel integration is a small SaaS ticketing tool that exposes a clean REST API but offers no webhooks and no streaming endpoint. Why is polling the correct choice here rather than a compromise?

Polling is always cheaper than any push mechanism regardless of the provider.
No amount of architecture on your side can create a push channel the provider does not offer, so pulling is the only way to learn about new events.
REST APIs are technically incapable of supporting webhooks.
Polling automatically converts the REST API into a WebSocket under the hood.

Your webhook endpoint was down for two minutes during a deploy. Why does a cursored poll recover the missed events while the webhooks could not?

Webhooks store failed deliveries forever, so the poll just reads them from the same queue.
A poll asks "give me everything since my cursor," so the provider hands over the whole backlog without you needing to know which events you missed.
The deploy automatically replays every dropped webhook once the endpoint returns.
Polling runs on a separate network that is immune to deploy downtime.

An integration must run from a locked-down environment with no public IP and no inbound ports open. Why does polling fit while webhooks do not?

Polling is a plain outbound HTTP request, whereas webhooks require a publicly reachable inbound endpoint the provider can POST to.
Webhooks cannot use TLS, so firewalls always block them.
Polling opens an inbound port automatically, satisfying the provider.
Webhooks require more bandwidth than firewalls typically allow.
Pre-Quiz: Building an Async Poller

The adaptive rule sets interval = 1.0 after a non-empty poll and interval = min(interval * 2, 30) after an empty one. What behavior does this produce?

It polls fastest exactly when messages are flowing and backs off during quiet periods, balancing latency against load.
It polls at a constant 1-second rate no matter what, since the floor is always applied.
It polls slowest when messages are flowing to avoid overwhelming the server.
It stops polling entirely once the interval reaches 30 seconds.

Why does the poller persist its cursor after processing a batch rather than before?

Persisting before is faster because it avoids a second disk write.
A crash mid-batch then causes at worst a safe re-fetch (absorbed by dedup) rather than an unrecoverable skip of unprocessed messages.
The cursor cannot be computed until the next poll returns.
Persisting after guarantees the provider never sends the boundary message twice.

In the assembled poller, each message ID is checked against the seen set before process(m) runs. Why is the ordering of that check important?

Checking first lets the loop skip the network fetch for duplicates.
It ensures the expensive or irreversible side effect never runs twice for an ID already handled.
It allows the cursor to be advanced before any message is processed.
It is only a performance optimization and has no correctness impact.

3. Building an Async Poller

Key Points

We now assemble the fundamentals into a single production-shaped worked example: a cancellable async polling loop that tracks a cursor, honors Retry-After, adapts its interval to load, and deduplicates against already-processed events.

A Cancellable Polling Loop with Adaptive Intervals

An adaptive interval changes with observed load: poll fast when messages flow, back off — doubling up to a cap — when idle. After a poll that returned messages, reset to the floor; after an empty poll, double up to the ceiling. In asyncio, sleeping with await asyncio.sleep(...) yields to the event loop and is a cancellation point; catching asyncio.CancelledError lets the loop persist its cursor cleanly on shutdown.

Figure 7.1: The adaptive-interval feedback loop.

flowchart TD A["poll(cursor)"] --> B{"messages returned?"} B -- yes --> C["interval = 1s (busy, poll fast)"] B -- no --> D["interval = min(interval*2, 30s) (idle, back off)"] C --> E["await asyncio.sleep(interval) — cancellation point"] D --> E E --> A E -.->|CancelledError at await| F["finally: persist cursor + dedup state, then re-raise"]

Persisting Cursor State Between Polls

The adaptive loop is worthless if a restart makes it forget where it was. Each successful batch must advance the cursor to the highest ID processed and persist it durably (a DB row, a Redis key, a state file) before treating the batch as done. The ordering matters: persist after processing, so a crash mid-batch causes at worst a safe re-fetch (absorbed by dedup) rather than an unrecoverable skip. On startup the poller loads the persisted cursor; if none exists it starts from "now" so a fresh poller does not replay all history.

Deduplicating Against Already-Processed Events

The final safety net is reconciliation at the level of individual events: keep a record of already-processed message IDs and skip any ID already handled before doing any side-effectful work. This catches the replayed boundary message, the hybrid-ingress overlap when the same event arrives via both webhook and poll, and the re-fetch after a crash-before-persist. Here is the complete assembled poller:

import asyncio
import aiohttp
import backoff

API = "https://api.example.com/messages"

class Poller:
    def __init__(self):
        self.cursor = load_cursor()      # persisted last-seen message id (or None)
        self.seen = load_seen_ids()      # persisted set of processed ids (dedup)
        self.interval = 1.0              # adaptive base interval, seconds

    @backoff.on_exception(backoff.expo, aiohttp.ClientError, max_time=60)
    async def fetch(self, session):
        # Ask only for messages newer than the cursor.
        async with session.get(API, params={"since": self.cursor}) as r:
            if r.status == 429:
                # Honor the server's Retry-After, then let backoff retry.
                delay = int(r.headers.get("Retry-After", "1"))
                await asyncio.sleep(delay)
                raise aiohttp.ClientError("rate limited")
            r.raise_for_status()
            return await r.json()

    async def run(self):
        async with aiohttp.ClientSession() as session:
            try:
                while True:
                    batch = await self.fetch(session)
                    if batch["messages"]:
                        for m in batch["messages"]:
                            if m["id"] in self.seen:
                                continue          # dedup: already processed
                            await process(m)      # side-effectful work
                            self.seen.add(m["id"])
                        # advance + persist cursor AFTER processing
                        self.cursor = batch["messages"][-1]["id"]
                        save_cursor(self.cursor)
                        save_seen_ids(self.seen)
                        self.interval = 1.0                       # busy -> fast
                    else:
                        self.interval = min(self.interval * 2, 30)  # idle -> back off
                    await asyncio.sleep(self.interval)            # cancellation point
            except asyncio.CancelledError:
                save_cursor(self.cursor)          # persist on clean shutdown
                save_seen_ids(self.seen)
                raise

Visual animation — coming soon

Post-Quiz: Building an Async Poller

The adaptive rule sets interval = 1.0 after a non-empty poll and interval = min(interval * 2, 30) after an empty one. What behavior does this produce?

It polls fastest exactly when messages are flowing and backs off during quiet periods, balancing latency against load.
It polls at a constant 1-second rate no matter what, since the floor is always applied.
It polls slowest when messages are flowing to avoid overwhelming the server.
It stops polling entirely once the interval reaches 30 seconds.

Why does the poller persist its cursor after processing a batch rather than before?

Persisting before is faster because it avoids a second disk write.
A crash mid-batch then causes at worst a safe re-fetch (absorbed by dedup) rather than an unrecoverable skip of unprocessed messages.
The cursor cannot be computed until the next poll returns.
Persisting after guarantees the provider never sends the boundary message twice.

In the assembled poller, each message ID is checked against the seen set before process(m) runs. Why is the ordering of that check important?

Checking first lets the loop skip the network fetch for duplicates.
It ensures the expensive or irreversible side effect never runs twice for an ID already handled.
It allows the cursor to be advanced before any message is processed.
It is only a performance optimization and has no correctness impact.
Pre-Quiz: Choosing and Combining Delivery Modes

Using the decision matrix, why is plain polling disqualified as the primary transport for a live chat UI yet still valuable as a safety net?

Polling cannot carry chat messages at all, but it can carry status pings.
Its latency is bounded by the interval (too slow for conversation), but its completeness — you control the ask and it heals after downtime — is the strongest of the three.
Polling is bidirectional like WebSockets but far more expensive per message.
Polling has the weakest delivery guarantee, so it is only useful for non-critical UIs.

In a hybrid ingress, WebSockets/webhooks are the primary path and a cursored reconciliation poll is the backstop. What weakness of push does the reconciliation poll specifically address?

Push messages arrive encrypted and the poll decrypts them.
Push is fire-and-forget and can be missed (endpoint down, network blip, provider failure); the poll re-delivers exactly what push dropped.
Push has unbounded latency and the poll makes it faster.
Push cannot traverse firewalls and the poll opens the ports for it.

When the same message can arrive by both a webhook and the reconciliation poll, and two messages can arrive out of order, which two mechanisms together fix overlap and ordering?

Retry-After for overlap and exponential backoff for ordering.
A longer polling interval for overlap and a shorter one for ordering.
ID-keyed dedup collapses the duplicate to exactly-once processing, and timestamps or sequence numbers restore correct order.
TLS termination for overlap and connection pooling for ordering.

4. Choosing and Combining Delivery Modes

Key Points

Three mechanisms cover almost all real-time integration. Polling is the client asking on an interval — simple and universal but latency-bound. Webhooks are one-way event-driven HTTP: the source POSTs to your URL the moment an event happens — lightweight and efficient because nothing is sent when nothing happens. WebSockets are two-way persistent connections with sub-second latency — the standard for live bidirectional chat.

A Decision Matrix: Webhook vs WebSocket vs Polling

Figure 7.2: Webhook vs WebSocket vs Polling decision matrix.

DimensionPollingWebhooksWebSockets
DirectionClient pullsServer pushes (one-way)Bidirectional
LatencyBounded by interval (sec–min)Near-instant on eventSub-second, continuous
Efficiency when idlePoor (many empty requests)Excellent (nothing sent)Good (idle open connection)
Infrastructure you runJust an HTTP clientA public TLS endpoint for POSTsPersistent connection mgmt
Firewall friendlinessExcellent (outbound only)Needs inbound public endpointMiddleboxes may break conns
Delivery guaranteeStrong (you control the ask)Weak (fire-and-forget)Medium (drops on disconnect)
Completeness after downtimeSelf-healing via cursorMissed events lostMissed events lost
Best fitNo-push APIs, batch, fallbackServer-to-server notificationLive bidirectional chat UI

Read across the "Delivery guarantee" and "Completeness after downtime" rows and the reason for hybrids is obvious: push modes win on latency but lose on completeness, and polling wins on completeness but loses on latency. For chat, latency is decisive — a 5-minute interval yields an average delay of ~2.5 minutes, which no conversation survives — so plain polling is disqualified as the primary transport while remaining the best safety net.

flowchart LR P["Need a delivery mode?"] --> Q{"Bidirectional live UI?"} Q -- yes --> WS["WebSockets"] Q -- no --> R{"Does the provider push events?"} R -- "yes (server-to-server)" --> WH["Webhooks"] R -- "no push, or need catch-up / firewall-friendly" --> POLL["Polling"] WS --> HY["Hybrid ingress: push primary + reconciliation poll backstop"] WH --> HY POLL --> HY

Hybrid Ingress: Push Primary, Polling as Fallback

A hybrid ingress pairs a low-latency push path with a slower authoritative pull path, so the system is fast in the common case and complete in the failure case. The push path (WebSockets for the live stream, webhooks for third-party server events) handles nearly all traffic sub-second; a periodic cursor-based reconciliation poll runs quietly as a backstop. It exists because webhooks are fire-and-forget and can be missed — endpoint down during a deploy, a network blip, or the provider failing to deliver.

Analogy — the smoke detector and the inspection. The push path is a smoke detector: it screams the instant something happens. But detectors have dead batteries and blind spots. The reconciliation poll is the scheduled inspection that walks every room on a timetable — slower, but it catches the smoldering wire the detector missed.

Figure 7.4: Hybrid ingress — push-primary with polling-fallback reconciliation.

flowchart LR WS["WebSocket (live client stream)"] --> D{"dedup by message ID (seen?)"} WH["Webhook (third-party events)"] --> D RP["Reconciliation poll (cursored, adaptive)"] --> D D -- "already seen" --> SKIP["skip (no side effect)"] D -- "new" --> ORD["reorder by timestamp / sequence"] ORD --> PROC["process exactly once"] PROC --> STREAM["complete, ordered message stream"]

Reconciliation to Guarantee No Lost Messages

The promise of a hybrid ingress is "no message is permanently lost," and reconciliation redeems it: the poll fetches everything since the cursor, dedup skips whatever push already handled, and whatever remains is what push missed — now delivered by the pull path. Two second-order problems arise when events can arrive by two routes. Ordering: fix by reordering with event timestamps or provider sequence numbers rather than trusting arrival order. Overlap: a message delivered by both webhook and poll must be processed exactly once, which the ID-keyed dedup layer guarantees. Together — push for speed, reconciliation for completeness, timestamps for order, dedup for exactly-once — the gateway can lose a webhook, drop a socket, or restart mid-batch and still converge on the correct, complete, ordered stream.

Visual animation — coming soon

Post-Quiz: Choosing and Combining Delivery Modes

Using the decision matrix, why is plain polling disqualified as the primary transport for a live chat UI yet still valuable as a safety net?

Polling cannot carry chat messages at all, but it can carry status pings.
Its latency is bounded by the interval (too slow for conversation), but its completeness — you control the ask and it heals after downtime — is the strongest of the three.
Polling is bidirectional like WebSockets but far more expensive per message.
Polling has the weakest delivery guarantee, so it is only useful for non-critical UIs.

In a hybrid ingress, WebSockets/webhooks are the primary path and a cursored reconciliation poll is the backstop. What weakness of push does the reconciliation poll specifically address?

Push messages arrive encrypted and the poll decrypts them.
Push is fire-and-forget and can be missed (endpoint down, network blip, provider failure); the poll re-delivers exactly what push dropped.
Push has unbounded latency and the poll makes it faster.
Push cannot traverse firewalls and the poll opens the ports for it.

When the same message can arrive by both a webhook and the reconciliation poll, and two messages can arrive out of order, which two mechanisms together fix overlap and ordering?

Retry-After for overlap and exponential backoff for ordering.
A longer polling interval for overlap and a shorter one for ordering.
ID-keyed dedup collapses the duplicate to exactly-once processing, and timestamps or sequence numbers restore correct order.
TLS termination for overlap and connection pooling for ordering.

Your Progress

Answer Explanations