Background jobs look simple until the queue becomes part of your application architecture. You need a way to describe work, encode its parameters, store it somewhere, process several jobs at once, retry failures, stop accepting new work during shutdown, and decide what happens to jobs that fail halfway through.
swift-jobs is interesting because it does not put all of those responsibilities into one queue type. The package separates the job-facing API from the storage driver and from the processor that executes work.
That makes the source useful even if you are not looking for a new queue library. It is a compact example of how to split job semantics, storage, and process lifetime in modern Swift.
Start with the Job, Not the Storage
The smallest public contract is JobParameters. A parameter type must be Codable and Sendable, and it provides a stable job name.
import Foundation
import Jobs
struct WelcomeEmail: JobParameters {
static let jobName = "welcome-email"
let userID: UUID
let email: String
}
The queue can then register how that job should execute.
import Jobs
import Logging
let logger = Logger(label: "Example")
let jobs = JobQueue(MemoryQueue(), logger: logger)
jobs.registerJob(parameters: WelcomeEmail.self) { parameters, context in
context.logger.info("Sending welcome email to \(parameters.email)")
}
try await jobs.push(
WelcomeEmail(
userID: UUID(),
email: "reader@example.com"
)
)
WelcomeEmail is application code. MemoryQueue, JobQueue, registration, and push are library APIs.
There is an important detail here: registering and pushing a job does not by itself create the worker loop. Processing is a separate concern.
The Processor Is a Long-Running Service
JobQueue.processor(options:) creates a JobQueueProcessor, exposed as any Service from swift-service-lifecycle. That means the worker can live under the same application lifecycle as an HTTP server or another long-running component.
A minimal process can run the queue processor like this:
import Foundation
import Jobs
import Logging
import ServiceLifecycle
struct WelcomeEmail: JobParameters {
static let jobName = "welcome-email"
let userID: UUID
let email: String
}
@main
struct WorkerApplication {
static func main() async throws {
let logger = Logger(label: "Worker")
let jobs = JobQueue(MemoryQueue(), logger: logger)
jobs.registerJob(parameters: WelcomeEmail.self) { parameters, context in
context.logger.info("Sending welcome email to \(parameters.email)")
}
try await jobs.push(
WelcomeEmail(
userID: UUID(),
email: "reader@example.com"
)
)
let processor = jobs.processor(options: .init(numWorkers: 2))
let services = ServiceGroup(
services: [processor],
gracefulShutdownSignals: [.sigterm, .sigint],
logger: logger
)
try await services.run()
}
}
This example uses the in-memory driver, so it is useful for understanding the API rather than demonstrating durable production storage. MemoryQueue keeps its pending, processing, paused, and failed state inside an actor in the current process.
The separation is the more interesting part: the application registers job behavior on JobQueue, the driver owns queue-specific storage operations, and the processor owns execution.
Retries and Timeouts Belong to the Job Definition
A job can override the queue’s default retry strategy and set a timeout when it is registered.
jobs.registerJob(
parameters: WelcomeEmail.self,
retryStrategy: .exponentialJitter(
maxAttempts: 5,
maxBackoff: .seconds(30)
),
timeout: .seconds(10)
) { parameters, context in
context.logger.info("Sending welcome email to \(parameters.email)")
}
The current ExponentialJitterJobRetryStrategy retries while the current attempt is below maxAttempts. Its backoff grows exponentially, is capped by maxBackoff, and applies random jitter.
The processor does not implement timeout as a flag checked after the job returns. In handleJob, it creates a throwing task group: one child runs the middleware and job, while another sleeps for the configured duration and then throws JobQueueError.jobTimedOut. When the timeout wins, the group cancels the remaining work and propagates the timeout into the normal failure and retry path.
That is a useful design choice because timeout behavior stays in the same async control flow as job execution rather than becoming a separate timer subsystem.
The Driver Is an AsyncSequence
The central storage abstraction is JobQueueDriver.
It is not only an interface with push and pop. It conforms to AsyncSequence, and its element is JobQueueResult<JobID>. A processor creates an async iterator and waits for jobs by calling next().
The protocol also defines the storage-facing lifecycle: waitUntilReady, push, retry, finished, failed, stop, and shutdownGracefully.
This keeps storage-specific behavior behind the driver. The processor does not need to know whether a driver uses an in-memory buffer or another backing system. It receives decoded job instances through the same async sequence and reports the result back through the driver contract.
MemoryQueue makes this especially easy to inspect. Pending jobs live in a Deque. When a job is returned by next(), its encoded buffer moves into a processingJobs dictionary. Separate dictionaries track paused and failed jobs. The whole mutable state sits inside an actor.
It is deliberately simple, but it exposes the state transitions a persistent driver also has to model.
Worker Concurrency Is Explicit
JobQueueProcessor does not create an unlimited task for every queued item. It starts by reading up to numWorkers jobs and creates one child task for each. Each time a child finishes, the processor asks the iterator for another job and starts replacement work.
So numWorkers is a real concurrency limit in the processor, not just metadata passed to the driver.
The implementation currently uses nested throwing task groups for this worker pool. That code is worth reading because it shows exactly where queue iteration, execution, and process lifetime meet.
Shutdown Is Part of the Queue Contract
The processor itself conforms to Service. Its run() method installs withTaskCancellationOrGracefulShutdownHandler around the worker task group.
When cancellation or graceful shutdown arrives, the handler finishes an internal stream used to trigger the shutdown timeout and asynchronously asks the driver to stop(). That tells the driver to stop serving more jobs. The processor then waits for either the worker side to finish or the configured graceful-shutdown timeout to expire, cancels the remaining group tasks, and finally calls queue.shutdownGracefully().
This is another useful separation. ServiceLifecycle owns the process-level shutdown signal, JobQueueProcessor owns the worker shutdown policy, and the driver owns what stopping and final shutdown mean for its storage implementation.
Where the Abstraction Is Still Being Worked Out
There is an open issue around queue-iteration errors. A driver’s async iterator can throw, and today that error can propagate out of the processor far enough for ServiceLifecycle to start graceful shutdown of the application.
The issue discusses why there is no obviously correct generic answer. A temporary database connection failure may deserve retry and backoff. A structural storage error may be serious enough to stop the process. Silently killing only the queue processor could leave the rest of the application running while jobs are no longer being handled.
That open question is useful because it shows where a driver-neutral API becomes difficult: not every storage failure has the same meaning.
Project State
The latest GitHub release I found while checking the project on September 23, 2026 is 1.4.0, published on August 17, 2026. That release added JobDefinition options for not retaining completed or failed job data.
The package uses Swift tools 6.1. Its manifest declares minimum Apple platforms of macOS 15, iOS 18, and tvOS 18, and it depends on Swift Collections, Distributed Tracing, SwiftLog, Swift Metrics, SwiftNIO, Swift Service Lifecycle, and ExtrasBase64. The repository is Apache-2.0 licensed.
The Useful Idea
The strongest idea in swift-jobs is not a particular queue backend. It is the split between three responsibilities.
A job definition describes what work means. A JobQueueDriver describes how queued work moves through storage. A JobQueueProcessor describes how work is executed under concurrency, retry, timeout, middleware, and shutdown rules.
That split gives driver implementations room to change without moving application job code into the storage layer. It also makes the processor a normal long-running Swift service instead of a special loop hidden inside a web framework.
For a small application that only needs to fire one background Task, this is unnecessary machinery. Once jobs need retries, delayed execution, bounded worker concurrency, shutdown behavior, and a replaceable storage implementation, the separation starts to earn its place.
