Learning Objectives
- Say why a
fetchstarted in an unload handler often never leaves the machine - Send data with
navigator.sendBeaconand say what its return value does and does not promise - Name the
Content-Typethe browser sets for each kind of beacon payload - Choose between
sendBeaconandfetch(url, { keepalive: true })and state the size limit they share - Fire an exit request on the event that actually runs, and say what
unloadcosts - Describe polling, long polling, and streaming into a never-finished response, and what each one was working around
- Open an
EventSource, read the wire format, and stop it from reconnecting when the run is over - Say what
retry:,id:, andLast-Event-IDdo - List what a WebSocket gives up by not being HTTP after the handshake
- Decide between polling, SSE, and WebSocket from who initiates and how often
One way out: navigator.sendBeacon
Analytics, error reports, and "how long did they read this" all want to send something as the user leaves. The obvious code does not work. When a document goes away the browser tears down its fetch group and cancels the requests in it, and a fetch started in an exit handler is usually one of them.
navigator.sendBeacon(url, data) is the fix. It hands the data to the browser, which owns the request from then on, and returns a boolean immediately. There is no promise, no callback, and no response object. You never learn what the server said.
| What it returns | What that means |
|---|---|
true | The browser queued the data for transfer. Not that it was sent, not that it arrived, not that the server answered 2xx. The transfer happens after your code returns. The Beacon specification says so. |
false | The browser refused to queue it. The specification names one cause: the payload would push the keepalive budget over its limit. |
A thrown TypeError | The URL did not parse, or its scheme is not http or https. Chrome 151 says Beacons are only supported over HTTP(S). |
The request is a POST, always, with credentials included, so cookies go with it. The Content-Type is decided by what you pass, and that choice decides whether a cross-origin beacon preflights. Measured on the wire in Chrome 151 against /api/echo:
| What you pass | Content-Type sent | Request mode | Preflights cross-origin |
|---|---|---|---|
| A string | text/plain;charset=UTF-8 | no-cors | No |
URLSearchParams | application/x-www-form-urlencoded;charset=UTF-8 | no-cors | No |
FormData | multipart/form-data; boundary=… | no-cors | No |
ArrayBuffer, a typed array, or an untyped Blob | None | no-cors | No |
new Blob([json], { type: 'application/json' }) | application/json | cors | Yes |
Do not type the blob application/json
It is the natural way to send JSON and it is the one row in that table that costs a preflight. Step 08 covers why: application/json is not a CORS-safelisted Content-Type, so the browser asks permission first. On an exit path that OPTIONS round trip has to complete while the page is being destroyed, and if it does not, the beacon is dropped with no report to anyone. sendBeacon already returned true.
Send the JSON as a plain string. It goes out as text/plain;charset=UTF-8, which preflights nothing, and the server parses it with the same call it was going to use anyway. The demo below does this.
fetch with keepalive
fetch(url, { keepalive: true }) sets the same flag on the request that sendBeacon sets, so the browser keeps it alive past the document. It is the more capable of the two: any method, your own headers, and a real Response, if the page survives to read it.
Both share one budget. The Fetch standard adds up the bodies of every keepalive request still in flight in the document's fetch group, adds the new one, and returns a network error if the total is over 64 KiB.
The 1 KiB call and the 10 KiB call are both small, and only the second one fails. The budget is summed. Over it, sendBeacon returns false and fetch rejects with a TypeError. Send small payloads, and if you have a lot to report, send it during the session rather than at the end of it.
Fire it on the right event
Having a transport that survives the page does not tell you when to use it. Pick the wrong event and the code never runs, or it runs and costs the user a slower back button.
unload is not the answer, and is going away
- It often does not fire on mobile. A user who switches apps and never comes back gets the page killed by the operating system, and no unload handler runs. The Beacon specification names
visibilitychangeas the only event guaranteed to fire in that case. - It costs the back button. Chrome on desktop and Firefox both refuse to put a page with an
unloadlistener into the back/forward cache. Chrome'snotRestoredReasonsreports it asunload-listener. Chrome on Android and Safari cache the page anyway and never run the handler. The data is lost either way. - Chrome is turning it off. The deprecation changes the default so unload handlers stop firing, rolled out by percentage of page loads: 1% in Chrome 146 in March 2026, 40% in Chrome 150, 60% in Chrome 151 on July 28, 2026, and 100% scheduled for Chrome 154 on September 22, 2026. A site that needs the old behavior opts back in through Permissions Policy, and
Permissions-Policy: unload=()turns it off early.
beforeunload is not a substitute. Its job is the "you have unsaved changes" prompt and nothing else. Chrome caches a page that has one; MDN says Firefox will not.
The two events to use
Send on visibilitychange when document.visibilityState is 'hidden'. That fires when the tab is switched, when the window is minimized, when the phone goes to the home screen, and on the way out of the page. Add pagehide for the desktop case where a tab is closed without ever being hidden.
pagehide fires before visibilitychange
Plenty of tutorials say the opposite, and they inherited it from the standard. whatwg/html pull request 5928, merged September 23, 2020, put visibilitychange first. The standard's current steps for unloading a document fire pagehide and update the visibility state after it. Measured in Chrome 151, navigating away from a page on this site:
Reading document.visibilityState inside a pagehide handler and expecting 'hidden' gets you 'visible'. Write the two handlers so neither depends on which ran first.
A page entering the back/forward cache fires pagehide with persisted set to true, then visibilitychange, and no unload. Coming back fires pageshow with persisted true, and visibilitychange again. Both handlers above run more than once in a session that uses the back button. Make the payload safe to send twice.
Say what you are sending
An exit beacon is a request the user cannot see, cannot cancel, and gets no feedback about. Analytics beacons routinely carry a session ID, the URL, the referrer, scroll depth, and how long the page was open, which together identify a person's reading across a site. Write down what is in the payload, put it in the privacy policy, and drop the fields nobody has asked a question about. The demo below prints its payload on the page before it sends it.
One way in, before the browser could receive
HTTP is a request and a response. The client speaks first, every time. For the first decade of the web there was no way for a server to say anything to a page that had not just asked, and applications that needed it built three workarounds. Alex Russell named the family Comet on March 3, 2006, in a post called Comet: Low Latency Data for the Browser. His own gloss on the name was "it doesn't stand for anything, and I'm not sure that it should."
Poll faster
Ask again on a timer. setInterval and a fetch every two seconds, and the page is never more than two seconds behind. Every one of those requests costs a round trip, a set of headers, and a handler invocation on the server, and almost all of them answer "nothing new." Halving the delay doubles the cost and halves the lag.
Long polling
Send the request and let the server hold it. No answer comes back until there is something to say or a timeout fires, and the client sends the next request the moment the last one lands. The lag drops to the network time. The cost is a server thread or connection parked per idle client, which killed it on the thread-per-request servers of 2006, and a gap between the response arriving and the next request leaving where an event can be missed.
The long slow load
Return a response and never finish it. The server sends a chunk, flushes, and keeps the connection open, and the browser processes what has arrived. The 2006 version put a hidden <iframe> on the page and wrote <script> tags into it, each one calling a function in the parent document. Proxies that buffered responses broke it, the browser's loading indicator spun the entire time, and the document's memory use grew for as long as the stream lasted.
All three are the same request-response protocol with the client arranging to always have a request in flight. Server-sent events and WebSocket replaced the arrangement with a protocol feature. RFC 6455 standardized WebSocket in December 2011.
Server-sent events
One line opens a connection the server writes to for as long as it wants.
The server answers with Content-Type: text/event-stream and writes records. A record is one or more fields, one per line, followed by a blank line. The blank line dispatches the event.
| Field | What it does | Default when absent |
|---|---|---|
event: | Names the event, so addEventListener('tick', …) receives it | message, delivered to onmessage and to addEventListener('message', …) |
data: | The payload. Repeat the line and the values are joined with a newline. | The event is not dispatched at all |
id: | Stores an ID the client sends back as Last-Event-ID on its next reconnect | No header on reconnect, so the server cannot resume |
retry: | Sets the reconnection delay in milliseconds, for this and every later reconnect | Implementation-defined. Chrome uses 3000, Firefox 5000. |
: at the start of a line | A comment, ignored. Servers send one every so often to keep an idle connection from being closed by a proxy. | Nothing |
The stream is UTF-8, and the specification allows no other encoding. The client sends a GET and cannot send anything else, cannot set a request header, and cannot receive binary. The constructor takes a URL and one option, withCredentials. Cross-origin, an EventSource is subject to CORS like any other read, so step 08 applies to it in full.
The reconnect is not an error path
An EventSource reopens a connection that ended. The server finishing its work and the network dropping look the same from the client, and the answer to both is to try again about three seconds later. Chrome restarts the demo's ten-tick run from n=1, forever, unless something in the page stops it.
Call es.close(). Nothing the server sends over an open stream ends it: event: done in this series is an agreement between worker/api/stream.js and the page, not a protocol feature.
A dropped TCP connection retries. A 500 on the opening response does not. The client gives up only on the response that opens the connection: if that response is not a 200, or its Content-Type is not text/event-stream, it fails the connection, readyState goes to 2, one error event fires, and nothing retries.
Resuming, and the connection budget
Send an id: with each record and the client stores it. On the next reconnect it sends the last one back in a Last-Event-ID request header, and a server that keeps a log can send what was missed. Without id: there is no header, and a reconnect is a fresh start with a gap in it. The endpoint in this series sends no id:, so the demo's lastEventId column is empty in every row.
Over HTTP/1.1 a browser opens at most six connections per origin, and an open EventSource holds one of them for its whole life. Six streams to one host and every other request to that host waits, across all tabs, because the limit is per browser and origin rather than per page. Over HTTP/2 the streams are multiplexed on one connection and the ceiling is SETTINGS_MAX_CONCURRENT_STREAMS, which the server advertises and RFC 9113 recommends be no lower than 100. Serve SSE over HTTP/2.
WebSocket
Both ends send whenever they have something to send, over one connection, in text or binary.
It starts as HTTP. The browser sends a GET with an Upgrade header, and a server that agrees answers 101 and stops speaking HTTP.
Sec-WebSocket-Key is a random 16-byte nonce, base64 encoded. The server appends a fixed public GUID, takes the SHA-1 of that string, base64 encodes the digest, and returns it as Sec-WebSocket-Accept. It proves the responder understood the request as a WebSocket handshake rather than a cache or a plain HTTP endpoint being talked into forwarding frames.
There is no CORS here
A WebSocket is not restricted by the same-origin policy and sends no preflight. Any page may open a socket to any host that accepts one. The browser sends an Origin header and the server decides. A server that does not check it accepts every origin.
Cookies ride along on the handshake, because the handshake is an HTTP request. A server that authenticates by cookie and skips the origin check can be driven by any page a logged-in user visits. That attack has a name, cross-site WebSocket hijacking, and the defense is to check Origin and to require a token the attacker's page cannot read.
What you give up
After the 101 the connection is a frame stream. Nothing from HTTP applies to what travels on it.
- No status codes. There is no 404 for a message that named something missing and no 429 for one you sent too fast. Your messages carry their own success and failure fields.
- No caching. No browser cache, no CDN, no
Cache-Control, no conditional request. Everything is sent to every client every time. - No content negotiation and no automatic compression.
Content-TypeandAccept-Encodingapply to the handshake and to nothing after it. Compression exists as a negotiated extension,permessage-deflate. - No request headers from the browser.
new WebSocket(url, protocols)takes a URL and a subprotocol list. There is no way to send anAuthorizationheader, so credentials go in a cookie, a query string, or the first message. - No reconnect and no heartbeat. The protocol has ping and pong frames, and the browser API does not expose them. Both the keepalive and the backoff are code you write.
A close event carries a code. 1000 is a normal close and 1001 means one end is going away, including the browser navigating off the page. 1006 never travels on the wire: the browser writes it locally when the connection died without a close frame, and event.wasClean is false. Most production reconnect logic is written against 1006.
ws: and wss: are the two schemes, on ports 80 and 443. A page served over HTTPS cannot open a ws: connection: it is blockable mixed content, it is not upgraded for you, and there is no prompt. Use wss:.
When it earns the connection
Use a WebSocket when the server initiates and the messages are frequent. Chat, multiplayer game state, collaborative editing, live cursors, a trading feed. All of them carry traffic both ways, often enough that a round trip per message is the cost that matters.
A notification badge that changes twice an hour is not that. Neither is a dashboard that refreshes on the minute, or a progress bar for a job on your own server. Poll the first two and use SSE for the third. Each held socket is a connection, a load-balancer slot, and per-client state on a server that could otherwise have forgotten you between requests. You write the reconnect and the heartbeat.
| Polling | SSE | WebSocket | |
|---|---|---|---|
| Direction | Client asks | Server sends | Both |
| Protocol | HTTP | HTTP | HTTP for the handshake only |
| Payload | Anything | UTF-8 text | Text or binary |
| Reconnect | Not applicable | Automatic, with Last-Event-ID | Yours to write |
| Caching, status codes, headers | All of HTTP | All of HTTP | Handshake only |
| Cross-origin | CORS | CORS | Origin header, checked by the server |
| Cost while idle | A request per interval | One held connection | One held connection |
Demo: a stream in, a beacon out
What changed from step 08: the second origin, the preflight, and the posts list are gone. Every call is same-origin, and none of them is a question with an answer you read. The instrumentation strip keeps the load counter, the build time, and the query string, and counts ticks, beacons, and restarts.
Five things to run.
- Open the stream with the box checked. Ten
tickrows arrive a second apart, thendone, then aclose()row.readyStateends at2 (CLOSED)and nothing else happens. - Uncheck the box and open it again. After
donethe row is anerrorwithreadyStateback at0 (CONNECTING), and about three seconds later the ticks start over atn=1. The restart counter moves. The page stops after two restarts. A page with this bug in production would not. - Read the payload before you send it. The box above the buttons prints the JSON this page will post about you. Three numbers, no identifier.
- Switch to another tab and come back. A beacon went out on
visibilitychangewith no button pressed, and the row names the trigger. The value in the last column is whatnavigator.sendBeaconreturned. - Press both send buttons and watch the panel at the bottom. Only the
fetchkeepalive call appears in it, with its 202. The beacons are invisible to it, and so is the stream.
The panel records this page's traffic by wrapping fetch and XMLHttpRequest, which covers every call steps 03 through 08 made and one of the three this page makes. EventSource and navigator.sendBeacon are separate APIs, so nothing wrapping fetch sees them. Open DevTools and select the Network panel: the stream is one row of type eventsource that stays pending for the length of the run, and each beacon is its own POST row.
The demo has no WebSocket in it. The Worker behind this series has no WebSocket route and adding one to teach the client API is more server than the series is willing to write. The handshake above is what goes over the wire; run it against a local ws:// server or a public echo service to watch it happen.
Closing the series
Step 01 was a form with no JavaScript in it. It serialized its fields, chose a method, sent the request, handled the response, and told the user what happened, and you wrote none of that. Every step since replaced one piece of it with code of your own. You took the request, the encoding, the error handling, the retry, the permission check, and finally the connection.
Each replacement bought something the form could not do. Each one also moved a job the browser was already doing onto you, permanently. Ask two questions of the next network API you pick up: what was the browser doing here, and what happens to that now.
The APIs series continues into storage and the observers. The Browser for Programmers is the other half of every step here: module 2 for what happens between the URL and the first byte, module 7 for the security model behind step 08, and module 8 for what the browser keeps after the response is read.