Production and Performance

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 (the latest 4.x release, 2026-09-17) on Swift 6.4 in the Swift 6 language mode, with fluent 4.13.0, fluent-sqlite-driver 4.9.0 and fluent-postgres-driver 2.14.0, checked on 2026-10-17. Every Swift listing is a real source file of a scratch package that compiled without warnings. The behaviour described was run: the health endpoints and the drain were exercised with curl and SIGTERM on macOS, the blocking experiments ran in a Linux container (swift:6.3-noble, limited to 4 CPUs, a release build) on a 10-core Mac, and the throughput numbers come from ab on the same machine. Not run: PostgreSQL (the pool settings were compiled only), Kubernetes, perf, wrk and oha (not installed here). The numbers are illustrations of relative effects on one laptop, not benchmarks to quote. Vapor 5 is at 5.0.0-beta.4 and is not released; where it matters, that is noted. The page extends the Bookshelf service of the earlier pages.

A Vapor process that works on a laptop is not yet a service you can operate. Production adds three questions: how do you know it is healthy and how do you take it out of rotation without dropping requests (health endpoints and the shutdown sequence), how do you run several copies of it (stateless design), and what makes it slow when load arrives (blocked threads, database connections, avoidable work). This page answers them in that order and ends with a checklist.

Release builds and the production environment

Two different switches are called "production", and they are independent.

Switch What it is What it changes

swift build -c release

A compiler configuration, fixed when the binary is built.

Optimisation (the binary is several times faster than a debug one), assert and assertionFailure are removed, Environment.isRelease becomes true.

--env production or Environment.production

A name chosen at run time (serve --env production).

The default log level (.notice instead of .info), which .env.production file is read, and every if app.environment == .production in your own code.

The documentation of Environment.isRelease in the 4.122.2 source says so explicitly: the value is determined at compile time and "is not based on the actual environment name", so Environment.production.isRelease is false in a debug build. The one place Vapor itself uses isRelease matters: ErrorMiddleware replaces the reason of an unexpected error with Something went wrong. in a release build and shows the full error description in a debug build. A debug binary run with --env production therefore still leaks error text to clients. Always ship a release build, and treat the environment name as a configuration selector, not a safety feature.

The commands the official template Dockerfile uses are a good default for building on Linux:

# optimised, with the Swift runtime linked statically and jemalloc linked in (Linux)
swift build -c release --product App --static-swift-stdlib -Xlinker -ljemalloc

# where the binary ends up (the Dockerfile copies it from there)
swift build -c release --show-bin-path

--static-swift-stdlib makes the binary independent of the Swift toolchain, so the runtime image can be a plain ubuntu:noble plus a few libraries. -Xlinker -ljemalloc needs libjemalloc-dev in the build image and libjemalloc2 in the runtime image (see jemalloc). The Docker and systemd details belong to Deployment with Docker and Deployment Targets; this page covers what the process does once it runs.

A release build needs measuring too. The same /catalogue route (100 BookDTO values encoded as JSON, no database), run under serve --env production with ab -k -n 20000 -c 50, gave these numbers on the test machine:

Build Requests per second p50 (ms) p99 (ms)

Debug (swift build)

about 11 400

3

28

Release (swift build -c release)

about 20 700

2

5

The gap is smaller than for pure computation because most of the time goes into the kernel and the event loop, and the dependencies are the same code in both. It is still close to a doubling of throughput and a tail about five times shorter, which is why benchmarks of debug builds are meaningless.

Environment and log levels

LoggingSystem.bootstrap(from:) picks the level in this order: the --log option, the LOG_LEVEL environment variable, then .notice for the production environment and .info for everything else (the logic is in LoggingSystem+Environment.swift of 4.122.2). In a development run every request writes a GET /path [request-id: ...] line at info; in production those lines disappear and the log holds only notices, warnings and errors. Concretely: a release build serving 20 000 requests under --env production wrote one log line (the start-up notice), and the same binary in the development environment wrote about 20 500. On this machine the throughput difference was within run-to-run noise (20 300 to 22 700 requests per second in repeated runs) because the log went to a file, so the argument for .notice is mostly the signal-to-noise ratio and the cost of shipping the logs, not the CPU time. Two rules follow:

  • Set the level from the environment (LOG_LEVEL=notice or info), not in code, so an operator can raise it to debug for one pod without a rebuild. Do not leave debug on in production: it is voluminous and includes details (SQL statements, connection events) that you do not want in shared logs.

  • Keep the request IDs. Whatever the level, req.logger carries the request-id metadata, which is what lets you join a log line to a trace (see Logging and Tracing, Metrics & OpenTelemetry; this page only needs the ID).

Reading configuration and secrets from the process environment, .env files and the Environment.secret helper is covered in Configuration and Environment; the server settings mentioned below (idleTimeout, shutdownTimeout, responseCompression) are explained property by property in Server Configuration.

One place for production settings

The function below is the whole difference between a development and a production process in the scratch package. It applies the HTTP server settings that matter behind a proxy and chooses the database from DATABASE_URL. The placeholders (DATABASE_URL, DB_POOL_PER_LOOP) come from the environment; no connection string is in the source.

import Fluent
import FluentPostgresDriver
import FluentSQLiteDriver
import Vapor

/// Everything that differs between a laptop and a production replica, driven by the environment.
func configureProduction(_ app: Application) throws {
    // HTTP server (see the Server Configuration page).
    var server = app.http.server.configuration
    server.hostname = Environment.get("HOST") ?? "0.0.0.0"
    server.port = Environment.get("PORT").flatMap(Int.init) ?? 8080
    server.responseCompression = .enabled          // leave it .disabled when the proxy compresses
    server.idleTimeout = .seconds(75)              // longer than the proxy's upstream keep-alive
    server.shutdownTimeout = .seconds(25)          // shorter than the orchestrator's grace period
    app.http.server.configuration = server
    app.routes.defaultMaxBodySize = "1mb"

    // Database: PostgreSQL from DATABASE_URL when set, in-memory SQLite otherwise.
    if let url = Environment.get("DATABASE_URL") {
        let perLoop = Environment.get("DB_POOL_PER_LOOP").flatMap(Int.init) ?? 2
        app.databases.use(
            try .postgres(
                url: url,
                maxConnectionsPerEventLoop: perLoop,
                connectionPoolTimeout: .seconds(5)
            ),
            as: .psql
        )
    } else {
        app.databases.use(.sqlite(.memory), as: .sqlite)
    }
}

Three choices in the listing are worth stating. idleTimeout (new in 4.122.0) is longer than the proxy’s upstream keep-alive so the proxy, not Vapor, closes idle connections first. shutdownTimeout is shorter than the orchestrator’s grace period (30 seconds in Kubernetes by default) so Vapor reports a slow drain itself instead of being killed. defaultMaxBodySize of 1 MB replaces the 16 KB default for routes that collect bodies; keep it as low as the largest legitimate JSON body, and stream big uploads (see File Uploads and Streaming).

Health endpoints

Orchestrators and load balancers ask a process two different questions, and a single /health route that answers both causes outages.

Probe Question What a failure does

Liveness

Is the process able to answer at all?

The orchestrator restarts the container.

Readiness

Should this instance receive traffic now?

The load balancer stops routing to it; nothing restarts.

Startup

Has the process finished starting? (slow migrations, cache warm-up)

Holds the other probes back.

Liveness must not depend on anything outside the process. If it checked the database, a database outage would restart every replica at once, turning a dependency failure into a total one. Readiness may check the dependencies the instance needs to serve requests, and must also fail while the instance is draining. The listing implements both with two routes and a flag, plus the lifecycle handler that sets the flag (explained in the next section).

import Fluent
import NIOConcurrencyHelpers
import Vapor

/// Process-wide readiness flag. It flips to "draining" when shutdown begins.
final class ReadinessState: Sendable {
    private let draining = NIOLockedValueBox(false)

    var isDraining: Bool { draining.withLockedValue { $0 } }

    func beginDraining() { draining.withLockedValue { $0 = true } }
}

/// The body of `/ready`.
struct ReadyReport: Content {
    let status: String
    let database: String
}

/// Registers `/health` (liveness), `/ready` (readiness) and the drain-before-shutdown handler.
func registerHealth(_ app: Application, drainGrace: Duration = .seconds(5)) {
    let state = ReadinessState()

    // Liveness: "the process can answer". No dependencies, so a database outage never restarts the pod.
    app.get("health") { _ async -> HTTPStatus in .ok }

    // Readiness: "send me traffic". Fails while draining and when the database cannot be used.
    app.get("ready") { req async throws -> Response in
        if state.isDraining {
            let report = ReadyReport(status: "draining", database: "skipped")
            return try await report.encodeResponse(status: .serviceUnavailable, for: req)
        }
        do {
            _ = try await Author.query(on: req.db).count()
            return try await ReadyReport(status: "ok", database: "ok").encodeResponse(for: req)
        } catch {
            req.logger.warning("readiness check failed", metadata: ["error": "\(error)"])
            let report = ReadyReport(status: "unavailable", database: "failed")
            return try await report.encodeResponse(status: .serviceUnavailable, for: req)
        }
    }

    app.lifecycle.use(DrainBeforeShutdown(state: state, grace: drainGrace))
}

/// Fails readiness first, waits for the load balancer to notice, then stops the HTTP server.
///
/// Register it after every other lifecycle handler: handlers shut down in reverse order, so the last
/// one registered runs first.
struct DrainBeforeShutdown: LifecycleHandler {
    let state: ReadinessState
    let grace: Duration

    func shutdownAsync(_ application: Application) async {
        state.beginDraining()
        application.logger.notice("draining", metadata: ["grace": "\(grace)"])
        try? await Task.sleep(for: grace)
        await application.http.server.shared.shutdown()
    }
}

Run against the scratch package (SQLite in memory), the endpoints answer:

$ curl -s -i localhost:18390/health | head -3
HTTP/1.1 200 OK
content-length: 0
connection: keep-alive
$ curl -s localhost:18390/ready
{"status":"ok","database":"ok"}

Design notes:

  • /health returns HTTPStatus.ok and touches nothing, so it is cheap enough for an every-second probe. It still needs a free thread to run, which makes it a useful canary for blocked threads (see Performance: never block the threads that run your handlers): if it times out, the process is not serving anything.

  • /ready runs one cheap query on the pool that real requests use, so it fails exactly when real requests would: when the pool is exhausted or the database is unreachable. A query that waits for a pool connection fails after connectionPoolTimeout (5 seconds in the production listing), so set the probe timeout longer than that or shorter, on purpose, but know which.

  • Return a small JSON body saying which check failed. It costs nothing and saves the first hour of every incident. Do not put version strings or hostnames in a public readiness body.

  • Keep the routes outside authentication and rate-limit middleware. Expose them only to the network the probes come from (a separate internal port is possible with a second Application configuration, but a path filtered at the proxy is the usual solution).

  • The runtime image of the official Dockerfile (see Deployment with Docker) contains no curl, so a Compose healthcheck needs curl installed in the image, while Kubernetes httpGet probes need nothing inside the container.

A Kubernetes Deployment that uses them (not run here; see Health Probes and Lifecycle for the probe semantics):

containers:
  - name: bookshelf
    image: registry.example.com/bookshelf:1.0.0
    ports:
      - containerPort: 8080
    livenessProbe:
      httpGet: { path: /health, port: 8080 }
      periodSeconds: 10
      failureThreshold: 3
    readinessProbe:
      httpGet: { path: /ready, port: 8080 }
      periodSeconds: 5
      failureThreshold: 1
    startupProbe:
      httpGet: { path: /health, port: 8080 }
      periodSeconds: 2
      failureThreshold: 30
terminationGracePeriodSeconds: 30

Graceful shutdown

The mechanics of shutdown in Vapor 4 have a trap, documented with its logs in the real shutdown order section of Application Lifecycle and the Entrypoint: serve stops on SIGTERM or SIGINT by returning from execute(), and only then does asyncShutdown() run the lifecycle handlers and, in an unspecified order relative to them, stop the HTTP server. A handler that closes a pool can therefore run while requests still use it. The fix is a lifecycle handler registered last, which runs first, and calls application.http.server.shared.shutdown() itself.

DrainBeforeShutdown in the listing above does that and adds the missing step for a load balancer: it flips the readiness flag, waits grace (5 seconds in the listing), and only then stops the server. In between, the instance still serves requests that were already routed to it, but /ready answers 503, so the balancer takes it out of rotation. The wait should be a little longer than the balancer’s probe interval times its failure threshold. The registration is the last line of the application’s configure (registerHealth(app) in the wiring excerpt below).

Real run: one request to a three-second route in flight, SIGTERM one second later, /ready polled every second:

$ curl -s -w ' [slow: %{http_code} in %{time_total}s]\n' localhost:18391/slow &
$ kill -TERM $PID
{"status":"draining","database":"skipped"} ready after SIGTERM +1s: 503
slow done [slow: 200 in 3.055480s]
{"status":"draining","database":"skipped"} ready after SIGTERM +2s: 503
{"status":"draining","database":"skipped"} ready after SIGTERM +3s: 503
{"status":"draining","database":"skipped"} ready after SIGTERM +4s: 503
after exit: 000

The log shows draining [grace: 5.0 seconds], the in-flight request finished with status 200, /ready reported 503 for the whole grace period, and connections were refused only after the server had stopped. Two limits remain: Vapor has no hook that ends WebSockets and server-sent event streams at shutdown (close them yourself in the handler, before shutdown()), and the sum of the grace and the longest request must stay below shutdownTimeout, which in turn stays below the orchestrator’s grace period. In Docker, the process must be PID 1 started in exec form or SIGTERM never reaches it, as the lifecycle page explains.

The wiring of this page’s scratch application, with the production settings first and the drain handler last:

func configure(_ app: Application) async throws {
    try configureProduction(app)
    app.migrations.add(CreateBookshelf())
    try await app.autoMigrate()   // fine for SQLite in memory; see "Horizontal scaling" for real databases

    // ... routes ...

    // Last, so it shuts down first.
    registerHealth(app)
}

Horizontal scaling: stateless instances

A single Vapor process uses every core (one event loop per core), so the first scaling step is a bigger machine. The second is more copies of the process behind a load balancer, which works only if an instance can be killed at any moment without losing anything and any request can be served by any instance. The rule is simple and the audit is not: nothing that must survive or be shared lives in the process.

flowchart LR C[Clients] --> LB[Load balancer] LB -->|round robin| A1[Vapor replica 1] LB -->|round robin| A2[Vapor replica 2] LB -->|round robin| A3[Vapor replica N] A1 --> DB[(PostgreSQL)] A2 --> DB A3 --> DB A1 --> R[(Redis: sessions, cache, queues, limits)] A2 --> R A3 --> R A1 --> O[(Object storage: uploads)] A2 --> O A3 --> O W[Queue worker and scheduler process] --> DB W --> R

The things a default Vapor application keeps in memory, and the replacement:

State Default in Vapor 4 Shared replacement

Sessions

app.sessions.use(.memory) is the default (set in Application.init): each instance has its own sessions, and a restart logs everyone out.

The Redis or Fluent session driver; see Sessions and Cookies.

Cache

app.caches.use(.memory): per-process, never invalidated by other instances.

The Redis cache driver and pub/sub invalidation; see Redis and Caching.

Rate limits and counters

Whatever you wrote, usually a dictionary in a lock.

Redis INCR with expiry, or the proxy; see Security Hardening.

Uploaded files

The local Public directory or a temp file.

Object storage (S3-compatible) with a database row pointing to it. Vapor ships no S3 client; Soto is the usual library. Local disk works only with one replica or a shared volume.

Background jobs

The process that enqueued them.

Vapor Queues on Redis and one or more worker processes; see Queues & Background Jobs.

Scheduled work

Runs in every process that has --scheduled.

One scheduler process, or a lock in Redis or the database; see Scheduled Jobs.

WebSocket and SSE connections

A registry in memory, per instance.

Redis pub/sub or a message broker to fan out between instances; connections themselves are sticky to the instance that accepted them.

Migrations

autoMigrate() in configure runs on every start.

A one-off job (App migrate --yes) before the new version starts.

Some consequences:

  • Sticky sessions are a workaround, not a design. They hide memory sessions behind the balancer until a deploy restarts everything. Put sessions in Redis and the balancer can be dumb. Stateless bearer-token APIs (see the JWT page, JSON Web Tokens) have no session store at all.

  • Migrations are not part of start-up. Two replicas starting together both running autoMigrate() race on the same schema. Run the migration as a separate step of the deployment; migrate --yes is a one-off command (see Commands), and Migrations & Schema explains why a migration must be compatible with the previous release while the rollout is in progress.

  • Every replica opens its own connections. Read the next sections before scaling out, because the database, not Vapor, is usually the first thing that runs out.

  • Rolling updates need compatible versions. While old and new replicas coexist, a cookie, a cache entry, a queued job payload or a JSON field written by one must be readable by the other. Version job payloads and cache keys.

  • Time and identity. Do not derive IDs from a per-process counter, and do not assume that two requests of one user reach the same instance.

Performance: never block the threads that run your handlers

Vapor 4 runs on two kinds of threads. NIO event loops (one per core, System.coreCount) read and write sockets. The code of an async handler, however, runs on Swift’s cooperative thread pool, whose width is the number of CPUs the runtime sees. An await suspends the task and frees the thread; a call that blocks (a synchronous library, Thread.sleep, a large synchronous hash, Data(contentsOf:), a semaphore wait, EventLoopFuture.wait()) does not. A blocked cooperative thread cannot run any other task, and when all of them are blocked every handler in the process waits, including /health.

.wait() on an event loop thread is stopped by NIO with a BUG DETECTED precondition failure; elsewhere it silently stalls the thread. Neither is acceptable in a handler.

The experiment

Blocking.swift has four routes: /ping returns immediately, /blocking runs one second of synchronous work inside an async handler (the wrong way), /sleeping awaits Task.sleep for one second (a stand-in for real asynchronous I/O), and /offloaded runs the same synchronous work on the application’s blocking thread pool.

import Foundation
import Vapor

/// Stands in for synchronous work: a blocking client library, a big hash, `Data(contentsOf:)`.
func blockingWork() -> String {
    Thread.sleep(forTimeInterval: 1)
    return "done"
}

func registerBlockingDemo(_ app: Application) {
    app.get("ping") { _ async -> String in "pong" }

    // WRONG: blocks one of the threads that run every async handler.
    app.get("blocking") { _ async -> String in
        blockingWork()
    }

    // Right when the work is really asynchronous.
    app.get("sleeping") { _ async throws -> String in
        try await Task.sleep(for: .seconds(1))
        return "done"
    }

    // Right when the work is synchronous: run it on the blocking thread pool.
    app.get("offloaded") { req async throws -> String in
        try await req.application.threadPool.runIfActive {
            blockingWork()
        }
    }
}

The release build ran in a Linux container limited to 4 CPUs (docker run --cpus 4). System.coreCount reported 4, because NIO reads the cgroup CPU quota, while the cooperative pool ran 10 tasks at a time (the Docker VM has 10 CPUs). The tool was ab -n 40 -c 40 (40 requests at once, each needing one second of work):

Route Slowest of 40 requests Median Reading

/sleeping

1.01 s

1.01 s

All 40 waited at the same time; no thread was held.

/blocking

4.01 s

2.01 s

Ten at a time (the cooperative pool width): four waves.

/offloaded

10.02 s

5.01 s

Four at a time (the thread pool width, coreCount): ten waves.

Offloading is slower for the blocking work itself because the blocking pool is narrower, and that is the point. It protects everything else. The second experiment sent 200 requests to /blocking (and then to /offloaded) and, one second later, asked /ping for a single answer:

Load running Time for one /ping request

200 x /blocking

12.0 s (a second one a second later: 6.0 s)

200 x /offloaded

0.011 s (the next: 0.002 s)

With the cooperative pool full of blocked tasks, even a handler that does nothing waits for a free thread, and so would the health endpoint: the orchestrator sees a failed liveness probe and kills a process that was merely busy. With the work on app.threadPool, the cooperative pool stays free. (With 40 requests the same /ping was answered in 2 ms during the blocking run: whether a late request starves depends on how many blocked tasks queued in front of it, which is why the effect shows up in production under load and not in a quick test.)

The rules

  • Know what blocks. Synchronous database or HTTP libraries, FileManager and Data(contentsOf:) on big files, NSLock held across slow work, DispatchSemaphore.wait, bcrypt/Argon2 hashing, image resizing, PDF generation, large JSONDecoder or JSONEncoder runs on huge documents, and regular expressions on untrusted input.

  • Prefer asynchronous APIs. Fluent, req.client, req.fileio (see Static Files and File I/O), Task.sleep and Redis are non-blocking.

  • Offload the rest to req.application.threadPool.runIfActive { ... }, as /offloaded does. The pool is NIO’s shared blocking pool, sized to the core count by default. To change its size, assign a new one before boot (app.threadPool = NIOThreadPool(numberOfThreads: 16)); Vapor then owns it and starts and stops it, and replacing it after boot is a fatalError.

  • Hash passwords off the loop with req.password.async (see Passwords and Cryptography), which already uses that pool.

  • CPU-bound work is not made faster by offloading, only moved. A route that needs seconds of CPU per request belongs in a queue job on a worker process (see Queues & Background Jobs), not on the web tier.

  • Do not "fix" blocking with more threads in the cooperative pool; there is no supported setting for it. Installing NIO as the global executor is a different trade-off, described in the optional NIO global executor: it removes thread hops, but blocking work would then run on the event loop threads themselves.

  • The Swift 6 angle: values captured by the runIfActive closure must be Sendable, so copy what you need out of the Request first; the compiler enforces it. The Swift Reference explains the rules in Async/Await and Tasks and Actors, Isolation and Sendable.

Connection-pool arithmetic

Fluent keeps one connection pool per event loop, and Redis does the same per RedisID, so the connections one process opens are event loops x pool size, and the total is that times the number of replicas. The mechanism, defaults and error messages are in Fluent connection pools; this section is the capacity plan.

database connections = replicas x cores per replica x maxConnectionsPerEventLoop

PostgreSQL allows 100 connections by default, some of them reserved for administration. Ten replicas of 8 cores with the Fluent default of maxConnectionsPerEventLoop: 1 already use 80. With 4, they would ask for 320 and the database would refuse connections with too many clients already. The remedies, in the order to try them:

  1. Lower the pool until database connections fits comfortably under max_connections. In the production listing DB_POOL_PER_LOOP is read from the environment (default 2), so it can be tuned per deployment without a build.

  2. Count cores correctly. System.coreCount honours the container CPU quota (4 in the experiment above), so a --cpus 4 replica on a 64-core host has 4 event loops, not 64. A limit expressed only as CPU shares or as a memory limit is not seen, and a replica can start with one loop per host core.

  3. Put a pooler in front (PgBouncer in transaction mode, RDS Proxy) when the replica count is large; remember that the pooler’s own limit now plays the role of max_connections.

  4. Bound the wait. connectionPoolTimeout decides how long a query queues for a connection before failing; the default is 10 seconds, far longer than a user waits. A short timeout turns overload into fast errors that readiness can see.

More connections do not mean more throughput. A database server with few cores is slower with 300 busy connections than with 30, because it context-switches between them; the right pool is the smallest one that keeps the database busy. Hold a connection only as long as needed: a transaction { } closure that awaits an HTTP call holds its connection for the duration of that call. Redis needs the same arithmetic against maxclients, with pool.maximumConnectionCount per event loop (see Redis & Caching), and it is a good reason to keep that maximum small.

Compression, caching and other cheap wins

  • Response compression. app.http.server.configuration.responseCompression = .enabled compresses text-like responses when the client sends Accept-Encoding. On the /catalogue route (8 880 bytes of JSON) a client that accepted gzip received 1 004 bytes with Content-Encoding: gzip, 11 percent of the size. It costs CPU on every response, so measure the CPU, and let the reverse proxy or the CDN do it if one is in front. Per-route overrides and the BREACH caveat are on the Server Configuration page.

  • Do less work per request. Eager-load relations instead of the N+1 pattern (the measured example is in Fluent Relations), paginate (see Fluent Queries and Pagination), select the columns you return, and never return a model when a smaller DTO is enough.

  • Cache what is expensive and shared, with the cache-aside pattern from Redis & Caching, and let clients cache with ETag/Cache-Control headers on read-mostly routes.

  • Stream big bodies instead of collecting them: body: .stream on upload routes and Response.Body streams on downloads keep memory flat (see File Uploads and Streaming).

  • Keep logging proportional. Per-request info lines, SQL logging (sqlLogLevel) and large metadata dictionaries all cost time and storage at volume.

  • Reuse HTTP clients. req.client uses a shared connection pool; do not create an HTTPClient per request.

  • Validate early. Reject oversized or malformed input in middleware or with Validatable before doing expensive work.

jemalloc

Swift allocates and frees a great deal of small objects, and a server’s long-lived, multi-threaded allocation pattern (many threads allocating on one and freeing on another) is where the glibc allocator fragments memory. The official template links jemalloc, a better-behaved allocator, on Linux: the build image installs libjemalloc-dev, swift build gets -Xlinker -ljemalloc, and the run image installs libjemalloc2. Three ways to use it:

  • Link it as the Dockerfile does. Note the template’s own comment: the static version of jemalloc is incompatible with the static Swift runtime, so link the shared library and install libjemalloc2 at run time.

  • Preload it without relinking: LD_PRELOAD=/usr/lib/aarch64-linux-gnu/libjemalloc.so.2 ./App serve (the directory is x86_64-linux-gnu on Intel). In the test container this loaded the library into the process (/proc/PID/maps lists it), which is how to verify any of these three routes.

  • Tune it through MALLOC_CONF (for example background_thread:true or stats_print:true for a report at exit); read the jemalloc manual before changing anything.

This page makes no numeric claim for jemalloc: the gain depends on the allocation pattern of each service, so measure memory and latency with and without it under your own load. The reason to use it is that the official image does, and resident-set growth over days is the symptom to watch. On macOS the system allocator is already a modern one and there is nothing to configure.

Profiling

Measure before changing code. A profile answers "where does the time go"; a benchmark answers "is it better now".

  • Symbols. A release binary profiles well if it has symbols. Add -Xswiftc -g to the release build for line-level attribution. Swift names are mangled; pipe through swift demangle.

  • macOS, Instruments. Run the release build, choose the Time Profiler (CPU) or Allocations template, attach to the process while it is under load, and read the heaviest call tree frames. The command-line sample PID 5 -file out.txt does a quick version without the GUI. On the /catalogue run above, the busiest non-idle frames were JSONWriter.serializeString (JSON encoding), malloc/free, writev (writing the response) and Swift reference counting (swift_release); most of the process’s threads were idle in kevent and psynch_cvwait, which is the picture of a server waiting for work. Idle frames dominate any sample of a healthy server, so look at the busy ones.

  • Linux, perf. The standard tool is perf:

    # 30 seconds of CPU samples with call graphs, then a flame graph (Brendan Gregg's FlameGraph scripts)
    perf record -F 99 -g -p "$(pgrep -x App)" -- sleep 30
    perf script | stackcollapse-perf.pl | flamegraph.pl > flame.svg

    In a container perf needs the capability to read performance counters (--cap-add SYS_ADMIN, or a host with kernel.perf_event_paranoid lowered), and the tool must be installed in the image or run from the host against the container’s PID. Many managed platforms do not allow it; there, use the sampling profiler of your observability stack. The commands above are `perf’s documented usage, not a captured session; adjust the sampling rate and duration to your load.

  • Where to look. Typical findings are JSON encoding of large payloads, N+1 queries, time spent waiting for pool connections (visible as many tasks suspended and a low CPU), and lock contention on a shared dictionary.

  • Crashes. The official image sets SWIFT_BACKTRACE=enable=yes,... and copies the static swift-backtrace-static binary, which makes a fatal error print a symbolicated backtrace into the log. Keep it.

  • Traces and metrics. Latency percentiles per route, pool wait time and event-loop lag come from swift-metrics and OpenTelemetry (see Tracing, Metrics & OpenTelemetry); profiling tells you why, metrics tell you when.

Load testing

  • Use a release build, on hardware like production, with the generator on another machine. A generator on the same host competes for CPU and distorts both sides. The numbers on this page violate that rule on purpose, to show relative effects.

  • Warm up first (500 requests in the runs above), then measure for long enough (30 seconds or more) for the garbage of connection setup to wash out.

  • Report percentiles, not only the mean. p99 and p99.9 are what users feel, and a generator that waits for each answer before sending the next one under-reports tail latency when the server stalls (coordinated omission); tools with a fixed request rate (wrk2, oha -q, k6 arrival-rate scenarios) do not.

  • Change one thing at a time and keep the numbers.

The tool used here is Apache Bench, which ships with macOS and Linux. It is single-threaded and defaults to HTTP/1.0 without keep-alive, so always pass -k:

# warm-up, then the measured run: 20 000 requests, 50 at a time, keep-alive
ab -q -k -n 500 -c 20 http://127.0.0.1:8080/catalogue > /dev/null
ab -q -k -n 20000 -c 50 http://127.0.0.1:8080/catalogue

wrk and oha are better generators (multi-threaded, latency histograms); their usual invocations are below (not run on this machine):

wrk -t4 -c64 -d30s --latency http://127.0.0.1:8080/catalogue
oha -z 30s -c 64 --no-tui http://127.0.0.1:8080/catalogue

For scenario-based tests with thresholds in CI, see the JMeter pages of this site, starting with JMeter best practices and load generator tuning. The equivalent page for another framework, with the same measure-first discipline, is Performance Tuning (axum).

A production checklist

Area Check

Build

Release build (-c release), --static-swift-stdlib, jemalloc linked or preloaded, the compiler version and Package.resolved pinned, swift-backtrace available.

Configuration

--env production (or Environment.production), LOG_LEVEL set deliberately, no secrets in the image or the repository, all settings from environment variables, configuration validated at start-up so a bad value stops the process before it listens.

Server

hostname 0.0.0.0 in containers, idleTimeout longer than the proxy’s upstream keep-alive, shutdownTimeout shorter than the orchestrator’s grace period, defaultMaxBodySize set, TLS at the proxy or in the process.

Health

/health with no dependencies, /ready with the real dependencies and a draining state, probes configured, startup probe if start-up is slow.

Shutdown

Handler that fails readiness first, waits, then stops the server; process is PID 1 in exec form; WebSockets and streams closed explicitly.

State

Sessions, caches, rate limits, uploads and job queues are outside the process; migrations run as a separate step; one scheduler.

Capacity

replicas x cores x pool size under the database max_connections; connectionPoolTimeout short; Redis maxclients checked.

Threads

No blocking calls in handlers; blocking work on app.threadPool or in queue jobs; password hashing through req.password.async.

Performance

Eager loading and pagination; response compression decided (Vapor or proxy); big bodies streamed; a baseline load test with p99 recorded.

Observability

Request IDs in logs, metrics per route, traces, alerts on error rate, latency, pool waits and restarts.

Security

Security headers, rate limits, CORS allow-list, dependency updates and the points of Security Hardening.

Operations

Rollback plan, previous image kept, backups restored at least once, runbook for "ready but slow" and "not ready".

Swift 6 concurrency notes

  • ReadinessState is a final class that is Sendable without @unchecked, because its only stored property is an NIOLockedValueBox<Bool>. A route closure captures it, and Swift 6 requires route closures to be @Sendable.

  • DrainBeforeShutdown is a struct conforming to LifecycleHandler (which is Sendable); its stored properties are Sendable, so no isolation attributes are needed.

  • A closure passed to threadPool.runIfActive must be @Sendable and its result Sendable; the example returns a String.

  • Blocking inside an async function compiles, which is why the hazard is invisible. Thread.sleep is rejected by the compiler in an async function (class method 'sleep' is unavailable from asynchronous contexts), so the example puts the blocking call in a synchronous helper, which is exactly how it hides in real code (a library call). A synchronous call you did not write cannot warn you.

Common errors and pitfalls

Symptom Cause and fix

Everything is slow or /health times out under load, CPU is low

Blocked cooperative threads. Find the synchronous calls, move them to app.threadPool or a queue worker.

BUG DETECTED: wait() must not be called when on an EventLoop

.wait() on an event loop thread inside a handler. Use await on the future.

Clients see error text in production responses

A debug build is running with --env production. Ship a release build; isRelease is a compile-time property.

too many clients already / FATAL: remaining connection slots are reserved

replicas x cores x pool size exceeds max_connections. Lower maxConnectionsPerEventLoop, add a pooler.

Users are logged out on every deploy or hit different sessions per request

MemorySessions (the default) with more than one replica or restarts. Use the Redis or Fluent driver.

Two replicas fail on start with schema errors or duplicate objects

autoMigrate() runs on every start. Run migrations as a one-off step.

Requests dropped during a rollout

No drain: the process stops accepting while the balancer still routes to it. Use the readiness flag and a grace period; use exec-form ENTRYPOINT.

The pod is restarted although the app is fine

A liveness probe that depends on the database or has a timeout shorter than a busy period. Keep liveness trivial.

Server stop took too long.

An in-flight request or stream outlived shutdownTimeout. Raise it (below the grace period) or close long-lived connections first.

Memory grows for days

Allocator fragmentation (try jemalloc), an unbounded in-memory cache or dictionary, or retained request bodies. Compare with and without jemalloc and profile allocations.

502 from the proxy at idle moments

Vapor closed a keep-alive connection the proxy still pooled; set idleTimeout longer than the proxy’s.

Benchmark numbers vary wildly

Debug build, generator on the same host, no warm-up, or a single run. Repeat, and report percentiles.

Version notes

Version Change

4.x

Environment.isRelease, serve, LifecycleHandler.shutdownAsync and app.threadPool (NIO’s shared blocking pool by default) exist in 4.122.2; their introduction versions were not dated for this page

4.122.0

idleTimeout on the HTTP server (2026-07-01)

4.122.2

Current 4.x; production default log level .notice; ErrorMiddleware hides error reasons in release builds

5.0.0-beta.4

Server on swift-http-server and swift-service-lifecycle: graceful shutdown becomes a service-group concern, see the Application Lifecycle page (unreleased)

Vapor 5 is a beta and not released; plan to re-read the shutdown and server sections of the Vapor 5 documentation before moving a production service to it.

Where the books are outdated

The Vapor 3 book (Kodeco, Condon and others) ends with production chapters on environments and release builds, Supervisor, a reverse proxy, and scaling out with Redis-backed sessions. The ideas survive; the commands do not. Vapor 3 apps were built with the Toolbox and run under Supervisor on an Ubuntu VM sized by hand; today the common unit is a container image built from the official multi-stage Dockerfile, with Swift’s runtime linked statically, jemalloc, a non-root user and a crash backtrace handler, scheduled by systemd, Kubernetes or a platform service. The books predate async/await, so their advice on not blocking the event loop was about EventLoopFuture chains and .wait(); the new hazard is the cooperative pool, as measured above. They configured health, drain and compression not at all or in Vapor 3 NIOServerConfig terms (see the Server Configuration page). Yeung’s and Patel’s Vapor 2 and 3 deployments to Heroku and Vapor Cloud no longer exist as described: Vapor Cloud was shut down and Heroku has had no free tier since 2022 (Deployment Targets covers the alternatives).