molayab/swift-background-scheduler
A lightweight, actor-based task scheduler for Swift. Schedule immediate, delayed, or periodic work and execute it through a signal-driven executor — no busy waiting, no polling.
Platform Requirements
- iOS 17+
- macOS 10.15+
- Swift 6.2+
Installation
Add the package to your Package.swift:
dependencies: [
.package(url: "https://github.com/molayab/swift-background-scheduler.git", branch: "master")
]Then add TaskScheduler as a dependency of your target:
.target(
name: "YourTarget",
dependencies: ["TaskScheduler"]
)Quick Start
1. Define a task
Conform to ExecutableTask — a single-method Sendable protocol:
import TaskScheduler
struct PrintTask: ExecutableTask {
let message: String
func execute() async throws {
print(message)
}
}2. Schedule and run
let scheduler = TaskScheduler.shared
let signal = TaskExecutorSignal.timerTrigger(every: 1.0)
let executor = TaskExecutor(taskScheduler: scheduler, taskSignal: signal)
await executor.resume()
await scheduler.schedule(task: PrintTask(message: "Hello now"), mode: .immediate)
await scheduler.schedule(task: PrintTask(message: "Hello in 2s"), mode: .delayed(2))
await scheduler.schedule(task: PrintTask(message: "Hello every 5s"), mode: .periodic(5))Architecture
flowchart LR
subgraph Schedule
TS["TaskScheduler<br><i>@globalActor</i>"]
TS --- Q1[immediate queue]
TS --- Q2[delayed queue]
TS --- Q3[periodic queue]
end
subgraph Signal
TES["TaskExecutorSignal<br><i>AsyncStream<Void></i>"]
TES --- S1[.manualTrigger]
TES --- S2[.timerTrigger]
TES --- S3[.customDrivenTrigger]
end
subgraph Execute
TE["TaskExecutor<br><i>Sendable</i>"]
TE --- E1[.justNext]
TE --- E2[.runContinuously]
TE --- E3[.resume / .pause]
end
TE -- awaits signal --> TES
TES -- wakes executor --> TS- TaskScheduler queues tasks into three lists (immediate, delayed, periodic). It is a
@globalActor— all queue access is serialized. - TaskExecutorSignal wraps an
AsyncStream<Void>. Each.yield()wakes the executor. No CPU is consumed while idle. - TaskExecutor awaits the signal stream in a
.background-priorityTask, callingscheduler.runNext()on each signal. When pending tasks remain, it re-triggers itself automatically.
Scheduling Modes
| Mode | Description | |------|-------------| | .immediate | Runs on the next executor cycle | | .delayed(TimeInterval) | Runs once after the specified seconds elapse | | .periodic(TimeInterval) | Runs repeatedly at the given interval |
await scheduler.schedule(task: myTask, mode: .immediate)
await scheduler.schedule(task: myTask, mode: .delayed(2))
await scheduler.schedule(task: myTask, mode: .periodic(10))Signal Types
Manual trigger
Fire on demand — useful when you want explicit control over when work runs:
let signal = TaskExecutorSignal.manualTrigger()
let executor = TaskExecutor(taskScheduler: .shared, taskSignal: signal)
await executor.resume()
await TaskScheduler.shared.schedule(task: myTask, mode: .immediate)
signal.trigger() // wake the executorTimer trigger
Wake the executor at a fixed interval:
let signal = TaskExecutorSignal.timerTrigger(every: 0.5)
let executor = TaskExecutor(taskScheduler: .shared, taskSignal: signal)
await executor.resume()Custom backend trigger
Connect the executor to a platform-specific or custom backend:
let executor = TaskExecutor(taskScheduler: .shared, taskSignal: .manualTrigger())
await executor.resume()
let signal = TaskExecutorSignal.customDrivenTrigger(
usingBackend: myBackend,
withExecutor: executor
)Platform Backends
The library ships with built-in backends for Apple platforms:
- macOS —
MacOSBackendusesNSBackgroundActivityScheduler(15-minute repeating interval). - iOS/tvOS/watchOS —
iOSBackendintegrates with Apple'sBackgroundTasksframework via a SwiftUIWindowGroupmodifier (work in progress).
Custom Backends
Conform to the Backend protocol to create your own trigger source (push notifications, WebSockets, file-system events, etc.):
final class PushBackend: Backend {
private var executor: (any TaskExecutorInterface)?
func register(_ executor: any TaskExecutorInterface) {
self.executor = executor
}
func unregister() {
executor = nil
}
// Call this when a push arrives
func onPushReceived() {
Task { try? await executor?.justNext() }
}
}Wire it up:
let scheduler = TaskScheduler.shared
let executor = TaskExecutor(taskScheduler: scheduler, taskSignal: .manualTrigger())
await executor.resume()
let backend = PushBackend()
TaskExecutorSignal.customDrivenTrigger(
usingBackend: backend,
withExecutor: executor
)
await scheduler.schedule(task: myTask, mode: .immediate)
backend.onPushReceived()Executor Lifecycle
TaskExecutor has three states: idle, running, and paused.
let executor = TaskExecutor(taskScheduler: .shared, taskSignal: signal)
// Start continuous execution
await executor.resume()
// Pause — the executor stops processing after the current task
await executor.pause()
// Or run a single task on demand without starting the loop
try await executor.justNext()Example App
The repository includes a demo iOS app (BackgroundApp) in the parent workspace that shows how to integrate TaskScheduler with SwiftUI, SwiftData, and iOS background tasks. See the workspace README for setup instructions.
Contributing
Contributions are welcome! Please open issues or pull requests on the GitHub repository.
Package Metadata
Repository: molayab/swift-background-scheduler
Default branch: master
README: README.md