backgrounder
0.11.0indexedUnified API for scheduling background one-shot and periodic workers with constraint-aware scheduling, retry/backoff, factory-based dependency injection (no reflection), ephemeral sweep, and network reachability gating.
Unified API for scheduling background one-shot and periodic workers with constraint-aware scheduling, retry/backoff, factory-based dependency injection (no reflection), ephemeral sweep, and network reachability gating.
Backgrounder wraps each platform's background-scheduling primitive behind one API you call from commonMain:
WorkManager (one-shot + periodic, with constraints, retry, expedited).BGTaskScheduler (one-shot + library-emulated periodic; force-quit caveat documented).NSBackgroundActivityScheduler (one-shot + native periodic).Full documentation: happycodelucky.github.io/backgrounder-kmp.
Backgrounder publishes to Maven Central. From a Kotlin Multiplatform project, depend on it from commonMain and KMP resolves the right slice per target (Android AAR, jvm JAR, iosArm64, iosSimulatorArm64, macosArm64):
// shared/build.gradle.kts
kotlin {
sourceSets {
commonMain.dependencies {
implementation("com.happycodelucky.backgrounder:backgrounder:0.9.0")
}
}
}
Android-only apps depend on the Android artifact directly:
// app/build.gradle.kts
dependencies {
implementation("com.happycodelucky.backgrounder:backgrounder-android:0.9.0")
}
Pure-Swift apps add this repository as a Swift Package Manager dependency pinned to a release tag; the tagged Package.swift hands SPM a prebuilt, SKIE-enhanced Backgrounder.xcframework. See Installation for platform floors and the SPM details.
iOS only fires a background task whose identifier is listed in the app's Info.plist under BGTaskSchedulerPermittedIdentifiers. Keeping that list in sync with code by hand is the classic way to ship a task that silently never runs. The Backgrounder Gradle plugin writes the list from your code instead:
// shared/build.gradle.kts
plugins {
kotlin("multiplatform")
id("com.happycodelucky.backgrounder") version "0.9.0"
}
backgrounder {
iosInfoPlist = file("../iOSApp/App/Info.plist")
iosBundleIdentifier = "dev.example.app" // adds the default tick identifier for you
}
Mark each task id that iOS must know about — every id you may schedule as a one-shot — and run ./gradlew updateBackgrounderInfoPlist (from an Xcode run-script phase, a pre-commit hook, or by hand):
class UploadWorker(...) : BackgroundWorker {
companion object {
@BGTaskSchedulerPermittedIdentifier const val ID = "dev.example.app.upload"
}
}
Periodic work and runNow never touch BGTaskScheduler and need no entry. If you'd rather maintain the plist yourself, the required entries are the tick identifier (<bundle id>.backgrounder-tick by default) plus one per one-shot id; the library logs an error at start() when the tick is missing and a warning for each registered id that is. See Generate the iOS permitted identifiers.
Configuration.Provider and the manifestBackgrounder builds your workers through its own WorkerFactory. WorkManager only accepts a custom factory from an Application that implements Configuration.Provider, so declare it there:
// Implement Configuration.Provider so WorkManager asks *you* for its setup
// instead of initialising itself with defaults.
class MyApp : Application(), Configuration.Provider {
// WorkManager calls this once, the first time anything touches
// WorkManager.getInstance(). It must return Backgrounder's factory, or
// WorkManager can't construct your BackgroundWorkers.
override val workManagerConfiguration: Configuration () =
Configuration.Builder()
.setWorkerFactory(BackgroundTaskManager.shared.androidWorkerFactory())
.build()
}
Implementing Configuration.Provider also requires disabling WorkManager's automatic initialisation in AndroidManifest.xml, otherwise it initialises with defaults before onCreate and never asks you. Remove only WorkManager's entry: Backgrounder's own startup initializer, the thing that creates BackgroundTaskManager.shared, rides on the same provider:
<!-- Merge into androidx.startup's provider rather than replacing it, so
initializers registered by libraries (including Backgrounder's) survive. -->
<provider
android:name="androidx.startup.InitializationProvider"
android:authorities=
=>
If your app removes the provider entirely, call BackgroundTaskManager.configure(application = this) at the top of onCreate instead.
Three kinds of background work, one BackgroundTaskManager:
Run now and survive backgrounding. The user taps Save and switches apps. runNow runs your lambda immediately on the platform's real background primitive (UIApplication.beginBackgroundTask on iOS, WorkManager on Android, a library scope on macOS and the JVM), so it finishes even if the app is backgrounded mid-call, and suspends until the typed result is back. No constraints, no retries: the lambda is the work.
val saved: SavedDocument = BackgroundTaskManager.shared.runNow(SaveTask.ID) {
repo.save(draft)
}
Schedule a one-time job. Work that should happen once, when conditions allow, and outlive the current process: an upload that waits for a network, a cleanup that waits for charging. Persisted by the platform, retried with backoff on WorkResult.Retry, replaceable or deduplicated by task id.
BackgroundTaskManager.shared.schedule(
WorkRequest.OneTime(
taskId = UploadWorker.ID,
constraints = WorkConstraints(networkRequired = NetworkRequirement.Any),
backoff = BackoffPolicy.exponential(initialDelay = 30.seconds, maxAttempts = 5),
),
)
Periodic work. A sync every few hours. Runs on WorkManager's periodic requests on Android and NSBackgroundActivityScheduler on macOS. On iOS the library drives it through one BGAppRefreshTaskRequest plus an in-process loop while the app is foregrounded, coalescing so a task fires once per cycle, never in a catch-up burst.
BackgroundTaskManager.shared.schedule(
WorkRequest.Periodic(taskId = SyncWorker.ID, interval = 6.hours),
)
Around those: cancel(taskId) and cancelAll(), scheduled() to inspect what's pending and why, an events() flow for monitoring, and guarantees() for the per-platform truth table below.
Define a worker in commonMain. Workers are built by a factory you register, never by reflection, so they take dependencies through the constructor:
class SyncWorker(private val repo: MyRepository) : BackgroundWorker {
override suspend fun execute(context: WorkerContext): WorkResult =
try {
repo.sync()
WorkResult.Success
} (t: Throwable) {
WorkResult.Retry
}
{
ID =
}
}
There is one BackgroundTaskManager per process, BackgroundTaskManager.shared. At launch, register every worker factory, then start. Nothing is constructed or passed around.
Android — shared already exists when onCreate runs (built by the startup initializer):
iOS — shared builds itself on first access. Register and start before the launch method returns:
macOS and JVM — the same two calls from applicationDidFinishLaunching or main(), plus BackgroundTaskManager.shared.shutdown() on exit to cancel the library-owned coroutine scopes.
The factory closure is where DI happens: resolve from Koin, Hilt, kotlin-inject, or hand-wired singletons. To register a whole module's workers at once, pass a BackgroundWorkerFactory. Then schedule from anywhere in your code as shown above.
Full launch sequences, including the iOS force-quit caveat and how to simulate background dispatch in the simulator, live in the docs: Android, iOS, macOS, JVM.
Read at runtime via BackgroundTaskManager.shared.guarantees():
iOS-specific: when the user force-quits the app from the App Switcher, all background tasks stop firing until the user launches the app again. That's Apple's design. Surface it in your UX ("Open the app daily so we can sync."). See Force-quit caveat.
WorkRequest(ephemeral = true) marks work that must be re-scheduled by app code after init; every cold start cancels leftover ephemeral jobs before any worker can dispatch. See The ephemeral flag.
mise pins the JDK, Gradle bootstrap, Python (mkdocs), and gh — see mise.toml. One-time bootstrap:
brew install mise
mise trust && mise install
Common tasks:
mise run check # all unit tests across iOS sim, macOS native, Android JVM, the Gradle plugin
mise run build:ios # iOS device + Apple Silicon simulator debug frameworks, SKIE-enhanced
mise run xcframework # release Backgrounder.xcframework (KMMBridge artifact)
# Raw Gradle equivalents, for reference:
./gradlew check
./gradlew :backgrounder:linkDebugFrameworkIosArm64
./gradlew :backgrounder:assembleBackgrounderXCFramework
Background tasks don't fire automatically in the iOS Simulator. Drive them from LLDB while paused, using the tick identifier for periodics and the per-task id for one-shots:
(lldb) e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateLaunchForTaskWithIdentifier:@"dev.example.app.backgrounder-tick"]
See CLAUDE.md for the full project conventions.
// Configuration.Provider is required: see "Android: Configuration.Provider and
// the manifest" under Installation for what it does and the manifest entry.
class MyApp : Application(), Configuration.Provider {
override fun onCreate() {
super.onCreate()
// Register a factory for every worker. The closure runs on each
// dispatch and builds a fresh worker, resolving dependencies from
// whatever DI graph you use (Koin, Hilt, hand-wired).
BackgroundTaskManager.shared.register(SyncWorker.ID) { SyncWorker(repo = appGraph.repo) }
// Start: sweeps ephemeral work left over from the previous process,
// seals the registry (no more register calls), and lets any work
// WorkManager already has queued dispatch to your workers.
BackgroundTaskManager.shared.start()
}
// Hand WorkManager the factory that knows how to build your workers.
override val workManagerConfiguration: Configuration get() =
Configuration.Builder()
.setWorkerFactory(BackgroundTaskManager.shared.androidWorkerFactory())
.build()
}
func application(_ application: UIApplication,
didFinishLaunchingWithOptions options: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
// First access builds the instance, using "<bundle id>.backgrounder-tick"
// as the BGAppRefreshTaskRequest identifier that wakes periodic work. To
// pass your own tick identifier or an event listener, call
// BackgroundTaskManager.companion.create(tickIdentifier:) before this line.
let manager = BackgroundTaskManager.shared
// Register a factory for every worker; the closure builds a fresh worker
// per dispatch from your iOS app's DI graph.
manager.register(taskId: SyncWorker.companion.ID) {
SyncWorker(repo: AppGraph.shared.repository)
}
// Start: sweeps ephemeral work, registers the BGTaskScheduler launch
// handlers (the tick plus one per one-shot id), starts the in-process
// loop that fires periodics while the app is foregrounded, and resumes
// any periodic schedule persisted by a previous launch. iOS requires the
// handlers to be registered before this method returns.
manager.start()
return true
}
Android WorkManager | iOS 18 BGTaskScheduler | macOS 15 NSBackgroundActivityScheduler |
|---|
survivesProcessDeath | true | true | true |
survivesReboot | true | true | true |
survivesForceQuit | true | false | true |
honoursWallClock | approx | false (hint only) | approx |
supportsRetryBackoff | true | true (emulated) | true (emulated) |
cancelsInFlight | true | false | true |
minimumPeriodicInterval | 15 min | 15 min recommended | 1 sec |
maxConcurrentTasks | unbounded-ish | ~1000 | unbounded-ish |
gradle/libs.versions.toml) are the single source of truth. Web-search before bumping any dependency (CLAUDE.md §2). Kotlin is pinned at the highest version SKIE supports.@ObjCName(swiftName = ...) so the call site reads like Swift. suspend funs reachable from Swift do not include CancellationException in @Throws — SKIE bridges cancellation through Swift's native CancellationError automatically (CLAUDE.md §8).internal by default; widen visibility only when needed (CLAUDE.md §3).Surfaced from shared tags and platforms — no rankings paid for.