Documentation

$dispatch / $periodic

Schedule background work that survives app restarts.

$dispatch - One-Time Work

Run a suspend function in the background:

// Simple dispatch
$dispatch(api.syncData)

// With constraints
$dispatch(api.uploadPhotos, constraints: ["wifi"])

// With delay
$dispatch(api.sendReminder, delay: { mins: 5 })

// Named (for later retrieval)
$dispatch(api.uploadPhoto, name: "upload-${photoId}")

$periodic - Recurring Work

Run work periodically:

// Run every hour
$periodic(api.refreshFeed, every: { hours: 1 })

// With constraints and name
$periodic(api.syncData, every: { mins: 60 }, name: "data-sync", constraints: ["wifi", "charging"])

Android enforces a minimum 15-minute interval for periodic work.

See it happen

One-time and recurring work compile to different scheduling calls; a delay adds one more argument to either.

Sync.wh
import $lib.components.ui.Button

var syncing = false

fun sync() {
  syncing = true
  $dispatch(api.syncData, constraints: ["wifi"])
}

<Button onClick={sync}>
  <Text>Sync</Text>
</Button>
Kotlin waiting
Scroll into view to compile.

$dispatch compiles to a single scheduleDispatch call, keyed by the constraints you gave it — no WorkRequest builder to write by hand.

Constraints

Specify conditions for work execution:

  • "network" - Any network connection
  • "wifi" - WiFi connection required
  • "batteryOk" - Battery not low
  • "charging" - Device charging
  • "idle" - Device idle (Doze mode)
  • "storageOk" - Storage not low
  • "expedited" - Run as soon as possible
$dispatch(api.uploadVideo, constraints: ["wifi", "charging", "batteryOk"])

Duration Syntax

delay: { hours: 1 }
delay: { mins: 30 }
delay: { seconds: 45 }
delay: { hours: 1, mins: 30 }

every: { hours: 2 }
every: { mins: 60 }

Build errors

Each of these is refused at build time rather than failing on a device: an unknown constraint, an unknown duration unit, an argument the call does not take, "expedited" together with a delay, and "expedited" on $periodic.

Work Status

Track work progress:

fun startUpload() {
  val upload = $dispatch(api.uploadFile, name: "upload-123")

  // Check status
  val status = upload.status
  // "pending" | "running" | "succeeded" | "failed" | "cancelled"

  // Cancel work
  upload.cancel()
}

Retrieve Work by Name

@prop val photoId: String

var upload: DispatchHandle? = $state(null)

fun checkUpload() {
  upload = $work("upload-${photoId}")
}

<Column>
  <Button onClick={checkUpload}>
    <Text>Check status</Text>
  </Button>
  @if (upload != null) {
    <Text>Status: {upload?.status}</Text>
    <Button onClick={() => upload?.cancel()}>
      <Text>Cancel</Text>
    </Button>
  }
</Column>

Run Periodic Work Immediately

$onMount {
  $periodic(api.syncData, every: { hours: 1 }, name: "sync")
}

fun runNow() {
  $work("sync")?.runNow()
}

<Button onClick={runNow}>
  <Text>Sync Now</Text>
</Button>

Sequential Work

Wait for work to complete before starting the next:

suspend fun syncAll() {
  $await $dispatch(api.syncUsers)
  $await $dispatch(api.syncOrders)
  $await $dispatch(api.syncProducts)
}

Complete Example

// Background sync with UI control
$onMount {
  $periodic(api.syncData, every: { hours: 1 }, name: "background-sync", constraints: ["wifi"])
}

fun syncNow() {
  $work("background-sync")?.runNow()
}

fun cancelSync() {
  $work("background-sync")?.cancel()
}

<Column class="gap-16 p-16">
  <Button onClick={syncNow}>
    <Text>Sync Now</Text>
  </Button>

  <Button onClick={cancelSync}>
    <Text>Cancel Background Sync</Text>
  </Button>
</Column>

Photo Upload Example

fun uploadPhoto(photoId: String) {
  $dispatch(api.uploadPhoto, name: "upload-$photoId", constraints: ["wifi", "batteryOk"])
}

// Check upload status
fun isUploading(photoId: String): Boolean {
  return $work("upload-$photoId")?.status == "running"
}

Grouping several calls

A block runs as one unit of work:

$dispatch {
  api.sync()
  api.clean()
}

Behind the Scenes

WorkManager without a Worker class — the work is the call as written. A name makes it unique; periodic work is always named, after its file and call if you do not name it yourself, since Android's own minimum is fifteen minutes and a duplicate would just collide with itself.

The work is a lambda living in the app's own process. Work still pending when the process ends fails, with a log line rather than a retry you did not ask for. $periodic survives that by being called again at startup — which is why a root layout or a store, not a screen, is where it belongs.

Use workers for operations that should complete even if the app is closed, like uploads, sync, and scheduled tasks. For immediate work, use regular coroutines with $onMount or launch.

See Also