Skip to main content
ANVISoftware Solutions
Lesson 6 of 11Intermediate20 min

Local Storage and Offline

By the end of this lesson

Cache data on device and reconcile changes when connectivity returns.

Local storage on a device does two separate jobs, and keeping them apart makes the rest of this lesson tractable. The first is a cache: a copy of server data so a screen has something to show immediately, including when there is no connection. The second is a queue: a record of changes the user has made that have not reached the server yet.

The cache can be thrown away without consequence. The queue cannot — it holds work the user believes is done. Treating both as "the local database" is how apps end up clearing a cache and silently deleting someone's visit notes with it.

One warning before the mechanics: synchronising data between a device and a server is genuinely hard. Not fiddly, hard. It is one of the few areas in application development where a reasonable-looking design can lose user data without producing an error anywhere. What follows is honest about where the difficulty sits.

Cache-then-refresh is the pattern behind a screen that feels instant. Five steps, in this order:

  1. Read the local copy first

    Before any network call, read what you already have and render it. A list from this morning on screen in 30 milliseconds beats an empty screen with a spinner for two seconds, every time.

  2. Say how old it is

    Show the fetch time, or a subtle refreshing indicator. This is what separates a useful cached view from a misleading one, and it is the step most often skipped. The user is entitled to know they are reading this morning's data.

  3. Refresh in the background

    Fetch with a timeout while the cached copy stays on screen. The user can read and scroll throughout; nothing is blocked on the request.

  4. Replace and store on success

    Update the screen and write the new copy locally with a fresh timestamp. Take care not to yank content out from under a user who is mid-read — appending or marking changes is often kinder than a wholesale redraw.

  5. Keep the copy on failure

    A failed refresh is not a reason to empty the screen. Keep the cached data, say the refresh did not succeed, and offer retry. This is the step that makes the app usable on a bad connection rather than merely tolerant of one.

One entry in the pending change queue, as stored on device
JSON
{
  "id": "chg_0f3a91",
  "createdAt": "2025-03-04T08:12:09Z",
  "intent": "updateEmployeeSite",
  "method": "PATCH",
  "resource": "/api/employees/482",
  "idempotencyKey": "chg_0f3a91",
  "baseVersion": 41,
  "body": {
    "siteCode": "NTH-12"
  },
  "attempts": 2,
  "lastAttemptAt": "2025-03-04T09:40:11Z",
  "status": "pending"
}
  • This is a record of intent, not a saved HTTP request. It says what the user meant to change, which is what you need in order to retry it, explain it, or reconcile it days later.
  • The idempotency key is stored with the change rather than generated at send time. That is the whole reason a queued change can be retried safely: attempt five carries the same key as attempt one, so the server can recognise it as the same change.
  • baseVersion is the version of the record the user was looking at when they made the edit. Send it with the change and the server can tell whether anybody else has touched the record since. Without it, you are asking the server to overwrite blindly, and it has no way to warn you.
  • attempts and lastAttemptAt drive backoff and, more importantly, let you tell the user something true. A change that has failed twenty times over two days is not "syncing", and pretending otherwise is how a queue silently becomes a graveyard.
  • status is explicit: pending, sending, conflicted, failed. A queue with no failure states either loses entries or retries them forever, and both look like the app working until someone checks.
Draining the queue, and refusing to guess about conflicts
TypeScript
interface PendingChange {
  id: string;
  resource: string;
  baseVersion: number;
  body: Record<string, unknown>;
}

type SendResult =
  | { outcome: "applied"; version: number }
  | { outcome: "conflict"; serverVersion: number; server: Record<string, unknown> }
  | { outcome: "rejected"; reason: string }
  | { outcome: "retryLater" };

interface DrainReport {
  applied: string[];
  conflicts: { change: PendingChange; server: Record<string, unknown> }[];
  rejected: { change: PendingChange; reason: string }[];
  stopped: boolean;
}

export async function drainQueue(
  queue: PendingChange[],
  send: (change: PendingChange) => Promise<SendResult>,
): Promise<DrainReport> {
  const report: DrainReport = {
    applied: [],
    conflicts: [],
    rejected: [],
    stopped: false,
  };

  for (const change of queue) {
    const result = await send(change);

    if (result.outcome === "applied") {
      report.applied.push(change.id);
    } else if (result.outcome === "conflict") {
      // Keep it. Do not overwrite, do not discard, do not guess.
      report.conflicts.push({ change, server: result.server });
    } else if (result.outcome === "rejected") {
      report.rejected.push({ change, reason: result.reason });
    } else {
      report.stopped = true; // Still offline. Preserve order, try again later.
      break;
    }
  }

  return report;
}
  • The shape is the lesson. Whatever storage and HTTP client you use, a queue drain looks like this: in order, one at a time, with an explicit outcome per entry and a report at the end.
  • In order and one at a time matters. Two changes to the same record sent in parallel apply in whichever order the server happens to process them, so an edit the user made second can land first. Sequential sending keeps the user's own intent in sequence, and the cost is throughput nobody on a device is measuring.
  • Stopping on retryLater rather than skipping ahead preserves that order. Skipping a blocked change to send a later one is how a queue reorders a user's edits, and the effect is a record that ends up in a state the user never asked for.
  • A conflict is neither success nor failure, so it gets its own outcome and the change stays in the queue. The one thing this function refuses to do is decide what the merged record should be. That decision needs either a rule specific to the field, or the user.
  • rejected is separate from both. A 422 means the change will never apply, so it has to leave the queue and be shown to the user rather than retried forever. A queue that cannot express "this will never work" grows until it is useless.
  • The report is what the UI reads. It is how a sync indicator can say "12 sent, 1 needs your attention" instead of a spinner that means nothing.

A conflict means the server record changed after the version your user edited. Four ways to resolve it, each with a real cost:

Last write wins
Send the change anyway and let it overwrite. Trivial to build, and it discards the other person's edit with no record and no message. Defensible only for data where a later value genuinely supersedes an earlier one — a device's own location or battery reading, for instance. Not defensible for anything a person typed.
Server wins
Discard the queued change and keep the server copy. Safe for the shared record and unsafe for the user, who loses work they believe is saved. Acceptable only if you tell them clearly what happened, which means keeping the discarded value around to show.
Field-level merge
Apply the change only to the fields the user actually touched, keeping the server's values elsewhere. This resolves most real conflicts, because two people usually edit different fields. It requires tracking which fields changed rather than sending the whole record, and it still cannot resolve two edits to the same field.
Ask the user
Show both versions and let them choose or combine. The only approach that never loses data silently, and the most expensive to build and the most intrusive to use. Reserve it for what matters: a conflicting visit note is worth an interruption, a conflicting phone number is not.

Summary

  • Keep the cache and the pending change queue separate: one is disposable, the other holds work the user believes is saved
  • Cache-then-refresh puts data on screen immediately, with its age shown and the copy kept when a refresh fails
  • Queue changes as intent with a stable idempotency key and the version edited from, then drain in order with explicit outcomes including conflicted and rejected
  • Synchronisation is genuinely hard, and last write wins discards data with no error for anyone to notice
  • Decide a conflict policy per field, write it down, and cap cache growth without ever evicting the queue

Practice

Attempt each one before opening the solution. Getting it wrong first is how the idea sticks.

Try it yourself

Produce a conflict on purpose

Using two devices, two simulators, or one device and a direct API call, edit the same record from both while one of them is offline.

Bring the offline one back and watch what happens. Then check the record on the server and answer one question: can you tell from the data that two people edited it?

Show solution

With last write wins you cannot tell. The record holds one set of values, both clients reported success, and nothing in the data indicates that an edit was discarded. That is the point of the exercise — the failure is undetectable after the fact, from either end.

If the server checks a version and refuses the mismatch, you get a visible conflict instead. Now there is a decision to make, and a decision you can see is a vastly better position than an overwrite you cannot.

This is also the cheapest way to find out what your app currently does, which is frequently not what the team assumes. Most offline implementations have never been run against a genuine concurrent edit.

Challenge

Write the conflict policy for three fields

The field app lets engineers edit three things on an employee record: the site code, the free-text visit notes, and a checkbox marking the employee as on site today.

Choose a conflict policy for each and justify it. Then decide what the user is told in each case, including the case where they are told nothing.

Show solution

Site code: a short, factual value where two conflicting edits mean someone has stale information. A version check with a prompt showing both values is proportionate, because the wrong answer sends an engineer to the wrong place. Field-level merge handles the common case where the other person edited something else entirely.

Visit notes: free text, and the one field where losing an edit is most costly, since it represents work done at a site the engineer has left. Never resolve this automatically. Keep both versions, append rather than replace where the shape allows, and ask if you must. Last write wins here is close to indefensible.

On site today: a flag reflecting a moment in time, where a later value genuinely supersedes an earlier one. Last write wins is reasonable, with one caveat — "later" should mean when the user acted, not when the change happened to upload, and a device clock can be wrong. Carry the time of the user's action with the change and let the server compare.

Where the user is told nothing, that must be a deliberate choice you could defend to them. Silence is right for the flag, because an interruption over a checkbox would train people to dismiss conflict prompts without reading. Silence is wrong for the notes.

The wider point: the policy belongs per field, not per app. A single global strategy is either too heavy for the trivial fields or too lossy for the important ones, and the effort of deciding field by field is small next to the cost of getting the notes wrong.

Saved in this browser only.