---
title: 'Offline-First Design'
source: 'https://academia.sh/en/courses/cross-platform-production/offline-first-design'
course: 'Cross-Platform Production Practice'
language: en
updated: '2026-10-21T05:02:50+00:00'
license: 'CC BY-SA 4.0'
objectives:
    - 'Compares the design that makes local data the source of truth with the network-first design'
    - 'Explains the read path in which the network is used only for refreshing'
    - 'Measures how the same flow comes out disabled and degraded under no network with the two designs'
    - 'Explains why a screen that matches the local model completely is still degraded'
---

# Offline-First Design

Offline-first design makes local data the source of truth and uses the network only for refreshing. The lesson builds the same flow with two designs and measures how well each one works when there is no network.

The previous topic measured how shared code is sliced and how each platform carries its own layer. This topic goes down to the condition an app meets most often: no network. In a subway, in an elevator, on a plane, or outside coverage, the app still opens and the user still expects something. This lesson puts two designs for meeting that expectation side by side.

The course question stays the same: how well does this feature work under this condition? The nine records in the opening lesson came out as five **full**, one **degraded**, two **deferred**, and one **disabled**. This lesson does not change that count; it measures only its own slice.

## Two Designs

In a **network-first** design, the screen requests data from the server on every open and shows the response that arrives. The source of truth is the server; the local store is at most a cache. With a network, this design is simple and correct. Without one, the screen has nothing to show.

In an **offline-first** design, the source of truth is local. The screen always reads from the local store. The network is not the way to read; it is only the way to refresh the local store. New records from the server are written to the local store, and the screen shows the change in the local store. When the network goes away, what the screen reads does not change; it just stops being refreshed.

This distinction is a rule, not a library choice. The same embedded database can sit under both designs. The difference is where the screen's read path passes through. The Android snippet below shows the offline-first read path. The snippet is not measured.

```kotlin
// Platform code: Android local read
class FeedRepository(private val db: SQLiteDatabase) {
    fun readPosts(): List<Post> {
        val cursor = db.query("posts", null, null, null, null, null, "published DESC")
        return cursor.use { rows ->
            generateSequence { if (rows.moveToNext()) rows.toPost() else null }.toList()
        }
    }
}
```

The screen calls the repository, not the network. Refreshing is a separate job: records from the network are written to the `posts` table, and the read path sees them on the next read. On iOS the same contract is built the same way in Swift: one layer reads from the local store, and a separate layer refreshes it from the network.

## Building the Features

Measuring the two designs needs the course core. The core is familiar from the first lesson and is given again in the same form in every lesson; this lesson does not touch it.

```javascript
// operability.mjs — classifies how fully a feature works under a condition
export const LEVELS = ["full", "degraded", "deferred", "disabled"];
const FALLBACKS = ["degraded", "deferred"];

export function feature(name, needs, fallback = {}) {
  return { name, needs, fallback };
}

export function condition(name, has) {
  return { name, has };
}

function judge(item, cond) {
  const missing = item.needs.filter((need) => !cond.has.includes(need)).sort();
  let level = "full";
  for (const need of missing) {
    const given = FALLBACKS.includes(item.fallback[need]) ? item.fallback[need] : "disabled";
    if (LEVELS.indexOf(given) > LEVELS.indexOf(level)) level = given;
  }
  return { level, feature: item.name, condition: cond.name, missing };
}

export function assess(features, conditions) {
  const records = [];
  for (const cond of conditions) {
    for (const item of features) records.push(judge(item, cond));
  }
  return records;
}

export function printLevels(records) {
  for (const r of records) {
    const missing = r.missing.length === 0 ? "-" : r.missing.join(",");
    console.log(`${r.level}: ${r.feature} @ ${r.condition} missing=${missing}`);
  }
}

export function countLevels(records) {
  for (const name of LEVELS) {
    console.log(`${name}: ${records.filter((r) => r.level === name).length}`);
  }
  console.log(`affected: ${records.filter((r) => r.level !== "full").length}`);
}
```

After the core comes the lesson's helper file. Two features are built for the flow. `FeedRemote` is the network-first flow: it needs only `network` and has no fallback. `FeedLocal` is the offline-first flow: it needs `storage` and `network`, and when the network is missing it falls back to `degraded`. The third feature, `Bookmarks`, needs only `storage`. `stateOf(link)` builds a condition from a connection state: if there is a network, the `network` capability enters the condition, and if there is storage, the `storage` capability does.

```javascript
// feeds.mjs — builds the two feed designs and the connection states
import { feature, condition } from "./operability.mjs";

export const remoteFeed = feature("FeedRemote", ["network"]);
export const localFeed = feature("FeedLocal", ["storage", "network"], { network: "degraded" });
export const bookmarks = feature("Bookmarks", ["storage"]);

export function stateOf(link) {
  const has = [];
  if (link.network) has.push("network");
  if (link.storage !== false) has.push("storage");
  return condition(link.name, has);
}
```

## Two Designs Without a Network

The first measurement runs the two flows under two connection states. Under `online`, both the network and storage exist. Under `offline`, only storage exists.

```javascript
// main.mjs
import { assess, printLevels } from "./operability.mjs";
import { remoteFeed, localFeed, stateOf } from "./feeds.mjs";

const links = [
  { name: "online", network: true },
  { name: "offline", network: false },
];
printLevels(assess([remoteFeed, localFeed], links.map(stateOf)));
```

```text
full: FeedRemote @ online missing=-
full: FeedLocal @ online missing=-
disabled: FeedRemote @ offline missing=network
degraded: FeedLocal @ offline missing=network
```

With a network, both designs are **full**; the difference opens when the network goes away. `FeedRemote @ offline` is **disabled**, because the missing `network` capability has no fallback. `FeedLocal @ offline` is **degraded**, because for the same missing capability the feature has defined a fallback: it keeps working with local data. The two features carry the same gap and land on different levels because of the fallback.

**OS1.** The model builds the network as a single capability: it is either there or it is not. A real connection can be intermittent, slow, or half open; those states are handled separately in the fourth lesson. The rule sees the capability and the fallback, not how many records the local store holds, how old they are, or what the user expects. The model builds this operability's rule; the model is not the framework itself.

## Read Path and Refresh

An operability record does not say what the screen shows. To see that, the read path of the two flows is modeled separately. The store has three local records; the server has four records, and two of them are shared with the local ones. The network-first flow reads only from the server. The offline-first flow reads from local and, if there is a network, adds the new records from the server.

```javascript
// reader.mjs — builds the post read path of the two designs
export function readFeed(design, store, link) {
  if (design === "remote") {
    return link.network ? { shown: store.remote.length, fresh: true } : { shown: 0, fresh: false };
  }
  const merged = link.network ? [...new Set([...store.local, ...store.remote])] : store.local;
  return { shown: merged.length, fresh: link.network };
}

export function printReads(label, design, store, links) {
  for (const link of links) {
    const read = readFeed(design, store, link);
    console.log(`${label} @ ${link.name}: ${read.shown} posts, fresh=${read.fresh ? "yes" : "no"}`);
  }
}
```

The measurement prints the two reads together with the operability records of the `offline` condition.

```javascript
// main.mjs
import { assess, printLevels } from "./operability.mjs";
import { remoteFeed, localFeed, stateOf } from "./feeds.mjs";
import { printReads } from "./reader.mjs";

const store = { local: ["p1", "p2", "p3"], remote: ["p2", "p3", "p4", "p5"] };
const links = [
  { name: "online", network: true },
  { name: "offline", network: false },
];
printReads("FeedRemote", "remote", store, links);
printReads("FeedLocal", "local", store, links);
const records = assess([remoteFeed, localFeed], links.map(stateOf));
printLevels(records.filter((r) => r.condition === "offline"));
```

```text
FeedRemote @ online: 4 posts, fresh=yes
FeedRemote @ offline: 0 posts, fresh=no
FeedLocal @ online: 5 posts, fresh=yes
FeedLocal @ offline: 3 posts, fresh=no
disabled: FeedRemote @ offline missing=network
degraded: FeedLocal @ offline missing=network
```

With a network, the offline-first flow shows five posts: the three local ones and the two new records from the server. Without a network, it keeps showing the three local posts, and freshness drops. The network-first flow shows zero posts without a network. The operability records of the two designs turn this difference in the read path into a class: zero posts is **disabled**, three posts is **degraded**.

## Storage Is the Source of Truth

A design that makes local data the source of truth reduces dependence on the network and increases dependence on storage. When storage runs out, local reads and writes stop working too. The third measurement shows this with the `storage-full` condition: the network exists, storage does not.

```javascript
// main.mjs
import { assess, printLevels } from "./operability.mjs";
import { localFeed, bookmarks, stateOf } from "./feeds.mjs";

const links = [
  { name: "online", network: true },
  { name: "offline", network: false },
  { name: "storage-full", network: true, storage: false },
];
printLevels(assess([localFeed, bookmarks], links.map(stateOf)));
```

```text
full: FeedLocal @ online missing=-
full: Bookmarks @ online missing=-
degraded: FeedLocal @ offline missing=network
full: Bookmarks @ offline missing=-
disabled: FeedLocal @ storage-full missing=storage
disabled: Bookmarks @ storage-full missing=storage
```

`Bookmarks` depends only on the local store, so it is unaffected when the network goes away and it is **disabled** under `storage-full`. `FeedLocal` withstands a missing network with a fallback but cannot withstand missing storage: no fallback is defined for `storage`, so the same feature is **disabled** under `storage-full`. The offline-first design covers the network dependency with a fallback; it does not remove the dependency, it moves it to the local store.

## The Bounding Measurement

This lesson's focus record is `FeedLocal @ offline`. The last measurement runs three features under two states, prints the records of the `offline` condition and the count, and then asks whether the screen matches the local model.

```javascript
// main.mjs
import { assess, printLevels, countLevels } from "./operability.mjs";
import { remoteFeed, localFeed, bookmarks, stateOf } from "./feeds.mjs";
import { readFeed } from "./reader.mjs";

const store = { local: ["p1", "p2", "p3"], remote: ["p2", "p3", "p4", "p5"] };
const offline = { name: "offline", network: false };
const links = [{ name: "online", network: true }, offline];
const records = assess([remoteFeed, localFeed, bookmarks], links.map(stateOf));
printLevels(records.filter((r) => r.condition === "offline"));
countLevels(records);
const shown = readFeed("local", store, offline).shown;
console.log(`screen matches local model: ${shown === store.local.length ? "yes" : "no"}`);
```

```text
disabled: FeedRemote @ offline missing=network
degraded: FeedLocal @ offline missing=network
full: Bookmarks @ offline missing=-
full: 4
degraded: 1
deferred: 0
disabled: 1
affected: 2
screen matches local model: yes
```

The first two lines carry the lesson's operability: the same flow came out **disabled** when built network-first and **degraded** when built offline-first. Of the six records, four are **full**, one is **degraded**, and one is **disabled**; two are affected.

The last line shows what operability does not classify. M30/K04 asked whether a screen matches its model. Here the screen matches the local model completely: the three posts it shows are the three posts in the local store, and there is no gap between screen and model. Yet `FeedLocal @ offline` is not **full**; it is **degraded**. Conformance and operability are separate axes. Conformance asks whether the screen shows what the model says; operability classifies how much of what the feature promises it does under that condition. A feed that receives no new content does less than it does with a network, even when it matches its model completely.

This difference also affects design decisions. For an offline-first screen, saying "it works offline" is not enough; you have to say, feature by feature, which jobs continue through a fallback and which stop.

## Summary

- This lesson's operability: **degraded**. `FeedLocal @ offline` came out degraded because `network` is missing and a `degraded` fallback is defined; the network-first `FeedRemote` stayed **disabled** under the same condition because it has no fallback.
- In the offline-first design the source of truth is the local store; the network is not the way to read but the way to refresh the local store.
- With a network, both designs were **full**. Without one, the read path showed the difference: the network-first flow showed zero posts, the offline-first flow showed three.
- Local data covers the network dependency with a fallback but does not remove the storage dependency: under `storage-full`, `FeedLocal` is **disabled**.
- Even when the screen matches the local model completely, the feature stays **degraded**; conformance and operability are separate axes.

## Next Step

This lesson covered only reading while there is no network: local data was shown, but the changes the user makes were never discussed. The next lesson, `Synchronization Strategies`, covers how a write made offline is put into a queue, how it is sent to the server when the network returns, and how a conflict is resolved when both sides have changed the same record. It then measures how well offline note editing works.
