WebSockets
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against Vapor 4.122.2 on Swift 6.4 (the package builds in the Swift 6 language mode with tools
version 6.3, as the official template does; Vapor 4.122.2 itself needs tools version 6.0), with websocket-kit 2.16.2,
swift-nio 2.104.0, vapor/redis 4.14.0 and Fluent 4.13.0 with the SQLite driver, checked on 2026-10-17. Every Swift
listing on this page was compiled in the Swift 6 language mode with zero warnings in a scratch package. The registry,
the ticket store, the routes, the client and the echo route were run: a VaporTesting and Swift Testing suite starts a
real server on a TCP port, connects real WebSocket clients to it and checks what they receive, and curl was used for
the handshake examples; the output shown is captured from those runs. No Redis server and no websocat binary were
available when the page was written, so the Redis fan-out code was compiled but never run against a live server, and
the websocat and browser commands are shown as usage without captured output. Vapor 5 is at 5.0.0-beta.4 and is not
released; what is known about WebSockets there is in Version notes and outdated material. The page extends
the Bookshelf service of the earlier pages of this section (the review table, in this case). Swift itself is covered
by the Swift Reference, and async functions, Sendable and actors by
Async/Await and Tasks and
Actors, Isolation and Sendable. The same protocol
in Rust is covered in WebSockets with axum.
What a WebSocket is
HTTP is request and response: the client speaks, the server answers, and the server can never start a conversation. A WebSocket (RFC 6455) keeps one TCP connection open after an ordinary HTTP request and lets both sides send messages at any time, with very little framing overhead. It fits things the server must push as they happen (a live feed of new reviews, chat, presence, collaborative editing, live dashboards), and it is a poor fit for ordinary request and response APIs, which gain nothing from it and lose HTTP caching, status codes, middleware and easy load balancing.
A connection has two phases:
-
The opening handshake is a normal HTTP/1.1
GETwith extra headers:Connection: Upgrade,Upgrade: websocket,Sec-WebSocket-Version: 13and a randomSec-WebSocket-Key. If the server agrees it answers101 Switching ProtocolswithSec-WebSocket-Accept(a hash of the key), and from that moment the bytes on the connection are no longer HTTP. Everything an HTTP request carries (path, query string, cookies,Origin,Authorization) is available only in this one request. -
Frames flow in both directions: text frames (UTF-8, in practice JSON), binary frames, and control frames: ping, pong and close. A close frame carries a numeric code, for example 1000 (normal), 1001 (going away), 1007 (invalid data), 1008 (policy violation) or 1009 (message too big). Each message is delivered whole to the application; the library reassembles fragments.
Vapor does this with SwiftNIO’s WebSocket support and the websocket-kit package (a dependency of Vapor, re-exported by
import Vapor), which provides the WebSocket class on the server and the client side.
Where it sits in the request
The handshake is an ordinary Vapor request, so it passes through everything that precedes a handler. The route handler
(or the webSocket helper) does not return JSON; it returns a Response with status 101 that carries an upgrader.
Once the response is written, the HTTP server swaps the connection’s channel pipeline for the WebSocket one and calls
your onUpgrade closure with the new WebSocket. After that nothing in the HTTP stack sees the connection again: no
more middleware runs, and req.logger is the only trace of the request that remains.
Client GET -> global middleware -> route group middleware -> route handler (checks, then req.webSocket(...) )
|
Client <- 101 Switching Protocols (headers from shouldUpgrade) <---+
... pipeline replaced ...
onUpgrade(req, ws) -> ws.onText / ws.onBinary / ws.onClose ... (no middleware from here on)
The sequence for the Bookshelf review feed built on this page, from the ticket through the broadcast:
Two ways to declare a WebSocket route
Vapor 4.122.2 offers two entry points, both in Sources/Vapor/Concurrency/WebSocket+Concurrency.swift (abridged):
// 1. A route registered directly on a RoutesBuilder (the Application, a group, a controller's RoutesBuilder).
// It is a GET route for the given path.
@discardableResult
public func webSocket(
_ path: PathComponent..., // also an overload taking [PathComponent]
maxFrameSize: WebSocketMaxFrameSize = .default, // 1 << 14 = 16 384 bytes
shouldUpgrade: @escaping (@Sendable (Request) async throws -> HTTPHeaders?) = { _ in [:] },
onUpgrade: @Sendable @escaping (Request, WebSocket) async -> ()
) -> Route
// 2. A method on Request, to be returned from your own handler after your own checks.
public func webSocket(
maxFrameSize: WebSocketMaxFrameSize = .default,
shouldUpgrade: @escaping (@Sendable (Request) async throws -> HTTPHeaders?) = { _ in [:] },
onUpgrade: @Sendable @escaping (Request, WebSocket) async -> ()
) -> Response
| Parameter | Meaning |
|---|---|
|
path components, so |
|
a |
|
runs before the |
|
runs after the upgrade, with the original |
The earlier, EventLoopFuture-based forms still exist in 4.122 (shouldUpgrade returning
EventLoopFuture<HTTPHeaders?> and a synchronous onUpgrade), and the official documentation page still shows them.
They are legacy: new code uses the async overloads shown above. The practical difference between the two entry points:
-
app.webSocket("echo") { req, ws in ... }is the shortest form and is right whenshouldUpgradeis enough for your checks (or when route-group middleware does them, because "WebSocket routes can be grouped and protected by middleware like normal routes", as the official documentation says). -
app.get("ws", "reviews") { req in ... return req.webSocket(...) }lets the handler run anyasynccode andthrowanyAbortbefore choosing to upgrade, so the client gets a normal HTTP401or403response. This is the form used for the feed below.
A minimal echo route, with the shape every other example builds on:
import Vapor
import WebSocketKit
func echoRoutes(_ app: Application) {
app.webSocket("echo") { req, ws async in
req.logger.info("echo socket opened")
try? await ws.send("connected")
ws.onText { ws, text async in
try? await ws.send("echo: \(text)")
}
ws.onBinary { ws, buffer async in
try? await ws.send(Array(buffer.readableBytesView)) // reply with the same bytes
}
ws.onClose.whenComplete { _ in req.logger.info("echo socket closed, code \(String(describing: ws.closeCode))") }
}
}
Source: Vapor docs: WebSockets, server
Run with a real client in the test suite below, it produced:
ECHO -> ["connected", "echo: hi"] [1, 2, 3]
echo socket closed, code Optional(NIOWebSocket.WebSocketErrorCode.goingAway)
The WebSocket object
WebSocket is a final class and Sendable, so it can be stored in an actor or captured by a @Sendable closure. Its
members, as of websocket-kit 2.16.2:
| Member | Notes |
|---|---|
|
|
|
sends a ping control frame |
|
one callback of each kind; setting it again replaces the previous one. |
|
notifications only: the library answers a ping with a pong by itself, so a handler is needed only to observe. The |
|
sends a close frame; |
|
an |
|
what the peer sent (or the library decided) and whether the connection has ended |
|
a |
|
the event loop that owns the connection |
Swift 6 concurrency: the one rule that crashes servers
The async forms of onText, onBinary, onPing and onPong take an async closure and are safe to call from
anywhere: internally they hop to the socket’s event loop and wrap each callback in a Task. The synchronous forms (a
callback that is not async) store the closure in a box bound to the event loop and check, with a precondition, that
they are called on that loop. The async onUpgrade closure runs in a Task on the concurrency pool, not on the
event loop, so a synchronous callback registered there traps. This was reproduced while writing the page, both in a
client and in the server, by registering ws.onPing { _, _ in ... } (no async) inside onUpgrade:
NIOCore/NIOLoopBound.swift:172: Precondition failed
The whole process exits; the client sees a dropped connection. The rules that follow:
-
Inside an
asynconUpgrade(orWebSocket.connectclosure), write every callback as anasyncclosure, even when it contains noawait:{ ws, text async in ... }. The page’s listings do. -
If you must use the synchronous callbacks, register them from
ws.eventLoop.execute { ... }, or use the future-basedonUpgrade(legacy), which runs on the event loop. -
Do not keep per-connection state in captured
var`s. Mutable state shared between callbacks and requests belongs in an `actor(the registry below). Closures passed toonUpgrade,onTextand friends are@Sendable, and the Swift 6 language mode rejects captures of non-Sendablemutable state at compile time. -
ws.onCloseis a future, not an async property;ws.onClose.whenComplete { _ in Task { await registry.remove(id) } }is the bridge back to actor code. -
Callbacks may run concurrently with each other and with HTTP requests. Two
onTextcallbacks for the same socket are started in separate tasks, so the order in which they finish is not guaranteed; if message order matters, push messages into anAsyncStreamand process them in one task.
A connection registry as an actor
A server that pushes to clients must remember the connections. Older material (Condon, chapter 29, written for Vapor 3)
keeps a class with an array or dictionary of sockets and mutates it from callbacks; under the Swift 6 language mode that
is a data race and a compile error. An actor is the direct replacement: every mutation and every broadcast is
serialised, and the compiler checks it.
The registry below holds, for each connection, the socket, the authenticated user and an optional book to watch.
broadcast sends to the matching connections and drops a connection whose send fails. It also defines the two JSON
shapes used on the wire (ReviewEvent from the server, ClientMessage from the browser) and the ticket store used for
authentication in the next section.
import Vapor
import WebSocketKit
/// What the server pushes to browsers. Field names are the JSON keys.
struct ReviewEvent: Content {
let reviewID: UUID
let bookID: UUID
let rating: Int
let body: String
}
/// What browsers may send.
struct ClientMessage: Decodable, Sendable {
enum Action: String, Decodable, Sendable { case watch, unwatch }
let action: Action
let bookID: UUID?
}
/// Registry of open connections. An actor serialises every mutation of the dictionary.
actor ReviewFeed {
private struct Connection {
let socket: WebSocket
let userID: UUID
var watching: UUID? // nil = every book
}
private var connections: [UUID: Connection] = [:]
private let logger: Logger
init(logger: Logger) { self.logger = logger }
var count: Int { connections.count }
func add(_ socket: WebSocket, userID: UUID) -> UUID {
let id = UUID()
connections[id] = Connection(socket: socket, userID: userID, watching: nil)
logger.info("WebSocket \(id) opened for user \(userID); \(connections.count) open")
return id
}
func remove(_ id: UUID) {
if connections.removeValue(forKey: id) != nil {
logger.info("WebSocket \(id) removed; \(connections.count) open")
}
}
func watch(_ id: UUID, book: UUID?) {
connections[id]?.watching = book
}
/// Sends the event to every matching connection. A failed send drops that connection only.
func broadcast(_ event: ReviewEvent) async {
guard let data = try? JSONEncoder().encode(event) else { return }
let text = String(decoding: data, as: UTF8.self)
for (id, connection) in connections where connection.watching == nil || connection.watching == event.bookID {
do {
try await connection.socket.send(text)
} catch {
logger.warning("Send to \(id) failed: \(error); dropping it")
connections.removeValue(forKey: id)
}
}
}
}
/// Single-use, short-lived tickets that let a browser open a WebSocket without an Authorization header.
actor TicketStore {
private var tickets: [String: (userID: UUID, expires: Date)] = [:]
private let lifetime: TimeInterval
init(lifetime: TimeInterval = 30) { self.lifetime = lifetime }
func issue(for userID: UUID) -> String {
let now = Date()
tickets = tickets.filter { $0.value.expires > now } // drop expired ones
var generator = SystemRandomNumberGenerator() // cryptographically secure
let bytes = (0..<32).map { _ in UInt8.random(in: .min ... .max, using: &generator) }
let ticket = Data(bytes).base64EncodedString()
.replacingOccurrences(of: "+", with: "-").replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
tickets[ticket] = (userID, now.addingTimeInterval(lifetime))
return ticket
}
/// Returns the user and forgets the ticket, so a second use fails.
func redeem(_ ticket: String) -> UUID? {
guard let entry = tickets.removeValue(forKey: ticket), entry.expires > Date() else { return nil }
return entry.userID
}
}
struct ReviewFeedKey: StorageKey { typealias Value = ReviewFeed }
struct TicketStoreKey: StorageKey { typealias Value = TicketStore }
extension Application {
var reviewFeed: ReviewFeed {
get { storage[ReviewFeedKey.self]! }
set { storage[ReviewFeedKey.self] = newValue }
}
var tickets: TicketStore {
get { storage[TicketStoreKey.self]! }
set { storage[TicketStoreKey.self] = newValue }
}
}
Design notes:
-
Application.storageholds the actor, created once inconfigure. Handlers fetch it throughreq.application.reviewFeed; there is no global variable. Dependency injection in general is covered in Services, Storage and Dependency Injection. -
broadcastawaits each send in turn inside the actor. One slow client therefore delays the others, and nothing else can use the registry meanwhile. For a feed with many consumers, start oneTaskper socket (awithTaskGroup) and give each connection a bounded buffer, dropping or disconnecting clients that fall behind. This was not load tested here. -
The registry is per process. With several application instances behind a load balancer, each instance only knows its own sockets; see Scaling out with Redis Pub/Sub.
-
On
remove, the registry only forgets the socket. Closing it is the job of whoever decided to end the connection.
The feed routes
Three routes: one issues a ticket, one performs the upgrade, and one saves a review and broadcasts it. The X-Demo-User
header is a deliberate stand-in for the real authentication of Authentication
and JSON Web Tokens (JWT); everything else is the real flow.
import Fluent
import Vapor
import WebSocketKit
import NIOHTTP1
import NIOWebSocket
let allowedOrigins: Set<String> = ["https://app.example.com", "http://localhost:3000"]
/// Stand-in for real authentication (see xref:backend/vapor/authentication.adoc[Authentication]):
/// the demo trusts an X-Demo-User header holding a user's UUID.
func demoUserID(_ req: Request) throws -> UUID {
guard let text = req.headers.first(name: "X-Demo-User"), let id = UUID(uuidString: text) else {
throw Abort(.unauthorized)
}
return id
}
func routes(_ app: Application) throws {
// 1. The browser first asks, over normal authenticated HTTP, for a one-time ticket.
app.post("ws", "tickets") { req async throws -> [String: String] in
let userID = try demoUserID(req)
return ["ticket": await req.application.tickets.issue(for: userID)]
}
// 2. Then it opens the socket with ?ticket=...
app.get("ws", "reviews") { req async throws -> Response in
// Browsers always send Origin on a WebSocket handshake; check it before anything else.
guard let origin = req.headers.first(name: .origin), allowedOrigins.contains(origin) else {
throw Abort(.forbidden, reason: "Origin not allowed")
}
guard let ticket = req.query[String.self, at: "ticket"],
let userID = await req.application.tickets.redeem(ticket) else {
throw Abort(.unauthorized, reason: "Missing, expired or already used ticket")
}
let feed = req.application.reviewFeed
return req.webSocket(maxFrameSize: .init(integerLiteral: 1 << 12), onUpgrade: { req, ws in
let id = await feed.add(ws, userID: userID)
ws.pingInterval = .seconds(25)
ws.onText { ws, text in
guard let message = try? JSONDecoder().decode(ClientMessage.self, from: Data(text.utf8)) else {
try? await ws.close(code: .unacceptableData)
return
}
switch message.action {
case .watch: await feed.watch(id, book: message.bookID)
case .unwatch: await feed.watch(id, book: nil)
}
}
ws.onPing { _, _ async in req.logger.trace("ping from \(id)") }
ws.onClose.whenComplete { _ in
Task { await feed.remove(id) }
}
})
}
// The same origin check expressed with shouldUpgrade (see "shouldUpgrade" in the page).
app.get("ws", "strict") { req async throws -> Response in
req.webSocket(shouldUpgrade: { req in
guard let origin = req.headers.first(name: .origin), allowedOrigins.contains(origin) else {
return nil // refuse the upgrade
}
return ["X-Checked-Origin": origin] // extra headers for the 101 response
}, onUpgrade: { _, ws in
try? await ws.send("hello")
try? await ws.close()
})
}
// 3. A review is saved, then broadcast to the open sockets.
app.post("books", ":bookID", "reviews") { req async throws -> HTTPStatus in
struct Input: Content { let rating: Int; let body: String }
let input = try req.content.decode(Input.self)
let userID = try demoUserID(req)
guard let book = try await Book.find(req.parameters.get("bookID"), on: req.db) else { throw Abort(.notFound) }
let review = Review(rating: input.rating, body: input.body, bookID: try book.requireID(), userID: userID)
try await review.save(on: req.db)
await req.application.reviewFeed.broadcast(
ReviewEvent(reviewID: try review.requireID(), bookID: try book.requireID(), rating: review.rating, body: review.body))
return .created
}
}
What each piece does:
-
The upgrade handler throws
Abortfor a badOriginor ticket, before any upgrade. The client receives a normal HTTP response, which the browser reports as a failed connection but whichcurland the server log show clearly:$ curl -i -N -H "Connection: Upgrade" -H "Upgrade: websocket" -H "Sec-WebSocket-Version: 13" \ -H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" -H "Origin: https://evil.example" \ "http://127.0.0.1:8081/ws/reviews?ticket=..." HTTP/1.1 403 Forbidden content-type: application/json; charset=utf-8 content-length: 44 {"reason":"Origin not allowed","error":true} -
A good request gets the
101(the first bytes after the headers,0x89 0x00, are the first ping frompingInterval):HTTP/1.1 101 Switching Protocols Upgrade: websocket Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo= Connection: upgrade -
The server log shows the registry at work:
WebSocket 7F065472-... opened for user 11111111-... ; 1 openand, when the client goes away,WebSocket 7F065472-... removed; 0 open. -
maxFrameSizeis lowered to 4 096 bytes for this text protocol. A 5 000-character message made the server close the connection withmessageTooLarge(1009), and the registry removed it. -
Invalid JSON from the client makes the server close with
unacceptableData(1007). -
The POST handler is an ordinary route. It saves with Fluent and then calls
broadcast; on the test run a review posted over HTTP arrived at the connected client as{"rating":4,"body":"Solid","reviewID":"3F30289A-...","bookID":"3F1BA701-..."}. A client that had sent{"action":"watch","bookID":"<another book>"}received nothing. -
The code deliberately broadcasts after
save: a failed save throws first and nobody is told. If the database transaction can still roll back after the handler returns, broadcast after the commit, not inside it.
shouldUpgrade, Origin and the refusal behaviour
shouldUpgrade sees the same Request as the handler and runs just before the 101 is written. The /ws/strict route
above is the Origin check expressed with it; it also returns an extra response header:
$ curl -i -N ... -H "Origin: http://localhost:3000" http://127.0.0.1:8081/ws/strict
HTTP/1.1 101 Switching Protocols
X-Checked-Origin: http://localhost:3000
Upgrade: websocket
...
(then the frames: text "hello", then close 1001)
$ curl -i -N ... -H "Origin: https://evil.example" http://127.0.0.1:8081/ws/strict
curl: (52) Empty reply from server
Returning nil from shouldUpgrade does refuse the upgrade, but not with an HTTP status: the server simply closes
the connection without a response (curl exit code 52). A websocket-kit client in the test suite, pointed at an
equivalent route, was still pending after three seconds, neither failing nor connecting. So:
-
prefer
throw Abort(.forbidden)(inshouldUpgradeor, better, in the handler beforereq.webSocket) so every client gets a real status code and your logs show the reason; -
use
shouldUpgradefor what it is uniquely good at: adding headers to the101response (selecting a sub-protocol withSec-WebSocket-Protocol), or a last cheap check that cannot be done earlier; -
a plain
GETto a WebSocket route without the upgrade headers received a bare101 Switching Protocolsanswer with noUpgradeheader and then hung until the client timed out (observed withcurland a 3-second limit). The route has no non-WebSocket behaviour; if your service must answer ordinaryGET`s on the same path, check `req.headers.first(name: .upgrade)yourself first.
Origin: the check that stops cross-site WebSocket hijacking
Browsers send the page’s Origin header on every WebSocket handshake and send the site’s cookies with it, and the
same-origin policy does not apply to WebSockets. A page on https://evil.example can therefore open
wss://app.example.com/ws in the victim’s browser, with the victim’s session cookie attached. This is cross-site
WebSocket hijacking. The defences, all of which the example combines:
-
check
Originagainst an allow-list on the server (browsers cannot forge it; non-browser clients can send anything, so the check protects users, not the API); -
do not rely on cookies alone: require a ticket or token chosen by your own JavaScript (next section);
-
set
SameSite=LaxorStricton session cookies (see Sessions and Cookies and Security Hardening); -
serve over TLS (
wss://), usually terminated at the reverse proxy.
Authenticating WebSockets from browsers
The browser WebSocket API, new WebSocket(url, protocols), cannot set request headers. There is no way to send an
Authorization: Bearer header from a page, so the usual API authentication does not work on the handshake. The options:
| Option | How | Trade-offs |
|---|---|---|
Session cookie |
the browser sends the site’s cookies on the handshake, so a session authenticator (or |
only for same-site pages; needs the Origin check and |
One-time ticket in the query string (used here) |
the page asks an authenticated HTTP endpoint for a random, short-lived, single-use ticket and connects to |
works cross-origin and with bearer tokens; the ticket is useless seconds later, so its appearance in access logs and proxies is harmless |
Token in the query string |
the long-lived access token itself in the URL |
avoid: URLs end up in access logs, proxies and history |
Token in |
pass the token as a second argument of |
works, but abuses a header meant for sub-protocols and is logged by some proxies |
Authenticate after connecting |
connect anonymously and make the first message |
every connection costs resources before it is authenticated, and the first-message timeout is yours to write |
The ticket store above is a small actor: issue(for:) returns 32 random bytes from SystemRandomNumberGenerator,
URL-safe base64 encoded, with a 30-second life; redeem removes the entry as it returns it, so a second use fails (a
test checks this; the replayed ticket received 401 Missing, expired or already used ticket). The browser side, which
was written for this page but not executed (no browser in the test setup), is:
<script>
async function openFeed() {
// 1. normal authenticated HTTP request; cookies or a bearer header are fine here
const res = await fetch("/ws/tickets", { method: "POST", credentials: "include" });
const { ticket } = await res.json();
// 2. the WebSocket handshake carries only the ticket (and the browser's Origin)
const ws = new WebSocket(`wss://${location.host}/ws/reviews?ticket=${encodeURIComponent(ticket)}`);
ws.onmessage = (e) => console.log("review", JSON.parse(e.data));
ws.onclose = (e) => { if (e.code !== 1000) setTimeout(openFeed, 2000); }; // reconnect with a NEW ticket
ws.onopen = () => ws.send(JSON.stringify({ action: "watch", bookID: "<book id>" }));
}
openFeed();
</script>
Notes: a reconnect needs a new ticket (the old one is spent); the client must back off between attempts so that a
restart of the server does not produce a stampede; non-browser clients (a mobile app, a script) can send an
Authorization header and could use a bearer authenticator on the same route, which is why the handler, not a global
middleware, decides.
Close codes, heartbeats and shutdown
-
Closing from the server.
try await ws.close(code: .policyViolation)sends a close frame and the peer is expected to answer; use the code that describes the reason (.normalClosure,.goingAway,.protocolError,.unacceptableData,.policyViolation,.messageTooLarge,.unexpectedServerError). Reasons are numeric: browsers exposeevent.codeand often hide the reason text. -
Dead peers. TCP does not tell the server that a laptop was shut or a phone left the network. Set
ws.pingInterval = .seconds(25)(the registry route does): the library pings and closes a socket that did not answer the previous ping. Many proxies and load balancers drop idle connections after 60 seconds, so a ping interval below that also keeps the connection alive. -
Cleaning up. Always hook
ws.onClose(not just your ownclosecalls), because the connection can end by a network error or the peer without any frame from you. The registry’sremoveis idempotent for that reason. -
Server shutdown. During the first test run, an application shut down while a client socket was still open logged
Could not stop HTTP server: Abort.500: Server stop took too long.after about ten seconds. On shutdown, close your registered sockets (goingAway) before the application stops, for example from theshutdownhook of aLifecycleHandlerthat asks the registry to close every connection (not tested here; the Application Lifecycle and the Entrypoint page covers the hooks). -
Message size. Frames larger than
maxFrameSizeclose the connection with 1009; the message-level limit is the frame-level limit here. Validate message contents as for any input: decode defensively (the example decodes into a closedDecodableand closes with 1007 on failure) and never trust abookIDthe client sends without authorising it.
Scaling out with Redis Pub/Sub
The registry lives in one process. When two instances run behind a load balancer, a review saved by instance A must reach sockets that are connected to instance B. A message broker closes the gap: every instance publishes events to a channel, every instance subscribes to it, and each broadcasts to its own registry. Redis Pub/Sub is the lightest broker for this (Condon chapter 29 stops at one process; this is the modernised, multi-instance version). Delivery is fire and forget: a subscriber that is offline misses messages, which suits a live feed (the client reloads state when it reconnects) and is wrong for anything that must not be lost, where Redis Streams or a queue belongs.
import Redis
import Vapor
extension RedisChannelName {
static var reviewCreated: RedisChannelName { "bookshelf:reviews" }
}
/// Publishes to Redis instead of broadcasting locally; every instance (this one included)
/// receives the message from its subscription and broadcasts to its own sockets.
struct ReviewFanout: LifecycleHandler {
func didBoot(_ application: Application) throws {
let feed = application.reviewFeed
let logger = application.logger
Task {
do {
try await application.redis.subscribe(
to: [.reviewCreated],
messageReceiver: { _, message in
guard let text = message.string,
let event = try? JSONDecoder().decode(ReviewEvent.self, from: Data(text.utf8)) else {
logger.warning("Ignoring malformed review message")
return
}
Task { await feed.broadcast(event) }
},
onSubscribe: { key, count in logger.info("Subscribed to \(key) (\(count))") },
onUnsubscribe: { key, count in logger.warning("Unsubscribed from \(key) (\(count) left)") }
)
} catch {
logger.error("Review subscription failed: \(error)")
}
}
}
}
func publishReview(_ event: ReviewEvent, on req: Request) async throws {
let text = String(decoding: try JSONEncoder().encode(event), as: UTF8.self)
_ = try await req.redis.publish(text, to: .reviewCreated).get()
}
func configureRedisFanout(_ app: Application) throws {
app.redis.configuration = try RedisConfiguration(hostname: Environment.get("REDIS_HOST") ?? "localhost")
app.lifecycle.use(ReviewFanout())
}
Source: Vapor docs: Redis overview, Redis: Pub/Sub
The handler then calls publishReview(event, on: req) instead of req.application.reviewFeed.broadcast(event), so
there is exactly one delivery path: every instance, including the one that handled the POST, receives the message from
its subscription and broadcasts it locally. Points to keep in mind:
-
The subscription occupies a Redis connection; it is created once at boot (
LifecycleHandler.didBoot), not per socket. The per-socket registry stays in memory. Pub/Sub details (whatonUnsubscribemeans, the connection pools,RedisID) are in the Redis & Caching page, and the Redis server itself in the Redis section. -
REDIS_HOSTand the usual configuration are read from the environment, as Configuration and Environment describes; no credentials appear in the code. -
This code compiled but was never run, because no Redis server was available. What is established is the API shape in vapor/redis 4.14.0. What is not established is behaviour after a Redis outage; the
onUnsubscribecallback is where a production service would resubscribe, with backoff. -
The in-process path (the actor) is still what delivers to the sockets. Redis only moves events between processes, so the ordering guarantee is per Redis channel, not global.
-
Sticky sessions are not needed for the feed (each socket lives on one instance for its whole life), but a load balancer must keep one TCP connection to one backend for as long as it lasts, which every L4 and L7 balancer does for an upgraded connection.
The client side: WebSocket.connect
WebSocket.connect opens a client connection. It is what the test suite uses, and what a Vapor service uses to follow
another service’s feed. The overload with scheme, host, port, path and query is the one that carries a query
string correctly in websocket-kit 2.16.2:
import Vapor
import WebSocketKit
/// Opens the review feed as a client, prints every event and returns the socket.
func followReviews(host: String, port: Int, ticket: String, origin: String,
on group: any EventLoopGroup, logger: Logger) async throws {
try await WebSocket.connect(
scheme: "ws", host: host, port: port, path: "/ws/reviews", query: "ticket=\(ticket)",
headers: ["Origin": origin], on: group
) { ws async in
ws.onText { _, text async in logger.info("event: \(text)") }
ws.onClose.whenComplete { _ in logger.info("feed closed") }
try? await ws.send(#"{"action":"unwatch"}"#)
}
}
Things that went wrong while writing it, and are therefore worth stating:
-
connecthas several overloads (StringorURL; closureasyncor not;on:defaulted or required). The overloads that take a non-asynconUpgradeclosure and anawait-able call build the request from the URL’spathand drop the query string: the server sawGET /ws/reviewswith noticketand answered401. Writing the closure as{ ws async in ... }selects the overload that passesquery. Check the request line in the server log when a ticket "does not arrive". -
The
asyncoverload returns once the connection is established, not when it ends. Hold on to thews(or awaitonClose) if your program must stay alive. -
The client needs an event loop group:
app.eventLoopGroupinside Vapor (as above), orMultiThreadedEventLoopGroup.singletonelsewhere. TLS is selected by thewssscheme. -
Headers such as
Originare set by the client; a server-side check on them is meaningful only against browsers.
Outside a Vapor project, websocat is the usual command-line client (not available when this page was written, so no
output is shown):
# plain connection, type lines to send text frames, see replies on stdout
websocat "ws://127.0.0.1:8080/echo"
# with an Origin header, as a browser would send it, and a ticket from the HTTP endpoint
TICKET=$(curl -s -X POST -H "X-Demo-User: 11111111-1111-1111-1111-111111111111" http://127.0.0.1:8080/ws/tickets \
| sed 's/.*"ticket":"\([^"]*\)".*/\1/')
websocat -H "Origin: http://localhost:3000" "ws://127.0.0.1:8080/ws/reviews?ticket=$TICKET"
Source: vi/websocat
curl with the upgrade headers (used throughout this page) is enough to check the handshake and status codes, but it
cannot speak frames; use websocat, a browser console or a test for that.
Testing
app.testing() (VaporTesting, see Testing) runs requests in memory, and no WebSocket
helper was found in VaporTesting 4.122.2, so a WebSocket test starts the server on a port and uses WebSocket.connect
as the client. Two details that are easy to get wrong (both produced errors while writing the suite): app.startup()
parses CommandLine.arguments, which under swift test are the test runner’s, so set
app.environment.commandInput.arguments = ["serve"] first; and always close client sockets before the test ends or the
shutdown takes ten seconds. HTTP calls in the same test can still use app.testing().test(...) in memory, because the
registry is shared by both.
import Fluent
import NIOConcurrencyHelpers
import Testing
import Vapor
import VaporTesting
import WebSocketKit
@testable import App
struct NewReview: Content { let rating: Int; let body: String }
@Suite("Review feed", .serialized)
struct FeedTests {
func withRunningApp(_ body: (Application, Book, User) async throws -> Void) async throws {
try await withApp(configure: { app in
app.environment.commandInput.arguments = ["serve"] // do not parse the test runner's arguments
app.http.server.configuration.port = 8765
try await configure(app)
}) { app in
let author = Author(name: "Ada"); try await author.save(on: app.db)
let book = Book(title: "T", isbn: "1", publishedYear: 2026, priceCents: 1, status: .published, authorID: try author.requireID())
try await book.save(on: app.db)
let user = User(email: "a@example.com", passwordHash: "x"); try await user.save(on: app.db)
try await app.startup()
try await body(app, book, user)
}
}
func connect(_ app: Application, user: User, origin: String = "http://localhost:3000",
onText: @escaping @Sendable (String) -> Void = { _ in }) async throws -> WebSocket {
let ticket = await app.tickets.issue(for: try user.requireID())
let holder = NIOLockedValueBox<WebSocket?>(nil)
try await WebSocket.connect(to: "ws://127.0.0.1:8765/ws/reviews?ticket=\(ticket)",
headers: ["Origin": origin], on: app.eventLoopGroup) { ws async in
ws.onText { _, text async in onText(text) }
holder.withLockedValue { $0 = ws }
}
try await Task.sleep(for: .milliseconds(200))
return try #require(holder.withLockedValue { $0 })
}
@Test("broadcast, watch filter and close")
func broadcastAndFilter() async throws {
try await withRunningApp { app, book, user in
let all = NIOLockedValueBox<[String]>([])
let filtered = NIOLockedValueBox<[String]>([])
let a = try await connect(app, user: user) { text in all.withLockedValue { $0.append(text) } }
let b = try await connect(app, user: user) { text in filtered.withLockedValue { $0.append(text) } }
#expect(await app.reviewFeed.count == 2)
try await b.send(#"{"action":"watch","bookID":"\#(UUID().uuidString)"}"#) // some other book
try await Task.sleep(for: .milliseconds(200))
await app.reviewFeed.broadcast(ReviewEvent(reviewID: UUID(), bookID: try book.requireID(), rating: 5, body: "Great"))
try await Task.sleep(for: .milliseconds(200))
print("ALL:", all.withLockedValue { $0 })
print("FILTERED:", filtered.withLockedValue { $0 })
#expect(all.withLockedValue { $0.count } == 1)
#expect(filtered.withLockedValue { $0.count } == 0)
try await a.close()
try await b.close()
try await Task.sleep(for: .milliseconds(300))
#expect(await app.reviewFeed.count == 0)
}
}
@Test("invalid JSON closes with 1007")
func badMessage() async throws {
try await withRunningApp { app, _, user in
let ws = try await connect(app, user: user)
try await ws.send("not json")
try await Task.sleep(for: .milliseconds(300))
print("CLOSECODE bad json:", String(describing: ws.closeCode))
#expect(ws.isClosed)
}
}
@Test("oversized frame closes the socket")
func bigFrame() async throws {
try await withRunningApp { app, _, user in
let ws = try await connect(app, user: user)
try await ws.send(String(repeating: "x", count: 5000))
try await Task.sleep(for: .milliseconds(300))
print("CLOSECODE big frame:", String(describing: ws.closeCode), "closed:", ws.isClosed)
#expect(await app.reviewFeed.count == 0)
}
}
@Test("wrong origin is refused with 403")
func wrongOrigin() async throws {
try await withRunningApp { app, _, user in
do {
_ = try await connect(app, user: user, origin: "https://evil.example")
Issue.record("should have been refused")
} catch {
print("WRONG ORIGIN error:", error)
}
#expect(await app.reviewFeed.count == 0)
}
}
@Test("POST a review reaches the socket")
func postReview() async throws {
try await withRunningApp { app, book, user in
let got = NIOLockedValueBox<[String]>([])
let ws = try await connect(app, user: user) { text in got.withLockedValue { $0.append(text) } }
try await app.testing().test(.POST, "books/\(try book.requireID())/reviews",
headers: ["X-Demo-User": try user.requireID().uuidString],
beforeRequest: { req in try req.content.encode(NewReview(rating: 4, body: "Solid")) },
afterResponse: { res async in #expect(res.status == .created) })
try await Task.sleep(for: .milliseconds(200))
print("POSTED ->", got.withLockedValue { $0 })
#expect(got.withLockedValue { $0.count } == 1)
try await ws.close()
}
}
@Test("echo")
func echo() async throws {
try await withRunningApp { app, _, _ in
let got = NIOLockedValueBox<[String]>([])
let bin = NIOLockedValueBox<[UInt8]>([])
let holder = NIOLockedValueBox<WebSocket?>(nil)
try await WebSocket.connect(to: "ws://127.0.0.1:8765/echo", on: app.eventLoopGroup) { ws async in
ws.onText { _, text async in got.withLockedValue { $0.append(text) } }
ws.onBinary { _, buf async in bin.withLockedValue { $0 = Array(buf.readableBytesView) } }
holder.withLockedValue { $0 = ws }
}
let ws = try #require(holder.withLockedValue { $0 })
try await ws.send("hi")
try await ws.send([1, 2, 3])
try await Task.sleep(for: .milliseconds(300))
print("ECHO ->", got.withLockedValue { $0 }, bin.withLockedValue { $0 })
try await ws.close()
try await Task.sleep(for: .milliseconds(200))
}
}
@Test("a ticket works once")
func ticketOnce() async throws {
try await withRunningApp { app, _, user in
let ticket = await app.tickets.issue(for: try user.requireID())
#expect(await app.tickets.redeem(ticket) != nil)
#expect(await app.tickets.redeem(ticket) == nil)
}
}
}
Source: Vapor docs: Testing, Apple: Swift Testing
Captured output of the run (seven tests, 3.4 seconds): ALL: [...] lists one event for the unfiltered client and
FILTERED: [] for the one watching a different book; the close-code tests printed
unacceptableData and messageTooLarge; the wrong-origin test printed
invalidResponseStatus(... status: 403 Forbidden ...).
Behind a reverse proxy
The upgrade needs Upgrade and Connection headers to reach the application, and idle connections must outlive the
proxy’s timeout. The NGINX settings below are the standard ones from the NGINX documentation and were not exercised in
this session. Use wss:// externally: the proxy terminates TLS and forwards plain ws:// to Vapor.
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen 443 ssl;
server_name app.example.com;
location /ws/ {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_read_timeout 3600s; # default 60 s closes quiet sockets; pings every 25 s also keep it alive
}
}
Source: NGINX: WebSocket proxying
The same applies to cloud load balancers (an idle timeout longer than the ping interval) and to CDNs (some need
WebSocket support enabled per route). X-Forwarded-* handling and the proxy configuration of Vapor are on
Server Configuration,
Deployment with Docker and
Deployment Targets. For one-way server-to-browser streams (loan-due
notifications, progress) consider Server-Sent Events instead; the choice is compared on
Server-Sent Events.
Common errors and pitfalls
| Symptom | Cause | Fix |
|---|---|---|
|
a synchronous |
write the callback as |
The server says |
the |
use the |
The client’s |
|
throw |
A plain |
the route returns an upgrade response whatever the request headers are |
check the |
Connection closed with 1009 |
a frame is larger than |
send smaller messages, or raise the limit consciously |
Connection closed with 1007 |
your own code closed with |
send valid JSON; look at the close code on the client |
Browser error |
the handshake got |
look at the server log and |
A |
the proxy lacks |
use the NGINX settings above |
Sockets disappear after about a minute of silence |
a proxy idle timeout |
set |
Messages are not delivered to clients of the other instance |
the registry is per process |
publish through Redis (or another broker) as shown |
|
open sockets remained at shutdown |
close registered sockets from a lifecycle handler |
Swift 6 error about capturing a non- |
the callbacks are |
keep state in an actor, pass |
|
|
|
Any cookie-authenticated socket can be opened from another site |
no |
allow-list |
A ticket works twice, or lives for hours |
tickets are not removed on use or have no expiry |
remove on |
Version notes and outdated material
-
Vapor 4 integrates WebSockets into the router (
app.webSocket, usable in route groups with middleware). Vapor 3 material registered a separate WebSocket server service inconfigure.swift, which no longer exists in Vapor 4; Condon’s chapter 29 (written for the older API) keeps its registry in a plain class, which this page replaces with an actor. The older Vapor 2 material in the other books predates the SwiftNIO-based implementation entirely. -
The
asyncapp.webSocketandreq.webSocketoverloads and theasynccallback forms came with Vapor 4’s concurrency support; the examples in the official documentation page are still written with futures,ws.send("...")withoutawait, and aneventLoopargument forconnect. Both work in 4.122; the synchronous callbacks carry the event-loop restriction explained above. -
maxFrameSizetakes aWebSocketMaxFrameSize, which isExpressibleByIntegerLiteral, so a literal such as4096compiles; the page spells it.init(integerLiteral: 1 << 12)because a shift expression is not a literal. -
The
onPingoverload without thebufferparameter is deprecated in websocket-kit 2.16.2. -
Vapor 5 (5.0.0-beta.4, tools version 6.4, not released; see What’s New, Upgrading and Vapor 5): the beta’s
Package.swiftdeclares package traits, among themWebSockets,HTTPClient,MultipartandMacroRouting, withWebSocketsin the default set. The same manifest has thewebsocket-kitdependency and its product commented out with the note "Disable until websockets are working", so WebSocket support is not usable in that beta. Plan to opt in or out of the trait in yourPackage.swiftonce Vapor 5 is released; the registry design above (an actor, the Origin and ticket checks) does not depend on the library, but the exactWebSocketAPI was not verified for Vapor 5. -
Swift 6 language mode makes the
Sendableand isolation rules compile-time errors, where books from the Swift 5 era (all of the ones consulted for this section) do not mention them.