Skip to content

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:

  1. Any other in-flight runNow for the same TaskId — the prior caller's await rethrows CancellationException.
  2. Any pending scheduled request for the same TaskId.
  3. 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

  • iOSrunNow uses UIApplication.beginBackgroundTask(withName:expirationHandler:), not BGTaskScheduler. The TaskId does not need to appear in Info.plist's BGTaskSchedulerPermittedIdentifiers; it's purely an in-process pre-emption key. iOS grants ~30 seconds of grace if the app backgrounds mid-call.
  • AndroidrunNow enqueues a unique OneTimeWorkRequest under the name ${taskId}::runNow (won't collide with a scheduled run that uses ${taskId} as its unique name).
  • macOSrunNow spawns the lambda on Backgrounder's owned SupervisorJob scope. macOS apps generally have foreground time; there's no OS-level "background runway" wrapping the call.

What can go wrong

  • Backgrounder.start() not called yetrunNow throws IllegalStateException. Calling order is Backgrounder.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 in try/finally rather than after runNow. This is normal Kotlin coroutine hygiene.
  • Thinking of runNow as a schedule shortcut — it isn't. schedule outlives the caller and runs when the OS allows; runNow is the caller's work, just wrapped in an OS-granted background runway. If you backgrounded an in-flight runNow on iOS for 5 minutes, the work would still be cancelled when the grace window expired.