Guide · Backend
WebSockets vs SSE vs Long Polling: Real-Time Options and Costs
26 min readUpdated By Saket Bhatnagar
Researched and drafted with AI, reviewed and edited by Saket Bhatnagar.
In short
- WebSockets vs SSE comes down to direction. Server-sent events (SSE) stream updates one way, from server to browser, over one long HTTP response, the web's normal ask-and-answer reply. A WebSocket is a two-way channel on one open connection.
- Long polling is the older trick. The browser asks, the server holds the request until there is news or about 25 seconds pass, and the browser asks again.
- Push trades a flood of requests for open connections. You pay in server memory per connection, and in proxies, the machines in the middle, closing quiet connections. You also face thousands of browsers reconnecting at once after a deploy, and fan-out: copying each update to every server.
- SSE plus ordinary HTTP requests covers most live feeds, like prices, scores and alerts. Pick WebSockets when the browser also sends often, as in chat, games or a live control console.
- Beginners can stop after the section on how WebSockets work. Engineers then get tests on nginx 1.24.0, a popular proxy. By default it held SSE events in a 4 KB buffer, so at one bid every 2 seconds they arrived in clumps, the first 2.5 minutes late. It also cut quiet streams at 60 seconds. WebSockets failed until two headers were forwarded.
What goes wrong when a live auction page asks for prices on a timer?
The screen shows the past. When a browser learns about changes only by asking on a timer, prices on screen are seconds old and most asks come back empty. Push, where the server sends news the moment it happens, fixes this. Choosing WebSockets vs SSE is then the usual question.
Picture a common setup. An online auction site runs live antique auctions every Sunday evening, and its bidding page asks the server for the latest price every 2 seconds. The setup is made up, but the problem is real.
On a busy evening, 8,000 people watch the lot, the item currently up for sale. That is 4,000 requests a second, and almost every reply says the same thing: nothing changed.
Worse, the price on screen can be up to 2 seconds old, plus network delay. In the last ten seconds of a lot, a watcher bids Rs 41,000 on a gramophone that reached Rs 42,000 a second earlier. The bid is rejected, and the watcher is angry.
The team wants each new bid on screen within a second, and far fewer pointless requests. This guide walks through the four ways to do it, what each one costs, and what the site finally ships.
Which words do you need before comparing real-time options?
Six terms come up again and again. Each is explained here in plain words, so later sections can use them without stopping.
- Request and response: the browser asks the server for something over HTTP, the web's ask-and-answer language, and the server answers once.
- Connection: the network link between browser and server. Opening one takes time, so browsers reuse it for many requests.
- Push: the server sends data without being asked. Polling only fakes push by asking often.
- Proxy: a machine in the middle that passes traffic along, such as nginx. A load balancer is a proxy that spreads traffic across several servers.
- Idle timeout: how long a proxy lets a connection sit quiet before it closes it.
- Heartbeat: a tiny message sent on a timer, so a quiet connection does not look dead.
What are the ways to send live updates to a browser?
There are four common ways: short polling, long polling, server-sent events (SSE) and WebSockets. The first two use ordinary request and response. The last two keep a connection open, so the server can push.
Short polling. The browser asks on a timer, such as every 2 seconds, whether anything changed. This is the auction site's first version.
Long polling. The browser asks, and the server holds the request open until there is news or a time limit passes. Then the browser asks again straight away.
Server-sent events. The browser opens one HTTP request whose response does not end. The server writes a small text message into it whenever something happens. Messages flow one way only, from server to browser.
WebSocket. The browser asks the server to turn one HTTP connection into a two-way pipe. After that, either side can send a message at any time.
Picture the sabziwala, the vegetable seller who pushes his cart down your lane. Short polling is walking to the gate every five minutes to check whether he has come. Long polling is waiting at the gate until he turns the corner, then going back inside after each purchase.
SSE is the sabziwala calling out prices as he passes, while you listen from the balcony. To buy, you still walk down to the cart, which is a separate trip. A WebSocket is haggling back and forth over the balcony rail.
Where the picture breaks: a real server calls out to thousands of balconies at once. It must also remember what each listener missed while they were indoors, which no sabziwala does.
WebSockets vs SSE: which one should you use?
Use SSE when only the server needs to push, and WebSockets when both sides send often. For most live feeds, SSE plus ordinary POST requests, the HTTP requests that send data such as a bid, is simpler and enough.
The auction site's watchers mostly listen. A watcher bids a few times per lot at most, while the price can change every few seconds. That pattern is push-heavy, so SSE fits the watchers.
The auctioneer's console is different. The auctioneer sees bids, sends going once, going twice and sold, and swaps notes with staff taking phone bids. Messages flow both ways several times a minute, so one WebSocket beats a stream plus many POSTs.
| Short polling | Long polling | SSE | WebSockets | |
|---|---|---|---|---|
| Who starts each message | Browser, on a timer | Browser; server answers late | Server | Either side |
| Direction | Ask and answer | Ask and answer, held open | Server to browser only | Both ways |
| Delay after a new bid | Up to one poll gap | About one trip; over three in bursts | About one network trip | About one network trip |
| Reconnect after a drop | Not needed | Your code | Browser does it and sends the last id | Your code |
| Replay of missed messages | Ask after the last id | Your code | Your server, from the last id | Your code |
| Data | Any HTTP body | Any HTTP body | Text only | Text or binary |
| Behind proxies | Works | Keep under the idle timeout | Turn proxy buffering off | Forward the Upgrade headers |
| Server cost per watcher | Many short requests | One held request, repeated | One open connection | One open connection |
| Best fit | Rare updates | Fallback when streams fail | Prices, scores, alerts | Chat, games, consoles |
The diagram below turns the table into three questions. Answer them for any live feature you build.
Picking a real-time option
Drag to exploreAsk how often things change, who sends, and whether the network can hold a stream. Notice that WebSockets win only when the browser also talks often.
Diagram connections:
- A new live feature to Changes every few seconds?
- Changes every few seconds? to Plain polling is fine (No)
- Changes every few seconds? to Browser sends often too? (Yes)
- Browser sends often too? to Use WebSockets (Yes)
- Browser sends often too? to Network can hold a stream? (No)
- Network can hold a stream? to Use SSE plus POST (Yes)
- Network can hold a stream? to Use long polling (No)
- A new live feature: Price feed, chat, alerts, console
- Changes every few seconds?: Or users need it within a second
- Plain polling is fine: Ask every minute, cache the reply
- Browser sends often too?: Several messages a minute each way
- Use WebSockets: Chat, games, the auctioneer console
- Network can hold a stream?: No proxy that buffers or cuts it
- Use SSE plus POST: The watchers' price feed
- Use long polling: Fallback for strict networks
The site ends up using three options, each for its own job. Watchers get SSE, and bids go up as POST requests. The auctioneer's console uses a WebSocket. Watchers behind networks that break streams fall back to long polling. The team's rule: switch when a stream delivers nothing, not even a heartbeat, for 30 seconds.
How does long polling work?
Long polling works by making the server wait. The browser sends a request, and the server replies only when new data arrives or a time limit passes. The browser then sends the next request at once.
RFC 6202 describes the method and its known problems. An RFC is a numbered document from the IETF, the group that writes internet standards, and this one dates from April 2011. Follow one round below, starting from a request for bids after number 311.
One round of long polling
Drag to exploreThe server holds each request until a bid arrives or 25 seconds pass. Notice that an empty reply is normal, and either way the browser asks again at once.
Diagram connections:
- Ask for bids after 311 to Hold the request open
- Hold the request open to New bid within 25 seconds?
- New bid within 25 seconds? to Reply with the new bid (Yes)
- New bid within 25 seconds? to Reply with an empty list (No, time is up)
- Reply with the new bid to Ask for bids after 311 (Ask again at once)
- Reply with an empty list to Ask for bids after 311 (Ask again at once)
- Ask for bids after 311: Browser sends a normal GET request
- Hold the request open: Server waits on the lot's bid queue
- New bid within 25 seconds?: Or the 25-second limit runs out
- Reply with the new bid: Browser shows it, then asks again
- Reply with an empty list: Nothing new, and that is fine
Here is the server side in Python 3.11 or newer. The bids object is a helper you would write over your database. Each bid has a sequence number, a counter that goes up by one per bid in a lot.
The helper has three methods. The since method returns stored bids after a given number. The subscribe method returns a queue that receives each new bid as it lands, and unsubscribe removes that queue.
import asyncio
POLL_TIMEOUT = 25 # seconds, under the 30 that RFC 6202 calls safer
async def long_poll(lot_id, after_seq, bids):
queue = bids.subscribe(lot_id) # listen first, so no bid slips past
try:
missed = bids.since(lot_id, after_seq)
if missed:
return {"bids": missed} # news already waiting: reply now
bid = await asyncio.wait_for(queue.get(), POLL_TIMEOUT)
return {"bids": [bid]}
except TimeoutError:
return {"bids": []} # empty reply, the browser asks again
finally:
bids.unsubscribe(lot_id, queue)Two lines matter most. The server subscribes before it checks history, so a bid landing in between still reaches it. The early return sends any bids the browser missed while it was busy.
The 25-second limit keeps each held request inside proxy idle timeouts. nginx, for example, closes a proxied connection when the server behind it sends nothing for 60 seconds, its default proxy_read_timeout.
RFC 6202 reports that timeouts as high as 120 seconds worked in experiments, but calls 30 seconds generally safer. Go too high, and a proxy on the way may give up first and return an error.
That last result is long polling's real cost. RFC 6202 notes that the worst-case delay is over three network trips: the reply, the next request and the next reply. Every message also carries a full set of HTTP headers.
Its big strength is that each round is a plain HTTP request. So it passes through almost any network that allows ordinary web browsing.
What are server-sent events (SSE)?
Server-sent events are a browser standard for one long HTTP response that the server keeps writing to, one small text event at a time. The browser reads it with a built-in object called EventSource.
The rules live in the HTML Standard, maintained by WHATWG, a standards group steered by browser makers. The response has the type text/event-stream and is always decoded as UTF-8, the common way of storing text as bytes. Each event is a few lines, such as id, event and data, ended by a blank line.
The best part is what the browser does for free. If the connection drops, EventSource reconnects by itself after a delay the browser picks, which the standard expects to be a few seconds. It sends the last id it saw in a header called Last-Event-ID, so your server can replay what was missed.
The server can change that delay with a retry field, in milliseconds. Lines that start with a colon are comments, and the browser ignores them. The standard suggests one every 15 seconds or so, because some legacy proxies drop connections that look idle.
Here is the auction site's price stream as a Python async generator, a function that hands out values one at a time while it waits. A streaming response in your web framework sends each string it yields to the browser as it is produced.
import asyncio
import json
HEARTBEAT = 15 # seconds of quiet before a comment line
def sse_event(seq, payload):
return f"id: {seq}\nevent: bid\ndata: {json.dumps(payload)}\n\n"
async def price_stream(lot_id, after_seq, bids):
# after_seq: the Last-Event-ID header on a reconnect,
# or the last sequence number the page was rendered with
queue = bids.subscribe(lot_id) # listen before replaying
sent = after_seq
try:
for bid in bids.since(lot_id, after_seq): # replay after a drop
sent = bid.seq
yield sse_event(bid.seq, {"lot": lot_id, "price": bid.price})
while True:
try:
bid = await asyncio.wait_for(queue.get(), HEARTBEAT)
except TimeoutError:
yield ": keep-alive\n\n" # comment, ignored by EventSource
continue
if bid.seq > sent: # skip a bid already replayed
sent = bid.seq
yield sse_event(bid.seq, {"lot": lot_id, "price": bid.price})
finally:
bids.unsubscribe(lot_id, queue)Look at three parts. The replay loop starts after the last id, so a watcher back from a dead zone sees every bid they missed. The sent check drops a bid that arrived during replay, so nothing shows twice. After 15 quiet seconds, the comment line keeps the stream from looking idle.
Checking an SSE stream yourself
Point curl, a command-line HTTP client, at your stream with the -N flag, which turns off curl's own buffering. Send a Last-Event-ID header to test replay.
curl -N -H "Last-Event-ID: 2" https://YOUR-SITE/lots/7/streamExpect the stored bids from id 3 at once, each as id, event and data lines, then new bids as they land. On a quiet lot, a keep-alive comment line should appear every 15 seconds. If even the stored bids do not show at once, something on the path is buffering.
SSE rules that catch teams out
If a response comes back with a status other than 200, or a type other than text/event-stream, the standard says the browser fails the connection. It does not retry. A 204 No Content reply is the polite way to tell it to stop.
So a 502 error from a proxy during a deploy can end a stream for good. Listen for the error event, and if its readyState is CLOSED, create a new EventSource after a random delay.
The EventSource constructor takes only a URL and a withCredentials flag, which sends cookies when the stream comes from another origin, such as a different domain. You cannot add your own headers, so sign-in usually rides on cookies.
SSE is also one-way, so bids still go up as normal POST requests. And it is text only, so binary data, like an image, has to be encoded as text first.
There is a browser limit too. MDN, Mozilla's web documentation, notes that without HTTP/2, a newer version of HTTP, a browser allows only 6 open connections per domain, shared across all tabs. Chrome and Firefox marked raising it Won't fix.
HTTP/2 lets many streams share one connection. The server sets how many: RFC 9113 recommends at least 100, and nginx's default is 128.
How do WebSockets work?
A WebSocket starts as a normal HTTP request asking to switch protocols, and then the same connection becomes a two-way channel for messages. RFC 6455, from December 2011, defines the protocol.
The steps below follow the original handshake over HTTP/1.1. RFC 8441, from September 2018, added a way to run a WebSocket over one stream of an HTTP/2 connection, which this guide does not use.
The browser asks to upgrade
It sends an HTTP GET request with the headers Upgrade: websocket and Connection: Upgrade, plus a random key in Sec-WebSocket-Key.
The server agrees
It replies with status 101 Switching Protocols and a Sec-WebSocket-Accept value computed from that key. Browsers treat any other status, including a redirect, as a failed connection.
Both sides send frames
From now on, data moves in frames, chunks with a type: text, binary, continuation, ping, pong or close. A long message can be split across several frames.
Pings check the other end
Either side may send a ping, and the other must answer with a pong unless it is closing. This proves the peer is alive and keeps the link from looking idle.
A close frame ends it
Either side sends a close frame, so both know the end was planned and not a crash.
Two details surprise beginners. Every frame from browser to server must be masked, scrambled with a fresh random key. That stops a malicious page from choosing the exact bytes on the wire to confuse proxies. And wss means WebSocket over TLS, the encryption behind HTTPS, on port 443 by default.
Browser code cannot send pings at all. The WebSocket object in JavaScript has send and close but no ping. So the server sends the pings, or your app sends its own small heartbeat messages.
What WebSockets leave out matters just as much. There is no built-in reconnect, no message ids and no replay. You build all of that yourself, while EventSource gives SSE the reconnect and the last id for free.
Checking a WebSocket handshake
curl can send the opening request by hand. This one uses the sample key from RFC 6455, so the correct answer is known in advance.
curl -i -N --http1.1 \
-H "Connection: Upgrade" -H "Upgrade: websocket" \
-H "Sec-WebSocket-Version: 13" \
-H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" \
https://YOUR-SITE/console/Expect HTTP/1.1 101 Switching Protocols and the line Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=. curl then waits on the open connection, so press Ctrl+C. Any other status means the server or a proxy refused the upgrade. In our test, a 426 meant the proxy had dropped the upgrade headers.
Beginners can stop here. You now know the four options and how to pick one. The rest is for engineers: what open connections cost, how to scale them, and what nginx did to them in our tests.
How much do real-time connections cost?
Polling cost grows with the number of watchers times how often they ask. Push cost grows with open connections and messages sent. Push wins when many people watch and updates are frequent.
With short polling every 2 seconds, 8,000 watchers send 4,000 requests a second. Over HTTP/1.1, each one repeats its full headers, cookies included, and most replies say nothing changed.
With SSE, the same 8,000 watchers hold 8,000 open connections. A bid every 3 seconds becomes 8,000 messages, about 2,700 a second. Each carries about 50 bytes of event text, before network overhead, and every one is real news.
The new cost is memory. We opened idle SSE streams against a small Python 3.13 asyncio server and read its memory from the operating system. Each stream added about 11.5 KB, steady from 1,000 to 4,000 streams.
At that rate, 8,000 watchers need about 92 MB, before kernel socket buffers, which this number leaves out. Those buffers are memory the operating system keeps for each connection's data in flight. A full web framework adds more per connection, so measure your own stack before a big event.
To measure, note the server's resident memory, the RAM the process actually holds, with ps -o rss= -p PID, which prints kilobytes of 1,024 bytes each. Open a known number of idle streams, read it again, and divide the growth by the number of streams.
Open connections bring other bills that a simple request count misses:
- Idle timeouts. Proxies and load balancers close connections that look quiet; nginx's default is 60 seconds without data from the server.
- Pinned connections. A long connection stays on the server it first reached, so a new server gets no old connections until people reconnect.
- Deploys. Restarting a server drops every connection on it, and all those browsers come back at once.
- Fan-out. A bid accepted on one server must reach watchers connected to every other server.
- Open file limits. Each connection holds a socket, the operating system's handle for one network connection. Linux caps open handles per process, so check yours with ulimit -n.
How do you scale SSE and WebSockets across many servers?
You scale them by keeping the connection servers thin and putting a publish-subscribe channel behind them. Then every server hears every event for the lots its watchers follow.
Publish-subscribe, or pub/sub, means a sender publishes a message to a named topic, and every subscriber of that topic gets a copy. The sender does not need to know who is listening.
On the auction site, a bid arrives as a POST at the bid service. It checks the bid, saves it with the next sequence number for that lot, and publishes it. Every SSE server subscribed to that lot pushes it to its watchers.
In the diagram below, notice that bids go up one path and updates come down another. The SSE servers never decide anything about the auction.
The auction site's live bidding setup
Drag to exploreBids go up as normal requests. Updates come down through pub/sub and the SSE servers. Notice that the SSE servers hold no auction rules.
Diagram connections:
- Bidder's browser to Bid service (POST bid)
- Bid service to Bids database (Save with sequence)
- Bid service to Pub/sub channel (Publish)
- Pub/sub channel to SSE servers (4) (Fan out)
- SSE servers (4) to Bids database (Replay missed bids)
- Watchers' browsers to Load balancer (Open stream)
- Load balancer to SSE servers (4)
- Bidder's browser: Sends each bid as a POST request
- Bid service: Checks each bid against the stored price
- Bids database: Every bid with a sequence number per lot
- Pub/sub channel: One topic per lot
- SSE servers (4): Hold streams, push bids, no auction rules
- Load balancer: HTTP/2, buffering off, long idle timeout
- Watchers' browsers: 8,000 EventSource streams
This split has a useful side effect. The SSE servers hold no auction rules, so the team can add or restart them freely. The bid service stays small, because it handles only a few bids a second.
Because bids travel as normal POST requests, a retried bid could arrive twice. The bid service guards against that with an idempotency key, a unique id for each bid attempt, which has its own guide.
How do proxies like nginx break SSE and WebSockets?
In three ways: they buffer streams, cut quiet connections, and drop the headers a WebSocket needs. We reproduced all three on nginx 1.24.0 from Ubuntu's packages, with its default proxy settings.
The setup was the price stream above behind nginx, with a new bid every 2 seconds. curl read the stream through nginx, and a small script stamped the time each line arrived.
Default buffering held events back
Pause and predict: with nginx's defaults, how many bid events reach curl in 9 seconds? The server wrote the stored bids at once, then a new one every 2 seconds.
The answer is none. curl got 0 bytes in 9 seconds, because nginx buffers proxied responses by default (proxy_buffering on). The same stream sent with an X-Accel-Buffering: no header arrived live: stored bids at once, then one every 2 seconds.
A longer run showed where the events went. curl got nothing for 148 seconds, then 77 events at once, about 4 KB. That matches nginx's default buffer size, one memory page, which is 4 KB on this machine. The events were held until a buffer filled, then sent in a clump.
So turn buffering off for stream routes. Use proxy_buffering off in nginx, or have your app send X-Accel-Buffering: no. Both fixed it in our test.
Quiet connections were cut at 60 seconds
nginx's proxy_read_timeout closes a proxied connection when the server sends nothing for 60 seconds. A stream with no bids and no heartbeat died at 59.8 seconds.
With the 15-second heartbeat, the same quiet stream was still open after 78 seconds. But with buffering left on, curl had received 0 bytes. The heartbeats reset nginx's timer, then sat in its buffer.
WebSockets behaved the same way. With pings turned off on the server, nginx closed an idle WebSocket at 60.1 seconds without a close frame, so the client saw code 1006, meaning abnormal closure.
With the Python websockets library's default (version 17.3) of one ping every 20 seconds, the same idle WebSocket stayed open past 75 seconds and still echoed a message.
WebSockets need two headers passed on
Upgrade and Connection are hop-by-hop headers, meant only for the next machine, so nginx does not forward them. Without them, our server answered 426 Upgrade Required. With them, the handshake returned 101 and messages echoed back.
This is the nginx config we tested, with the SSE and WebSocket routes side by side. The two upstream names point at groups of servers defined elsewhere in the file.
location /lots/ {
proxy_pass http://sse_servers;
proxy_buffering off; # SSE: pass each event on at once
proxy_read_timeout 60s; # the default; heartbeats must beat it
}
location /console/ {
proxy_pass http://ws_servers;
proxy_http_version 1.1; # needed before nginx 1.29.7
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}As of October 2026, the nginx docs say proxy_http_version 1.1 is needed only before version 1.29.7, which made 1.1 the default. Version 1.24.0 still needs the line.
What goes wrong with real-time connections in production?
Most problems come from connections that drop: idle timeouts, deploys, phones switching networks, and thousands of browsers reconnecting at once. Treat drops as normal, not rare.
Reconnect storms
When the team restarts one SSE server, 2,000 browsers lose their stream together. If all of them wait the same default delay, the other servers take a sudden wave of new connections.
The fix is jitter, a small random delay. The team sends each stream its own retry value, somewhere between 2 and 10 seconds. It also restarts servers one at a time, and closes each one's streams in small batches.
The standard also lets browsers add exponential backoff, a delay that grows after each failure. So treat retry as a hint, not an exact timer.
Missed and repeated messages
A reconnect can skip events or repeat some. Give every event a sequence number per lot. The server replays from the last id and skips anything already sent, and the browser can ignore any number it has shown.
Slow readers
A watcher on a weak mobile signal may read slower than bids arrive. Their unsent messages pile up in server memory. Cap each connection's backlog, and drop the stream when it overflows. The browser reconnects and catches up through replay.
The team's checklist for any real-time feature:
- Send a heartbeat more often than the shortest idle timeout on the path.
- Turn proxy buffering off for stream routes, and forward Upgrade and Connection for WebSockets.
- Randomise reconnect delays, recreate a closed EventSource, and restart servers one at a time.
- Number every event and support replay from the last id.
- Cap the backlog per connection and drop slow readers.
- Watch open connections, reconnects per minute and delay from bid to screen.
How do you explain WebSockets vs SSE in a system design interview?
Start from the traffic pattern, pick the simplest option that fits, then show that you know what open connections cost.
Ask who sends what
Find out how many people watch, how often data changes, and whether clients also send often.
Pick the transport
SSE for one-way push, WebSockets for two-way chatter, long polling as the fallback. Give the reason in one sentence.
Split the layers
Connection servers hold streams. A normal service owns the rules. Pub/sub carries events between them.
Make drops safe
Sequence numbers, replay from the last id, heartbeats and random reconnect delays.
Size it out loud
Estimate open connections and memory per server, and messages per second at the busiest moment.
Name the failure modes
Proxy buffering and idle timeouts, reconnect storms after deploys, and slow readers that fill server memory.
Questions people ask
- Is SSE better than WebSockets?
- SSE is better when only the server needs to push. It is plain HTTP, and the browser reconnects and sends the last id for you. WebSockets are better when both sides send often, or when you need binary data. Neither wins everywhere.
- Can SSE send data from the browser to the server?
- No. An SSE stream flows from server to browser only. The browser sends data the usual way, with normal HTTP requests such as POST. Over HTTP/2, those requests can share one connection with the stream.
- Is long polling still used?
- Yes, mostly as a fallback. It works through almost any network, because each round is an ordinary HTTP request. Its costs are full headers on every message and extra delay when updates arrive in bursts.
- What is the difference between short polling and long polling?
- Short polling asks on a fixed timer, and the server answers at once, often with nothing new. Long polling asks once, and the server holds the request until there is news or a time limit passes. Long polling wastes fewer requests and delivers news sooner.
- Why does my SSE stream work locally but not behind nginx?
- Usually because nginx buffers the response. With default settings in our nginx 1.24.0 test, curl received nothing for 9 seconds while the server sent bids. Add proxy_buffering off to the stream route, or send an X-Accel-Buffering: no header, and keep heartbeats well under the 60-second read timeout.
- Do WebSockets work behind load balancers and proxies?
- Yes, if every machine on the path supports the upgrade. A proxy must forward the Upgrade and Connection headers, and its idle timeout must be longer than your ping gap. In our nginx 1.24.0 test, missing headers gave 426, and an idle socket without pings closed at 60 seconds.
- How many WebSocket connections can one server handle?
- It depends on memory per connection, the operating system's limit on open files, and how many messages you send each second. Our small Python test measured about 11.5 KB per idle SSE stream; we did not measure WebSockets, and your stack will differ. Load test your own server with realistic message rates, and note where memory or delay breaks first.
- Does SSE work with HTTP/2?
- Yes, and it works better there. Over HTTP/1.1, browsers allow only six connections per domain, so many tabs can block each other. Over HTTP/2, many streams share one connection; RFC 9113 recommends allowing at least 100, and nginx defaults to 128.
Sources and further reading
- RFC 6455: The WebSocket Protocol (IETF, December 2011)
- RFC 6202: Known Issues and Best Practices for the Use of Long Polling and Streaming in Bidirectional HTTP (IETF, April 2011)
- HTML Living Standard: Server-sent events (WHATWG)
- WebSockets Living Standard: the browser WebSocket API (WHATWG)
- MDN Web Docs: Using server-sent events (connection limit note)
- nginx documentation: WebSocket proxying
- nginx documentation: proxy module (proxy_buffering, proxy_read_timeout, proxy_http_version)
- nginx documentation: HTTP/2 module (http2_max_concurrent_streams)
- RFC 9113: HTTP/2, section 6.5.2 (SETTINGS_MAX_CONCURRENT_STREAMS)
- RFC 8441: Bootstrapping WebSockets with HTTP/2 (IETF, September 2018)
- websockets library documentation: Keepalive and latency
- Python documentation: asyncio exceptions (TimeoutError alias since 3.11)