Handling WebSocket events

Learn how to intercept and mock WebSocket events.

MSW supports intercepting and mocking WebSocket connections using its designated ws API. This page will guide you through the basics of handling WebSocket events, explain the mental model behind MSW when working with duplex connections, and elaborate on the defaults the library ships to ensure great developer experience.

Respecting standards

Mock Service Worker is dedicated to respecting, promoting, and teaching you about the web standards. The way you intercept and mock WebSocket communications will be according to the WHATWG WebSocket Standard, which means treating clients as EventTarget, listening to events like "message" and "close", and reading the sent and received data from the MessageEvent objects.

We have no plans of supporting custom WebSocket protocols, such as those using HTTP polling or XMLHttpRequest. Those are proprietary to the third-party tooling that implements them, and there is no reliable way for MSW to intercept such protocols without introducing non-standard, library-specific logic.

That being said, we acknowledge that the standard WebSocket interface is rarely used in production systems as-is. Often, it’s used as the underlying implementation detail for more convenient third-party abstractions, like SocketIO or PartyKit. We aim to address that experience gap via Bindings.

Event types

A WebSocket communication is duplex, which means that both the client and the server may send and receive events independently and simultaneously. There are two types of events you can handle with MSW:

  • Outgoing client events. These are the events your application sends to the WebSocket server;
  • Incoming server events. These are the events the original server sends and the client receives via its "message" event listener.

Intercepting connections

To support the duplex nature of the WebSocket communication and allow you to intercept both client-sent and server-sent events, MSW effectively acts as a middleware layer that sits between your client and a WebSocket server.

client ⇄ MSW ⇄ server

You are in control of how you want to utilize MSW. It can become a full substitute for a WebSocket server in a mock-first development, act as a proxy to observe and modify the events coming from the production server, or emulate client-sent events to test various server behaviors.

Handling WebSocket events starts by defining the server URL that the client connects to. This is done using the ws.link() method.

import { ws } from 'msw'
 
const chat = ws.link('wss://chat.example.com')

You can use the same URL predicate for WebSocket as you use for the http handlers: relative and absolute URLs, regular expressions, and paths with parameters and wildcards.

Next, add an event handler to the list of your handlers:

export const handlers = [
  chat.addEventListener('connection', () => {
    console.log('outgoing WebSocket connection')
  }),
]

You will be handling both client-sent and server-sent events within the "connection" event listener.

Important defaults

MSW implements a set of default behaviors to ensure good developer experience in different testing and development scenarios concerning WebSockets. You can opt-out from all of those, and fine-tune the interception behavior to suit your needs.

Client connections

By default, no intercepted WebSocket connections are opened. This encourages mock-first development and makes it easier to manage connections to non-existing servers. You can establish the actual server connection by calling server.connect().

Client-to-server event forwarding

By default, once you establish the actual server connection, outgoing client events are forwarded to the original server. If the server connection hasn’t been established, no forwarding occurs (nowhere to forward). You can opt-out from this behavior by calling event.preventDefault() on the client message event.

Learn more about client-to-server forwarding.

Server-to-client event forwarding

By default, once you establish the actual server connection, all incoming server events are forwarded to the client. You can opt-out from this behavior by calling event.preventDefault() on the server message event.

Learn more about server-to-client forwarding.

Client events

Intercepting client events

To intercept an outgoing client event, get the client object from the "connection" event listener argument and add a "message" listener on that object.

chat.addEventListener('connection', ({ client }) => {
  client.addEventListener('message', (event) => {
    console.log('from client:', event.data)
  })
})

Now, whenever a WebSocket client sends data via the .send() method, the "message" listener in this handler will be called. The listener exposes a single event argument, which is a MessageEvent received from the client, with the sent data available as event.data.

Sending data to the client

To send data to the connected client, get the client object from the "connection" event listener argument and call its .send() method with the data you wish to send.

chat.addEventListener('connection', ({ client }) => {
  client.send('Hello from the server!')
})

MSW supports sending strings, Blob, and ArrayBuffer.

client.send(data)

The `client.send()` API.

Broadcasting data to clients

To broadcast data to all connected clients, use the .broadcast() method on the event handler object (the one returned from the ws.link() call) and provide it with the data you wish to broadcast.

chat.addEventListener('connection', () => {
  chat.broadcast('Hello everyone!')
})

You can also broadcast data to all clients except a subset of clients by using the .broadcastExcept() method on the event handler object.

chat.addEventListener('connection', ({ client }) => {
  // Broadcast data to all clients except the current one.
  chat.broadcastExcept(client, 'Hello everyone except you!')
 
  // Broadcast data to all the clients matching a predicate.
  chat.broadcastExcept(chat.clients.filter((client) => {
    return client
  }, "Hello to some of you!")
})

.broadcast(data)

The `.broadcast()` API.

.broadcastExcept(clients, data)

The `.broadcastExcept()` API.

Closing client connections

You can close an existing client connection at any time by calling client.close().

chat.addEventListener('connection', ({ client }) => {
  client.close()
})

By default, the .close() method will result in a graceful closure of the connection (1000 code). You can control the nature of the connection closure by providing the custom code and reason arguments to the .close() method.

chat.addEventListener('connection', ({ client }) => {
  client.addEventListener('message', (event) => {
    if (event.data === 'hello') {
      client.close(1003)
    }
  })
})

For example, in this handler, once the client sends a "hello" message, its connection will be terminated with the 1003 code (received data the server cannot accept).

Unlike the WebSocket.prototype.close() method, the .close() method on the client connection can accept even non user-configurable closure codes like 1001, 1002, 1003, etc, which gives you more flexibility in describing the WebSocket communication.

client.close(code, reason)

The `client.close()` API.

Server events

Establishing server connection

To establish the connection to the actual WebSocket server, get the server object from the "connection" event listener argument and call its .connect() method.

chat.addEventListener('connection', ({ server }) => {
  server.connect()
})

server.connect()

The `server.connect()` API.

Client-to-server forwarding

Once the server connection has been established, all outgoing client message events are forwarded to the server. To prevent this behavior, call event.preventDefault() on the client message event. You can use this to modify the client-sent data before it reaches the server or ignore it completely.

chat.addEventListener('connection', ({ client }) => {
  client.addEventListener('message', (event) => {
    // Prevent the default client-to-server forwarding.
    event.preventDefault()
 
    // Modify the original client-sent data and send
    // it to the server instead.
    server.send(event.data + 'mocked')
  })
})

Intercepting server events

To intercept an incoming event from the actual sever, get the server object from the "connection" event listener argument and add a "message" event listener on that object.

chat.addEventListener('connection', ({ server }) => {
  server.addEventListener('message', (event) => {
    console.log('from server:', event.data)
  })
})

Now, whenever the actual server sends data, the "message" listener in this handler will be called. The listener exposes a single event argument, which is a MessageEvent received from the client, with the sent data available as event.data.

Server-to-client forwarding

By default, all server events are forwarded to the connected client. You can opt-out from this behavior by calling event.preventDefault() on the server message event. This is handy if you wish to modify the server-sent data before it reaches the client or prevent some server events from arriving at the client completely.

chat.addEventListener('connection', ({ client, server }) => {
  server.addEventListener('message', (event) => {
    // Prevent the default server-to-client forwarding.
    event.preventDefault()
 
    // Modify the original server-sent data and send
    // it to the client instead.
    client.send(event.data + 'mocked')
  })
})

Sending data to the server

To send data to the actual server, get the server object from the "connection" event listener argument and call its .send() method with the data you wish to send to the server.

chat.addEventListener('connection', ({ server }) => {
  server.send('hello from client!')
}

This is equivalent to a client sending that data to the server.

server.send(data)

The `server.send()` API.

Closing server connections

You can close a previously established original WebSocket server connection by calling server.close().

chat.addEventListener('connection', ({ server }) => {
  server.connect()
  server.close()
})

Calling server.close() will close the server connection and dispatch the close event on the server object.

Logging

Since MSW implements the WebSocket interception mock-first, no actual connections will be established until you explicitly say so. This means that the mocked scenarios won’t appear as network entries in your browser’s DevTools and you won’t be able to observe them there.

MSW provides custom logging for both mocked and original WebSocket connections in the browser.

Reading the log output

The logger will print out various events occurring during the WebSocket communication as collapsed console groups in your browser’s console.

There are four types of logs you can observe:

  1. ▶■×System events;

  2. ⬆⇡Client events;

  3. ⬇⇣Server events.

  4. ⬆⬇Mocked events.

System events

Connection opened

[MSW] 12:34:56 ▶ wss://example.com

Dispatched when the connection is open (i.e. the WebSocket client emits the open event).

× Connection errored

[MSW] 12:34:56 × wss://example.com

Dispatched when the client receives an error (i.e. the WebSocket client emits the error event).

Connection closed

[MSW] 12:34:56 ■ wss://example.com

Dispatched when the connection is closed (i.e. the WebSocket client emits the close event).

Message events

Any message, be it outgoing or incoming message, follows the same structure:

        timestamp         sent data
[MSW] 00:00:00.000  hello from client 17
                  icon              byte length

Binary messages print a text preview of the sent binary alongside its full byte length:

[MSW] 12:34:56.789 ⬆ Blob(hello world) 11
[MSW] 12:34:56.789 ⬆ ArrayBuffer(preview) 7

Long text messages and text previews are truncated:

[MSW] 12:34:56.789 ⬆ this is a very long stri… 17

You can access the full message by clicking on its console group and inspecting the original MessageEvent instance.

⬆⇡ Outgoing client message

[MSW] 12:34:56.789 ⬆ hello from client 17

A raw message sent by the WebSocket client in your application. If the arrow is dashed, the forwarding of this message event has been prevented in the event handler.

Outgoing mocked client message

[MSW] 12:34:56.789 ⬆ hello from mock 15

A message sent from the client by the event handler via server.send(). Requires an open server connection.

⬇⇣ Incoming server message

[MSW] 12:34:56.789 ⬇ hello from server 17

An incoming message sent from the original server. Requires an open server connection. If the arrow is dashed, the forwarding of this message event has been prevented in the event handler.

Incoming mocked server message

[MSW] 12:34:56.789 ⬇ hello from mock 15

A mocked message sent to the client from the event handler via client.send().

Event flow

Much like the WebSocket communication, handling it with MSW is event-based. Your experience mocking WebSockets will involve the understanding of EventTarget and how events work in JavaScript. Let’s have a quick reminder.

When your application establishes a WebSocket connection, the connection event will be emitted on all matching WebSocket links.

const chat = ws.link('wss://example.com')
 
export const handlers = [
  chat.addEventListener('connection', () => console.log('This is called')),
  chat.addEventListener('connection', () => console.log('This is also called')),
]

This way, both the happy path and the runtime handlers can react to the same connection.

The client/server events are dispatched on all client and server objects of the same connection as well. Meanwhile, you can attach multiple listeners to the same object, or to different objects across different handlers.

export const handlers = [
  chat.addEventListener('connection', ({ client }) => {
    // Attaching multiple message listeners to the same `client` object.
    client.addEventListener('message', () => console.log('This is called'))
    client.addEventListener('message', () => console.log('This is also called'))
  }),
  chat.addEventListener('connection', ({ client }) => {
    // Attaching another message listener to a different `client` object.
    client.addEventListener('message', () =>
      console.log('Hey, this gets called too!'),
    )
  }),
]

Since these events are dispatched against the same event target, you can utilize that to prevent them. That comes in handy when creating runtime handlers (i.e. network behavior overrides), as you can control whether your override augments or completely overrides particular event handling.

const server = setupServer(
  chat.addEventListener('connection', ({ client }) => {
    client.addEventListener('message', (event) => {
      // In a happy path handler, send back the event data
      // received from the WebSocket client.
      client.send(event.data)
    })
  }),
)
 
it('handles error payload', async () => {
  server.use(
    chat.addEventListener('connection', ({ client }) => {
      client.addEventListener('message', (event) => {
        // In this runtime handler, prevent the "message" client from
        // propagating to another event target (the happy path handler).
        // Then, send a completely different message to the client.
        event.stopPropagation()
        client.send('error-payload')
      })
    }),
  )
})

Omitting event.stopPropagation() will result in two messages being sent to the client upon receiving the same event—the 'error-payload' first, then the original event.data second.

Just like with a regular EventTarget, you can utilize event.preventImmediatePropagation() to stop an event from propagating across sibling listeners. For example, when handling a particular WebSocket event, you can use that to short-circuit any other event listeners that would otherwise be called.

chat.addEventListener('connection', ({ client }) => {
  client.addEventListener('message', (event) => {
    if (event.data === 'special-scenario') {
      // This prevents this "message" event from propagating
      // to the "message" event listener below.
      event.stopImmediatePropagation()
      client.close()
    }
  })
 
  client.addEventListener('message', (event) => {
    client.send(event.data)
  })
})

If the client sends a 'special-scenario' payload in the message, it will be closed, and the client.send(event.data) logic from the second event listener will never be called.

Type safety

Bringing type safety to the WebSocket communication is essential when using TypeScript, and that includes your handlers too! That being said, MSW intentionally doesn’t support any type arguments to annotate the outgoing/incoming events:

import { ws } from 'msw'
 
ws.link<Arg1, Arg2>(url)
//     ^^^^^^^^^^^^ Type error!

The reasoning behing this decision is twofold:

  1. Narrowing down the data type doesn’t guarantee that a different data type wouldn’t be sent over the network (the classic types vs runtime debate);
  2. The event.data value you receive in the message event listener will always be of type string | Blob | ArrayBuffer because MSW provides no message parsing.

If you are using objects to communicate with a WebSocket server, those objects have to be stringified and parsed when sending and receiving them, respectively, which already implies a parsing layer being present in your application.

You can achieve a proper type and runtime safety in WebSockets by introducing parsing utilities. Libraries like Zod can help you greatly in achieving type and runtime safety.

import { z } from 'zod'
 
// Define a Zod schema for the incoming events.
// Here, our WebSocket communication supports two
// events: "chat/join" and "chat/message".
const incomingSchema = z.union([
  z.object({
    type: z.literal('chat/join'),
    user: userSchema,
  }),
  z.object({
    type: z.literal('chat/message'),
    message: z.object({
      text: z.string(),
      sentAt: z.string().datetime(),
    }),
  }),
])
 
chat.addEventListener('connection', ({ client, server }) => {
  client.addEventListener('message', (event) => {
    const result = incomingSchema.safeParse(event.data)
 
    // Ignore non-matching events.
    if (!result.success) {
      return
    }
 
    const message = result.data
 
    // Handle incoming events in type-safe way.
    switch (message.type) {
      case 'chat/join': {
        // ...
        break
      }
 
      case 'chat/message': {
        // ...
        break
      }
    }
  })
})

Feel free to introduce a higher-order listener for the message event that abstracts that parsing, helping you reuse it across your handlers.

Bindings

To provide a more familiar experience when mocking third-party WebSocket clients, MSW uses bindings. A binding is a wrapper over the standard WebSocket class that encapsulates the third-party-specific behaviors, such as message parsing, and gives you a public API similar to that of the bound third-party library.

For example, here’s how to handle SocketIO communication using MSW and a designated SocketIO binding:

import { ws } from 'msw'
import { bind } from '@mswjs/socket.io-binding'
 
const chat = ws.link('wss://chat.example.com')
 
export const handlers = [
  chat.addEventListener('connection', (connection) => {
    const io = bind(connection)
 
    io.client.on('hello', (username) => {
      io.client.emit('message', `hello, ${username}!`)
    })
  }),
]

@mswjs/socket.io-binding

Connection wrapper for mocking Socket.IO with MSW.

Note that binding is not meant to cover all the public APIs of the respective third-party library. Unless the binding is shipped by that library, maintaining full compatibility is not feasible.