Swift Concurrency in Vapor
|
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 on 2026-10-17) on Swift 6.4 in the Swift 6 language
mode (swift-tools-version:6.3, the version of the current vapor/template; Vapor itself needs tools 6.0), with
SwiftNIO 2.104.0 and Fluent 4.13.0 (the Fluent part of Vapor is covered in
Fluent Overview and Database Drivers and
Fluent Models). Vapor 5 is at 5.0.0-beta.4 and is not generally available; where
it changes the picture the page says so. All examples were compiled in a scratch package with those versions pinned and
no warnings, and the HTTP examples were run with curl.
The page answers four questions that every Vapor developer meets sooner or later:
-
Which threads run my code, and what happens if I block one?
-
What does Swift 6 strict concurrency require of my routes, services and models?
-
How do I read and bridge the older
EventLoopFutureAPI that is still all over Vapor and the third-party packages? -
How do I do CPU-bound or blocking work, cancellation, request-scoped context and parallel work in a handler?
The language itself (async, await, Task, actors, Sendable) is covered in the Swift Reference:
Async/await and tasks,
Actors, isolation and Sendable and
Dispatch and legacy concurrency. This page does
not re-explain them; it shows how Vapor uses them. For the app side of the same language features see
Concurrency in Apple apps.
Three kinds of threads
A Vapor process runs your code on three different families of threads. Almost every concurrency bug in a Vapor service is a confusion between them.
| Family | Who owns it | What runs there | What you must not do there |
|---|---|---|---|
Event loops |
SwiftNIO |
socket reads and writes, HTTP parsing, TLS, |
block (sleep, synchronous file or network I/O, semaphores, |
Swift cooperative pool |
the Swift runtime; a fixed pool of roughly one thread per CPU core |
every |
block: a blocked pool thread cannot run any other task, and the pool does not grow to compensate |
NIO blocking pool |
|
blocking work on purpose: file I/O through |
nothing special; it is the right place for blocking work, but it is finite too |
The Vapor template’s Application.make creates the event loop group for you (the default is
.singleton, which is NIO’s shared MultiThreadedEventLoopGroup.singleton). The diagram shows how the three families
relate and where each kind of work belongs. The lines marked "hop" are the points where execution moves between
families and where the cost of a context switch is paid.
Each arrow into H is a context switch from a NIO thread to a pool thread and each arrow out is another one. That cost
is small, but it is why the Vapor documentation still says that applications needing explicit control over event loops,
or very high performance, may keep using EventLoopFuture, while "everyone else" should use async/await for
readability. The NIOSingletons executor takeover described later removes part of those switches.
Where it runs in the request
In the request lifecycle (server accepts the connection on an event loop, the HTTP decoder builds a Request, the
Responder chain runs: middleware in registration order, then the router, then your handler; the Response is encoded
and written back) concurrency touches every step:
-
The event loop that owns the connection builds the
Requestand calls the responder chain.req.eventLoopis that loop. -
Each
AsyncMiddlewareand eachasynchandler is wrapped by Vapor: it makes anEventLoopPromise<Response>and callspromise.completeWithTask { ... }, which starts a new unstructuredTaskon the cooperative pool and completes the promise from it (seeResponder+Concurrency.swiftandAsyncMiddleware.swiftin the Source links below). The task is not stored or cancelled by Vapor, a point that matters in the cancellation section. -
When the handler awaits a database query, an HTTP
Clientcall orfuture.get(), the task suspends and the event loop does the I/O; the continuation resumes on the cooperative pool. -
When the handler returns,
encodeResponse(for:)runs in the same task, the promise succeeds, and the event loop writes the bytes.
async/await handlers are the default
Every routing method (get, post, put, patch, delete, on) has an overload whose closure is
@Sendable (Request) async throws -> Response, where Response conforms to AsyncResponseEncodable (every
ResponseEncodable, Content type, String, HTTPStatus and so on already does). An async handler can await
Fluent queries, Client calls, req.fileio, req.password.async and anything else; errors are thrown, and a thrown
Abort becomes the matching HTTP status through ErrorMiddleware.
The official documentation recommends one programming model per handler (do not mix async with futures) and recommends
async/await for everyone except applications that need explicit event-loop control. Nothing in Vapor 4.122 is deprecated
on the future side, but the blog post The future of Vapor states the direction: Vapor 5 will have no API that returns
EventLoopFuture.
import Vapor
struct BookDTO: Content, Sendable {
var id: Int
var title: String
}
The BookDTO is a plain Content struct; because it is a struct of Sendable values it is Sendable and can be
returned from the handler without any annotation. Writing Sendable on it, as above, documents the intent and makes the
compiler check it.
Source: Vapor docs: Content
func routes(_ app: Application) throws {
app.get("books", ":id") { req async throws -> BookDTO in
guard let id = req.parameters.get("id", as: Int.self) else { throw Abort(.badRequest) }
try await Task.sleep(for: .milliseconds(10))
return BookDTO(id: id, title: "Book #\(id)")
}
}
Source: Vapor docs: Async and Vapor docs: Routing
Swift 6 strict concurrency in a Vapor app
A package whose tools version is 6.0 or later compiles in the Swift 6 language mode by default: data-race safety is
checked at compile time and violations are errors, not warnings. The Vapor template already builds this way (tools
version 6.3). The Vapor team states that Vapor 4 builds with zero warnings under strict concurrency; the price is that
Vapor’s own types are Sendable and that your types must be too wherever they cross an isolation boundary. Vapor’s
blog announced this work in 2023 (Upcoming Sendable changes); the rules below are the practical outcome in 4.122.
What Vapor already makes Sendable
-
ApplicationandRequestarefinal class ... : Sendable(their mutable state is behind locks), so a handler can capturereqin a child task, a task group or a@Sendableclosure. -
Route closures are
@Sendable:@Sendable (Request) async throws -> Response. A handler may only captureSendablevalues, and may not mutate captured `var`s. -
The response type of a handler conforms to
AsyncResponseEncodable, declared as refiningSendableMetatypeon Swift 6.2 and later (an alias that is justAnyon older compilers). -
Application.storageandRequest.storagehold values keyed by aStorageKey;StorageKey.Valuemust beSendable. This is where long-lived services belong. -
app.threadPool,app.fileioand the other services are lock-protected, so reading them from any task is fine.
Sendable DTOs
A Content struct whose stored properties are all Sendable (strings, numbers, UUID, Date, arrays and optionals of
those, other such structs) is implicitly Sendable as long as it is not public/open; a public struct needs the
explicit conformance. Declare it anyway (struct BookDTO: Content, Sendable) so that adding a reference-type property
later turns into a compile error at the declaration, not at some distant capture. Put DTOs in value types; keep Fluent
models out of handler results where you can (the Models and DTOs guidance in the Fluent pages).
Shared mutable state: actors, locks, Mutex
The classic mistake, from tutorials written before Swift 6 (and from books that keep a controller-level array), is mutable state shared by every request:
// Does NOT compile in the Swift 6 language mode.
var cache: [Int: String] = [:]
func routes(_ app: Application) {
app.get("titles", ":id") { req async -> String in
let id = req.parameters.get("id", as: Int.self) ?? 0
cache[id] = "cached"
return cache[id] ?? "none"
}
}
The compiler says:
error: var 'cache' is not concurrency-safe because it is nonisolated global shared mutable state
Two requests on two cooperative-pool threads would race on that dictionary. Swift 6 refuses to compile it. There are three correct replacements, from most to least recommended:
-
An actor that owns the state, stored in
Application.storage(below; see Services, Storage and Dependency Injection). Callersawaitits methods; no lock is visible. Use it when the operations are short, asynchronous callers are fine, and you want the compiler to enforce isolation. -
A
final classwith a lock when you need synchronous access (for example inside a non-async callback):NIOLockedValueBoxfromNIOConcurrencyHelpers(available on every platform Vapor supports) or the standard library’sMutexfrom theSynchronizationmodule (Swift 6.0, requires macOS 15, iOS 18 and later at runtime; the Vapor template targets macOS 13, so on Apple platforms guard it with@available). Neverawaitwhile holding a lock (the lock APIs take synchronous closures, so the compiler stops you). -
Do not share: derive the state from the database or Redis (see Redis & Caching and Queues & Background Jobs) so that several server processes agree.
import Vapor
import NIOConcurrencyHelpers
actor LoanLedger {
private var loans: [Int: String] = [:]
func lend(bookID: Int, to member: String) throws {
guard loans[bookID] == nil else {
throw Abort(.conflict, reason: "Book \(bookID) is already on loan")
}
loans[bookID] = member
}
func giveBack(bookID: Int) -> String? {
loans.removeValue(forKey: bookID)
}
var count: Int { loans.count }
}
struct LoanLedgerKey: StorageKey {
typealias Value = LoanLedger
}
extension Application {
var loanLedger: LoanLedger {
get {
guard let ledger = storage[LoanLedgerKey.self] else {
fatalError("LoanLedger not configured; set app.loanLedger in configure(_:)")
}
return ledger
}
set { storage[LoanLedgerKey.self] = newValue }
}
}
final class VisitCounter: Sendable {
private let value = NIOLockedValueBox(0)
func increment() -> Int {
value.withLockedValue { count in
count += 1
return count
}
}
}
import Synchronization
@available(macOS 15, *)
final class RecentTitles: Sendable {
private let titles = Mutex<[String]>([])
func record(_ title: String) {
titles.withLock { list in
list.append(title)
if list.count > 10 { list.removeFirst() }
}
}
func snapshot() -> [String] {
titles.withLock { $0 }
}
}
Registering and using the actor (LoanLedger), and the lock-based counter, in configure/routes:
func configure(_ app: Application) async throws {
app.loanLedger = LoanLedger()
try routes(app)
}
app.post("loans", ":bookID", ":member") { req async throws -> HTTPStatus in
let bookID = try req.parameters.require("bookID", as: Int.self)
let member = try req.parameters.require("member")
try await req.application.loanLedger.lend(bookID: bookID, to: member)
return .created
}
app.get("loans", "count") { req async -> String in
"\(await req.application.loanLedger.count)"
}
let visits = VisitCounter()
app.get("visit") { _ async -> String in "visit \(visits.increment())" }
$ curl -s -XPOST -w " %{http_code}\n" localhost:8080/loans/1/ana
201
$ curl -s -XPOST -w " %{http_code}\n" localhost:8080/loans/1/bob
{"error":true,"reason":"Book 1 is already on loan"} 409
$ curl -s localhost:8080/loans/count
1
$ curl -s localhost:8080/visit; curl -s localhost:8080/visit
visit 1
visit 2
Actors serialise their callers on the cooperative pool, which is exactly what you want here. Two cautions: an actor’s
method that await`s lets other callers in between (re-entrancy, covered in the Swift Reference), so keep
check-then-act sequences in one synchronous method as `lend does; and never make a Fluent query inside a lock.
Fluent models are final class … @unchecked Sendable
Fluent models (see Fluent Models) are classes whose stored properties are
property wrappers (@ID, @Field, @Parent, @Children). The template’s Todo model documents the pragmatic answer:
property wrappers interact poorly with Sendable checking, so write the model with sendability checking on and then
suppress the diagnostic with @unchecked Sendable.
Fluent’s Model inherits from Fields, which is Sendable, so a model class must either prove it is safe (it cannot:
the property wrappers are mutable `var`s) or vouch for itself:
// Does NOT compile: the stored property wrappers are mutable.
final class Draft: Model {
static let schema = "drafts"
@ID(key: .id) var id: UUID?
@Field(key: "title") var title: String
init() {}
}
error: stored property '_id' of 'Sendable'-conforming class 'Draft' is mutable
Source: Vapor docs: Fluent model
The fix is the model the Vapor template ships:
import Foundation
import Fluent
final class Book: Model, @unchecked Sendable {
static let schema = "books"
@ID(key: .id)
var id: UUID?
@Field(key: "title")
var title: String
init() {}
init(id: UUID? = nil, title: String) {
self.id = id
self.title = title
}
}
The @unchecked is a promise, not a proof. A model instance is safe to pass between tasks as long as one task at a time
mutates it. The practical rules that keep the promise:
-
Load, mutate and save a model inside one handler (one task). Do not share one instance between concurrent child tasks.
-
Return DTOs (value types) from handlers and send DTOs across actors; convert in a
toDTO()method as the template does. -
Never stash a model in a global, an actor property or
app.storagefor reuse across requests. Models carry state ($id.exists, original values) belonging to a single save cycle.
Source: vapor/template: Todo.swift and Vapor docs: Fluent model
Common compiler diagnostics
| Diagnostic (Swift 6.4, language mode 6) | Usual cause and fix |
|---|---|
|
Shared mutable global. Use an actor or a lock-protected |
|
A route closure captured a non- |
|
A Fluent model without |
|
|
Isolation of handlers
Route handlers are not isolated to the main actor. A @MainActor view-model style type has no place in a server: every
call would hop to the main thread. If you enable the defaultIsolation(MainActor.self) setting that Swift 6.2 added for
app targets, keep it out of a Vapor target. Services shared by handlers are either actors, Sendable classes with
locks, or value types.
The template also turns on the upcoming-feature flags ExistentialAny, InternalImportsByDefault,
MemberImportVisibility, InferIsolatedConformances and ImmutableWeakCaptures; they appear in the snippets as
any AsyncResponder and so on. They are language hygiene, not concurrency requirements.
Legacy: EventLoopFuture and EventLoopPromise
Before Swift 5.5 all asynchronous Vapor code was written with SwiftNIO’s EventLoopFuture. The type is still
everywhere: in Vapor APIs that have not got an async twin, in Fluent’s older query builders, in third-party packages
and in every tutorial written for Vapor 3 and early Vapor 4. This section is a legacy reference. New code uses
async/await; you need futures when you maintain an older codebase, call a library that only offers futures, or need to
understand a stack trace. The Vapor 5 line removes all future-returning APIs.
A EventLoopFuture<Value> is a read-only reference to a value (or an error) that will exist later; it is bound to one
EventLoop and its callbacks run on that loop. An EventLoopPromise<Value> is the write side: whoever holds it calls
succeed(_:), fail(_:) or completeWith(_:) exactly once, and promise.futureResult is the future. Void
futures are "signals" that only report completion.
| Operator | Shape | Use |
|---|---|---|
|
|
transform the value synchronously, cannot throw |
|
|
transform synchronously and allow a thrown error to fail the chain |
|
|
chain another asynchronous step; avoids a nested future |
|
|
ignore the value, produce a constant (typical after a |
|
|
wait for two futures and get a tuple |
|
|
wait for all and get |
|
|
turn a failure into a value or another future |
|
|
side-effect callback; returns nothing, cannot be awaited |
|
|
get a future whose callbacks run on another event loop |
Every operator above in one function, written for Swift 6 (the closures are @Sendable, and Request is Sendable, so
capturing req or its event loop is fine):
import Vapor
import NIOCore
// Legacy EventLoopFuture chain: each operator in one place.
func legacyChain(_ req: Request, text: String) -> EventLoopFuture<String> {
let loop = req.eventLoop
let parsed: EventLoopFuture<Int> = loop
.makeSucceededFuture(text)
.flatMapThrowing { value -> Int in // sync, may throw
guard let number = Int(value) else {
throw Abort(.badRequest, reason: "not a number")
}
return number
}
let doubled = parsed.map { $0 * 2 } // sync, cannot throw
let described = doubled.flatMap { number in // returns another future
loop.makeSucceededFuture("n=\(number)")
}
let saved: EventLoopFuture<Void> = loop.makeSucceededVoidFuture()
let marker = saved.transform(to: "saved") // ignore the value
let both = described.and(marker) // EventLoopFuture<(String, String)>
let list: [EventLoopFuture<Int>] = (1...3).map { loop.makeSucceededFuture($0) }
let flat: EventLoopFuture<[Int]> = list.flatten(on: loop)
return both.and(flat)
.map { pair, numbers in "\(pair.0) \(pair.1) \(numbers)" }
.flatMapError { error in // recover from failure
loop.makeSucceededFuture("recovered: \(error)")
}
}
func observe(_ future: EventLoopFuture<String>, logger: Logger) {
future.whenComplete { result in // callback, no new future
switch result {
case .success(let value): logger.info("done: \(value)")
case .failure(let error): logger.error("failed: \(error)")
}
}
}
func onRequestLoop(_ req: Request, _ foreign: EventLoopFuture<String>) -> EventLoopFuture<String> {
foreign.hop(to: req.eventLoop)
}
// Bridging
func legacyToAsync(_ req: Request) async throws -> String {
try await legacyChain(req, text: "21").get()
}
func asyncToLegacy(_ req: Request) -> EventLoopFuture<String> {
req.eventLoop.makeFutureWithTask {
try await Task.sleep(for: .milliseconds(5))
return "from async"
}
}
func asyncToLegacyWithPromise(_ req: Request) -> EventLoopFuture<String> {
let promise = req.eventLoop.makePromise(of: String.self)
promise.completeWithTask {
try await Task.sleep(for: .milliseconds(5))
return "from async via promise"
}
return promise.futureResult
}
func manualPromise(_ req: Request, work: @escaping @Sendable (@escaping @Sendable (String) -> Void) -> Void) -> EventLoopFuture<String> {
let promise = req.eventLoop.makePromise(of: String.self)
work { promise.succeed($0) }
return promise.futureResult
}
A legacy route returns the future directly: Vapor accepts a handler (Request) throws -> EventLoopFuture<String>
because EventLoopFuture<Value> is ResponseEncodable when Value is:
app.get("legacy", ":text") { req -> EventLoopFuture<String> in
legacyChain(req, text: req.parameters.get("text") ?? "")
}
$ curl -s localhost:8080/legacy/21
n=42 saved [1, 2, 3]
$ curl -s localhost:8080/legacy/abc
recovered: Abort.400: not a number
Bridging futures and async/await
-
try await future.get()turns any future into anasynccall. It requiresValue: Sendable. Cancelling the awaiting task does not cancel the underlying work; in swift-nio 2.104 there is alsogetAbandoningOnCancel(), which throwsCancellationErrorwhen the task is cancelled while the future keeps running. -
eventLoop.makeFutureWithTask { ... }runs anasyncclosure and returns its result as a future (the closure is@Sendable,Returnmust beSendable). The equivalent promise form ispromise.completeWithTask { ... }, which is what Vapor itself uses to wrap every async handler and middleware; it returns theTask(discardable). -
To hand work to a callback-style API, make a promise with
eventLoop.makePromise(of:)and complete it from the callback (the last function in the legacy example).
In Vapor 4.122 the official Async page documents .get() and promise.completeWithTask; makeFutureWithTask is the
shorter form of the same thing, present in swift-nio 2.104 and used inside Vapor’s own sources (FileIO.swift,
MemoryCache.swift).
Never wait() on an event loop
future.wait() blocks the calling thread until the future completes. It is only safe on a thread that is not an event
loop, for example in a command-line main before the server starts or in a test. Three layers guard against misuse:
-
inside an
asyncfunction the compiler rejects it:wait()is marked@available(*, noasync, ...)and the diagnostic suggestsget(); -
on an event loop thread, swift-nio calls
preconditionNotInEventLoopand the process crashes with a "BUG DETECTED" message; -
from a cooperative-pool thread in a synchronous helper called by an async handler it compiles and blocks that pool thread, the worst case: no crash, just a service that stops answering under load.
A call that the compiler flags (in 4.122.2 as a warning, because wait is @preconcurrency):
func bad(_ req: Request) async throws {
let future = req.eventLoop.makeSucceededFuture(1)
_ = try future.wait() // do not do this
}
warning: instance method 'wait' is unavailable from asynchronous contexts; wait() can block indefinitely, prefer get()
The fix is always try await future.get() (or, in genuinely synchronous startup code, running the work before the
server starts). Vapor’s own Application.execute() calls wait() only in the legacy app.run() path, never in a
request.
hop(to:) and event-loop affinity
A future’s callbacks run on the loop it was created on. Vapor’s req.eventLoop documentation notes that "if this is
difficult or awkward to guarantee, use EventLoopFuture.hop(to:) to jump to this event loop". The case that needs it is
a future obtained from a resource bound to another loop (a client connection pool, a database connection created
elsewhere, a stored global future). Return foreign.hop(to: req.eventLoop) so the rest of the chain runs where the
request lives. With async/await you do not hop: .get() returns the value to the cooperative pool regardless of the
loop.
Blocking and CPU-bound work
Two kinds of work do not belong on an event loop or on a cooperative-pool thread:
-
blocking work: synchronous file or network I/O, a C library call that waits,
sleep, a semaphore orDispatchGroup.wait,future.wait(); -
long CPU-bound work: image resizing, large JSON or PDF generation, hashing, compression, big sorts.
The cooperative pool has about one thread per CPU core and, by design, never creates more. If every thread is blocked, no other task makes progress anywhere in the process: not other requests, not the code that would unblock the first ones. The event loops fare no better, since each stalls all the connections pinned to it.
The compiler helps a little: Thread.sleep, DispatchSemaphore.wait and a few other well-known calls are rejected
inside async functions (class method 'sleep' is unavailable from asynchronous contexts). It cannot see through your
own synchronous helper, or through a library that blocks internally. The following measurement was taken on a 10-core
Mac with the example package: a route that calls a synchronous helper sleeping for one second, hit by 30 concurrent
requests, took the response time of an unrelated, instant route from about 14 ms to about 2.7 seconds; the same 30
requests through the thread pool left that route at 14 ms.
$ curl -s -o /dev/null -w "%{time_total}s\n" localhost:8080/books/1
0.014120s
$ for i in $(seq 1 30); do curl -s -o /dev/null localhost:8080/block-bad & done; sleep 0.3
$ curl -s -o /dev/null -w "%{time_total}s\n" localhost:8080/books/1
2.681546s
The thread pool: app.threadPool
Vapor exposes a NIOThreadPool as app.threadPool; by default this is NIO’s shared
NIOSingletons.posixBlockingThreadPool, one thread per core. NonBlockingFileIO (req.fileio) and Bcrypt hashing
(req.password.async) run on it. For your own blocking code use the async overload of runIfActive, which
runs the closure on a pool thread and resumes the caller afterwards. The closure is @Sendable and its result must be
Sendable; if the surrounding task is cancelled before the work starts, the call throws CancellationError
(the future-returning overload fails with NIOThreadPoolError.ThreadPoolInactive when the pool is inactive).
import Vapor
import NIOPosix
import Foundation
func checksum(of path: String) throws -> Int {
// Stand-in for a blocking, synchronous C or file API.
Thread.sleep(forTimeInterval: 0.05)
return path.utf8.reduce(0) { ($0 &* 31) &+ Int($1) }
}
func buildReport(on req: Request) async throws -> String {
let value = try await req.application.threadPool.runIfActive {
try checksum(of: "/var/data/report.csv")
}
return "checksum=\(value)"
}
func configureThreadPool(_ app: Application) {
// Optional: a larger pool dedicated to blocking work (set before the app boots).
app.threadPool = NIOThreadPool(numberOfThreads: 16)
}
func hashPassword(_ req: Request, _ plain: String) async throws -> String {
try await req.password.async.hash(plain)
}
func computeOffPool(_ input: Int) async -> Int {
await Task.detached(priority: .utility) {
(1...1_000).reduce(input, &+)
}.value
}
/// A synchronous helper from a blocking library. The compiler cannot see that it blocks.
func legacySynchronousCall() -> String {
Thread.sleep(forTimeInterval: 1)
return "done"
}
You can replace the pool with a larger one by assigning app.threadPool in configure, before the application boots
(Vapor takes ownership, starts it and shuts it down; assigning after boot is a fatal error). Size it for the number of
simultaneous blocking operations you accept, not for the number of requests. Past that point, requests queue up in the
pool, which is better than starving the cooperative pool but still hurts latency; the better fix is a queue (see
Queues & Background Jobs) or a separate worker process;
Production and Performance measures the effect.
Task.detached caveats
Task.detached is not a way to get off the cooperative pool: a detached task runs on the same pool, it merely drops
the parent’s actor isolation, priority and task-locals. Detaching a blocking call therefore still occupies one of the
few pool threads. It is appropriate for fire-and-forget CPU work that must not inherit a context (and for starting work
from a synchronous function), with these caveats:
-
it is unstructured: nobody awaits it unless you keep the
Taskhandle, errors are lost if you do not readtask.value, and cancelling the handler does not cancel it; -
it does not inherit task-locals (the request ID in the next section disappears);
-
it can outlive the application: on shutdown, Vapor does not wait for stray tasks. Keep the handle somewhere that your
LifecycleHandlercan await, or use a queue for work that must complete; -
its closure is
@Sendable, so what it captures must beSendable.
For heavy CPU work the options are: chunk it with await Task.yield() between chunks so the pool stays shared; run it
through app.threadPool.runIfActive (the pool is separate from the cooperative one, and a blocked thread there harms
nothing else); or hand it to a worker process through Vapor Queues
(Queues & Background Jobs).
The executor takeover: fewer thread hops
Every await of a future moves a task between a NIO thread and a pool thread. SwiftNIO has an experimental switch that
installs the NIO singleton event loop group as Swift’s global concurrency executor, so that async code runs on the event
loops and the switches disappear. The Vapor template carries this as commented-out code in entrypoint.swift:
import NIOCore
import NIOPosix
func tryExecutorTakeover() -> Bool {
// Call once, from the main thread, before any async work (see the template's entrypoint.swift).
NIOSingletons.unsafeTryInstallSingletonPosixEventLoopGroupAsConcurrencyGlobalExecutor()
}
The template’s comment is the best summary: you "can enable it" to reduce context switching, but it "has caused issues
with some libraries that use .wait() and cleanly shutting down", and if enabled you should be careful about calling
async functions before it runs. SwiftNIO documents more constraints: call it once, from the main thread, not on Windows,
and the method is named unsafe... for a reason. In swift-nio 2.104.0 the hook is only installed when compiling with
a compiler older than Swift 6.6 and the function returns false otherwise, so check the result and do not depend on it.
With takeover enabled, blocking an executor thread blocks an event loop; the advice of this section applies more
strongly, not less. Leave it off unless a profile shows thread-hop cost and you have tested shutdown.
Cancellation, task-locals and structured concurrency in handlers
Cancellation
Swift cancellation is cooperative: cancelling a task only sets a flag that your code (or the library you call) must
check with Task.isCancelled, try Task.checkCancellation() or by awaiting something that throws CancellationError
(Task.sleep does).
An important Vapor 4.122.2 detail, established by reading Responder+Concurrency.swift and by running the
example: Vapor does not cancel a handler when the client disconnects. The handler runs in a Task started with
promise.completeWithTask, and nothing keeps a reference to cancel it. Requesting the /slow route of the example (50
steps of 100 ms) with curl --max-time 1 aborted the client after one second, and the server log still showed all 50
steps and the final response being produced. So a handler that does expensive work for an absent client keeps spending
resources; add your own limits (time-outs with withThrowingTaskGroup, request size limits, rate limits) rather than
relying on disconnect-driven cancellation. Long-lived connections have their own handling of disconnects; see
Server-Sent Events and WebSockets.
Cancellation still matters inside a handler: child tasks of async let and task groups are cancelled when the group is
left by a thrown error, and withTaskCancellationHandler lets you release a resource when that happens.
Structured concurrency: async let and task groups
Inside a handler you can fan out with child tasks. They run on the cooperative pool, inherit task-locals and priority,
and are awaited before the scope ends. The req object is Sendable, so children may use it. Databases scale with
connection pool size, so use fan-out for I/O that is truly independent, and bound the number of simultaneous operations.
import Vapor
struct Dashboard: Content, Sendable {
var books: Int
var members: Int
var loans: Int
}
func countBooks() async throws -> Int { try await Task.sleep(for: .milliseconds(50)); return 120 }
func countMembers() async throws -> Int { try await Task.sleep(for: .milliseconds(50)); return 18 }
func countLoans() async throws -> Int { try await Task.sleep(for: .milliseconds(50)); return 7 }
func loadDashboard(_ req: Request) async throws -> Dashboard {
async let books = countBooks()
async let members = countMembers()
async let loans = countLoans()
return try await Dashboard(books: books, members: members, loans: loans)
}
func lookup(isbn: String) async throws -> String {
try await Task.sleep(for: .milliseconds(100))
return "Title for \(isbn)"
}
/// Looks up many ISBNs with at most `limit` lookups in flight.
func lookupAll(_ isbns: [String], limit: Int) async throws -> [String: String] {
try await withThrowingTaskGroup(of: (String, String).self) { group in
var iterator = isbns.makeIterator()
var results: [String: String] = [:]
for _ in 0..<limit {
guard let isbn = iterator.next() else { break }
group.addTask { (isbn, try await lookup(isbn: isbn)) }
}
while let (isbn, title) = try await group.next() {
results[isbn] = title
if let next = iterator.next() {
group.addTask { (next, try await lookup(isbn: next)) }
}
}
return results
}
}
enum RequestContext {
@TaskLocal static var requestID: String?
}
struct RequestIDMiddleware: AsyncMiddleware {
func respond(to request: Request, chainingTo next: any AsyncResponder) async throws -> Response {
let id = request.headers.first(name: "X-Request-ID") ?? UUID().uuidString
return try await RequestContext.$requestID.withValue(id) {
try await next.respond(to: request)
}
}
}
The dashboard route awaits three independent counts concurrently (about 50 ms total instead of 150 ms) and /isbns
looks up five ISBNs with at most two in flight (about 300 ms: three waves of 100 ms):
app.get("dashboard") { req async throws -> Dashboard in
try await loadDashboard(req)
}
app.get("isbns") { req async throws -> [String: String] in
try await lookupAll(["978-0", "978-1", "978-2", "978-3", "978-4"], limit: 2)
}
$ curl -s localhost:8080/dashboard
{"members":18,"loans":7,"books":120}
$ time curl -s localhost:8080/isbns
{"978-1":"Title for 978-1","978-0":"Title for 978-0", ...} # 0.33 s total
Rules for fan-out inside a handler:
-
Never share one Fluent model between child tasks. Give each child its own query and produce a DTO.
-
One
Databaseconnection executes one statement at a time; parallel queries onreq.dbuse several pooled connections and are each subject to pool limits. Do not run hundreds in parallel. -
Do not fan out inside
req.db.transaction { ... }: a transaction is one connection, so the work is serialised anyway. -
Task groups return results in completion order; sort or key them if order matters, as the
/isbnsexample keys by ISBN.
Task-locals for request-scoped context
A @TaskLocal value is visible in the task that binds it and in all of its child tasks, and is gone when the scope
exits. That makes it a fit for request-scoped context (request ID, tenant, authenticated user ID) without threading a
parameter through every function. Bind it in an AsyncMiddleware around the call to the next responder (the
RequestIDMiddleware at the end of the example above is registered with app.middleware.use(RequestIDMiddleware())),
and read it anywhere below:
app.get("request-id") { _ async -> String in
RequestContext.requestID ?? "none"
}
$ curl -s -H 'X-Request-ID: req-42' localhost:8080/request-id
req-42
This works in 4.122.2 (the example above was run with curl): the task-local is set while next.respond runs, and the
Task that Vapor creates with completeWithTask for the handler is created inside that scope, so it inherits the
value. Do not count on the value in code that runs outside the task:
-
Task.detached(by design it drops task-locals); -
callbacks that NIO runs on an event loop (
whenComplete, scheduled tasks), since they do not run inside the task.
Vapor’s own request-scoped logging does not need task-locals: req.logger already carries a request-id metadata
field, visible in the log lines above. Prefer req.logger for logging and keep @TaskLocal for application data.
Common errors and pitfalls
| Symptom | Cause and fix |
|---|---|
|
A top-level or |
|
A route closure captured a non- |
|
A Fluent |
|
A non- |
|
|
|
|
"BUG DETECTED" crash from |
A synchronous callback on an event loop called |
The whole service freezes under load, CPU idle |
Blocking calls on cooperative-pool or event-loop threads. Move them to |
|
Missing |
Handler keeps running after the client left |
Vapor 4.122 does not cancel handler tasks on disconnect; add your own limits. |
Fatal error "Cannot replace thread pool after application has booted" |
|
Task-local value is |
The code runs in a |
Version notes
| Version | What changed for concurrency |
|---|---|
Vapor 3 and early Vapor 4 |
All asynchronous APIs return futures (Vapor 3 had its own |
Vapor 4 with Swift 5.5 (2021) |
async/await support arrives: |
Vapor 4 in 2023 |
Large |
Vapor 4 (2024-2026) |
Builds warning-free in the Swift 6 language mode; async twins for nearly every future-based API; the template (4.121.4 baseline) uses |
Vapor 4.122.2 (2026-09-17) |
Latest 4.x; |
Vapor 5 (beta, 5.0.0-beta.4 on 2026-10-13) |
No |
Where the books are outdated
-
Condon et al. (Server-Side Swift with Vapor, Kodeco) teach
EventLoopFuturethroughout (chapter 4 introduces futures and promises,map/flatMap/flatMapThrowing/transform/and/flatten, error recovery, and warns againstwait()). The concepts and the operators in the legacy section above are still accurate, but new code is written with async/await, andwait()is flagged inside async code. -
Books and tutorials from the Swift 5 era use global mutable state, controllers holding
vararrays and classes withoutSendable. Under Swift 6 they are compile errors, as shown in the diagnostics above. -
Fluent models in older books are plain
final class Book: Model, Content; today they arefinal class Book: Model, @unchecked Sendable, and returning the model asContentis replaced by DTOs. -
The official 4.0 documentation is silent about Swift 6 and
Sendableguidance; the template and the Vapor blog are the sources, and this page fills the gap.
References
-
Vapor docs: Async (async/await,
.get(),completeWithTask,EventLoopFutureoperators) -
Vapor docs: Fluent model and vapor/template: Todo.swift (
@unchecked Sendable) -
vapor/template: entrypoint.swift (entry point and executor takeover)
-
Vapor blog: Upcoming Sendable changes and The future of Vapor
-
vapor/vapor 4.122.2: Sources/Vapor/Concurrency and Core.swift
-
swift-nio 2.104.0: EventLoopFuture, NIOThreadPool and NIOConcurrencyHelpers
-
The Swift Programming Language: Concurrency and Apple: Mutex
-
Kodeco: Server-Side Swift with Vapor (Condon et al.)