Run now (instant dispatch)¶
runNow is for "do this work in the background right now and let me await the typed result." It complements scheduled work — no WorkConstraints, no BackoffPolicy, no retries, no register() step. The lambda is the work.
Typical use: the user just hit Save and you want the document persisted in the background, surviving an immediate app-background, with the result piped back into the UI when it returns.
import com.happycodelucky.backgrounder.*
class DocumentVM(private val backgrounder: Backgrounder, private val repo: DocumentRepository) {
private val saveTaskId = TaskId("dev.example.app.save-document")
suspend fun save(draft: Document): SavedDocument =
backgrounder.runNow(saveTaskId) {
repo.save(draft)
}
}
Swift call site:
let saved: SavedDocument = try await backgrounder.run(taskId: saveTaskId) {
try await repo.save(draft)
}
What it does, what it doesn't¶
Scheduled (Scheduler.schedule) |
Instant (Backgrounder.runNow) |
|
|---|---|---|
| When it runs | When the OS decides constraints are satisfied | Immediately on the calling coroutine |
| Network / charging gating | WorkConstraints honored |
None — caller checks if needed |
| Retries | BackoffPolicy, up to maxAttempts |
None — thrown exception is terminal |
| Worker source | Registered factory via Backgrounder.register |
Lambda passed at call site |
| Result | Worker returns WorkResult; caller doesn't see it directly |
Caller awaits the typed R |
| Survives the caller | Yes — the schedule outlives the calling coroutine | No — caller cancellation cancels the work |
If you need constraint gating, retries, or "schedule and forget," use Scheduler.schedule. If you need "do this and give me back the result," use runNow.
Pre-emption — last call wins¶
runNow(taskId, …) is pre-emptive for that TaskId. Before submitting its own request it cancels:
- Any other in-flight
runNowfor the sameTaskId— the prior caller'sawaitrethrowsCancellationException. - Any pending scheduled request for the same
TaskId. - Any in-flight scheduled worker for the same
TaskId(best-effort per platform — see cancel for the per-platform caveats).
This is because runNow returns a typed R to a specific caller; two concurrent invocations would yield ambiguous results. So concurrent calls with the same TaskId serialize as "newest wins":
// In some VM
suspend fun saveDraft(draft: Document): SavedDocument =
backgrounder.runNow(saveTaskId) { repo.save(draft) }
// ^ if the user hits Save twice in quick succession,
// the second runNow cancels the first.
If you want concurrent independent runs, use distinct TaskIds.
Cancellation — structured concurrency¶
The work runs on the caller's coroutine context (with a platform-specific runway around it on iOS / Android). Cancelling the caller cancels the work:
val job = scope.launch {
val saved = backgrounder.runNow(saveTaskId) { repo.save(draft) }
showToast("Saved")
}
// Later
job.cancel() // → repo.save() observes CancellationException, runNow rethrows, scope unwinds
Cancelling externally via Backgrounder.cancel(taskId) likewise propagates — see cancel.
Exceptions propagate¶
The lambda's thrown Throwable propagates to the caller's await:
try {
val saved = backgrounder.runNow(saveTaskId) {
repo.save(draft) // throws NetworkException
}
} catch (e: NetworkException) {
showError(e.message)
}
The platform layer reports WorkResult.Failure(message) to the OS (so iOS / WorkManager don't think the process crashed); the caller sees the original exception. CancellationException flows through SKIE as Swift's CancellationError.
Platform notes¶
- iOS —
runNowusesUIApplication.beginBackgroundTask(withName:expirationHandler:), notBGTaskScheduler. TheTaskIddoes not need to appear inInfo.plist'sBGTaskSchedulerPermittedIdentifiers; it's purely an in-process pre-emption key. iOS grants ~30 seconds of grace if the app backgrounds mid-call. - Android —
runNowenqueues a uniqueOneTimeWorkRequestunder the name${taskId}::runNow(won't collide with a scheduled run that uses${taskId}as its unique name). - macOS —
runNowspawns the lambda on Backgrounder's ownedSupervisorJobscope. macOS apps generally have foreground time; there's no OS-level "background runway" wrapping the call.
What can go wrong¶
Backgrounder.start()not called yet —runNowthrowsIllegalStateException. Calling order isBackgrounder.create(...)→register(...)(if you also have scheduled workers) →start()→runNow(...).- Caller cancelled while the lambda holds a resource — the lambda must observe cancellation; use
coroutineContext.ensureActive()between non-suspending blocks, and put cleanup intry/finallyrather than afterrunNow. This is normal Kotlin coroutine hygiene. - Thinking of
runNowas ascheduleshortcut — it isn't.scheduleoutlives the caller and runs when the OS allows;runNowis the caller's work, just wrapped in an OS-granted background runway. If you backgrounded an in-flightrunNowon iOS for 5 minutes, the work would still be cancelled when the grace window expired.