---
title: 'Background Work'
source: 'https://academia.sh/en/courses/architecture-components-and-data-layer/background-work'
course: 'Architecture Components and the Data Layer'
language: en
updated: '2026-10-21T05:02:37+00:00'
license: 'CC BY-SA 4.0'
objectives:
    - 'Explains deferrable work and constraints as rules that say under which condition the job runs'
    - 'Measures the origin of a read after a job that writes to disk once the constraints are satisfied as disk'
    - 'Compares one-time and periodic work by how many times the job runs'
    - 'Shows that when the constraints are not satisfied the job does not run and the read falls to default or to the old disk'
---

# Background Work

A deferrable job, once its constraints are satisfied, pulls from the network and writes to disk, and the next read of the screen comes from disk. The lesson measures the origin of the read once the constraints are satisfied, what the read falls to when they are not, and the distribution of origins across the fourteen lessons of the course.

In the previous lesson the pages were loaded as the user scrolled, while the application was open. Some data, however, must be refreshed even when the user is not looking at the screen: at a moment when the network is available, during charging, or while the application is closed. In this lesson we assume that the refresh is done not by the screen but by a deferrable job. The question of the lesson is the same as in the earlier lessons: **where does the answer to this read come from?**

```kotlin
// Origin.kt — classifies which tier a read's answer came from
class Tier(val origin: String, private val entries: Map<String, String>) {
    init {
        require(origin in listOf("memory", "disk", "network")) { "invalid tier: $origin" }
    }

    fun lookup(key: String): String? = entries[key]
}

class Read(val label: String, val key: String, chain: List<Tier>, fallback: String) {
    private val hit: Tier? = chain.firstOrNull { it.lookup(key) != null }
    val value: String = hit?.lookup(key) ?: fallback
    val origin: String = hit?.origin ?: "default"

    fun show() {
        println("$label: key=$key | value=$value -> $origin")
    }
}

fun countOrigins(reads: List<Read>) {
    for (name in listOf("memory", "disk", "network", "default")) {
        println("$name: ${reads.count { it.origin == name }}")
    }
}
```

## Deferrable Work and Constraints

Background work is a job that the user is not waiting for on a screen and that does not need to finish immediately. Because such a job is deferrable, the system can leave it to a suitable moment. The constraints say when the job may run: being connected to the network, being on charge. The job does not run until all the constraints are satisfied; once they are satisfied, the system decides when to run the job.

```kotlin
// Platform code: RefreshWorker and the requests
class RefreshWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params) {
    override suspend fun doWork(): Result = try {
        repository.refresh()
        Result.success()
    } catch (e: IOException) {
        Result.retry()
    }
}

val constraints = Constraints.Builder()
    .setRequiredNetworkType(NetworkType.CONNECTED)
    .setRequiresCharging(true)
    .build()
val once = OneTimeWorkRequestBuilder<RefreshWorker>().setConstraints(constraints).build()
val periodic = PeriodicWorkRequestBuilder<RefreshWorker>(1, TimeUnit.DAYS).setConstraints(constraints).build()
```

This piece is platform code, and here we make no claim about output, duration, or run count. `doWork` calls the `refresh` function from the earlier lessons; if there is no network, it asks for the job to be retried later. `Constraints` imposes the network connection and charging as conditions. The request can be built as one-time or periodic. The model builds this rule of the platform; the model is not the platform itself.

## The Job in the Model

In the model, the job is a function. If all the required constraints are satisfied, it takes the requested keys from the network and writes them to disk; if they are not, it leaves the disk as it is. The screen reads only the disk, as in the previous lesson.

```kotlin
// Work.kt — writes the requested keys to disk if all constraints are satisfied, leaves the disk if they are not
fun runWork(
    required: Set<String>,
    satisfied: Set<String>,
    disk: Map<String, String>,
    network: Tier,
    keys: List<String>,
): Map<String, String> {
    if (!satisfied.containsAll(required)) return disk
    return disk + keys.mapNotNull { key -> network.lookup(key)?.let { key to it } }
}
```

```kotlin
// Screen.kt — the home screen reads only the disk
fun readHomeScreen(disk: Map<String, String>): List<Read> {
    val chain = listOf(Tier("disk", disk))
    return listOf(
        Read("price", "product.price", chain, "unknown"),
        Read("stock", "product.stock", chain, "0"),
        Read("campaign", "campaign.code", chain, "none"),
    )
}
```

## Once the Constraints Are Satisfied, It Writes to Disk

The disk is empty. The job asks for two constraints: `network-connected` and `charging`. Both are satisfied, and the server knows all three keys.

```kotlin
// Main.kt — once the constraints are satisfied the job writes the network values to disk, the next read comes from disk
fun main() {
    val required = setOf("network-connected", "charging")
    val network = Tier("network", mapOf("product.price" to "125", "product.stock" to "8", "campaign.code" to "SUMMER10"))
    val keys = listOf("product.price", "product.stock", "campaign.code")
    println("before the work runs")
    readHomeScreen(emptyMap()).forEach { it.show() }
    val disk = runWork(required, required, emptyMap(), network, keys)
    println("after the work runs")
    val reads = readHomeScreen(disk)
    reads.forEach { it.show() }
    countOrigins(reads)
}
```

```text
before the work runs
price: key=product.price | value=unknown -> default
stock: key=product.stock | value=0 -> default
campaign: key=campaign.code | value=none -> default
after the work runs
price: key=product.price | value=125 -> disk
stock: key=product.stock | value=8 -> disk
campaign: key=campaign.code | value=SUMMER10 -> disk
memory: 0
disk: 3
network: 0
default: 0
```

Before the job ran, three of the three reads were default. After the job ran, the same three reads came from disk. The network did not enter the chain of the read: the network only filled the disk, and the screen read from disk. This lesson's origin is disk. The refresh was done not when the screen opened but by a job before the screen opened.

**DL3.** The model takes the moment the constraints are satisfied as a given set and runs the job at that moment in a single step. When the system starts the job, the job being cut halfway, and the same job being queued twice are absent from the model.

## One-Time and Periodic Work

One-time work runs once. Periodic work runs again in every period. Their effect on the read of the screen separates when the server value changes. The disk starts with 120 and 9; the server first says 125 and 8, then 130 and 6.

```kotlin
// Main.kt — one-time work refreshes the disk once, periodic work refreshes it every period
fun main() {
    val required = setOf("network-connected")
    val start = mapOf("product.price" to "120", "product.stock" to "9")
    val first = Tier("network", mapOf("product.price" to "125", "product.stock" to "8"))
    val second = Tier("network", mapOf("product.price" to "130", "product.stock" to "6"))
    val keys = listOf("product.price", "product.stock")
    val afterFirst = runWork(required, required, start, first, keys)
    println("one-time: after the first run")
    readHomeScreen(afterFirst).forEach { it.show() }
    println("one-time: server changed, work did not run again")
    readHomeScreen(afterFirst).forEach { it.show() }
    val afterSecond = runWork(required, required, afterFirst, second, keys)
    println("periodic: after the second run")
    readHomeScreen(afterSecond).forEach { it.show() }
}
```

```text
one-time: after the first run
price: key=product.price | value=125 -> disk
stock: key=product.stock | value=8 -> disk
campaign: key=campaign.code | value=none -> default
one-time: server changed, work did not run again
price: key=product.price | value=125 -> disk
stock: key=product.stock | value=8 -> disk
campaign: key=campaign.code | value=none -> default
periodic: after the second run
price: key=product.price | value=130 -> disk
stock: key=product.stock | value=6 -> disk
campaign: key=campaign.code | value=none -> default
```

The one-time job made the price 125, and even though the server says 130, the screen showed 125: because the job did not run again, the disk did not change. The periodic job made the disk 130 and 6 on its second run. In all three stages the origin is the same: disk. What changes is the value of the disk. The rule does not say whether the value is current; we see the old one because we put the server value side by side.

## The Bounding Measurement

What happens if a constraint is not satisfied? We try two cases. In the first, the disk is empty and there is no charging. In the second, the disk carries values left from an earlier run and there is no network. In both, the job cannot find the required constraints.

```kotlin
// Main.kt — the job does not run unless the constraints are satisfied, the read stays default or old disk
fun main() {
    val required = setOf("network-connected", "charging")
    val network = Tier("network", mapOf("product.price" to "125", "product.stock" to "8", "campaign.code" to "SUMMER10"))
    val keys = listOf("product.price", "product.stock", "campaign.code")
    val old = mapOf("product.price" to "120", "product.stock" to "9")
    val states = listOf(
        Triple("empty disk, no charging", emptyMap<String, String>(), setOf("network-connected")),
        Triple("old disk, no network", old, setOf("charging")),
    )
    for ((name, disk, satisfied) in states) {
        println(name)
        val reads = readHomeScreen(runWork(required, satisfied, disk, network, keys))
        reads.forEach { it.show() }
        countOrigins(reads)
    }
}
```

```text
empty disk, no charging
price: key=product.price | value=unknown -> default
stock: key=product.stock | value=0 -> default
campaign: key=campaign.code | value=none -> default
memory: 0
disk: 0
network: 0
default: 3
old disk, no network
price: key=product.price | value=120 -> disk
stock: key=product.stock | value=9 -> disk
campaign: key=campaign.code | value=none -> default
memory: 0
disk: 2
network: 0
default: 1
```

On the empty disk the job did not run, so three of the three reads stayed default. On the old disk two reads came from disk with old values, and the campaign stayed default. The server knew the new values, but the job did not carry them to disk. The rule does not see this: the chain has only disk in it, and the answer is given by the disk or by the fallback value.

What the measurement does not say is how long it will take for the constraints to be satisfied and what the screen should show until then. The first is a decision of the platform, the second a decision of design.

**DL4.** The model takes a constraint as either satisfied or not satisfied. A constraint being partly satisfied, being satisfied and then broken again, and the way the job waits until a constraint is satisfied are absent from the model.

## Closing Table

The fourteen lessons of the course asked the same question. The read each lesson measured, the origin of the lesson, and its bounding measurement are in the table. The origin column is the one written in the `Summary` section of each lesson.

| Lesson | Measured read | Origin | Bounding measurement |
|---|---|---|---|
| The Layer Model | The price passing through the domain layer | network | The old value in memory answers even though the network says a new one |
| The View Model | Count and price on rotation | memory | On process death the reads fall to the network and to default |
| State Holders | The saved search text and tab | disk | A field that is not saved falls to default or to the network |
| Observable State | The price read before loading finishes | default | When loading finishes, the price and the stock come from memory |
| Unidirectional Data Flow | The count the event set | memory | Before the event arrives, four reads are default |
| Dependency Injection | The header in the chain the container built | network | The fake answers from the memory tier |
| The Repository Pattern | The first read to enter the repository | memory | When the chain order changes, the origin moves to the network |
| Local Database | The row that went through the migration | disk | A field that was not written is default, and once it is filled it is disk |
| Key–Value Storage | A preference that was never written | default | A written preference and an empty string come from disk |
| File and Media Storage | A file in the application's directory | disk | A file in the media store gets no answer until it is copied |
| The Network Client | The fields of the decoded response | network | A field that cannot be decoded falls to default |
| Caching and Offline Support | The read after a refresh | disk | When the network goes down it stays disk, and the value goes stale |
| Paged Data Loading | An item on an unloaded page | default | On a loaded page the item comes from the network or from disk |
| Background Work | The read after a job that writes to disk once the constraints are satisfied | disk | If the constraints are not satisfied, default or old disk |

The block that counts the origins in the table counts a written list; it is not a measurement of the rule. We put the distribution of the eight reads of the core into the same block as well.

```kotlin
// Main.kt — counts the origins of the fourteen lessons and of the core
fun main() {
    val lessons = listOf(
        "layer-model" to "network", "view-model" to "memory", "state-holders" to "disk",
        "observable-state" to "default", "unidirectional-data-flow" to "memory",
        "dependency-injection" to "network", "repository-pattern" to "memory", "local-database" to "disk",
        "key-value-storage" to "default", "file-and-media-storage" to "disk",
        "network-client" to "network", "caching" to "disk", "paged-data-loading" to "default",
        "background-work" to "disk",
    )
    val core = mapOf("memory" to 2, "disk" to 3, "network" to 1, "default" to 2)
    println("lessons: ${lessons.size}")
    for (name in listOf("memory", "disk", "network", "default")) {
        println("$name: ${lessons.count { it.second == name }}")
    }
    println("core reads: ${core.values.sum()}")
    for ((name, n) in core) {
        println("$name: $n")
    }
}
```

```text
lessons: 14
memory: 3
disk: 5
network: 3
default: 3
core reads: 8
memory: 2
disk: 3
network: 1
default: 2
```

Of the fourteen lessons, three are memory, five are disk, three are network, and three are default. The distribution of the eight reads of the core, on the other hand, is 2, 3, 1, 2. The two are not the same thing: the core counts eight reads, the table counts fourteen lessons. The numbers looking alike is not a rule; one is not expected to predict the other.

## Summary

- This lesson's origin: disk. Once the constraints are satisfied, the job pulls from the network and writes to disk, and the next read of the screen comes from disk.
- The difference between the job and the screen is that the job does the refresh before the screen opens; the network does not enter the chain of the read.
- One-time work refreshes the disk once, periodic work every period; as long as the job does not run, the disk stays old.
- Unless the constraints are satisfied the job does not run: the read becomes default on an empty disk, and disk with the old value on an old disk.
- The closing table puts the origins of the fourteen lessons side by side: memory 3, disk 5, network 3, default 3.

## Course Wrap-Up

This course measured, in fourteen lessons, which tier the answer to a read in an Android application comes from: from the architecture layers to the view model, from state holders to dependency injection, from the repository to the local database, from preference and file storage to the network client, from the cache to paged loading and background work. The measure was always the same and the rule always saw only the order; freshness, correctness, and design decisions stayed outside the measure. The next course, Platform Services and Integrations, takes as its subject the relationship the application builds with platform services and external integrations.
