Scheduled Jobs

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 queues 1.18.0, queues-redis-driver 1.1.2, redis 4.14.0 and Fluent 4.13.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 schedule arithmetic, the scheduler (started with startScheduledJobs), the failure and long-run behaviour, the test suite shown and the queues --scheduled and custom overdue-scan commands were run, and the output quoted is captured from those runs. The Redis slot lock was compiled but not run against a live Redis server, and the Kubernetes manifests were not applied to a cluster. Vapor 5 is at 5.0.0-beta.4 and is not released yet; the scheduling API is not described for Vapor 5 here because it was not verified. The page extends the Bookshelf service of the earlier pages: a nightly scan finds overdue loans and hands the slow work, one reminder per loan, to a queued job. Swift itself is covered by the Swift Reference, and async functions, Sendable and actors by Async/Await and Tasks and Actors, Isolation and Sendable. Queued jobs, workers and retries are the subject of the Queues & Background Jobs page; this page only needs the part that a scheduled job uses. The reminder that the job finally sends is covered by Sending Email and APNs Push Notifications.

What a scheduled job is

A scheduled job is a piece of code that runs by itself at times described by a schedule ("every day at 02:00", "every 10 minutes"), as opposed to a request handler, which runs when a client calls it, and a queued job, which runs because some other code dispatched it. Vapor ships scheduled jobs in the same package as queued jobs, vapor/queues, and they share the Application.Queues configuration, but they are different mechanisms:

Aspect Queued job (AsyncJob) Scheduled job (AsyncScheduledJob)

Trigger

Code calls dispatch with a payload

A timer in a scheduler process fires at the next time of the schedule

Payload

A Codable payload, stored in the queue driver

None: the job is a value registered once, with the QueueContext

Storage

Redis, a database or the test driver; survives restarts

Nothing is stored: the schedule lives in memory only

Who runs it

Any worker process on the queue

Every process that started the scheduler

Retries

maxRetryCount, nextRetryIn(attempt:)

None: a failed run is logged and the next occurrence is scheduled normally

Needs a queue driver

Yes

Only if the job dispatches queued jobs or uses context.queue

Two consequences drive the rest of the page. First, a scheduled job should be short and cheap: find the work and dispatch it, then let queued jobs (with retries and several workers) do it. The Bookshelf scan below only reads the overdue loans and dispatches one reminder job per loan. Second, because the schedule is not stored anywhere, something must start the scheduler exactly once per deployment; that is the main operational question and has its own section.

Where it sits in the application

A scheduled job is not part of the request lifecycle. There is no middleware, router or handler involved. The Application holds a list of scheduled jobs in app.queues.configuration; a scheduler is a set of NIO timers (`RepeatedTask`s on an event loop) created when the scheduler starts, in one of two ways, and cancelled when the application shuts down:

  • try app.queues.startScheduledJobs() starts the timers inside the current process, typically the same process that serves HTTP (serve). It returns immediately; the timers run on the application’s event loop group.

  • swift run App queues --scheduled (in a container, ./App queues --scheduled) is a separate process whose only job is to run the timers. Its QueuesCommand installs SIGTERM and SIGINT handlers, keeps the application running until one arrives and shuts the timers down in an orderly way.

flowchart LR C[Clients] --> W1[serve replica 1] C --> W2[serve replica 2] S[queues --scheduled, exactly one replica] -->|dispatch reminders| R[(Redis queue)] R --> K[queues worker, N replicas] W1 --> DB[(Database)] W2 --> DB S --> DB K --> DB

The figure shows the topology this page ends up recommending: HTTP replicas and queue workers scale out, while the scheduler is a single process. The rest of the page explains why.

The job: AsyncScheduledJob

AsyncScheduledJob refines ScheduledJob (whose run(context:) returns an EventLoopFuture<Void>) with an async throws requirement; the future-returning method is implemented for you. The protocol requires Sendable because the scheduler hands the job value to a timer on another event loop, so a job is normally a struct holding only Sendable configuration. Anything else it needs, such as the database or the application logger, comes from the QueueContext argument, which has application, logger, eventLoop, queue (the default Queue), queues(_:) for a named one, and configuration.

The default name of a job is its type name. Override it to make log lines and the scheduler’s bookkeeping unambiguous (see the pitfalls below).

import Fluent
import Foundation
import Queues
import Vapor

struct OverdueReminderPayload: Codable, Sendable {
    let loanID: UUID
}

/// A queued job: the scan below only finds the loans, this one does the slow work (email, push).
struct OverdueReminderJob: AsyncJob {
    typealias Payload = OverdueReminderPayload

    func dequeue(_ context: QueueContext, _ payload: OverdueReminderPayload) async throws {
        context.logger.info("Sending overdue reminder", metadata: ["loan": "\(payload.loanID)"])
    }
}

/// A scheduled job: runs on the scheduler process at the time given by the schedule.
struct OverdueLoanScan: AsyncScheduledJob {
    var name: String { "overdue-loan-scan" }

    func run(context: QueueContext) async throws {
        let now = Date()
        let overdue = try await Loan.query(on: context.application.db)
            .filter(\.$returnedAt == nil)
            .filter(\.$dueAt < now)
            .all()

        for loan in overdue {
            let id = try loan.requireID()
            try await context.queue.dispatch(
                OverdueReminderJob.self,
                OverdueReminderPayload(loanID: id),
                maxRetryCount: 3
            )
        }
        context.logger.info("Overdue scan finished", metadata: ["overdue": "\(overdue.count)"])
    }
}

Points that matter in this listing:

  • OverdueLoanScan is a struct with no stored properties, so it is trivially Sendable. The Loan model class is @unchecked Sendable in the Bookshelf models of the earlier pages (see Fluent Models); the query result never leaves the run function except as the loan identifiers placed in the payload.

  • Loan.query(on: context.application.db) uses the application-wide database: a scheduled job has no Request, so there is no req.db. The same holds for caches, the HTTP client and every other service stored on the application.

  • context.queue.dispatch(...) uses the default queue of the configured driver. Because the dispatch needs a driver, the application must have called app.queues.use(...) and app.queues.add(OverdueReminderJob()); the scheduler itself would start without them.

  • The scan does not send e-mail or push notifications itself. A scheduled run that takes minutes delays the next occurrence of the same job (see below), while dispatching a job costs one Redis round trip.

  • A scheduled job should be idempotent or guard itself, because a restart, a second scheduler or a manual command run can execute the same occurrence again. The scan above is naturally safe to repeat except that it would send a second reminder; store a "reminded on" marker on the loan, or deduplicate with the job id: argument of dispatch, if repeated reminders matter.

Describing the schedule: app.queues.schedule

app.queues.schedule(_:) registers a job and returns a ScheduleBuilder. You then describe when by chaining calls on the builder; nothing runs until a scheduler is started. The builder has two families of methods.

Calendar-based schedules use Calendar.nextDate(after:matching:matchingPolicy:) with the components you provided and fire at the next wall-clock time that matches:

Builder call Fires

.yearly().in(.january).on(1).at(.noon)

on 1 January at 12:00

.monthly().on(1).at(.midnight)

on the 1st of every month at 00:00

.monthly().on(31).at(.midnight)

on the 31st, in the months that have a 31st only

.weekly().on(.monday).at("08:00")

every Monday at 08:00

.daily().at(2, 0)

every day at 02:00 (24-hour Hour24, Minute)

.daily().at(9, 30, .pm)

every day at 21:30 (12-hour Hour12, Minute, .am or .pm)

.daily().at("9:30pm")

the same, as a Time string literal: "HH", "HH:mm" or "H:mmam"/"H:mmpm"

.hourly().at(30)

at minute 30 of every hour

.minutely().at(15)

at second 15 of every minute

.everySecond()

every second

.at(date)

once, at that Date, and never again

Interval-based schedules do not look at the calendar: every(seconds:), every(minutes:), every(hours:), every(days:) and every(weeks:) (all take an Int) fire a fixed time after the previous scheduled time. They are right for "refresh every 10 minutes" and wrong for "every day at 02:00", because the first run is interval after the moment the scheduler started, and that moment is arbitrary.

The Weekday and Month enumerations give names to the numbers (Weekday.sunday is 1 and .saturday is 7, as in Calendar), and Day is .first, .last or an integer literal. .midnight and .noon are shorthand Time values.

import Foundation
import Queues
import Vapor

/// Each helper registers one job with one of the builder forms; `nextDate` is shown in the tests.
struct Heartbeat: AsyncScheduledJob {
    func run(context: QueueContext) async throws {
        context.logger.info("tick")
    }
}

func registerSchedules(_ app: Application) {
    // 02:00 every day, in an explicit time zone instead of the process default
    var calendar = Calendar(identifier: .gregorian)
    calendar.timeZone = TimeZone(identifier: "Europe/Madrid")!

    app.queues.schedule(OverdueLoanScan())
        .using(calendar)
        .daily()
        .at(2, 0)

    // other builder forms
    app.queues.schedule(Heartbeat()).hourly().at(30)                    // minute 30 of every hour
    app.queues.schedule(Heartbeat()).weekly().on(.monday).at("08:00")  // Mondays 08:00
    app.queues.schedule(Heartbeat()).monthly().on(1).at(.midnight)     // the 1st at 00:00
    app.queues.schedule(Heartbeat()).yearly().in(.january).on(1).at(.noon)
    app.queues.schedule(Heartbeat()).minutely().at(15)                  // second 15 of every minute
    app.queues.schedule(Heartbeat()).every(minutes: 10)                 // fixed delay after the previous run
}

Every Heartbeat registration above shares one type, which is fine for a demonstration but not for production: give each registered job a distinct name (next section on pitfalls).

What the builder computes, measured

ScheduleBuilder.nextDate(current:) is public, so you can ask any builder when it would fire next. The table lists the results for each form, given "now" of Saturday 2026-10-17 09:30:15 in Europe/Madrid (UTC+2 on that date) and a builder created with a Calendar in that zone. The values were printed by a test run.

Builder nextDate after 2026-10-17 09:30:15

.daily().at(2, 0)

2026-10-18 02:00:00

.daily().at("9:30pm")

2026-10-17 21:30:00

.daily().at(.noon)

2026-10-17 12:00:00

.daily().at(.midnight)

2026-10-18 00:00:00

.hourly().at(30)

2026-10-17 10:30:00

.minutely().at(15)

2026-10-17 09:31:15

.weekly().on(.monday).at("08:00")

2026-10-19 08:00:00

.weekly().on(.sunday).at("08:00")

2026-10-18 08:00:00

.monthly().on(1).at(.midnight)

2026-11-01 00:00:00

.monthly().on(31).at(.midnight)

2026-10-31 00:00:00

.yearly().in(.january).on(1).at(.noon)

2027-01-01 12:00:00

.every(minutes: 10)

2026-10-17 09:40:15 (the current time plus ten minutes)

.everySecond()

2026-10-17 09:30:16

.at(<2026-12-25 09:00>)

2026-12-25 09:00:00; after it has fired, nil

.at(<a date in the past>)

nil: the job is never scheduled

Time zones, daylight saving and month edges

The calendar that does the arithmetic is Calendar.current unless you pass another with using(_:) (or ScheduleBuilder(calendar:) in a test). Calendar.current takes its time zone from the process, and in a container that is normally UTC. daily().at(2, 0) on a UTC container therefore fires at 02:00 UTC, which is 04:00 in Madrid in summer. The trace log of the queues --scheduled run shows the effect of the explicit calendar used above: with the Madrid calendar the next overdue scan was scheduled for 2026-10-18 00:00:00 +0000, which is 02:00 in Madrid. Set the time zone explicitly on every calendar-based job whose wall-clock time matters, and keep it out of the job (an environment variable is a good source if operations needs to change it).

Other measured edge cases, all from the same test run with a Madrid or UTC calendar:

  • Daylight saving, spring forward. Madrid skipped from 02:00 to 03:00 on 2027-03-28. After 2027-03-27 12:00 the next daily().at(2, 30) was 2027-03-29 02:30, so the run of the day with the missing hour is skipped, not shifted. A job scheduled at 03:00 fired on 2027-03-28 03:00 as usual. The reason is matchingPolicy: .strict, which the builder uses, so a time that does not exist on a given day does not match. If a run must happen on every calendar day, schedule it away from the 02:00 to 03:00 window.

  • Daylight saving, autumn. In the repeated hour the behaviour of Calendar.nextDate applies; this page did not measure it, so for a job that must run exactly once per day choose a time outside the repeated hour or use the slot lock below.

  • The 31st. monthly().on(31) from 2027-01-31 fired next on 2027-03-31, then 2027-05-31, 2027-07-31 and 2027-08-31: months without a 31st are skipped, they do not fall back to the last day. Likewise yearly().in(.february).on(29) waited until 2028-02-29.

  • The last day of the month is not supported. monthly().on(.last) compiles, but computing the next date reaches a fatalError. The measured output was Fatal error: Last day of the month is not yet supported., which crashes the whole process when the scheduler starts. Do not use .last; schedule daily() and check the date inside the job, or schedule on the 1st and treat the run as "the previous month".

  • Incomplete builders never run. daily() or hourly() with no at(...) produced nil, so the job is silently never scheduled (only a debug-level log line, "Scheduler returned no date"). weekly().on(.friday) without at fired on Friday at 00:00. Always finish a builder chain with at.

  • String literals trap. "08:00" and friends are parsed at run time; a malformed literal such as "8am" stops the process with a fatalError, and an hour above 23 or a minute above 59 fails a precondition.

Starting the scheduler

There are two ways, as outlined above. In both, the scheduler needs the jobs to have been registered first, and with no registered jobs it logs a warning ("No scheduled jobs exist, exiting scheduled jobs worker.") and does nothing.

In the web process. The simplest setup for one instance or a development machine is to start the timers in configure. Do not call startScheduledJobs() unconditionally: configure runs for every command of the executable (migrate, routes, a custom command), so each of them would start a scheduler. Guard it with something that says "this instance is the scheduler":

import Fluent
import FluentSQLiteDriver
import Queues
import QueuesRedisDriver
import Vapor

func configure(_ app: Application) async throws {
    app.databases.use(.sqlite(.memory), as: .sqlite)
    app.migrations.add(CreateBookshelf())
    try await app.autoMigrate()

    if app.environment != .testing {
        try app.queues.use(.redis(url: Environment.get("REDIS_URL") ?? "redis://127.0.0.1:6379"))
    }
    app.queues.add(OverdueReminderJob())
    registerSchedules(app)
    app.asyncCommands.use(OverdueScanCommand(), as: "overdue-scan")

    // in-process scheduler, only when this instance is the designated scheduler
    if Environment.get("RUN_SCHEDULER") == "true" {
        try app.queues.startScheduledJobs()
    }
    try routes(app)
}

The app.environment != .testing guard keeps unit tests from needing Redis (see testing below). RUN_SCHEDULER is a name chosen for this example, not a Vapor setting.

As a dedicated process. queues --scheduled starts the same timers in a process that serves no HTTP:

swift run App queues --scheduled
# in a container built from the official template (ENTRYPOINT is the App binary)
./App queues --scheduled --env production

The --scheduled flag and the --queue option of the same command are alternatives: with --scheduled the process runs only the scheduler and no queue worker, and without it the process is a worker for the default queue (or the named one). Running both is two processes. Both flags are declared by QueuesCommand.Signature in queues 1.18.0. With trace logging the process reports each job being scheduled; the lines below are from a run of the example application (shortened):

[ INFO ] Starting scheduled jobs worker
[ TRACE ] Scheduling job [name: overdue-loan-scan]
[ DEBUG ] Job scheduled [job-name: overdue-loan-scan, scheduled-date: 2026-10-18 00:00:00 +0000]
[ TRACE ] Job was scheduled successfully [name: overdue-loan-scan]

Both start paths cancel their timers when the application shuts down, and the queues command also stops on SIGTERM and SIGINT, so a container runtime can end it with a normal termination signal; see Application Lifecycle and Entrypoint for how shutdown works. A run that is in progress when the timers are cancelled is not awaited by the scheduler, so keep scheduled bodies short or idempotent.

What happens when a job fails or runs long

The scheduler is a loop per job: compute the next date, wait, run, and on completion compute the next date again. The details come from QueuesCommand and AnyScheduledJob in queues 1.18.0 and were confirmed with a test that registered a normal job, a job that always threw and a job that slept longer than its interval.

  • A throwing job does not stop the schedule. The error is logged at error level ("Scheduled job failed") with the job name, and the next occurrence is scheduled normally. In a run with one failing and one healthy every(seconds: 1) job over 3.6 seconds, both ran three times. There is no retry, no backoff and no dead-letter: if a failure must be retried, have the scheduled job dispatch a queued job, which has maxRetryCount.

  • A job never overlaps itself. The next run is scheduled only after the current run completes. For a job that sleeps 2.5 seconds with every(seconds: 1), the job ran three times in 7.2 seconds, back to back, not seven times; when a run is longer than the interval, the next one starts right away. By reading the source (not measured), a calendar-based job that overruns its slot likewise computes its next date after the previous scheduled time, which may already be in the past, and so starts that occurrence immediately; a slow job cannot run twice at once, but it does shift.

  • Nothing is remembered. The schedule is in memory only. If the scheduler process is down at 02:00, that run does not happen later, and when the process restarts the next date is computed from the current time (an every(minutes: 10) job runs ten minutes after the restart). Jobs that must not be missed need their own record of the last successful run, which the job checks when it starts.

  • Timer jitter. In queues 1.18.0 the next date is computed from the previously scheduled date, not from the time the timer actually fired, so a timer that wakes slightly early does not run the same occurrence twice.

Exactly one scheduler

Because the schedule lives in each process, every process that starts the scheduler runs every job. Two web replicas that both call startScheduledJobs() send two reminders per overdue loan; ten replicas send ten. There is no built-in leader election, lock or deduplication in vapor/queues. The options, from the simplest, are:

Approach How Trade-offs

One scheduler process

A Deployment (or systemd unit) with exactly one replica running queues --scheduled; web replicas never start the scheduler

Simple. A crash or rollout leaves a gap; a rolling update briefly runs two. Use strategy: Recreate.

Slot lock

Any number of instances start the scheduler; each run first takes a Redis key for the slot (the day, the hour) with SET key value NX EX seconds and runs only if it got it

Survives a replica loss without a gap. Needs Redis and clocks that agree on the slot. The lock is a coordination hint, not a safety proof, so the job must still be idempotent.

Cluster scheduler

A Kubernetes CronJob starts a short-lived container that runs a custom command and exits

The platform owns the schedule, history and concurrency policy; nothing in your pods has timers. Minimum granularity of one minute, a pod start per run, and the schedule is configuration in the manifests, not in the code.

One replica

apiVersion: apps/v1
kind: Deployment
metadata:
  name: bookshelf-scheduler
spec:
  replicas: 1                # never scale this out
  strategy:
    type: Recreate           # no overlap between the old and the new scheduler pod
  selector:
    matchLabels: {app: bookshelf-scheduler}
  template:
    metadata:
      labels: {app: bookshelf-scheduler}
    spec:
      containers:
        - name: scheduler
          image: registry.example.com/bookshelf:1.0.0   # placeholder image
          args: ["queues", "--scheduled", "--env", "production"]
          env:
            - name: REDIS_URL
              valueFrom: {secretKeyRef: {name: bookshelf-secrets, key: redis-url}}

This manifest was not applied to a cluster; it relies on the image’s ENTRYPOINT being the App binary, as in the official Dockerfile (see Deployment with Docker), so args replace its default serve command. See Deployment Strategies for the Recreate strategy and Health Probes and Lifecycle for termination.

A slot lock in Redis

The lock below wraps the scan. Every instance can run the scheduler, but only the first to take bookshelf:scheduled:<slot> does the work; the key expires on its own after 23 hours, so a dead owner cannot block the next day. There is no release step: the key is the record that the day’s run happened.

import Foundation
import Queues
import Redis
import Vapor

/// Runs `work` on at most one instance per slot (for example one day) using SET key value NX EX seconds.
struct SlotLock: Sendable {
    let app: Application

    func runOnce(slot: String, ttlSeconds: Int, instance: String, _ work: () async throws -> Void) async throws {
        // Legacy bridge: RediStack 1.6.3 has no async overload of set(_:to:onCondition:expiration:), so its
        // EventLoopFuture is awaited with .get().
        let acquired = try await app.redis.set(
            RedisKey("bookshelf:scheduled:\(slot)"),
            to: instance,
            onCondition: .keyDoesNotExist,
            expiration: .seconds(ttlSeconds)
        ).map { $0 == .ok }.get()
        guard acquired else {
            app.logger.info("Another instance owns this slot", metadata: ["slot": "\(slot)"])
            return
        }
        try await work()
    }
}

struct LockedOverdueLoanScan: AsyncScheduledJob {
    var name: String { "overdue-loan-scan-locked" }

    func run(context: QueueContext) async throws {
        let day = Date().formatted(.iso8601.year().month().day())
        let instance = Environment.get("HOSTNAME") ?? "local"
        try await SlotLock(app: context.application)
            .runOnce(slot: "overdue-loan-scan:\(day)", ttlSeconds: 23 * 3600, instance: instance) {
                try await OverdueLoanScan().run(context: context)
            }
    }
}

Notes on the lock: the conditional set is the one EventLoopFuture call on this page, a legacy bridge because neither RediStack 1.6.3 nor vapor/redis 4.14.0 has an async overload for it. Environment.get("HOSTNAME") stores which instance won, useful when inspecting the key; the .map { $0 == .ok } turns the non-Sendable RedisSetCommandResult into a Bool before it crosses the async boundary, which avoids a Swift 6 Sendable warning; the ISO date makes the slot the UTC day, so schedule the job and the slot in the same time zone. This listing compiled but was not run against a live Redis server. The Redis & Caching page covers RedisConfiguration and the SET key value NX pattern in more detail.

A Kubernetes CronJob and a custom command

Instead of timers, the platform can call the same code once per slot. The code is an AsyncCommand (see Commands) that builds a QueueContext for the scan and exits; Vapor runs a registered command by name as the process’s first argument.

import Vapor
import Queues

/// `swift run App overdue-scan` runs the same job once and exits: schedule it with cron or a Kubernetes CronJob.
struct OverdueScanCommand: AsyncCommand {
    struct Signature: CommandSignature {}
    var help: String { "Runs the overdue-loan scan once and exits" }

    func run(using context: CommandContext, signature: Signature) async throws {
        let app = context.application
        let queueContext = QueueContext(
            queueName: .init(string: "scheduled"),
            configuration: app.queues.configuration,
            application: app,
            logger: app.logger,
            on: app.eventLoopGroup.any()
        )
        try await OverdueLoanScan().run(context: queueContext)
    }
}

app.asyncCommands.use(OverdueScanCommand(), as: "overdue-scan") in configure registers it, and swift run App overdue-scan runs it. Commands themselves, Signature, arguments and options have their own page in this section; this one only needs the shortest form. The run printed Overdue scan finished [overdue: 0] against the empty in-memory database of the example. The manifest that calls it every night at 02:00 Madrid time:

apiVersion: batch/v1
kind: CronJob
metadata:
  name: bookshelf-overdue-scan
spec:
  schedule: "0 2 * * *"
  timeZone: Europe/Madrid        # field available in current Kubernetes releases
  concurrencyPolicy: Forbid      # skip a run while the previous one is active
  successfulJobsHistoryLimit: 3
  failedJobsHistoryLimit: 3
  jobTemplate:
    spec:
      backoffLimit: 2
      template:
        spec:
          restartPolicy: OnFailure
          containers:
            - name: overdue-scan
              image: registry.example.com/bookshelf:1.0.0   # placeholder image
              args: ["overdue-scan", "--env", "production"]
              env:
                - name: DATABASE_URL
                  valueFrom: {secretKeyRef: {name: bookshelf-secrets, key: database-url}}
                - name: REDIS_URL
                  valueFrom: {secretKeyRef: {name: bookshelf-secrets, key: redis-url}}

The manifest was not applied to a cluster. The schedule is a Unix cron expression, a different dialect from the Quartz one; see Cron Expressions for the differences and Jobs and CronJobs for concurrencyPolicy, timeZone and history limits. The database and Redis URLs are secrets read from the environment, never from the image. For the general trade-off between an in-process scheduler and an external one, see Triggers and Schedules.

Testing

Test the schedule and the job body separately; neither needs a running scheduler or a queue server. The general setup is on the Testing page.

The schedule is date arithmetic. Build a ScheduleBuilder with a fixed Calendar, call nextDate(current:) with a fixed "now", and compare the components. This also guards against a time zone being forgotten.

The job body is an ordinary async function. Create a QueueContext from the application and call run(context:); replace the queue driver with the in-memory .asyncTest provider from the XCTQueues product so that dispatch stores the job in memory instead of Redis, and inspect app.queues.asyncTest.queue and contains(_:). The suite below was run with VaporTesting and Swift Testing; the test target in Package.swift needs the XCTQueues product of the queues package.

import Fluent
import Foundation
import Queues
import Testing
import VaporTesting
import XCTQueues
@testable import App

@Suite struct OverdueScanTests {
    /// The schedule is plain date arithmetic: check it without starting a scheduler.
    @Test func dailyAtTwoInMadrid() throws {
        var calendar = Calendar(identifier: .gregorian)
        calendar.timeZone = try #require(TimeZone(identifier: "Europe/Madrid"))

        let builder = ScheduleBuilder(calendar: calendar)
        builder.daily().at(2, 0)

        let now = try #require(calendar.date(from: DateComponents(year: 2026, month: 10, day: 17, hour: 9, minute: 30)))
        let next = try #require(builder.nextDate(current: now))
        let parts = calendar.dateComponents([.year, .month, .day, .hour, .minute], from: next)
        #expect(parts.day == 18 && parts.hour == 2 && parts.minute == 0)
    }

    /// The job body is an ordinary async function: call it with a QueueContext and the in-memory test driver.
    @Test func dispatchesOneReminderPerOverdueLoan() async throws {
        let app = try await Application.make(.testing)
        do {
            try await configure(app)
            app.queues.use(.asyncTest)

            let author = Author(name: "Ursula K. Le Guin")
            try await author.save(on: app.db)
            let book = Book(title: "The Dispossessed", isbn: "978-0-06-051275-0", publishedYear: 1974,
                            priceCents: 1299, authorID: try author.requireID())
            try await book.save(on: app.db)
            let user = User(email: "reader@example.com", passwordHash: "placeholder")
            try await user.save(on: app.db)

            let now = Date()
            let overdue = Loan(bookID: try book.requireID(), userID: try user.requireID(),
                               loanedAt: now.addingTimeInterval(-30 * 86_400), dueAt: now.addingTimeInterval(-86_400))
            let current = Loan(bookID: try book.requireID(), userID: try user.requireID(),
                               loanedAt: now, dueAt: now.addingTimeInterval(14 * 86_400))
            try await overdue.save(on: app.db)
            try await current.save(on: app.db)

            let context = QueueContext(
                queueName: .init(string: "scheduled"),
                configuration: app.queues.configuration,
                application: app,
                logger: app.logger,
                on: app.eventLoopGroup.any()
            )
            try await OverdueLoanScan().run(context: context)

            #expect(app.queues.asyncTest.queue.count == 1)
            #expect(app.queues.asyncTest.contains(OverdueReminderJob.self))
        } catch {
            try await app.asyncShutdown()
            throw error
        }
        try await app.asyncShutdown()
    }
}

To test the scheduler itself, register a job with every(seconds: 1) and a counter, call startScheduledJobs(), wait a few seconds and assert the counter; keep this kind of test to a few seconds and remember to shut the application down so the timers are cancelled. The runs quoted on this page used exactly that.

Common mistakes

Symptom Cause and fix

Jobs never run

startScheduledJobs() was never called, or no queues --scheduled process exists; or daily()/hourly() has no at(...); or the at(Date) is in the past.

Reminders sent twice, three times…​

More than one process started the scheduler (several web replicas, a rolling update, a second queues --scheduled). Run one, or use the slot lock.

A scheduler starts during migrate or routes

startScheduledJobs() is unconditional in configure. Guard it (RUN_SCHEDULER) or use queues --scheduled.

Runs at the wrong hour

The calendar uses the process time zone (UTC in a container). Pass a Calendar with an explicit time zone to using(_:).

A daily 02:30 job missed one day in spring

Daylight saving: the time does not exist on that day and the builder skips it. Move the job.

The monthly "last day" job crashes the process

.last hits a fatalError in queues 1.18.0. Use daily() and check the date inside the job.

The 31st job misses months

Months without a 31st are skipped. Use day 28 or daily() plus a check.

Only one of several jobs is cancelled at shutdown

Jobs of the same type share the default name, and the scheduler keeps its timers in a dictionary keyed by name (QueuesCommand), so later registrations replace earlier ones in that bookkeeping. Override name with a unique value per registration.

fatalError while starting with a Time literal

Malformed string such as "8am"; use "08:00", "8:00am" or the numeric at(_:_:).

Sendable error or warning in a job

A job stores a class, a closure or the Application in a stored property. Keep the value type empty or Sendable, and read services from context.application.

No Queues driver configured crash at dispatch

The job calls context.queue but app.queues.use(...) was not called for this environment (a test, or a scheduler process with no Redis settings).

A long job delays its next run

Runs are sequential per job. Dispatch the heavy work as queued jobs and keep the scheduled body short.

A restart loses the run

The schedule is not persisted. Keep a last-run marker in the database and have the job catch up.

Version notes and where older material differs

  • AsyncScheduledJob is the async protocol beside the future-based ScheduledJob, whose run(context:) returns an EventLoopFuture<Void>; the future form still exists in queues 1.18.0 and AsyncScheduledJob is implemented on top of it. Older Vapor 3-era and early Vapor 4 material shows only the future form.

  • The books consulted for this section target Vapor 3 and early Vapor 4 with Swift 5; none of their examples was reused. Swift 6 language mode makes the Sendable requirements of jobs compile-time errors.

  • queues 1.18.0 documents QueuesCommand.init(application:scheduled:) as a historical artifact whose scheduled argument has no effect; the flag that matters is --scheduled on the command line.

  • Vapor 5 (5.0.0-beta.4, not released) is a major version; the page’s code targets 4.122.x. Review the Vapor 5 upgrade notes before moving the scheduler; whether vapor/queues changes was not verified for this page.