Insights Whitepaper
Whitepaper

Designing for Intermittent Infrastructure

Adeolu Timothy 20 July 2026 6 min read Product Architecture · Emerging Markets · Offline-First
Summary

Most apps assume the network is there. Across the markets I build for, it usually is not. Here is the architecture I keep coming back to — local-first writes, a durable intent queue, explicit conflict policy, payload budgets — and the parts of it you should skip if you are still looking for product-market fit.

#The assumption nobody writes down

Modern product engineering encodes an assumption about where your app runs. Server-rendered state, synchronous request/response, real-time collaboration as a default — all of it assumes the network is there, the device stays powered, and a failed request is an anomaly worth retrying.

In the markets I build for, that is inverted. Connectivity is intermittent by default. Power is scheduled. Data is metered, and its cost is on the user's mind while they are using your product.

Apps built on the standard assumption do not fail loudly here. They fail quietly: a form that loses ten minutes of work, a sync that drops a record, a spinner that never resolves. Nobody files a bug. They just stop opening the app.

The question is not "how do we handle being offline?" It is "what is the smallest unit of work a user can finish and keep, without the network?"

#What actually breaks

Four failure modes account for most of the abandonment I have seen in production. Each has a different fix, and in every case the obvious fix is the wrong one.

What breaks What the user sees The reflex What actually works
Lost work Input vanishes after a drop Autosave to server Local-first write, server as replica
Silent divergence Two devices disagree, nothing flags it Last-write-wins Explicit conflict surface + merge policy
Unbounded wait Spinner with no exit Longer timeout Bounded timeout, queued intent, honest state
Cost shock Data budget gone, no warning Compress images Payload budget as a build-time constraint

The pattern: the reflex treats the network failure as a transient problem to smooth over. The fix treats it as the permanent operating condition it is.

#Losing work is not a normal bug

Losing someone's work is not a defect of ordinary weight. It is a trust event. A user who loses fifteen minutes of input does not reason about whose fault the network was — they conclude your product is unreliable, and that is expensive to undo.

That asymmetry is why it is worth spending more engineering effort on write durability than on read performance. It is the reverse of the usual instinct, and it is correct here.

#The architecture

Four layers. Each is useful alone; together they give you a product that degrades in defined steps instead of collapsing.

#Layer 1 — Local-first writes

Every action that produces state is written to local durable storage first, and confirmed to the user from local storage. The network is never between an action and its confirmation.

// Confirmed by local durability, not by the server.
async function saveDraft(draft) {
  await localStore.put('drafts', { ...draft, dirty: true, updatedAt: Date.now() });
  ui.confirm('Saved');           // honest: it IS saved, on this device
  syncQueue.enqueue('draft.sync', draft.id);
  return draft.id;
}

The discipline that matters here is the wording. "Saved" means saved locally. "Synced" or "Published" means the server has it. Products that blur those two teach users to distrust both.

#Layer 2 — A real intent queue

Model pending work as durable, inspectable intents — not as retried HTTP calls. An intent records what the user meant to do, so it can be replayed, reordered, deduplicated, or shown to them.

That last one is the operational win. A retried HTTP call is invisible to the user and to your support team. A queued intent can be surfaced: "3 items waiting to sync." That single piece of visible state kills a large share of support tickets, because people can see the system working instead of guessing.

Intents need idempotency keys. Without them, a queue replaying after a partial failure duplicates records — and duplicate records in a payments or certification flow are much worse than the original failure.

#Layer 3 — Conflict policy you actually chose

Where two devices can touch the same entity, the merge policy has to be a decision you made per entity type — not whatever the database does when two writes race.

  • Last-write-wins — only where loss is genuinely immaterial. UI preferences, cached view state.
  • Merge — where the entity is a set or a log and union means something. Tags, activity history, collected items.
  • Ask the user — anywhere the entity carries real value and auto-resolution would destroy something they cannot get back.

The failure I see most often is last-write-wins applied silently to things in that third category. Nobody chose it. It was inherited.

#Layer 4 — Payload budgets

Treat bytes-per-session as a build-time constraint with a number attached, enforced in CI like any other budget. When the number belongs to the team rather than to nobody, it holds.

In practice it pushes a handful of decisions that compound: aggressive image formats, deferring anything below the fold, skipping a client framework where a static document would do, and treating every third-party script as a line item that has to justify itself.

A megabyte is not a technical measurement in a metered market. It is a price your user pays to use your product.

#Three things that decide whether this survives contact with a deadline

Test the real condition, not the ideal one. Devtools throttling models a slow connection. It does not model a connection that succeeds, fails mid-request, then succeeds again. Build a harness that produces genuinely flaky behaviour and run your critical paths against it as a release gate.

Instrument abandonment, not just errors. Errors are the failures your system knows about. Abandonment — sessions ending mid-flow with no error — is where the failures it cannot see are hiding. The gap between the two is the most useful signal you have.

Budget for the queue. Local-first moves complexity, it does not remove it. The queue, the conflict surface and the sync state machine are real components needing real maintenance. Adopt the architecture without budgeting for that and you end up with a queue nobody owns, which is worse than no queue.

#When to skip this

Being straight about the boundary: this carries real cost — more state to reason about, more test surface, more ways to be subtly wrong. Skip it when:

  • The product runs only in controlled, connected environments.
  • The core action is inherently online with no offline equivalent — a live auction, a multiplayer session.
  • Your team is small enough that this maintenance would eat the capacity you need to find product-market fit at all.

If you are early, build Layer 1 only. Local-first writes give you most of the resilience for a fraction of the complexity, and they do not block adding the rest later.

#Key takeaways

  • Design for the network you have, not the one in your dev environment.
  • Confirm writes from local storage, and never call something "Saved" when you mean "Sent".
  • Make pending work visible — it converts support tickets into a progress indicator.
  • Choose a conflict policy per entity. If you did not choose one, you have last-write-wins.
  • Ship Layer 1 first. Add the rest when the product has earned the complexity.

None of this is a concession to a hard market. It produces products that hold up everywhere, because continuous connectivity was never as true as it looked — anywhere.


Patterns here come from building and running Dirra, Certico, QuoteMe and Relic in production. If you are making these calls on your own product, the contact details are below.

Shipping something like this?

Everything above came out of building and running the thing, not researching it. If you are hitting the same problems, I work with product and engineering teams on exactly this — architecture reviews, technical strategy, and staying on through implementation.