Redis & Caching

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Vapor 4.122.2 on Swift 6.4 (the package builds in the Swift 6 language mode with tools version 6.3, as the official template does; Vapor 4.122.2 itself needs tools version 6.0), with vapor/redis 4.14.0 (which wraps RediStack 1.6.3), Fluent 4.13.0 and fluent-sqlite-driver 4.9.0, checked on 2026-10-17. Every Swift listing on this page was compiled in the Swift 6 language mode with zero warnings in a scratch package. The memory and Fluent cache drivers, the cache-aside handlers and the tests were actually run, and the curl and test output shown is captured from those runs. No Redis server was available when this page was written (no redis-server binary and no running Docker daemon on the machine), so the Redis-specific code, including the Redis cache driver, Pub/Sub, the lock, the rate limiter and Redis sessions, was compiled but not run against a live server. The only Redis behaviour observed is how the client fails when nothing is listening, shown in Common errors. Vapor 5 is at 5.0.0-beta.4 and is not released yet; where its cache differs, that is noted. The page extends the Bookshelf service of the earlier pages: book lookups are cached as small DTOs, and the external catalog lookup of the HTTP Client page gets a cache in front of it. The Redis server itself (data types, persistence, eviction, clustering) is covered in the Redis section; the same topic in Rust is in Redis & Caching with axum. Swift itself is covered by the Swift Reference, and Sendable by Actors, Isolation and Sendable. Everything on the Redis side also works with Valkey, the open-source Redis fork, because the wire protocol is the same. Redis is also the usual store for Queues & Background Jobs and for the slot lock of Scheduled Jobs.

What is cached, and who owns the truth

A cache is a copy that is allowed to be wrong for a while. Every design decision on this page follows from three questions: what is the key, how long may the copy be stale, and what makes it stale early (a write)? Vapor gives you two different things that people call "Redis caching", and it pays to keep them apart:

  • The Cache protocol: a tiny, driver-neutral key-value API (get, set with an optional expiry, delete) reached through req.cache and app.cache. Behind it sits the memory driver (built in), the Fluent driver (in the fluent package, see Fluent Overview and Database Drivers) or the Redis driver (in the redis package). Your handler code does not change when the driver does.

  • The Redis client: app.redis and req.redis give you every Redis command (counters, locks, Pub/Sub, sorted sets, streams), not just a key-value store. Use it directly when the Cache protocol is too small, as in the rate limiter and lock below.

Cache-aside in a Vapor handler: the handler asks req.cache, falls back to the source of truth on a miss and stores the result, the write path deletes the key, and the memory, Fluent and Redis drivers sit behind the same calls

The Cache protocol

Cache lives in Sources/Vapor/Cache/Cache.swift. In 4.122.2 it is a futures-based protocol with async overloads in an extension:

// Vapor 4.122.2, abridged
public protocol Cache {
    func get<T>(_ key: String, as type: T.Type) -> EventLoopFuture<T?> where T: Decodable
    func set<T>(_ key: String, to value: T?) -> EventLoopFuture<Void> where T: Encodable
    func set<T>(_ key: String, to value: T?, expiresIn expirationTime: CacheExpirationTime?) -> EventLoopFuture<Void> where T: Encodable
    func `for`(_ request: Request) -> Self
}

// Cache+Concurrency.swift: async throws overloads, the ones new code calls
// func get<T>(_ key: String, as type: T.Type) async throws -> T? where T: Decodable & Sendable
// func set<T>(_ key: String, to value: T?, expiresIn: CacheExpirationTime?) async throws where T: Encodable & Sendable
// func delete(_ key: String) async throws

The points that matter in practice:

  • Values are Codable. A cache entry is whatever the driver’s coder turns your value into (JSON for the Redis and Fluent drivers); memory keeps the Swift value itself in a dictionary. The async overloads additionally require Sendable.

  • get returns nil on a miss. Passing nil as the value of set removes the key, and delete(_:) is exactly that (set(key, to: String?.none)).

  • CacheExpirationTime is an enum with .seconds(_), .minutes(_), .hours(_) and .days(_) cases and a seconds property. There is no "forever" case; omit expiresIn (or pass nil) for no expiry.

  • for(_ request:) returns a request-bound copy of the driver. That is why req.cache exists next to app.cache: the Redis and Fluent drivers use it to run on the request’s event loop and logger. Inside a handler use req.cache.

  • Cache is not Sendable in 4.122. Storing an any Cache in a Sendable struct is a compile error ("stored property 'cache' of 'Sendable'-conforming struct has non-Sendable type 'any Cache'"), which is why the CachedCatalog type below is a plain struct created per request from req.cache. In Vapor 5 the protocol is Cache: Sendable and has only the async requirements.

Choosing the driver: app.caches.use

Application.init registers the memory driver (caches.initialize() calls use(.memory)), so req.cache works without any configuration. Choose another driver in configure, once, before the application starts serving requests. Reading app.cache when no driver is registered stops the process with a fatalError("No cache configured..."), but that cannot happen with the default in place.

import Fluent
import FluentSQLiteDriver
import Redis
import Vapor

/// Which cache driver backs `app.cache` and `req.cache`.
enum CacheDriver: String {
    case memory, fluent, redis
}

func configure(_ app: Application) async throws {
    app.databases.use(.sqlite(.memory), as: .sqlite)
    app.migrations.add(CreateBookshelf())
    app.migrations.add(CacheEntry.migration) // creates "_fluent_cache"; needed only for the fluent driver

    let driver = Environment.get("CACHE_DRIVER").flatMap(CacheDriver.init(rawValue:)) ?? .memory
    switch driver {
    case .memory:
        app.caches.use(.memory)
    case .fluent:
        app.caches.use(.fluent)
    case .redis:
        try configureRedis(app)
        app.caches.use(.redis)
    }
    try await app.autoMigrate()
    try routes(app)
}

use takes either a Provider (.memory, .fluent, .fluent(_ db:), .redis, .redis(_ id:), .redis(encoder:decoder:)) or a closure (Application) -> Cache. Selecting the driver from an environment variable, as above, is the usual way to run the memory driver in tests and development and Redis in production.

The three drivers compared

Memory (.memory) Fluent (.fluent) Redis (.redis)

Storage

A dictionary in an actor inside the process; lost on restart; not shared between instances

Table _fluent_cache (id, key, value as a JSON string); shared by every instance using the database

Redis keys holding JSON Data; shared by every instance

Setup

None

Add CacheEntry.migration to app.migrations and migrate

Add the Redis product, app.redis.configuration, then .redis

expiresIn

Honoured: an expired entry is dropped on the next read

Ignored: the default set(_:to:expiresIn:) forwards to set(_:to:); entries never expire

Honoured by Redis (SETEX); the lifetime is max(1, seconds)

set on an existing key

Replaces it (and drops the old expiry)

Fails: the driver always inserts, so the unique index on key throws

Replaces it (SET/SETEX)

Type mismatch on get

Returns nil (the stored Swift type differs)

Throws a DecodingError

Throws a DecodingError

Best for

Tests, development, a single instance, hot data that is cheap to rebuild

A cache you cannot get from anywhere else and are willing to clean yourself

Production with more than one instance

The behaviours in the second and third rows are the ones that surprise people, and they are the reason this page does not just say "any driver works". They were checked against the sources and by the test suite below, run against the memory and Fluent drivers:

@testable import App
import Fluent
import FluentSQLiteDriver
import Testing
import Vapor
import VaporTesting

@Suite("Cache drivers")
struct CacheTests {
    private func withApp(_ driver: CacheDriver, _ body: (Application) async throws -> Void) async throws {
        let app = try await Application.make(.testing)
        do {
            app.databases.use(.sqlite(.memory), as: .sqlite)
            app.migrations.add(CacheEntry.migration)
            switch driver {
            case .memory: app.caches.use(.memory)
            case .fluent: app.caches.use(.fluent)
            case .redis: Issue.record("not run")
            }
            try await app.autoMigrate()
            try await body(app)
        } catch {
            try await app.asyncShutdown()
            throw error
        }
        try await app.asyncShutdown()
    }

    @Test("memory cache expires entries and replaces existing ones")
    func memoryExpiryAndReplace() async throws {
        try await withApp(.memory) { app in
            try await app.cache.set("k", to: "one", expiresIn: .seconds(1))
            try await app.cache.set("k", to: "two") // replaces, and drops the expiry
            #expect(try await app.cache.get("k", as: String.self) == "two")
            try await app.cache.set("e", to: "soon", expiresIn: .seconds(1))
            try await Task.sleep(for: .seconds(1.2))
            #expect(try await app.cache.get("e", as: String.self) == nil)
            try await app.cache.set("k", to: String?.none)
            #expect(try await app.cache.get("k", as: String.self) == nil)
        }
    }

    @Test("fluent cache ignores the expiry and cannot overwrite a key")
    func fluentQuirks() async throws {
        try await withApp(.fluent) { app in
            try await app.cache.set("e", to: "soon", expiresIn: .seconds(1))
            try await Task.sleep(for: .seconds(1.2))
            #expect(try await app.cache.get("e", as: String.self) == "soon") // never expires

            await #expect(throws: (any Error).self) {
                try await app.cache.set("e", to: "again") // unique violation on "key"
            }
            try await app.cache.delete("e")
            try await app.cache.set("e", to: "again")
            #expect(try await app.cache.get("e", as: String.self) == "again")
        }
    }

    @Test("a type mismatch reads as a miss in memory and throws in fluent")
    func typeMismatch() async throws {
        try await withApp(.memory) { app in
            try await app.cache.set("n", to: 42)
            #expect(try await app.cache.get("n", as: String.self) == nil)
        }
        try await withApp(.fluent) { app in
            try await app.cache.set("n", to: 42)
            await #expect(throws: (any Error).self) { _ = try await app.cache.get("n", as: String.self) }
        }
    }
}

The output of swift test:

✔ Test "a type mismatch reads as a miss in memory and throws in fluent" passed after 0.020 seconds.
✔ Test "memory cache expires entries and replaces existing ones" passed after 1.214 seconds.
✔ Test "fluent cache ignores the expiry and cannot overwrite a key" passed after 1.238 seconds.
✔ Suite "Cache drivers" passed after 1.238 seconds.
✔ Test run with 3 tests in 1 suite passed after 1.238 seconds.

Two consequences. First, the Fluent driver is a poor fit for caching derived data, since stale rows pile up until something deletes them; a nightly job that deletes old _fluent_cache rows is not possible by age either, because the table has no timestamp, so prefer the memory or Redis driver for expiring data. Second, with the Fluent driver, two requests that miss on the same key at the same time both try to insert it and the loser gets a unique-constraint error (a 500), so wrap the write in try? if a lost race is acceptable.

Cache-aside for a database row

The pattern used throughout this page is cache-aside (lazy loading): read the cache; on a miss read the source of truth and write the result back with a TTL; on a write to the source of truth, delete the cached copy and let the next read refill it. The cache never holds anything the database does not, so losing it costs speed, not data.

The next listing shows the whole handler set. GET /books/:id caches a small DTO (never a Fluent model: models are reference types with property wrappers, are @unchecked Sendable and encode with their database shape, whereas a DTO is a value with a stable JSON shape). PUT /books/:id/price writes and then deletes the key. The key has a version segment (book:v1:) so a change to `BookDTO’s shape can be rolled out by bumping it, instead of decoding old JSON into a new type.

import Fluent
import Vapor

struct BookDTO: Content, Equatable {
    let id: UUID
    let title: String
    let priceCents: Int
}

struct PriceUpdate: Content { let priceCents: Int }

func routes(_ app: Application) throws {
    let counter = UpstreamCounter()

    // GET /catalog/:isbn -> cache-aside around a (stubbed) external API
    app.get("catalog", ":isbn") { req async throws -> Response in
        let isbn = try req.parameters.require("isbn")
        let catalog = CachedCatalog(cache: req.cache, ttl: .minutes(10)) { isbn in
            let n = await counter.next()
            return BookMetadata(isbn: isbn, title: "Upstream title #\(n)", pages: 300)
        }
        let (value, hit) = try await catalog.metadata(isbn: isbn)
        let response = Response(status: .ok)
        try response.content.encode(value)
        response.headers.replaceOrAdd(name: "X-Cache", value: hit ? "HIT" : "MISS")
        return response
    }

    // DELETE /catalog/:isbn -> explicit invalidation
    app.delete("catalog", ":isbn") { req async throws -> HTTPStatus in
        let isbn = try req.parameters.require("isbn")
        try await req.cache.delete(CachedCatalog.key(for: isbn))
        return .noContent
    }

    // GET /books/:id -> database row cached as a DTO; PUT invalidates
    app.get("books", ":id") { req async throws -> Response in
        let id = try req.parameters.require("id", as: UUID.self)
        let key = "book:v1:\(id.uuidString.lowercased())"
        let response: Response
        if let cached = try await req.cache.get(key, as: BookDTO.self) {
            response = try await cached.encodeResponse(for: req)
            response.headers.replaceOrAdd(name: "X-Cache", value: "HIT")
        } else {
            guard let book = try await Book.find(id, on: req.db) else { throw Abort(.notFound) }
            let dto = BookDTO(id: id, title: book.title, priceCents: book.priceCents)
            try await req.cache.set(key, to: dto, expiresIn: .minutes(5))
            response = try await dto.encodeResponse(for: req)
            response.headers.replaceOrAdd(name: "X-Cache", value: "MISS")
        }
        return response
    }

    app.put("books", ":id", "price") { req async throws -> HTTPStatus in
        let id = try req.parameters.require("id", as: UUID.self)
        let update = try req.content.decode(PriceUpdate.self)
        guard let book = try await Book.find(id, on: req.db) else { throw Abort(.notFound) }
        book.priceCents = update.priceCents
        try await book.update(on: req.db)
        try await req.cache.delete("book:v1:\(id.uuidString.lowercased())") // invalidate after the write commits
        return .noContent
    }

    // Seeds one book and returns its id (demo only)
    app.post("seed") { req async throws -> String in
        let author = Author(name: "Ada")
        try await author.save(on: req.db)
        let book = Book(title: "Cache Patterns", isbn: "978-0-00-000000-1", publishedYear: 2026, priceCents: 2500,
                        status: .published, authorID: try author.requireID())
        try await book.save(on: req.db)
        return try book.requireID().uuidString
    }
}

Running it with the memory driver (CACHE_DRIVER=memory .build/debug/App serve) and with CACHE_DRIVER=fluent; the output is the same apart from the order of the JSON keys, which JSONEncoder does not fix:

$ curl -si localhost:8091/catalog/978-1 | grep -E "X-Cache|title"     # first call: miss, upstream is called
X-Cache: MISS
{"isbn":"978-1","title":"Upstream title #1","pages":300}
$ curl -si localhost:8091/catalog/978-1 | grep -E "X-Cache|title"     # second call: served from the cache
X-Cache: HIT
{"title":"Upstream title #1","isbn":"978-1","pages":300}
$ curl -s -o /dev/null -w "DELETE %{http_code}\n" -X DELETE localhost:8091/catalog/978-1
DELETE 204
$ curl -si localhost:8091/catalog/978-1 | grep -E "X-Cache|title"     # after invalidation: upstream called again (#2)
X-Cache: MISS
{"isbn":"978-1","title":"Upstream title #2","pages":300}
$ ID=$(curl -s -X POST localhost:8091/seed)
$ curl -si localhost:8091/books/$ID | grep -E "X-Cache|price"          # MISS, then HIT
$ curl -s -o /dev/null -w "PUT %{http_code}\n" -X PUT -H 'content-type: application/json' -d '{"priceCents":3000}' localhost:8091/books/$ID/price
PUT 204
$ curl -si localhost:8091/books/$ID | grep -E "X-Cache|price"          # MISS again, price now 3000
X-Cache: MISS
{"priceCents":3000,"id":"...","title":"Cache Patterns"}

Things to notice in the handlers:

  • Order in the write path. The delete happens after the update succeeds. Deleting first would let a concurrent reader refill the cache with the old row between the delete and the commit. Even in the right order a reader that loaded the old row just before the update can write it back just after the delete; the TTL is what bounds that staleness, so keep it short enough that you can live with it. For a write inside a database transaction, delete the key after the transaction has committed.

  • Negative results. A 404 is not cached here, so every request for a missing id reaches the database. If that is a risk, cache a "not found" marker with a short TTL.

  • Per-user data. Put whatever varies the response into the key (user:\(id):cart). A key that omits the user turns the cache into a data leak.

  • Keys are strings you invent. Use a prefix per concern and a version, and keep them out of user control (see Common errors).

The external API behind a cache

The HTTP Client page builds a CatalogClient that calls an external catalog service with timeouts and maps upstream failures to 502/504. Calling it on every request is slow and counts against the upstream’s rate limit, so the lookup gets a cache-aside wrapper. CachedCatalog takes the lookup as a closure, so production code passes catalog.metadata(isbn:) from that page and tests pass a stub that counts calls.

import Vapor

struct BookMetadata: Content, Equatable {
    let isbn: String
    let title: String
    let pages: Int
}

/// Cache-aside around any async source of metadata. The source is a closure so that production code passes
/// `CatalogClient.metadata` (see the HTTP client page) and tests pass a counting stub.
struct CachedCatalog {
    let cache: any Cache
    let ttl: CacheExpirationTime
    let fetch: @Sendable (String) async throws -> BookMetadata

    static func key(for isbn: String) -> String { "catalog:v1:isbn:\(isbn)" }

    func metadata(isbn: String) async throws -> (value: BookMetadata, hit: Bool) {
        let key = Self.key(for: isbn)
        if let cached = try await cache.get(key, as: BookMetadata.self) {
            return (cached, true)
        }
        let fresh = try await fetch(isbn)
        try await cache.set(key, to: fresh, expiresIn: ttl)
        return (fresh, false)
    }

    func invalidate(isbn: String) async throws {
        try await cache.delete(Self.key(for: isbn))
    }
}

/// Counts upstream calls so the example can show hits and misses.
actor UpstreamCounter {
    private(set) var calls = 0
    func next() -> Int { calls += 1; return calls }
}

The wiring is the GET /catalog/:isbn route in the previous listing, which builds CachedCatalog from req.cache and a closure (a stub here, CatalogClient in the real service). Worth knowing:

  • An upstream failure throws before set, so errors are not cached and a recovering upstream is picked up on the next request. A cached 404 from the upstream, if you want one, must be stored explicitly.

  • The TTL is a business decision. Book metadata changes rarely, so ten minutes (or hours) is fine; a stock level is not cacheable at that granularity. Honour the upstream’s own Cache-Control: max-age if it sends one.

  • The cache lets you serve something when the upstream is down only if you keep a second, longer-lived copy ("stale if error"); with a single TTL an expired entry is simply gone.

  • The route handler is the only place that knows the key format; invalidate(isbn:) and the DELETE route build the key with the same function, so the two cannot drift apart.

The vapor/redis package

vapor/redis (a library product named Redis) integrates RediStack, Swift’s NIO-based Redis client, with Vapor’s Application and Request. Add it to the package:

// Package.swift (dependencies)
.package(url: "https://github.com/vapor/redis.git", from: "4.14.0"),

// target dependencies
.product(name: "Redis", package: "redis"),

The Redis module re-exports RediStack (@_exported import RediStack), so import Redis is enough to name RESPValue, RedisKey and RedisChannelName. vapor/redis 4.14.0 requires Vapor 4.100 or newer and RediStack 1.4.1 or newer (1.6.3 was resolved here).

Configuration

import Redis
import Vapor

/// Reads `REDIS_URL` (for example `redis://:placeholder-password@localhost:6379/0`) and configures the default pool.
func configureRedis(_ app: Application) throws {
    let url = Environment.get("REDIS_URL") ?? "redis://localhost:6379"
    app.redis.configuration = try RedisConfiguration(
        url: url,
        pool: .init(
            maximumConnectionCount: .maximumActiveConnections(8),
            minimumConnectionCount: 1,
            connectionRetryTimeout: .seconds(5)
        )
    )
}

/// A second, independent Redis (for example sessions on database 1) addressed by its own `RedisID`.
extension RedisID {
    static let sessions: RedisID = "sessions"
}

func configureSessionsRedis(_ app: Application) throws {
    app.redis(.sessions).configuration = try RedisConfiguration(
        hostname: Environment.get("SESSIONS_REDIS_HOST") ?? "localhost",
        port: 6379,
        password: Environment.get("SESSIONS_REDIS_PASSWORD"),
        database: 1
    )
}

/// Commands on `req.redis`: the typed helpers and the escape hatch.
struct RedisCommandsDemo {
    static func run(_ req: Request) async throws -> String {
        let redis = req.redis

        // Legacy bridge: RediStack's set, get, setex and increment return EventLoopFuture and have no async
        // overload in vapor/redis 4.14.0, so they are awaited with .get(). ttl, expire and send are async.
        try await redis.set("greeting", to: "hello").get()
        let value = try await redis.get("greeting", as: String.self).get()

        try await redis.setex("short-lived", to: "bye", expirationInSeconds: 30).get()
        let ttl = try await redis.ttl("short-lived")

        let hits = try await redis.increment("hits:index").get()
        _ = try await redis.expire("hits:index", after: .seconds(60))

        let pong = try await redis.send(command: "PING", with: [RESPValue(from: "hello")])
        return "\(value ?? "nil") \(ttl) \(hits) \(pong.string ?? "?")"
    }
}

The configuration type, with every property:

Property or initializer Meaning

init(url:tlsConfiguration:pool:)

Parses redis://[:password@]host[:port][/database] or rediss://... (TLS, with a default client TLS configuration when none is given). The password is the URL’s password part; the database is the path’s last component. Any other scheme throws ValidationError.invalidURLScheme, a missing host missingURLHost, and so on.

init(hostname:port:password:tlsConfiguration:database:pool:)

The same for a single host; the port defaults to 6379. A negative database throws outOfBoundsDatabaseID.

serverAddresses

[SocketAddress], the endpoints to connect to; the third initializer takes it instead of a single host.

password, database

Applied when each connection is created (AUTH, SELECT). Do not send your own SELECT on a pooled connection: the pool does not remember it.

tlsConfiguration, tlsHostname

TLS settings and the name used for certificate verification.

pool.maximumConnectionCount

.maximumActiveConnections(n) (a hard cap; callers wait for a free connection) or .maximumPreservedConnections(n) (at most n idle connections are kept, more are opened under load and not preserved). The default is .maximumActiveConnections(2).

pool.minimumConnectionCount

Connections kept alive; the default 0 means connections are created on demand, which adds latency to the first requests.

pool.connectionBackoffFactor, initialConnectionBackoffDelay, connectionRetryTimeout

Reconnect back-off (factor 2 and 100 ms by default) and how long a caller waits for a connection before failing (nil by default). Setting it, as the listing does with 5 seconds, bounds how long a request waits when Redis is unreachable.

pool.onUnexpectedConnectionClose

A @Sendable closure called when a connection drops.

How the pools are built matters for sizing: vapor/redis creates one connection pool per event loop, per configured RedisID, in its didBoot lifecycle hook. A server on an 8-core machine has 8 event loops, so .maximumActiveConnections(8) means up to 64 connections to Redis from one process. Size the Redis maxclients limit for instances x event loops x maximum, and prefer a small maximum. Two consequences of building pools at boot:

  • app.redis.configuration must be assigned in configure, before boot. Replacing a configuration afterwards is not supported (fatalError("Modifying connection pools after application has booted is not supported")), and assigning nil is a fatalError too.

  • Using a RedisID that was never configured stops the process with fatalError("No redis found for id ...").

Several Redis instances. RedisID is a Sendable string-backed identifier; define constants in an extension (extension RedisID { static let sessions: RedisID = "sessions" }) and address them with app.redis(.sessions) or req.redis(.sessions). app.redis is shorthand for app.redis(.default). The Cache provider takes the same ID: app.caches.use { $0.caches.redis(.sessions) }.

Commands: typed helpers and send(command:with:)

app.redis and req.redis conform to RediStack’s RedisClient, which has typed methods for the common commands (get, set, setex, delete, exists, expire, ttl, increment, publish, hashes, lists, sets, sorted sets, …​). The typed methods return EventLoopFuture`s; the package adds `async forms for the most common ones (exists, expire, ttl, pttl, send, subscribe and the JSON helpers) in RedisClient+Concurrency.swift, and for everything else you call .get() on the future. Prefer req.redis in handlers (the command is logged with the request’s logger and runs on the request’s event loop) and app.redis in boot code and background tasks.

RedisKey is an ExpressibleByStringLiteral struct, so "greeting" works for a key, and so do string interpolations built into a RedisKey(...) initializer. For commands RediStack does not wrap, send(command:with:) takes the command name and an array of RESPValue.

let pong = try await req.redis.send(command: "PING", with: [RESPValue(from: "hello")])

The Vapor Redis documentation writes this argument as the literal ["hello"]; with Redis 4.14.0 and RediStack 1.6.3 RESPValue is not ExpressibleByStringLiteral, so that form is a compile error ("cannot convert value of type '[String]' to expected argument type '[RESPValue]'"). Convert each argument with RESPValue(from:).

The full command set used on this page, in one handler body (this is RedisCommandsDemo from the listing above): set, get with a decoded type, setex for a value with a lifetime, ttl, increment and expire for a counter, and send for a command with no typed wrapper. ttl returns RedisKey.Lifetime (.keyDoesNotExist, .unlimited or .limited(Duration)), not a number, so a missing key and a key without expiry are distinguishable.

JSON values and the Redis cache driver

For one-off values, the package adds setex(_:toJSON:expirationInSeconds:), set(_:toJSON:) and get(_:asJSON:) on any RedisClient, using JSONEncoder/JSONDecoder. These are what to use when the key belongs to Redis only (a search-result page, a computed report) and does not need to be portable across cache drivers:

import Foundation
import Redis
@preconcurrency import RediStack
import Vapor

// MARK: JSON values with the typed helpers

struct SearchResultPage: Codable, Sendable {
    let query: String
    let isbns: [String]
}

func cacheSearch(_ req: Request, page: SearchResultPage) async throws {
    // The JSON helpers live on RedisClient, so they are also available on req.redis.
    try await req.redis.setex(RedisKey("search:v1:\(page.query)"), toJSON: page, expirationInSeconds: 120)
}

func cachedSearch(_ req: Request, query: String) async throws -> SearchResultPage? {
    try await req.redis.get(RedisKey("search:v1:\(query)"), asJSON: SearchResultPage.self)
}

The Redis Cache driver (.redis) is the same idea behind the Cache protocol: set encodes the value with the configured coder and runs SETEX (with expiresIn) or SET (without), get runs GET and decodes, set(..., to: nil) runs DEL. The coders are any type conforming to RedisCacheEncoder/RedisCacheDecoder; JSONEncoder/JSONDecoder and PropertyListEncoder/ PropertyListDecoder already do. Pass your own configured pair when dates must be ISO 8601 or keys snake_case:

// RedisExtras.swift, continued

// MARK: Driver variants and sessions

func configureAlternativeDrivers(_ app: Application) throws {
    // Explicit coders (any RedisCacheEncoder/RedisCacheDecoder) instead of the defaults:
    let encoder = JSONEncoder()
    encoder.dateEncodingStrategy = .iso8601
    let decoder = JSONDecoder()
    decoder.dateDecodingStrategy = .iso8601
    app.caches.use(.redis(encoder: encoder, decoder: decoder))

    // A named second connection used for a second cache.
    try configureSessionsRedis(app)
    app.caches.use { app in app.caches.redis(.sessions) }

    // Sessions stored in Redis (cookie holds only the session id):
    app.sessions.use(.redis)
    app.middleware.use(app.sessions.middleware)
}

The Redis driver writes the key you give it with no prefix, so two services sharing one Redis, or two environments sharing one database index, will overwrite each other unless the keys carry a prefix (bookshelf:prod:catalog:v1:...) or the services use different database numbers.

Cache stampede, jitter and a single-flight lock

When a popular key expires, every request that arrives before the first one has refilled it misses and hits the source of truth at once: a cache stampede. Two cheap defences exist, and they combine.

Jitter. Entries written together expire together. Randomising the TTL by a fraction spreads the misses out in time, and costs one line.

A single-flight lock. On a miss, try to take a short-lived lock with SET key value NX EX seconds; the one caller that gets it rebuilds the value, the others either wait briefly and re-read the cache or serve slightly stale data. NX makes the set conditional on the key not existing, and EX makes the lock expire by itself if the rebuilding process dies:

// RedisExtras.swift, continued

// MARK: Single-flight lock against a cache stampede

enum StampedeGuard {
    /// Returns true for exactly one caller per key and lock lifetime (SET key value NX EX seconds).
    static func tryAcquire(_ req: Request, key: String, seconds: Int) async throws -> Bool {
        let result = try await req.redis.set(
            RedisKey("lock:\(key)"),
            to: req.id,
            onCondition: .keyDoesNotExist,
            expiration: .seconds(seconds)
        ).get()
        return result == .ok
    }

    static func release(_ req: Request, key: String) async throws {
        _ = try await req.redis.delete(RedisKey("lock:\(key)")).get()
    }
}

/// A TTL with up to 20 percent random jitter, so entries written together do not all expire together.
func jitteredTTL(seconds: Int) -> CacheExpirationTime {
    .seconds(seconds + Int.random(in: 0...(seconds / 5)))
}

set(_:to:onCondition:expiration:) returns RedisSetCommandResult (.ok or .conditionNotMet). The lock is advisory and best-effort: it is a hint to reduce load, not a guarantee of mutual exclusion (the lock can expire while the holder is still working, and release deletes whatever is there, not only your own token). Do not use it to protect a correctness-critical section; for that use a database constraint or a proper lock protocol.

The @preconcurrency import RediStack at the top of that file silences one warning, "type 'RedisSetCommandResult' does not conform to the 'Sendable' protocol": RediStack’s RedisSetCommandResult and RedisChannelName are not Sendable (the RedisKey type was not checked). The vapor/redis package itself imports RediStack that way. That also explains why RedisChannelName.bookChanged below is a computed property (static var ... { "..." }): a stored static let of a non-Sendable type is rejected in the Swift 6 language mode ("static property is not concurrency-safe because non-'Sendable' type may have shared mutable state").

Rate limiting with INCR and EXPIRE

Counters are a use of Redis that the Cache protocol cannot express, since INCR is atomic on the server. A fixed-window limiter is a middleware: increment a per-client-per-window key, set its expiry when the count is 1, and answer 429 above the limit.

// RedisExtras.swift, continued

// MARK: Fixed-window rate limiter on INCR + EXPIRE

struct RateLimitMiddleware: AsyncMiddleware {
    let limit: Int
    let windowSeconds: Int

    func respond(to request: Request, chainingTo next: any AsyncResponder) async throws -> Response {
        let client = request.remoteAddress?.ipAddress ?? "unknown"
        let window = Int(Date().timeIntervalSince1970) / windowSeconds
        let key = RedisKey("rl:\(client):\(window)")
        let count = try await request.redis.increment(key).get()
        if count == 1 {
            _ = try await request.redis.expire(key, after: .seconds(Int64(windowSeconds))).get()
        }
        guard count <= limit else {
            throw Abort(.tooManyRequests, headers: ["Retry-After": "\(windowSeconds)"])
        }
        return try await next.respond(to: request)
    }
}

A middleware like this sits in the request lifecycle before the router’s handler, so register it on the group that should be limited (app.grouped(RateLimitMiddleware(limit: 60, windowSeconds: 60))) rather than globally. Caveats: a fixed window allows up to twice the limit across a window boundary; if the process dies between INCR and EXPIRE the key never expires (use a Lua script or SET key 0 EX n NX first if that matters); behind a reverse proxy remoteAddress is the proxy, so configure the forwarded-for handling first; and a Redis outage turns into a 500 here, so decide whether to fail open (catch and call next) or closed. The Middleware page covers the chain in general.

Pub/Sub for cross-instance invalidation

With one process, deleting a cache entry is enough. With several instances each holding an in-process cache (the memory driver, or a dictionary in a service), a write handled by one instance must evict the entry in all of them. Redis Pub/Sub is a fire-and-forget broadcast: PUBLISH sends a message to every currently subscribed connection, and a subscriber that is offline misses it. That is acceptable for cache invalidation (the TTL is the safety net) and wrong for anything that must not be lost (use Redis Streams or a queue).

// RedisExtras.swift, continued

// MARK: Pub/Sub

extension RedisChannelName {
    static var bookChanged: RedisChannelName { "bookshelf:book-changed" }
}

struct BookChanged: Codable, Sendable {
    let bookID: UUID
    let instance: String
}

/// Subscribes at start-up; every instance receives every message, including the one that published it.
struct BookChangeSubscriber: LifecycleHandler {
    let instance: String

    func didBoot(_ application: Application) throws {
        let logger = application.logger
        let me = instance
        Task {
            do {
                try await application.redis.subscribe(
                    to: [.bookChanged],
                    messageReceiver: { channel, message in
                        guard let text = message.string,
                              let event = try? JSONDecoder().decode(BookChanged.self, from: Data(text.utf8)) else {
                            logger.warning("Ignoring malformed message on \(channel)")
                            return
                        }
                        logger.info("Book \(event.bookID) changed on \(event.instance); I am \(me)")
                        // Here: evict the in-process cache entry for event.bookID.
                    },
                    onSubscribe: { key, count in logger.info("Subscribed to \(key) (\(count) active)") },
                    onUnsubscribe: { key, count in logger.info("Unsubscribed from \(key) (\(count) left)") }
                )
            } catch {
                logger.error("Redis subscription failed: \(error)")
            }
        }
    }
}

func publishBookChanged(_ req: Request, bookID: UUID, instance: String) async throws {
    let payload = String(decoding: try JSONEncoder().encode(BookChanged(bookID: bookID, instance: instance)), as: UTF8.self)
    let receivers = try await req.redis.publish(payload, to: .bookChanged).get()
    req.logger.debug("Delivered to \(receivers) subscriber(s)")
}

The same mechanism fans out messages to connected clients of several instances; see WebSockets for pushing them to sockets. How the subscription behaves:

  • subscribe(to:messageReceiver:onSubscribe:onUnsubscribe:) has three phases: onSubscribe runs once when the subscription starts, messageReceiver runs for each message with the channel name and a RESPValue, and onUnsubscribe runs once when it ends, either because you called unsubscribe(from:) or because the connection was lost. psubscribe is the same for glob patterns.

  • A subscribed Redis connection can do nothing else, so a long-lived subscriber is registered once at boot, not per request.

  • The callbacks are plain (non-Sendable) closures called on an event loop; keep them short, capture only Sendable values (a Logger and a String here), and hand real work to a Task.

  • A subscriber receives its own publications too, so a handler that evicts on message must tolerate evicting something it already evicted.

  • Whether a subscription survives a lost connection was not verified here; onUnsubscribe is the hook for noticing that it ended and resubscribing.

  • The subscriber is added from a LifecycleHandler.didBoot, after configureRedis, so that vapor/redis has already created its pools (its own didBoot handler is registered the first time app.redis is touched, in configure).

Sessions in Redis

vapor/redis also provides a session driver, so cookie-based sessions can be shared by all instances. The cookie carries only a session ID; the data lives in Redis under a key derived from it, and a RedisSessionsDelegate lets you change the key format or how the data is stored. Registration is the last lines of the configureAlternativeDrivers function in the listing above: app.sessions.use(.redis) followed by app.middleware.use(app.sessions.middleware). Sessions are covered by the Sessions and Cookies page (including the custom driver with a sliding time to live) and by Session Authentication and Web Login; the point here is only that the driver uses the same pools and RedisID as the cache. Source: Vapor docs: Redis sessions

Testing code that uses the cache

Because the driver is chosen in configure, tests use the memory driver (or call app.caches.use(.memory) in the test’s own setup) and need no Redis at all. The suite above is the pattern; a handler test with VaporTesting asserts the observable behaviour, the X-Cache header here:

@testable import App
import Testing
import Vapor
import VaporTesting

@Suite("Cached handlers")
struct HandlerTests {
    @Test("the catalog route misses once, then hits, then misses after invalidation")
    func catalogCacheAside() async throws {
        let app = try await Application.make(.testing)
        do {
            try await configure(app) // CACHE_DRIVER is unset, so this is the memory driver
            try await app.testing().test(.GET, "catalog/978-1") { res async in
                #expect(res.headers.first(name: "X-Cache") == "MISS")
                #expect((try? res.content.decode(BookMetadata.self))?.title == "Upstream title #1")
            }.test(.GET, "catalog/978-1") { res async in
                #expect(res.headers.first(name: "X-Cache") == "HIT")
                #expect((try? res.content.decode(BookMetadata.self))?.title == "Upstream title #1")
            }.test(.DELETE, "catalog/978-1") { res async in
                #expect(res.status == .noContent)
            }.test(.GET, "catalog/978-1") { res async in
                #expect(res.headers.first(name: "X-Cache") == "MISS")
                #expect((try? res.content.decode(BookMetadata.self))?.title == "Upstream title #2")
            }
        } catch {
            try await app.asyncShutdown()
            throw error
        }
        try await app.asyncShutdown()
    }
}

The memory cache is shared by every request of one Application, and each test makes its own Application, so tests do not leak entries into each other. To test the Redis driver itself, run a disposable Redis (a redis:7 container, or Valkey) and point REDIS_URL at it from the test environment; keep that suite out of the default swift test run if CI has no container runtime.

Common errors

Symptom Cause and fix

{"error":true,"reason":"RedisConnectionPoolError(...timedOutWaitingForConnection)"} with a 500, and failed to create connection for pool [error: connection reset ... Connection refused) in the log, repeated

Nothing is listening at the configured address (observed with REDIS_URL=redis://127.0.0.1:6390). The request waited for connectionRetryTimeout (5 seconds in the listing; without it the wait is longer). Fix the address, start Redis, or catch the error and fall back to the source of truth so that a cache outage is a slowdown, not an outage.

Fatal error: Error raised at top level: ...ValidationError(kind: ...invalidURLScheme) at start-up

REDIS_URL is not redis:// or rediss:// (observed with http://x). missingURLHost, missingURLScheme and outOfBoundsDatabaseID are the other validation failures.

Fatal error: No redis found for id ..., or the app may not have finished booting

req.redis or app.redis was used before boot finished, a RedisID was never configured, or the call ran on an event loop that is not the application’s.

Fatal error: Modifying connection pools after application has booted is not supported

app.redis.configuration assigned after boot.

Fatal error: No cache configured

app.cache read with no driver registered (not possible with the default).

A 500 mentioning a unique constraint on _fluent_cache.key

Fluent driver: set on an existing key, or a race between two misses. delete first, or use another driver.

DecodingError from req.cache.get

Fluent or Redis driver: the stored JSON no longer matches the type you read it as (a changed DTO, or a key shared by two types). Version the key.

Cached data never expires with .fluent

The Fluent driver ignores expiresIn. Use memory or Redis.

compile error "stored property 'cache' of 'Sendable'-conforming struct has non-Sendable type 'any Cache'"

Cache is not Sendable in Vapor 4. Build the wrapper per request from req.cache.

compile error "static property is not concurrency-safe because non-'Sendable' type 'RedisChannelName'"

Make it a computed property.

compile error "cannot convert value of type '[String]' to expected argument type '[RESPValue]'"

Wrap each send(command:with:) argument in RESPValue(from:).

Cache hits show another user’s data

The key omits something that varies the response (user, language, tenant). Add it to the key.

Keys built from user input

A request that can choose arbitrary keys can fill Redis. Build keys from validated components only, as CachedCatalog does (the ISBN is validated before use in the real client).

Version notes

  • Vapor 3 to 4: caching in Vapor 4 is the Cache protocol with app.caches.use(...) and req.cache, and Redis is app.redis and req.redis over RediStack; the Vapor 3 service-container APIs are gone.

  • Redis package: the async helpers (exists, expire, ttl, send, subscribe, the JSON helpers) exist in 4.14.0; the typed commands such as get(_:as:), set, setex, increment and delete still return futures, which the listings finish with .get(). When each helper was added was not checked.

  • Vapor 5 (5.0.0-beta.4, not generally available): per the 5.0.0-beta.4 sources, Cache is Sendable and has only two requirements, get(_:as:) async throws and set(_:to:expiresIn:) async throws, with delete, get and set without expiry as extensions; the futures and for(_:) are gone, so a req.cache is the same value as app.cache. vapor/redis has a 5.0.0-alpha.2.2 tag and a v5-dev branch whose API is not released, so treat the Redis driver of Vapor 5 as unconfirmed. Code written with the async calls on this page ports with few changes; code that returns futures does not.

  • Swift 6: Cache, RedisChannelName and RedisSetCommandResult are not Sendable in the 4.x line (see above), so Swift 6 mode needs the computed static var, a per-request wrapper and a @preconcurrency import of RediStack where values cross an isolation boundary; Vapor’s own redis package compiles with StrictConcurrency=complete, and RedisID and RedisConfiguration are Sendable.

Where the books are outdated

  • Condon et al. (Server-Side Swift with Vapor, Kodeco) chapter 27 covers caching with memory, database and Redis stores and a cached external API call, written for Vapor 3 and futures. The concept, cache-aside with a TTL, is unchanged; today it is req.cache.get/set(_:to:expiresIn:) with async, and the driver is chosen with app.caches.use(...). Check any database-backed cache against the no-expiry and no-overwrite behaviour of the Fluent driver described above.

  • Yeung and Patel (Packt) predate app.redis and Vapor 4’s Cache; their Redis and caching material uses the Vapor 2 and 3 APIs. Replace it with the Redis product, app.redis.configuration and the .redis cache and session drivers.

  • Where the Kodeco book scales out with Redis as a shared session and cache store, the idea holds and the APIs are app.sessions.use(.redis) and app.caches.use(.redis).