skip to content

In the browser you write `const ws = new WebSocket(url); ws.send('hello');` on the very next line. What happens, and what does the WebSocket readyState tell you about when send() is legal?

level: juniorimportance: must knowfreq 68%

answer

  1. the constructor returns before the connection exists
  2. readyState has four numbered values
  3. one state throws, two states swallow
  4. open event is the green light
  5. CONNECTING send throws InvalidStateError

basics

~10 s

The send() call throws an InvalidStateError, because the socket is still in the CONNECTING state — the constructor only starts the connection. Send once the open event has fired and readyState is WebSocket.OPEN.

solid answer

~40 s

`new WebSocket(url)` returns immediately with `readyState === WebSocket.CONNECTING` (0); the connection is still being established in the background. Calling `send()` in that state throws an `InvalidStateError` DOMException — the API deliberately refuses to queue data for a socket that may never open. The legal window is `OPEN` (1), which you learn about from the `open` event, so real code either sends inside the `open` handler or pushes into its own outbox array and flushes it there. The asymmetry worth memorising: once the socket reaches `CLOSING` (2) or `CLOSED` (3), `send()` does **not** throw — the data is simply discarded, so a fire-and-forget send after a dropped connection fails invisibly. A `if (ws.readyState === WebSocket.OPEN)` guard covers both ends of that range.

code

javascript · 20 lines
javascript
const ws = new WebSocket('wss://example.com/feed');
const outbox = [];

function publish(message) {
  const payload = JSON.stringify(message);
  if (ws.readyState === WebSocket.OPEN) {
    ws.send(payload);
  } else if (ws.readyState === WebSocket.CONNECTING) {
    outbox.push(payload);
  }
  // CLOSING or CLOSED: this socket is finished, drop or hand to a reconnect layer
}

ws.addEventListener('open', () => {
  while (outbox.length > 0) {
    ws.send(outbox.shift());
  }
});

publish({ type: 'subscribe', channel: 'prices' });

go deeper

for a junior

Know that the constructor only starts the connection and that the first send() belongs inside the open handler. Be able to name the four events: open, message, error, close.

for a middle

Explain the four readyState values by number and the asymmetry between them: CONNECTING makes send() throw InvalidStateError, while CLOSING and CLOSED make it silently discard the data.

for a senior

Show how you make send() safe for arbitrary callers — a bounded outbox flushed on open, a readyState guard, and an application-level acknowledgement scheme, since the platform reports nothing about dropped sends.

for a principal

Own the question of what delivery guarantee your realtime layer actually offers. The socket gives at-most-once with no feedback, so decide where sequencing, replay and idempotency live before teams build product features on top of it.

## A synchronous constructor over an asynchronous connection `new WebSocket('wss://example.com/feed')` hands you a fully formed object on the very next line, but nothing has been established yet. The constructor's only job is to validate the URL and kick off the connection attempt; everything after that is reported through events. This is the single most common source of confusion with the API, because the object *looks* usable the instant you have it. The URL must use `ws:` or `wss:` (the constructor also accepts `http:`/`https:` forms and normalises them) and must not contain a fragment; a malformed URL throws a `SyntaxError` right there in the constructor, synchronously. That is a different failure from a connection that cannot be established, which arrives later as an `error` event followed by a `close` event. ## The four ready states `ws.readyState` is a number, and the interface exposes named constants for it: ```js WebSocket.CONNECTING // 0 — constructor has run, connection not yet established WebSocket.OPEN // 1 — open event has fired, send() is legal WebSocket.CLOSING // 2 — close() called, or a close is in progress WebSocket.CLOSED // 3 — connection is finished, or never succeeded ``` The progression is one-way. A `WebSocket` instance is single-use: once it reaches `CLOSED` it can never reopen, and reconnecting means constructing a brand-new object. There is no `ws.reopen()`. ## Why CONNECTING throws but CLOSED does not The specification defines exactly one throwing case for `send()`: if the ready state is `CONNECTING`, throw an `InvalidStateError` DOMException. The reasoning is that a not-yet-open socket might still fail, and silently holding your bytes would give you a false promise of delivery. Once the socket is `CLOSING` or `CLOSED`, `send()` is a no-op — no exception, no return value, no event. Nothing tells the caller the message evaporated. That asymmetry catches people out: they wrap the first send in a `try/catch`, see it work, and assume the API will keep complaining if something is wrong. It will not. Detecting *lost* messages is entirely your problem, which is why real-time protocols built on WebSockets carry their own sequence numbers or acknowledgements. ## The four events A `WebSocket` is an `EventTarget`, so both the `on*` properties and `addEventListener` work: ```js ws.addEventListener('open', () => { /* readyState is now OPEN */ }); ws.addEventListener('message', (e) => { /* e.data is a string, Blob, or ArrayBuffer */ }); ws.addEventListener('error', () => { /* a plain Event with no detail */ }); ws.addEventListener('close', (e) => { /* CloseEvent: e.code, e.reason, e.wasClean */ }); ``` `addEventListener` is worth preferring over `ws.onopen = …` when more than one part of your code cares about the socket, since the property form allows only one handler and a second assignment silently replaces the first. It also lets you use `{ once: true }` and `{ signal }` for teardown. Ordering guarantees are simple: `open` fires at most once; `close` always fires last; an abnormal ending fires `error` immediately before `close`, while a clean shutdown fires only `close`. ## An outbox makes send() safe to call anywhere Rather than forcing every caller to know the socket's state, most codebases wrap it: ```js const outbox = []; function publish(message) { const payload = JSON.stringify(message); if (ws.readyState === WebSocket.OPEN) ws.send(payload); else outbox.push(payload); } ws.addEventListener('open', () => { while (outbox.length) ws.send(outbox.shift()); }); ``` This handles both the startup race (a `publish()` call that happens during `CONNECTING`) and the reconnect case, provided you also bound the outbox — an unbounded array in front of a socket that never opens is just a memory leak with extra steps. ## What goes wrong in real code The bug rarely shows up locally, because a localhost handshake completes so fast that the first `send()` often lands after `open` by luck. On a real network with a hundred milliseconds of latency it fails every time. Reviewers should treat any `send()` that is not inside an `open` handler and not guarded by a `readyState` check as a race, not a style preference.

  • If send() silently discards data once the socket has closed, how does the sending code ever find out something was lost?
    It does not — `send()` has no return value and fires no event on a closed socket. Detecting loss has to be built into your own message protocol: sequence numbers, application-level acknowledgements, or a resend-on-reconnect queue. The platform gives you nothing beyond `readyState` and the `close` event to reason with.
  • Can you reuse a WebSocket object after it has closed?
    No. A `WebSocket` is single-use: the ready state only moves forward, and `CLOSED` is terminal. Reconnecting means constructing a new `WebSocket` and rewiring its four handlers. Any reconnect helper therefore has to manage object replacement, not just state, and must drop references to the dead socket so its listeners are collected.
  • Why prefer addEventListener over assigning ws.onmessage?
    `WebSocket` extends `EventTarget`, so `addEventListener` supports multiple independent listeners, `{ once: true }`, and removal via an `AbortSignal` passed as `{ signal }`. Assigning `ws.onmessage` allows exactly one handler, and a second assignment elsewhere in the codebase silently replaces the first — a genuinely hard bug to spot.

saying these in an interview costs you the question

  • Thinks new WebSocket() blocks until the connection is open
  • Says send() before open just queues the message until later
  • Assumes send() throws when the socket is already closed
  • Treats readyState as a simple boolean connected flag
  • Believes a closed WebSocket object can be reopened

context