WebSockets: Persistent Connections and the Discord Gateway

Learning Objectives

Pre-Quiz: The WebSocket Protocol

A firewall administrator allows outbound HTTPS on port 443 but blocks most other ports. Why can a wss:// WebSocket connection still be established through this firewall?

WebSockets negotiate a random high port during the handshake that firewalls treat as trusted.
The connection begins as an ordinary HTTPS request on port 443 and is upgraded on that same TCP connection.
The firewall inspects the payload and whitelists WebSocket frames automatically.
WebSockets bypass the firewall by tunneling through DNS on port 53.

During the upgrade handshake, what response status distinguishes a successful protocol switch from a normal HTTP request?

200 OK, because the request completed successfully.
426 Upgrade Required, which signals the protocol is now WebSocket.
101 Switching Protocols, after which the same TCP connection speaks WebSocket framing.
301 Moved Permanently, redirecting the client to a WebSocket URL.

Discord's gateway sends its own JSON "heartbeat" messages, yet the websockets library also exchanges Ping/Pong frames. Why does keeping these two mechanisms distinct matter?

They are the same mechanism; distinguishing them is only a naming convention.
Ping/Pong are protocol-level control frames handled by the library, while Discord's heartbeat is an application-level data-frame message you implement yourself.
Ping/Pong carry the event payloads, while Discord's heartbeat carries connection metadata.
Discord's heartbeat replaces the need for any protocol-level keepalive, so the library disables Ping/Pong.

The WebSocket Protocol

Key Points

HTTP is a request/response protocol: the client asks, the server answers, and the exchange is over. That model is a poor fit for a chat bot that must react the instant a user sends a message. Polling is wasteful, and webhooks require a publicly reachable server. The WebSocket protocol solves this by keeping a single connection open indefinitely, over which either side may send data at any moment.

From HTTP to WebSocket: the Upgrade Handshake

A WebSocket connection begins as an ordinary HTTP request. The client sends a GET request carrying an Upgrade: websocket header, signalling it wishes to switch protocols on this very TCP connection. If the server agrees, it responds not with 200 OK but with HTTP status 101 Switching Protocols, and from that point the same TCP connection is repurposed to speak the WebSocket framing protocol.

The beauty of reusing the HTTP handshake is compatibility. Because the connection starts as HTTP (typically HTTPS), it traverses the same ports (443) and the same proxies and firewalls that already permit web traffic — no new port to open, no new protocol for a firewall to distrust.

Figure 6.3: The HTTP-to-WebSocket upgrade handshake

sequenceDiagram participant Client as WebSocket Client (bot) participant Server as Gateway Server Client->>Server: GET /?v=10 (Upgrade websocket, Sec-WebSocket-Key) Note over Server: Server validates the upgrade request Server-->>Client: HTTP 101 Switching Protocols (Sec-WebSocket-Accept) Note over Client,Server: Same TCP connection now speaks WebSocket framing Server-->>Client: Data frame (event pushed anytime) Client->>Server: Data frame (command sent anytime) Server-->>Client: Ping control frame Client->>Server: Pong control frame

Visual animation — coming soon

Frames, Text vs. Binary, and Ping/Pong

Once upgraded, data flows as frames — small, self-describing units of the WebSocket wire format. Data frames carry payloads and are either text (UTF-8 strings, the common case for JSON gateways) or binary (raw bytes for compressed payloads). Control frames manage the connection: a Close frame initiates orderly shutdown, and Ping/Pong form a low-level keepalive.

The Ping/Pong pair recurs at two layers. At the protocol layer, either endpoint may send a Ping; the receiver must reply with a Pong, confirming liveness and measuring round-trip latency — the Python websockets library handles these automatically. Confusingly, some application protocols (like Discord's) also define their own heartbeat layered on ordinary data frames. Keeping these two layers distinct is essential: the protocol-level Ping/Pong is your library's job; the application-level heartbeat is often yours.

Persistent, Full-Duplex Communication

The defining property of a WebSocket is that it is full-duplex: both endpoints can send independently and simultaneously, without turn-taking. In a webhook, the platform must open a fresh connection for every event, wait for your 200 OK, and close it. Over a WebSocket, the platform simply writes the event onto the always-open socket the moment it occurs, and your bot writes commands back over the same socket whenever it likes.

Because the connection is long-lived and carries session context, it is inherently stateful. The socket is the session; if it drops, that session (and any un-replayed events) is at risk — a theme we return to when weighing trade-offs.

Post-Quiz: The WebSocket Protocol

A firewall administrator allows outbound HTTPS on port 443 but blocks most other ports. Why can a wss:// WebSocket connection still be established through this firewall?

WebSockets negotiate a random high port during the handshake that firewalls treat as trusted.
The connection begins as an ordinary HTTPS request on port 443 and is upgraded on that same TCP connection.
The firewall inspects the payload and whitelists WebSocket frames automatically.
WebSockets bypass the firewall by tunneling through DNS on port 53.

During the upgrade handshake, what response status distinguishes a successful protocol switch from a normal HTTP request?

200 OK, because the request completed successfully.
426 Upgrade Required, which signals the protocol is now WebSocket.
101 Switching Protocols, after which the same TCP connection speaks WebSocket framing.
301 Moved Permanently, redirecting the client to a WebSocket URL.

Discord's gateway sends its own JSON "heartbeat" messages, yet the websockets library also exchanges Ping/Pong frames. Why does keeping these two mechanisms distinct matter?

They are the same mechanism; distinguishing them is only a naming convention.
Ping/Pong are protocol-level control frames handled by the library, while Discord's heartbeat is an application-level data-frame message you implement yourself.
Ping/Pong carry the event payloads, while Discord's heartbeat carries connection metadata.
Discord's heartbeat replaces the need for any protocol-level keepalive, so the library disables Ping/Pong.
Pre-Quiz: Consuming a Real-Time Gateway

Right after connecting, a Discord bot receives a Hello (op 10) message. Why must it begin heartbeating before sending its Identify (op 2)?

Identify is only accepted after at least one Heartbeat ACK has been received.
Hello carries the heartbeat_interval the bot must honor, and the heartbeat loop keeps the connection alive independently of when Identify completes.
Heartbeating authenticates the bot, making the token in Identify redundant.
The Identify opcode cannot be sent until the sequence number reaches a non-null value.

A bot only needs message events and never touches presence or member data. Why should it set a narrow intents bitmask rather than requesting everything?

Intents throttle the heartbeat interval, so fewer intents means faster heartbeats.
Intents declare which event categories the bot receives, so a narrow mask avoids unwanted traffic and sidesteps privileged-intent approval it does not need.
A wide intents mask disables session resumption entirely.
Intents are purely cosmetic; the gateway sends all events regardless of the bitmask.

What is the practical difference between Slack's Socket Mode and its Events API webhooks that makes Socket Mode attractive for local development?

Socket Mode delivers events faster because it uses UDP instead of TCP.
With Socket Mode the app dials out to a ticketed wss:// URL, so it needs no public URL, no inbound firewall rule, and no tunneling tool.
Socket Mode requires a public HTTPS endpoint but skips the signature verification step.
Socket Mode events do not need acknowledgement, unlike webhooks which require a 200 OK.

Consuming a Real-Time Gateway

Key Points

The Discord Gateway: Identify, Dispatch, and Intents

To connect, a bot fetches the Gateway URL from the Get Gateway Bot REST endpoint (and caches it), then opens a WebSocket to something like wss://gateway.discord.gg/?v=10&encoding=json. Every message is a JSON object with an opcode (op), a data payload (d), and — for dispatched events — a sequence number (s) and event name (t). The lifecycle unfolds through a small set of opcodes:

Gateway intents deserve attention: an intent is a bitmask declaring which categories of events the bot wants — you pay only for what you subscribe to (e.g. GUILDS (1 << 0), MESSAGE_CONTENT (1 << 15)). Some intents are privileged and must be enabled in the Developer Portal. Identify is also heavily rate-limited (roughly 1000 per 24 hours), a strong incentive to reconnect via resume rather than a fresh identify.

Figure 6.1: The Discord Gateway lifecycle

sequenceDiagram participant Bot participant Discord as Discord Gateway Bot->>Discord: Open WebSocket (wss, v=10, encoding=json) Discord-->>Bot: Hello (op 10) with heartbeat_interval Note over Bot: Cache interval, start heartbeat loop loop Every heartbeat_interval (first beat jittered) Bot->>Discord: Heartbeat (op 1) with last seq s Discord-->>Bot: Heartbeat ACK (op 11) end Bot->>Discord: Identify (op 2) with token + intents Discord-->>Bot: Ready dispatch (op 0): session_id + resume_gateway_url Note over Bot: Cache session_id and resume_gateway_url loop Ongoing event stream Discord-->>Bot: Dispatch (op 0): event name t, sequence s Note over Bot: Cache last sequence s end

Reading an Async Stream of Events in Python

The websockets library turns the socket into something you iterate with async for, fitting the coroutine model perfectly. A minimal read loop parses each raw JSON message and dispatches on the opcode — op 0 for a Dispatch event, op 11 for a Heartbeat ACK. This skeleton is deliberately incomplete: it identifies and reads events but does not yet heartbeat or reconnect. Reading events is the easy part; keeping the connection alive and recoverable is where production robustness lives.

Slack Socket Mode: a WebSocket Alternative to Public Webhooks

Slack apps can receive events two ways: the traditional HTTP model (Events API via public webhooks) or Socket Mode, a WebSocket alternative. With Socket Mode, the app initiates an outbound WebSocket to Slack, and all dispatches flow back over that socket — nothing via HTTP. Because the app dials out, it needs no public URL, no inbound firewall rule, and no tunneling tool. Setup involves toggling Socket Mode on and generating an app-level token (prefixed xapp-, requiring the connections:write scope).

Two details distinguish Slack's design from Discord's. First, the WebSocket URL is not static: the app calls apps.connections.open to obtain a fresh, ticketed URL like wss://wss.slack.com/link/?ticket=..., which refreshes regularly. Second, every event carries an envelope_id, and the app must acknowledge each one by sending that envelope_id back — Slack's equivalent of the HTTP 200 OK. Slack sends a hello on connect and a disconnect (with a reason) before teardown, so a well-behaved client opens a new connection before the old one closes.

For a multi-channel gateway the consequence is architectural: Discord only offers a WebSocket gateway, while Slack offers both. A unified service must abstract over a persistent-socket consumer and, optionally, an inbound HTTP webhook receiver — normalizing both into a single internal event stream.

Post-Quiz: Consuming a Real-Time Gateway

Right after connecting, a Discord bot receives a Hello (op 10) message. Why must it begin heartbeating before sending its Identify (op 2)?

Identify is only accepted after at least one Heartbeat ACK has been received.
Hello carries the heartbeat_interval the bot must honor, and the heartbeat loop keeps the connection alive independently of when Identify completes.
Heartbeating authenticates the bot, making the token in Identify redundant.
The Identify opcode cannot be sent until the sequence number reaches a non-null value.

A bot only needs message events and never touches presence or member data. Why should it set a narrow intents bitmask rather than requesting everything?

Intents throttle the heartbeat interval, so fewer intents means faster heartbeats.
Intents declare which event categories the bot receives, so a narrow mask avoids unwanted traffic and sidesteps privileged-intent approval it does not need.
A wide intents mask disables session resumption entirely.
Intents are purely cosmetic; the gateway sends all events regardless of the bitmask.

What is the practical difference between Slack's Socket Mode and its Events API webhooks that makes Socket Mode attractive for local development?

Socket Mode delivers events faster because it uses UDP instead of TCP.
With Socket Mode the app dials out to a ticketed wss:// URL, so it needs no public URL, no inbound firewall rule, and no tunneling tool.
Socket Mode requires a public HTTPS endpoint but skips the signature verification step.
Socket Mode events do not need acknowledgement, unlike webhooks which require a 200 OK.
Pre-Quiz: Keeping the Connection Alive

A Discord bot sends a heartbeat and does not receive a Heartbeat ACK before the next beat is due. What has happened and what should the bot do?

Nothing is wrong; ACKs are optional, so the bot keeps streaming events.
The connection is a "zombie"; the bot should close the socket (with a non-1000 code) and reconnect.
The heartbeat interval has expired; the bot must re-fetch the gateway URL from REST before doing anything else.
The token has been revoked; the bot should stop permanently.

Why is the first Discord heartbeat delayed by heartbeat_interval * jitter (jitter being random between 0 and 1)?

To give the bot time to finish authenticating before the first beat.
To spread out a fleet of reconnecting clients so they do not all heartbeat in lockstep, avoiding a thundering herd.
Because the gateway ignores any heartbeat sent in the first full interval.
To measure round-trip latency more accurately on the first beat.

After a service-wide outage, thousands of clients try to reconnect. Why do capped exponential backoff and jitter together matter, rather than either alone?

Backoff alone caps total wait time, and jitter alone guarantees delivery order.
Exponential backoff stops hammering a struggling server and the cap bounds the delay, while jitter desynchronizes clients so they do not all retry at the same instant.
Jitter makes reconnection faster, and backoff makes it slower, so they cancel out to a fixed rate.
Both exist only to satisfy the WebSocket spec; they have no effect on server load.

Why does a naive reconnect that simply re-identifies from scratch fall short of proper session resumption?

Re-identifying is faster but uses more bandwidth than resuming.
Re-identifying loses any events that occurred during the gap and burns one of the bot's limited identify allocations, whereas resume replays missed events.
Re-identifying works only for Slack, not Discord.
Resuming and re-identifying are identical; the only difference is the opcode number.

Keeping the Connection Alive

Key Points

A persistent socket is a liability as much as an asset: over hours and days it faces network partitions, proxy timeouts, container restarts, and platform maintenance. Production-grade means three things: proving the connection is alive (heartbeats), reconnecting cleanly when it dies (backoff), and resuming without data loss when possible (session resume).

Heartbeats and Detecting a Dead Connection

At the protocol layer, the websockets library ships built-in keepalive using Ping/Pong control frames — by default a Ping every 20 seconds expecting a Pong within 20 seconds; if none arrives, the connection is deemed broken and a ConnectionClosed exception surfaces. This generates periodic traffic (so idle-killing proxies don't close the socket), detects half-open connections, and measures latency.

At the application layer, Discord's op-1 heartbeat requires the bot to send {"op": 1, "d": <last_seq>} every heartbeat_interval ms and expect a Heartbeat ACK (op 11). If it sends a heartbeat and does not receive an ACK before the next is due, the connection is a "zombie": the bot must close the socket (ideally non-1000) and reconnect. Discord may also proactively send op 1, in which case the bot must respond immediately.

One subtlety resurfaces: the first Discord heartbeat should be delayed by heartbeat_interval * jitter (jitter random between 0 and 1). This spreads reconnecting clients so a fleet does not heartbeat in lockstep — a real defence against the "thundering herd" problem. The heartbeat is best run as a background task alongside the read loop.

Reconnect with Exponential Backoff and Jitter

The websockets library offers a clean pattern: use connect() as an infinite asynchronous iterator, so each iteration establishes a fresh connection and a close transparently triggers a reconnect. Under the hood it retries transient errors with exponential backoff while fatal errors are raised, breaking the loop. Which errors count as transient is decided by process_exception(): by default it retries on EOFError, OSError, asyncio.TimeoutError, and InvalidStatus with HTTP 500/502/503/504, treating others (like auth failures) as fatal.

Even with built-in backoff, production clients codify the policy explicitly. Exponential backoff grows the delay geometrically after each failure (1s, 2s, 4s, 8s…) rather than hammering a struggling server at a fixed rate. Two refinements are essential: cap the backoff at a ceiling (e.g. 30s) so it never grows unbounded, and add jitter so that after an outage thousands of clients don't reconnect at exactly the same instant.

Figure 6.4: Reconnect-with-backoff state machine

stateDiagram-v2 [*] --> Connecting Connecting --> Connected: handshake + identify/resume succeeds Connecting --> Backoff: transient error (EOF, OSError, 5xx) Connecting --> Failed: fatal error (bad token, disallowed intents) Connected --> Streaming: reset delay to 1s Streaming --> Backoff: ConnectionClosed / zombie detected Backoff --> Connecting: sleep min(delay,30) * jitter, then double delay Failed --> [*]

Visual animation — coming soon

Session Resumption and Replaying Missed Events

Reconnecting alone is not enough — a naive reconnect re-identifies from scratch, losing events that occurred during the gap and burning one of your limited identify allocations. Discord distinguishes recoverable from unrecoverable drops. When a connection breaks, the bot opens a new WebSocket to the cached resume_gateway_url (not the original) and sends a Resume (op 6) with token, session_id, and seq (last sequence received). Discord replays every missed event and finishes with a Resumed dispatch — making delivery at-least-once rather than lossy.

Discord signals how to recover via opcodes and close codes: Reconnect (op 7) means "close and resume"; Invalid Session (op 9) carries a boolean where true means still resumable and false means dead (fresh identify needed). Close codes guide recovery too: 4000, 4001, 4008 are resumable, whereas 4004 (auth failed), 4007 (invalid seq), 4009 (session timeout), and 4013/4014 (invalid/disallowed intents) require a fresh identify or are fatal.

Figure 6.5: Deciding to resume vs. re-identify after a disconnect

stateDiagram-v2 [*] --> Disconnected: socket closed Disconnected --> Inspect: read close code / opcode Inspect --> Resumable: op 7, op 9 (true), or 4000/4001/4008 Inspect --> FreshIdentify: op 9 (false), or 4007/4009/4013/4014 Inspect --> Fatal: 4004 authentication failed Resumable --> Resuming: connect to resume_gateway_url Resuming --> Resumed: Resume (op 6) token session_id seq -> replay missed events FreshIdentify --> Reidentifying: connect to gateway URL Reidentifying --> ReadyAgain: Identify (op 2) -> new session_id Resumed --> [*] ReadyAgain --> [*] Fatal --> [*]
Post-Quiz: Keeping the Connection Alive

A Discord bot sends a heartbeat and does not receive a Heartbeat ACK before the next beat is due. What has happened and what should the bot do?

Nothing is wrong; ACKs are optional, so the bot keeps streaming events.
The connection is a "zombie"; the bot should close the socket (with a non-1000 code) and reconnect.
The heartbeat interval has expired; the bot must re-fetch the gateway URL from REST before doing anything else.
The token has been revoked; the bot should stop permanently.

Why is the first Discord heartbeat delayed by heartbeat_interval * jitter (jitter being random between 0 and 1)?

To give the bot time to finish authenticating before the first beat.
To spread out a fleet of reconnecting clients so they do not all heartbeat in lockstep, avoiding a thundering herd.
Because the gateway ignores any heartbeat sent in the first full interval.
To measure round-trip latency more accurately on the first beat.

After a service-wide outage, thousands of clients try to reconnect. Why do capped exponential backoff and jitter together matter, rather than either alone?

Backoff alone caps total wait time, and jitter alone guarantees delivery order.
Exponential backoff stops hammering a struggling server and the cap bounds the delay, while jitter desynchronizes clients so they do not all retry at the same instant.
Jitter makes reconnection faster, and backoff makes it slower, so they cancel out to a fixed rate.
Both exist only to satisfy the WebSocket spec; they have no effect on server load.

Why does a naive reconnect that simply re-identifies from scratch fall short of proper session resumption?

Re-identifying is faster but uses more bandwidth than resuming.
Re-identifying loses any events that occurred during the gap and burns one of the bot's limited identify allocations, whereas resume replays missed events.
Re-identifying works only for Slack, not Discord.
Resuming and re-identifying are identical; the only difference is the opcode number.
Pre-Quiz: Design Trade-offs

Slack argues that short-lived stateless HTTP connections are inherently more reliable than long-lived WebSockets. What underlies this claim?

HTTP is a newer protocol than WebSockets and therefore better engineered.
A persistent socket holds session state pinned to one process and is exposed over time to partitions and restarts, whereas each webhook request is independent and any instance can handle it.
Webhooks encrypt traffic while WebSockets do not.
WebSockets cannot retry failed deliveries, but HTTP has no failures to retry.

Why can you not simply put a single logical Discord gateway connection behind a load balancer the way you would scale stateless webhook receivers?

Load balancers cannot forward TLS traffic on port 443.
A WebSocket is stateful and has connection affinity to one process, so scaling requires sharding traffic across independent, individually-supervised connections.
Discord forbids the use of load balancers in its terms of service.
Webhooks and WebSockets scale identically; the only limit is bandwidth.

A team is building a Discord bot and a production Slack app for the Slack Marketplace. Which delivery model does each realistically require?

Both must use webhooks, since WebSockets are for development only.
The Discord bot must use a WebSocket gateway (its only option); the Marketplace Slack app must use HTTP webhooks (required for submission).
Both must use WebSockets, since Discord and Slack only support Socket Mode.
The Discord bot can freely choose either model, while Slack mandates Socket Mode for the Marketplace.

Design Trade-offs

Key Points

Statefulness and Connection Affinity

The central trade-off is state. A webhook is stateless: each event is an independent HTTP request any instance can handle, with no session to protect. A WebSocket is stateful: the connection holds session context (Discord's session_id, the sequence cursor, Slack's ticketed URL) and is pinned — has connection affinity — to one process. If that process dies, the session dies with it, and only resume-with-replay saves you from lost events. Slack articulates this bluntly: the webhook's very transience is a reliability feature; the WebSocket's persistence is simultaneously its greatest strength (for latency) and its greatest operational burden.

Scaling WebSocket Consumers and Sharding

Statelessness scales trivially: put more webhook receivers behind a load balancer and they share load with no coordination. Persistent sockets are harder — pinned to a server, you cannot freely spread one logical connection across instances. Slack caps an app at 10 concurrent Socket Mode connections. For high-volume Discord bots, the answer is sharding: splitting guilds across multiple gateway connections, each shard an independent WebSocket with its own identify, heartbeat, and resume state — the WebSocket analogue of horizontally scaling stateless workers, but each unit of scale is itself a stateful connection.

When to Prefer WebSockets Over Webhooks

Figure 6.2: WebSocket vs. Webhook delivery comparison

AxisWebhook (HTTP push)WebSocket (persistent socket)
DirectionOne-way, request/responseBidirectional, full-duplex
StateStateless (each event independent)Stateful (connection holds session)
ConnectivityRequires public HTTPS endpointDials out; no public URL or inbound rule
Local developmentNeeds a tunnel (e.g. ngrok)Works from a laptop / behind NAT
ReliabilityHigh (short-lived, retried on failure)Lower (long-lived; exposed to partitions)
Missed-event recoveryPlatform retries deliveryResume + replay from cursor, if supported
ScalingHorizontal, unlimited, load-balancedHarder; sharded, connection-affine, capped
LatencySlight per-event connection overheadLowest; event pushed on open socket

Slack's own guidance crystallizes the choice: Socket Mode for development, local use, and apps behind a firewall, while HTTP is recommended for production and required for the Slack Marketplace. The overriding practical factor, though, is often simply what the platform supports. Discord offers only a WebSocket gateway, so a Discord bot has no choice. A multi-channel gateway will typically need both capabilities — a hardened WebSocket consumer for platforms that mandate it, and a webhook receiver for those that prefer HTTP — feeding both into one normalized internal stream.

Visual animation — coming soon

Post-Quiz: Design Trade-offs

Slack argues that short-lived stateless HTTP connections are inherently more reliable than long-lived WebSockets. What underlies this claim?

HTTP is a newer protocol than WebSockets and therefore better engineered.
A persistent socket holds session state pinned to one process and is exposed over time to partitions and restarts, whereas each webhook request is independent and any instance can handle it.
Webhooks encrypt traffic while WebSockets do not.
WebSockets cannot retry failed deliveries, but HTTP has no failures to retry.

Why can you not simply put a single logical Discord gateway connection behind a load balancer the way you would scale stateless webhook receivers?

Load balancers cannot forward TLS traffic on port 443.
A WebSocket is stateful and has connection affinity to one process, so scaling requires sharding traffic across independent, individually-supervised connections.
Discord forbids the use of load balancers in its terms of service.
Webhooks and WebSockets scale identically; the only limit is bandwidth.

A team is building a Discord bot and a production Slack app for the Slack Marketplace. Which delivery model does each realistically require?

Both must use webhooks, since WebSockets are for development only.
The Discord bot must use a WebSocket gateway (its only option); the Marketplace Slack app must use HTTP webhooks (required for submission).
Both must use WebSockets, since Discord and Slack only support Socket Mode.
The Discord bot can freely choose either model, while Slack mandates Socket Mode for the Marketplace.

Your Progress

Answer Explanations