WebSocket & Server-Sent Events
Learn long polling, WebSocket, Server-Sent Events, and how to choose a real-time transport without opening external network connections.
- 01Compare the four waysTell short polling, long polling, Server-Sent Events, and WebSocket apart by who sends and when.
- 02Use the browser tools safelyName the WebSocket states and close codes, and read the text lines of a Server-Sent Events stream.
- 03Choose the right onePick a way to get updates for chat, scores, notifications, games, and checking on a slow job.
Some pages change while you watch. A score goes up, or a chat message appears, and you never press refresh. Often the new data arrives as a short line of text from the server. Here is one such line.
const line = "data: tea";
console.log(line.startsWith("data:"));
console.log(line.slice(6));Line 1 stores one line of text: data: tea. Line 2 checks that the line starts with data:, so it prints true. Line 3 cuts off the first six characters, data: with its space, and prints tea.
Later in this lesson, the browser does this cutting for you. It hands your code the rest of the line, tea, as event.data. The value stays text. The browser does not turn it into JSON.
Real-time means “tell me when it happens”
So far, most network code you have seen follows one pattern, called request and response:
- Your code asks the server for something. This is the request.
- The server sends back one answer. This is the response.
- Then that exchange is over.
This works well for loading a profile, saving a form, or getting JSON with fetch(). A real-time page is different. It shows new data as soon as the server has it, without a refresh.
JavaScript has four common ways to get these updates. This lesson covers each one:
- Short polling: the page asks the server again and again, on a timer.
- Long polling: the page asks once, and the server waits to answer until it has news.
- Server-Sent Events (SSE): the server keeps sending text updates to the page.
- WebSocket: the page and the server stay connected, and both can send messages at any time.
The four ways differ in a few things:
- Who can send: only the server, or both sides.
- Whether they reconnect by themselves after the connection drops.
- Whether they can carry binary data: raw bytes, such as an image, instead of text.
- How well they pass through a proxy. A proxy is a computer between the browser and the server that passes messages along.
- How much code you have to write yourself.
You follow a football match on your phone. You can refresh the page every minute. You can ask once and wait for the next goal. The score page can send you each score as it happens. Or you can chat with other fans while the scores arrive.
So the question is not “which way is newest?” Ask instead: who needs to send, how often, and what kind of data? A score page mostly listens. A game with many players sends both ways. A page that checks whether a report is ready may only need to ask once a minute.
- In real life: You refresh the score every minute
- In JavaScript: A timer calls
fetch()again and again - In real life: You ask once and wait for the next score
- In JavaScript: One request waits until there is news or time runs out. Then you ask again
- In real life: The score page sends you each new score
- In JavaScript: The server keeps sending text updates to the browser
- In real life: You also send messages about the match
- In JavaScript: The page and the server both send messages at any time over one open connection
This site never connects to other servers. So the WebSocket and long-polling demos use a pretend server that runs inside this page. It uses the same event names and numbers as the real thing.
The SSE demo first tries the browser's real EventSource, with the stream text stored inside the page in a data: URL. If the browser blocks that, a small, tested function reads the same text instead.
Long polling: ask, wait for news, ask again
INTERACTIVEShort polling is the simplest way. A timer asks the server every few seconds: “Anything new?” It works almost everywhere, because each ask is a normal HTTP request. HTTP is the same way of talking to a server that fetch() uses.
Short polling has two costs:
- When nothing changes, the requests still go out and come back empty.
- A new update has to wait until the next timer tick.
Long polling changes one thing. The browser sends a request, but the server does not answer right away. It holds the request until it has news, or until a time limit runs out. This time limit is called a timeout.
As soon as the answer arrives, the browser handles it and sends the next request. Each message still costs one request. But quiet times no longer fill up with empty “no change” answers.
const updateAt = 18;
const nextPoll = 20;
console.log(nextPoll - updateAt, "seconds late");
console.log("long polling: answer when the update arrives");Line 1 says the update happens at second 18. Line 2 says the next short-polling request goes out at second 20. Line 3 subtracts the two and prints 2 seconds late. Line 4 describes long polling. Its request is already waiting at the server, so the server can answer the moment the update happens. These numbers are a made-up example, not a real network clock.
// Short polling: ask every 10 secondsGET /updates?since=0 -> no changeGET /updates?since=0 -> update at 20s // Long polling: ask once and waitGET /updates?since=0 -> server holds update at 18sShort polling sends 6 requests per minute. It can get the update 2s after it happens. Long polling keeps one request waiting, so in this example it gets the update at once.
Long polling is useful in two cases:
- Old network equipment blocks connections that stay open, like WebSocket.
- The server is already built to answer ordinary requests, one at a time.
Long polling code needs the same care as other retry code: a loop, a timeout, and error handling. You saw this kind of retry code in the Async patterns lesson.
WebSocket: one open, two-way conversation
STEP THROUGHYou create a WebSocket with new WebSocket(url, protocols):
- The
urlstarts withwss://, like"wss://example.com/chat". - The
protocolsargument is optional. It names the message format both sides agree to use, like"chat.v1".
First the browser and the server agree to connect. This first exchange is called the handshake. After it, both sides can send messages at any time. That is why chat, games with many players, shared documents, and other pages where the user also sends a lot usually use WebSocket.
A WebSocket is always in one of four states. It keeps its current state in a property called readyState, as a number from 0 to 3.
const states = ["CONNECTING", "OPEN", "CLOSING", "CLOSED"];
console.log(states[1]);
console.log(String(1));Line 1 lists the four state names in number order. Line 2 prints states[1], the item at index 1, which is OPEN. Line 3 prints 1, the number for that state. The name and the number mean the same state. Printing them does not open anything, though. Only a real socket in the OPEN state can send.
OPEN can send() send your message.When a WebSocket closes, it reports a close code: a number that says why it closed.
const closeCodes = { normal: 1000, goingAway: 1001, abnormal: 1006 };
console.log(closeCodes.normal);
console.log(closeCodes.abnormal);Line 1 stores three common close codes in an object. Line 2 prints 1000, the code for a normal close. Line 3 prints 1006, the code for a connection that vanished without a proper close. The browser can report 1006 to your code. But your code is not allowed to send 1006 itself.
When you call someone, you can't talk right away. First the phone dials. Then the call connects, and only then can you talk.
WebSocket has the same rule: send only after the open event, when readyState is OPEN.
- In real life: The phone is dialing
- In JavaScript:
CONNECTING, readyState0 - In real life: The call is connected
- In JavaScript:
OPEN, readyState1;send()is allowed - In real life: You are hanging up
- In JavaScript:
CLOSING, readyState2 - In real life: The call has ended
- In JavaScript:
CLOSED, readyState3 - In real life: Asking ‘Still there?’ when the line goes quiet
- In JavaScript: Heartbeat messages: ping and pong
Step through one WebSocket from start to end below: create it, wait for it to open, send a message, get messages back, and close it.
This replay uses a pretend WebSocket, so nothing leaves the page. It uses the real WebSocket event names and readyState numbers.
script
socket.addEventListener("open", () => { socket.send("hello bots");}); socket.addEventListener("message", (event) => { console.log(event.data);}); socket.addEventListener("close", (event) => { console.log(event.code, event.reason);});A few more facts matter in real apps:
send()takes text or binary data. Binary data can be aBlob, anArrayBuffer, or a typed array. All three are ways to hold raw bytes.- Calling
send()while the socket is stillCONNECTINGthrows an error namedInvalidStateError. bufferedAmountis the number of bytes waiting to go out. You passed them tosend(), but the browser has not sent them yet.- Close code
1000means a normal close.1001means one side is going away, for example because the page is closing.1006means the connection vanished; only the browser reports it. - WebSocket never reconnects by itself.
- If the page was loaded over HTTPS, use a
wss://URL. It is the secure kind.
const socket = new WebSocket("wss://example.test/chat", "chat.v1"); socket.addEventListener("open", () => { socket.send("Hello room!");}); socket.addEventListener("message", (event) => { appendToLog(event.data);}); socket.addEventListener("close", (event) => { showCloseCode(event.code, event.reason);});- Not connected. Press Connect to join the pretend room.
Nothing leaves this page: the room is pretend. It uses the real WebSocket event names and readyState numbers. Current state: CLOSED.
In the playground, the Drop connection button reports 1006, so you can see a connection vanish. The Reconnect with backoff button waits a little, then creates a new socket. Backoff means waiting longer after each failed try. Real apps also set a longest wait, check whether the device is online, and log the user in again when needed.
function retryDelay(attempt) {
return Math.min(8000, 250 * 2 ** attempt);
}
console.log(retryDelay(0));
console.log(retryDelay(3));Line 1 starts a function that takes the attempt number. Line 2 works out the wait in milliseconds:
2 ** attemptis 2 to the power ofattempt: 1, 2, 4, 8, and so on. Times 250, that gives 250, 500, 1000, 2000. So the wait doubles with each attempt.Math.minreturns the smaller of its two numbers. So the wait never goes above8000.
Line 4 prints 250 for attempt 0. Line 5 prints 2000 for attempt 3, because 250 × 2 × 2 × 2 is 2000. The limit matters during a long outage. Without it, the next wait could grow to hours.
A heartbeat is a tiny message that asks “Still there?” on a quiet connection. If no answer comes back, the app knows the connection is broken.
const appHeartbeat = "ping";
console.log(appHeartbeat);
console.log("pong");Line 1 picks the app's heartbeat message, "ping". Line 2 prints it. Line 3 prints the answer the app expects back, pong. These are normal app messages. The WebSocket protocol has its own ping signal too, but page JavaScript cannot send it.
WebSocketStream is a newer way to use WebSockets, built on streams. At the time of writing, only Chromium-based browsers, such as Chrome and Edge, have it. Most code still uses WebSocket.
Server-Sent Events: a one-way text stream
INTERACTIVEServer-Sent Events (SSE) let a server keep sending text to the page. In the browser, you use an object called EventSource. The server answers one normal HTTP request, but it never finishes the answer. It keeps adding text to it. The answer is marked with the type text/event-stream.
Messages go one way only: from the server to the browser. That is fine when the page mostly listens. In return, the browser does several jobs for you:
- It reconnects by itself if the connection drops.
- It tells your code the name of each event.
- It remembers the id of the last event.
- It follows the server's advice on how long to wait before reconnecting.
The stream is plain text, with one field on each line:
event:names the event. You listen for it withaddEventListener(name, ...).data:holds the message. Your code reads it asevent.data.id:setslastEventId. When the browser reconnects, it sends this id back in aLast-Event-IDheader. A header is a named note sent along with a request. It tells the server where to start again.retry:says how many milliseconds to wait before reconnecting.- A blank line ends one event and sends it to your code.
An event with no event: line goes to onmessage. A named event does not. It only reaches a listener added for its own name.
const source = new EventSource("data:text/event-stream,..."); source.addEventListener("score", (event) => { console.log(event.lastEventId, event.data);}); source.onmessage = (event) => { console.log("message", event.data);}; source.close(); // stop reconnectingretry: 1200id: kickoffevent: scoredata: Owls 0, Foxes 0 id: goal-1event: scoredata: Owls 1, Foxes 0 id: note-1data: Halftime snacks are ready- Press a button to get the events.
Not started Mode: parser. The backup reader is a small function, tested outside the browser.
SSE carries only text, but that text can be JSON. It fits notification lists, live sports scores, progress messages, logs, and dashboards. To send cookies with the request, write new EventSource(url, { withCredentials: true }).
One more limit: with the older HTTP/1.1, a browser keeps only a few connections open to one site, often six. Many tabs with SSE open can use them all up. The newer HTTP/2 usually avoids this, because many streams share one connection.
Choosing a transport
SORT ITA transport is the way updates travel between the server and the page. To choose one, start with direction: who needs to send?
The table compares the choices. Read down a column to learn one choice, or across a row to compare one question.
| Question | Polling or long polling | Server-Sent Events | WebSocket |
|---|---|---|---|
| Who can send? | The page asks, and the server answers. | Only the server sends. | Both sides send at any time. |
| What does it use? | Normal HTTP requests. | One HTTP answer of type text/event-stream that keeps going. | A WebSocket connection. It starts as an HTTP request that asks to switch. |
| Does it reconnect by itself? | No. Your loop decides. | Yes. It uses retry and Last-Event-ID. | No. Your app decides. |
| Can it carry binary data? | Yes, as normal answers. | No. It carries only text (UTF-8). | Yes. It carries text and binary data. |
| Does it pass through proxies? | Yes, very well. | Mostly. Some proxies hold text back, and connection limits matter. | Sometimes the proxy must support WebSocket first. |
| Where is it used? | Checking a slow job, and as a backup plan. | Scores, notifications, progress, and dashboards. | Chat, games, shared editing, and live controls. |
- Live sports scores: the server sends each new score, and the page only listens
- A game with many players: every player sends moves and gets updates quickly
- Check once a minute whether a report is finished
- Chat room: people type at any time and get messages at any time
- Notifications list: new items appear while the tab is open
- Very old network equipment where proxies block connections that stay open
Put each situation under the simplest transport that fits who sends and how often.
A simple rule: pick the simplest thing that does the job.
- If updates can wait a while, use polling.
- If the server sends text and the page only listens, start with SSE.
- If both sides need to send at any moment, use WebSocket.
- If the network equipment blocks these, long polling still works.
Where you will use this
In a real app, the code around the connection matters as much as the connection. It has to:
- read each message,
- update what the page shows,
- show whether the connection is working,
- close the connection when that part of the page goes away,
- and avoid adding the same listener twice.
The examples below leave out framework code, so the connection code is easy to see.
const socket = new WebSocket("wss://example.com/chat", "chat.v1"); socket.addEventListener("open", () => { socket.send(JSON.stringify({ type: "join", room: "lobby" }));}); socket.addEventListener("message", (event) => { const message = JSON.parse(event.data); renderChatMessage(message);}); socket.addEventListener("close", (event) => { scheduleReconnect(event.code);});Line 1 opens the socket. Line 4 runs once the socket is open and sends a join message as JSON text. Line 8 turns each message that arrives back into an object with JSON.parse. Line 13 runs when the socket closes and plans a new connection. renderChatMessage and scheduleReconnect stand for your own app's functions.
const feed = new EventSource("/notifications/stream", { withCredentials: true,}); feed.addEventListener("notification", (event) => { const item = JSON.parse(event.data); addNotification(item);}); feed.onerror = () => { showStatus("Reconnecting...");};Lines 1 to 3 open the stream and send cookies with it. Line 5 listens for events named notification, and line 6 reads each one as JSON. Line 10 runs when the connection has a problem. The browser is already reconnecting by itself, so the code only shows a message.
function chooseRealtimeTransport({ clientTalks, binary, textUpdates, rareUpdates }) { if (clientTalks || binary) return "WebSocket"; if (textUpdates) return "Server-Sent Events"; if (rareUpdates) return "Polling"; return "Long polling";}Line 2 picks WebSocket when the page must send too, or when the data is binary. Line 3 picks Server-Sent Events for text updates from the server. Line 4 picks polling when updates are rare. Line 5 uses long polling for everything else.
One step is easy to forget: closing. When a part of the page no longer needs updates, call socket.close() or eventSource.close(). Otherwise a hidden tab, or a part of the page you already removed, keeps getting data and doing work.
Common misconceptions
- “Real-time always means WebSocket.” If data only goes from the server to the browser, SSE is often simpler. It also reconnects by itself.
- “SSE can replace every WebSocket.” SSE goes one way and carries only text. To send from the page, use a separate request, or use WebSocket.
- “WebSocket reconnects by itself.” It does not. After the
closeevent, that socket is finished. Your code must create a new one. - “I should send close code 1006.” No. 1006 is kept for the browser, to report a connection that vanished. Your code may not send it.
- “Long polling is fake real-time.” It is a real, useful way to get updates, especially as a backup plan. Its cost is one full request for each message.
- “A heartbeat fixes every network problem.” A heartbeat only finds a quiet connection that broke. It does not make sure messages arrive, and you still need retries.
Practice exercises
4 EXERCISESRead the code and predict the one line it prints.
class TinySocket {
constructor() { this.readyState = 0; }
send() {
if (this.readyState === 0) throw new Error("InvalidStateError");
console.log("sent");
}
}
const socket = new TinySocket();
try {
socket.send("hi");
} catch (error) {
console.log(error.message);
}The program prints InvalidStateError. readyState is 0, so send() throws an error. The catch block catches it and prints its message. A real WebSocket also throws InvalidStateError if you call send() while it is still CONNECTING.
Type the readyState number that means a WebSocket is open.
OPEN is readyState 1. CONNECTING is 0, CLOSING is 2, and CLOSED is 3.
The array holds five lines of stream text. Predict the number the code prints: how many lines start with data:?
const lines = ["event: score", "data: Owls 1", "", "data: plain", ""];
console.log(lines.filter((line) => line.startsWith("data:")).length);The filter keeps two lines, data: Owls 1 and data: plain, so the code prints 2. The event: line and the blank lines don't count.
The two true-or-false values describe a feature. Predict which transport the code prints.
const needsClientMessages = true;
const binaryFrames = true;
console.log(needsClientMessages && binaryFrames ? "WebSocket" : "SSE");const needsClientMessages = true;
const binaryFrames = true;
console.log(needsClientMessages && binaryFrames ? "WebSocket" : "SSE");The code prints WebSocket. Both values are true, so && gives true, and the code picks the first string. Sending from the page and carrying binary data are both things WebSocket can do. SSE is one-way text.
Check your understanding
7 QUESTIONSQuestion 1 of 7Which sentence describes long polling?
Choose an answer to see the explanation.
Question 2 of 7What does this readyState lookup print?
Read the code, then predictconst states = ["CONNECTING", "OPEN", "CLOSING", "CLOSED"]; console.log(states[1]);Choose an answer to see the explanation.
Question 3 of 7Which sentence about WebSocket is true?
Choose an answer to see the explanation.
Question 4 of 7Does this SSE text contain a named event?
Read the code, then predictconst stream = "event: score\ndata: 2-1\n\ndata: plain\n\n"; console.log(stream.includes("event: score"));Choose an answer to see the explanation.
Question 5 of 7Which job does EventSource do for you?
Choose an answer to see the explanation.
Question 6 of 7Which transport would you choose to send player moves in a game with many players?
Choose an answer to see the explanation.
Question 7 of 7What does this transport check print?
Read the code, then predictconst supportsBinary = { sse: false, websocket: true }; console.log(supportsBinary.websocket ? "binary ok" : "text only");Choose an answer to see the explanation.
Key takeaways
- Short polling asks on a timer. Long polling sends one request, and the server waits to answer it.
- WebSocket is one open, two-way connection. Send only when it is
OPEN, and reconnect in your own code. - Server-Sent Events are a one-way text stream. They have named events, ids, and a retry delay, and they reconnect by themselves.
- Choose by who sends, how often, text or binary, what the network allows, and what should happen when the connection fails.
Real-time code means picking the simplest connection that reliably brings updates when users need them.
Up next: XMLHttpRequest, the older way to make requests. You will still see it in old code.