Skip to content

WebSocket

WebSocket keeps a connection open so your client and server can send messages to each other. Unlike repeated HTTP requests, each message doesn’t need a new request and response exchange.

Elysia uses Bun’s WebSocket support, built on uWebSockets. Socket.IO uses its own protocol on top of transports; use a standard WebSocket client for these routes.

Register the WebSocket plugin once, then add a .ws() route. Connect to ws://localhost:3000/ws; each message is sent back to its client.

import { Elysia } from 'elysia'
import { websocket } from 'elysia/websocket'
new Elysia()
.use(websocket())
.ws('/ws', ({ body }) => body)
.listen(3000)

The body schema validates incoming messages. Send {"message":"Hello"} as JSON and the server replies with Hello.

import { Elysia, t } from 'elysia'
import { websocket } from 'elysia/websocket'
new Elysia()
.use(websocket())
.ws('/ws', {
body: t.Object({ message: t.String() })
}, ({ body }) => body.message)
.listen(3000)

A generator can yield multiple replies. Each yield is a separate message, checked against the response schema.

import { Elysia, t } from 'elysia'
import { websocket } from 'elysia/websocket'
new Elysia()
.use(websocket())
.ws('/ws', {
body: t.Object({ message: t.String() }),
response: t.String()
}, function* ({ body }) {
yield 'Message received'
yield body.message
})
.listen(3000)

Like HTTP routes, WebSocket routes accept schemas for request data and responses. Put the options before the message handler:

import { Elysia, t } from 'elysia'
import { websocket } from 'elysia/websocket'
const app = new Elysia()
.use(websocket())
.ws('/ws', {
body: t.Object({ message: t.String() }),
query: t.Object({ id: t.String() }),
response: t.Object({
id: t.String(),
message: t.String(),
time: t.Number()
})
}, ({ query, body }) => ({
id: query.id,
message: body.message,
time: Date.now()
}))
.listen(3000)

Connect to ws://localhost:3000/ws?id=42 and send {"message":"Hello"}. The response includes that message, the query’s id, and the current timestamp.

Context properties such as query and params sit directly on the socket argument in Elysia 2. Use ws.query, not the old ws.data.query shape.

Schema propertyValidates
bodyAn incoming message
queryThe connection URL’s query string
paramsPath parameters
headersThe upgrade request’s headers
cookieThe upgrade request’s cookies
responseMessages returned, yielded, or sent by the handler

Elysia parses JSON-shaped string messages before validation. It also recognizes booleans, null, and numeric strings that can be converted safely. Other text remains a string; binary messages remain binary.

Pass server-wide WebSocket options to websocket():

import { Elysia } from 'elysia'
import { websocket } from 'elysia/websocket'
new Elysia()
.use(websocket({ idleTimeout: 30 }))
.ws('/ws', ({ body }) => body)
.listen(3000)

This replaces the old constructor websocket option. Elysia’s Bun adapter uses Bun’s WebSocket configuration. The following defaults are Bun defaults.

Default: false.

Enable compression for clients that support it. Compression is disabled unless you enable it.

The maximum incoming message size in bytes. Bun’s default is 16 * 1024 * 1024 (16 MiB).

Default: 120 seconds.

Close an idle connection after this time without messages or pings.

Default: 16 * 1024 * 1024 bytes (16 MiB).

The maximum number of bytes buffered for one connection.

Default: false.

Close the connection when its backpressure limit is reached.

Use .ws() to register a WebSocket route. The socket argument provides methods such as send, publish, subscribe, and close, alongside the route’s context properties.

.ws(path, handler) registers a message handler. Use .ws(path, options, handler) when it also needs schemas or lifecycle hooks.

import { Elysia } from 'elysia'
import { websocket } from 'elysia/websocket'
new Elysia()
.use(websocket())
.ws('/ws', ({ body }) => body)
.listen(3000)

The two-argument object form also works:

import { Elysia } from 'elysia'
import { websocket } from 'elysia/websocket'
new Elysia()
.use(websocket())
.ws('/ws', {
message(ws, message) {
ws.send(message)
}
})
.listen(3000)
  • path is the route exposed to WebSocket clients.
  • options configures schemas and lifecycle callbacks.
  • handler handles each incoming message. Don’t also supply options.message when passing a separate handler.

Return or yield a value when possible so response types can be inferred. ws.send() remains available when you need to send explicitly.

The route options can contain connection callbacks, message callbacks, schemas, and WebSocket tuning options. The callbacks receive Elysia’s socket wrapper with the request context at the top level.

The following sections describe those callbacks.

Called when a connection opens. It receives the socket argument.

import { Elysia } from 'elysia'
import { websocket } from 'elysia/websocket'
new Elysia()
.use(websocket())
.ws('/ws', {
open(ws) {
console.log('Connected', ws.id)
},
message: ({ body }) => body
})

ws.id identifies the connection. Other socket properties include remoteAddress, readyState, and your route’s query, params, and derived values.

Called for each incoming message. The parsed, validated value is available as ws.body and as the second callback argument.

import { Elysia, t } from 'elysia'
import { websocket } from 'elysia/websocket'
new Elysia()
.use(websocket())
.ws('/ws', {
body: t.String(),
message(ws, message) {
return message.toUpperCase()
}
})

The body schema determines the message type. Without a schema, handle the parsed value as unknown rather than assuming every message is a string.

Called when a connection closes. It receives the socket, close code, and reason.

import { Elysia } from 'elysia'
import { websocket } from 'elysia/websocket'
new Elysia()
.use(websocket())
.ws('/ws', {
close(ws, code, reason) {
console.log('Disconnected', ws.id, code, reason)
},
message: ({ body }) => body
})

Called when the socket can accept more data after backpressure. It receives the socket; close codes and reasons belong to close, not drain.

import { Elysia } from 'elysia'
import { websocket } from 'elysia/websocket'
new Elysia()
.use(websocket())
.ws('/ws', {
drain(ws) {
console.log('Ready for more data', ws.id)
},
message: ({ body }) => body
})

The WebSocket parse hook processes each message after default parsing and before message validation. It receives the socket and parsed value; return a replacement value or undefined to keep it.

import { Elysia, t } from 'elysia'
import { websocket } from 'elysia/websocket'
new Elysia()
.use(websocket())
.ws('/ws', {
body: t.String(),
parse(_ws, message) {
if (typeof message === 'string') return message.trim()
}
}, ({ body }) => body)

This is different from an HTTP body parser on the upgrade request.

Runs before the connection upgrades, and participates in message handling before the message handler. Return a status to reject the upgrade before opening a socket.

import { Elysia, status } from 'elysia'
import { websocket } from 'elysia/websocket'
new Elysia()
.use(websocket())
.ws('/ws', {
beforeHandle() {
return status(403, 'This socket is closed to new connections')
}
}, ({ body }) => body)

For authentication, validate the connection’s credentials here. Message validation belongs in the body schema.

Runs before validation. In the current WebSocket lifecycle it runs during the upgrade and again for each message, so don’t assume it executes only once per connection.

The old transformMessage option is not part of Elysia 2’s WebSocket route options. Use parse to transform an individual message before validation.

Use upgrade to add response headers to the WebSocket handshake. It accepts a static object or a function of the upgrade context.

import { Elysia } from 'elysia'
import { websocket } from 'elysia/websocket'
new Elysia()
.use(websocket())
.ws('/ws', {
upgrade: { 'x-powered-by': 'Elysia' }
}, ({ body }) => body)

To validate incoming request headers, use the headers schema instead.

Type to search the documentation.

    Up arrowDown arrow to navigateEnter to selectEscape to close