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:

  1. The opening handshake is a normal HTTP/1.1 GET with extra headers: Connection: Upgrade, Upgrade: websocket, Sec-WebSocket-Version: 13 and a random Sec-WebSocket-Key. If the server agrees it answers 101 Switching Protocols with Sec-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.

  2. 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:

sequenceDiagram participant B as Browser participant H as HTTP routes participant T as TicketStore actor participant R as ReviewFeed actor participant O as Other browsers B->>H: POST /ws/tickets (session or token) H->>T: issue(for: userID) T-->>B: one-time ticket (30 s) B->>H: GET /ws/reviews?ticket=T (Upgrade: websocket, Origin) H->>H: check Origin, redeem ticket H->>T: redeem(ticket) T-->>H: userID, ticket forgotten H-->>B: 101 Switching Protocols H->>R: add(ws, userID) B->>R: text frame {"action":"watch", ...} Note over H,R: later, any HTTP request saves a review H->>R: broadcast(ReviewEvent) R-->>B: text frame (matching watchers) R-->>O: text frame B->>R: close frame R->>R: remove(id)

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

path components, so app.webSocket("ws", "reviews") and route parameters (":room") work as for get; the route is registered for the GET method, and req.parameters, req.query and req.headers are those of the handshake request

maxFrameSize

a WebSocketMaxFrameSize (an integer-literal wrapper); default 1 << 14 (16 KiB). A bigger incoming frame closes the connection with code 1009 messageTooLarge (observed below). Raise it deliberately, for a binary protocol, never "to be safe"

shouldUpgrade

runs before the 101 is written, in the handshake request. It may throw (becomes an HTTP error response), return [:] or headers to add to the 101 response (for example a chosen Sec-WebSocket-Protocol), or return nil to refuse the upgrade

onUpgrade

runs after the upgrade, with the original Request and the new WebSocket. The async form is started in an unstructured Task, so the closure may suspend, but its return does not close the socket

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 when shouldUpgrade is 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 any async code and throw any Abort before choosing to upgrade, so the client gets a normal HTTP 401 or 403 response. 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))") }
    }
}

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

send(_ text: String), send(_ binary: [UInt8]), send(_:fragmentStyle:)

async throws forms exist next to the promise: forms. A String is a text frame, a [UInt8] or other DataProtocol a binary frame

sendPing(), sendPing(_ data: Data)

sends a ping control frame

onText { ws, text in }, onBinary { ws, buffer in }

one callback of each kind; setting it again replaces the previous one. onText delivers a String, onBinary a ByteBuffer

onPing { ws, buffer in }, onPong { ws, buffer in }

notifications only: the library answers a ping with a pong by itself, so a handler is needed only to observe. The onPing overload without the buffer parameter is deprecated (a compiler warning was observed; onPong has a matching overload)

close(code: WebSocketErrorCode = .goingAway)

sends a close frame; async throws and future forms. WebSocketErrorCode lives in NIOWebSocket, which must be imported with MemberImportVisibility enabled (the template enables it)

onClose

an EventLoopFuture<Void> that completes when the connection is closed, for any reason (the peer closed, an error, a timeout, your own close)

closeCode, isClosed

what the peer sent (or the library decided) and whether the connection has ended

pingInterval

a TimeAmount?. When set, the library sends a ping at that interval and closes the socket if the previous ping was not answered. Setting it also sends a first ping immediately (a 0x89 0x00 frame arrived on curl right after the 101)

eventLoop

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:

  1. Inside an async onUpgrade (or WebSocket.connect closure), write every callback as an async closure, even when it contains no await: { ws, text async in ... }. The page’s listings do.

  2. If you must use the synchronous callbacks, register them from ws.eventLoop.execute { ... }, or use the future-based onUpgrade (legacy), which runs on the event loop.

  3. 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 to onUpgrade, onText and friends are @Sendable, and the Swift 6 language mode rejects captures of non-Sendable mutable state at compile time.

  4. ws.onClose is a future, not an async property; ws.onClose.whenComplete { _ in Task { await registry.remove(id) } } is the bridge back to actor code.

  5. Callbacks may run concurrently with each other and with HTTP requests. Two onText callbacks 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 an AsyncStream and 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.storage holds the actor, created once in configure. Handlers fetch it through req.application.reviewFeed; there is no global variable. Dependency injection in general is covered in Services, Storage and Dependency Injection.

  • broadcast awaits 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 one Task per socket (a withTaskGroup) 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 Abort for a bad Origin or ticket, before any upgrade. The client receives a normal HTTP response, which the browser reports as a failed connection but which curl and 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 from pingInterval):

    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 open and, when the client goes away, WebSocket 7F065472-... removed; 0 open.

  • maxFrameSize is lowered to 4 096 bytes for this text protocol. A 5 000-character message made the server close the connection with messageTooLarge (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) (in shouldUpgrade or, better, in the handler before req.webSocket) so every client gets a real status code and your logs show the reason;

  • use shouldUpgrade for what it is uniquely good at: adding headers to the 101 response (selecting a sub-protocol with Sec-WebSocket-Protocol), or a last cheap check that cannot be done earlier;

  • a plain GET to a WebSocket route without the upgrade headers received a bare 101 Switching Protocols answer with no Upgrade header and then hung until the client timed out (observed with curl and a 3-second limit). The route has no non-WebSocket behaviour; if your service must answer ordinary GET`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:

  1. check Origin against an allow-list on the server (browsers cannot forge it; non-browser clients can send anything, so the check protects users, not the API);

  2. do not rely on cookies alone: require a ticket or token chosen by your own JavaScript (next section);

  3. set SameSite=Lax or Strict on session cookies (see Sessions and Cookies and Security Hardening);

  4. 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 req.session) works unchanged

only for same-site pages; needs the Origin check and SameSite cookies, or it is hijackable

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 ?ticket=...

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 Sec-WebSocket-Protocol

pass the token as a second argument of new WebSocket and echo it back from shouldUpgrade

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 {"auth": "..."}; close with 1008 if it does not arrive in time

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 expose event.code and 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 own close calls), because the connection can end by a network error or the peer without any frame from you. The registry’s remove is 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 the shutdown hook of a LifecycleHandler that 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 maxFrameSize close 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 closed Decodable and closes with 1007 on failure) and never trust a bookID the 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())
}

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 (what onUnsubscribe means, the connection pools, RedisID) are in the Redis & Caching page, and the Redis server itself in the Redis section.

  • REDIS_HOST and 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 onUnsubscribe callback 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:

  • connect has several overloads (String or URL; closure async or not; on: defaulted or required). The overloads that take a non-async onUpgrade closure and an await-able call build the request from the URL’s path and drop the query string: the server saw GET /ws/reviews with no ticket and answered 401. Writing the closure as { ws async in ... } selects the overload that passes query. Check the request line in the server log when a ticket "does not arrive".

  • The async overload returns once the connection is established, not when it ends. Hold on to the ws (or await onClose) if your program must stay alive.

  • The client needs an event loop group: app.eventLoopGroup inside Vapor (as above), or MultiThreadedEventLoopGroup.singleton elsewhere. TLS is selected by the wss scheme.

  • Headers such as Origin are 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)
        }
    }
}

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
    }
}

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

NIOCore/NIOLoopBound.swift:172: Precondition failed and the process exits

a synchronous onText, onBinary, onPing or onPong callback registered inside an async onUpgrade or connect closure, so off the event loop

write the callback as { ws, x async in ... }, or register it inside ws.eventLoop.execute

The server says 401 for a ticket the client "sent"

the connect overload with a non-async closure dropped the query string

use the async closure or the scheme:host:port:path:query: overload; look at the request line in the log

The client’s connect never returns or throws

shouldUpgrade returned nil, so the server closed the connection without a response

throw Abort instead

A plain GET to the route hangs after a bare 101

the route returns an upgrade response whatever the request headers are

check the Upgrade header first if the path also serves HTTP

Connection closed with 1009

a frame is larger than maxFrameSize (16 KiB by default)

send smaller messages, or raise the limit consciously

Connection closed with 1007

your own code closed with unacceptableData after decoding failed

send valid JSON; look at the close code on the client

Browser error WebSocket connection failed with no detail

the handshake got 401, 403, a proxy error or 200; the page cannot see the status

look at the server log and curl -i the same handshake

A wss:// connection fails through a proxy but ws:// directly works

the proxy lacks Upgrade and Connection forwarding, or proxy_http_version 1.1

use the NGINX settings above

Sockets disappear after about a minute of silence

a proxy idle timeout

set pingInterval below the timeout, and raise proxy_read_timeout

Messages are not delivered to clients of the other instance

the registry is per process

publish through Redis (or another broker) as shown

Application.shutdown() takes ten seconds or logs Server stop took too long

open sockets remained at shutdown

close registered sockets from a lifecycle handler

Swift 6 error about capturing a non-Sendable value or a mutable var in an onText closure

the callbacks are @Sendable and run concurrently

keep state in an actor, pass Sendable values

enum case 'unacceptableData' is not available due to missing import of defining module 'NIOWebSocket'

MemberImportVisibility is on and the code names a WebSocketErrorCode case

import NIOWebSocket

Any cookie-authenticated socket can be opened from another site

no Origin check

allow-list Origin, use tickets, set SameSite

A ticket works twice, or lives for hours

tickets are not removed on use or have no expiry

remove on redeem and expire (the example does both)

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 in configure.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 async app.webSocket and req.webSocket overloads and the async callback forms came with Vapor 4’s concurrency support; the examples in the official documentation page are still written with futures, ws.send("...") without await, and an eventLoop argument for connect. Both work in 4.122; the synchronous callbacks carry the event-loop restriction explained above.

  • maxFrameSize takes a WebSocketMaxFrameSize, which is ExpressibleByIntegerLiteral, so a literal such as 4096 compiles; the page spells it .init(integerLiteral: 1 << 12) because a shift expression is not a literal.

  • The onPing overload without the buffer parameter 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.swift declares package traits, among them WebSockets, HTTPClient, Multipart and MacroRouting, with WebSockets in the default set. The same manifest has the websocket-kit dependency 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 your Package.swift once Vapor 5 is released; the registry design above (an actor, the Origin and ticket checks) does not depend on the library, but the exact WebSocket API was not verified for Vapor 5.

  • Swift 6 language mode makes the Sendable and isolation rules compile-time errors, where books from the Swift 5 era (all of the ones consulted for this section) do not mention them.