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 |
|---|---|---|
|
A compiler configuration, fixed when the binary is built. |
Optimisation (the binary is several times faster than a debug one), |
|
A name chosen at run time ( |
The default log level ( |
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
Source: vapor/template: Dockerfile and Vapor docs: Docker
--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 ( |
about 11 400 |
3 |
28 |
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=noticeorinfo), not in code, so an operator can raise it todebugfor one pod without a rebuild. Do not leavedebugon 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.loggercarries therequest-idmetadata, 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)
}
}
Source: Vapor docs: Server and Vapor docs: Fluent overview
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()
}
}
Source: Vapor docs: Routing and Vapor docs: Server
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:
-
/healthreturnsHTTPStatus.okand 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. -
/readyruns 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 afterconnectionPoolTimeout(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
Applicationconfiguration, but a path filtered at the proxy is the usual solution). -
The
runtimeimage of the official Dockerfile (see Deployment with Docker) contains nocurl, so a Composehealthcheckneedscurlinstalled in the image, while KuberneteshttpGetprobes 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)
}
Source: Vapor docs: Services, Lifecycle
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.
The things a default Vapor application keeps in memory, and the replacement:
| State | Default in Vapor 4 | Shared replacement |
|---|---|---|
Sessions |
|
The Redis or Fluent session driver; see Sessions and Cookies. |
Cache |
|
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 |
Uploaded files |
The local |
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 |
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 |
|
A one-off job ( |
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 --yesis 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()
}
}
}
Source: Vapor docs: Async and Vapor API: Application.threadPool
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 |
|---|---|---|---|
|
1.01 s |
1.01 s |
All 40 waited at the same time; no thread was held. |
|
4.01 s |
2.01 s |
Ten at a time (the cooperative pool width): four waves. |
|
10.02 s |
5.01 s |
Four at a time (the thread pool width, |
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 |
12.0 s (a second one a second later: 6.0 s) |
200 x |
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,
FileManagerandData(contentsOf:)on big files,NSLockheld across slow work,DispatchSemaphore.wait, bcrypt/Argon2 hashing, image resizing, PDF generation, largeJSONDecoderorJSONEncoderruns 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.sleepand Redis are non-blocking. -
Offload the rest to
req.application.threadPool.runIfActive { ... }, as/offloadeddoes. 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 afatalError. -
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
runIfActiveclosure must beSendable, so copy what you need out of theRequestfirst; 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:
-
Lower the pool until
database connectionsfits comfortably undermax_connections. In the production listingDB_POOL_PER_LOOPis read from the environment (default 2), so it can be tuned per deployment without a build. -
Count cores correctly.
System.coreCounthonours the container CPU quota (4 in the experiment above), so a--cpus 4replica 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. -
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. -
Bound the wait.
connectionPoolTimeoutdecides 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 = .enabledcompresses text-like responses when the client sendsAccept-Encoding. On the/catalogueroute (8 880 bytes of JSON) a client that accepted gzip received 1 004 bytes withContent-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-Controlheaders on read-mostly routes. -
Stream big bodies instead of collecting them:
body: .streamon upload routes andResponse.Bodystreams on downloads keep memory flat (see File Uploads and Streaming). -
Keep logging proportional. Per-request
infolines, SQL logging (sqlLogLevel) and large metadata dictionaries all cost time and storage at volume. -
Reuse HTTP clients.
req.clientuses a shared connection pool; do not create anHTTPClientper request. -
Validate early. Reject oversized or malformed input in middleware or with
Validatablebefore 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
libjemalloc2at run time. -
Preload it without relinking:
LD_PRELOAD=/usr/lib/aarch64-linux-gnu/libjemalloc.so.2 ./App serve(the directory isx86_64-linux-gnuon Intel). In the test container this loaded the library into the process (/proc/PID/mapslists it), which is how to verify any of these three routes. -
Tune it through
MALLOC_CONF(for examplebackground_thread:trueorstats_print:truefor 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 -gto the release build for line-level attribution. Swift names are mangled; pipe throughswift 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.txtdoes a quick version without the GUI. On the/cataloguerun above, the busiest non-idle frames wereJSONWriter.serializeString(JSON encoding),malloc/free,writev(writing the response) and Swift reference counting (swift_release); most of the process’s threads were idle inkeventandpsynch_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.svgIn a container
perfneeds the capability to read performance counters (--cap-add SYS_ADMIN, or a host withkernel.perf_event_paranoidlowered), 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 staticswift-backtrace-staticbinary, 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
Source: Apache HTTP Server: ab, wrk and oha
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 ( |
Configuration |
|
Server |
|
Health |
|
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 |
|
Threads |
No blocking calls in handlers; blocking work on |
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
-
ReadinessStateis afinal classthat isSendablewithout@unchecked, because its only stored property is anNIOLockedValueBox<Bool>. A route closure captures it, and Swift 6 requires route closures to be@Sendable. -
DrainBeforeShutdownis astructconforming toLifecycleHandler(which isSendable); its stored properties areSendable, so no isolation attributes are needed. -
A closure passed to
threadPool.runIfActivemust be@Sendableand its resultSendable; the example returns aString. -
Blocking inside an
asyncfunction compiles, which is why the hazard is invisible.Thread.sleepis rejected by the compiler in anasyncfunction (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 |
Blocked cooperative threads. Find the synchronous calls, move them to |
|
|
Clients see error text in production responses |
A debug build is running with |
|
|
Users are logged out on every deploy or hit different sessions per request |
|
Two replicas fail on start with schema errors or duplicate objects |
|
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 |
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. |
|
An in-flight request or stream outlived |
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. |
|
Vapor closed a keep-alive connection the proxy still pooled; set |
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 |
|
4.122.0 |
|
4.122.2 |
Current 4.x; production default log level |
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).
References
-
vapor/vapor 4.122.2: Environment and LoggingSystem+Environment.swift
-
apple/swift-nio (event loops,
NIOSingletons, container-awareSystem.coreCount) -
Apache HTTP Server: ab, wrk and oha