Skip to content
academia.sh

Lesson 05 / 14

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.

Published Updated

In this lesson

  • 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
Contents

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.

// 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.

// 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.

// 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.

// 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)));
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.

// 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.

// 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"));
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.

// 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)));
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.

// 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"}`);
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.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close