The case for offline-first software in 2026.
Lagos has fibre now. So does Kaduna. So why is every system we ship still designed as if the network might leave at any moment — and why are we still right to do it.
Lagos has fibre now. So does Kaduna. Abuja has had it for years, and the coverage maps the operators publish look, at a glance, like the coverage maps of a country where the network question has been settled. Every year someone asks me whether offline-first is still worth the engineering, and every year the honest answer gets more interesting: the premise of the question has changed, and the answer has not.
The premise used to be that Nigerian connectivity was bad. That is no longer the useful framing. Nigerian connectivity is good and unreliable, which is a different problem and in some ways a harder one to design for.
Average bandwidth is the wrong number
If you build against the average, you build for a user who does not exist. A median connection in Ikeja is genuinely fast. The same connection, in the same building, is gone for ninety seconds when the power cuts over to the generator and the router reboots. Your user did not go offline in any sense their ISP would recognise. They went offline in the sense that matters — mid‑action, without warning, holding a form they had been filling in for four minutes.
The metric worth designing against is not throughput. It is the probability that the connection survives the length of a user's task. For a task that takes eight seconds, almost any connection qualifies. For one that takes four minutes — a KYC submission, an invoice with fifteen line items, a receipt upload over a slow uplink — the odds get worse in a way that has nothing to do with how fast the good moments are.
Build against the average connection and you build for a user who doesn't exist. The number that matters is whether the connection outlives the task.
What “PWA with a service worker” actually buys you
Very little on its own, and this is where most teams stop. A service worker with a precache manifest gets you an app shell that loads without a network. That solves the cold‑start case — the user opens the app on the underground, sees your interface instead of the dinosaur — and solves nothing else.
The app shell was never the hard part. The hard part is what happens to a write that the user believes succeeded. Everything expensive lives downstream of that question:
- Where the write is durably stored before the server has acknowledged it — and whether it survives the tab being closed.
- What the interface shows for a record that exists locally and nowhere else, without lying about it.
- How the queue drains when connectivity returns, in what order, and what happens when item three of nine fails.
- What happens when the same record was edited on a second device while this one was dark.
Only the first of those is a service-worker concern. The rest are data-model decisions, and they are much easier to make at the start of a build than to retrofit into one — which is the practical argument for deciding early, ahead of any argument about Nigerian infrastructure.
Optimistic UI is a promise, and promises get broken
The instinct when a write is queued is to show the user a success state. It feels responsive, and for the ninety‑odd percent of writes that eventually land, it is even true. The problem is the remainder, and the fact that the interface has already told the user the opposite.
A queued invoice rendered identically to a cleared invoice is not optimism. It is a claim your system cannot back, and the user finds out at the worst possible moment — usually in front of the person they sent it to. The rule we work to is that a record may be shown immediately, but its state must always be honest. Pending looks different from confirmed. Failed is loud and is never silently retried into oblivion.
This is the same discipline the ledger argument in the NRS compliance piece depends on. A system that quietly disagrees with itself is worse than one that is visibly incomplete, because the second kind gets fixed.
The queue is the load-bearing wall
On TaxJeje the outbound queue turned out to be the component everything else leaned on, and it is the piece we would now write first on any build of this shape. It is not complicated. It is just unforgiving about the details — idempotency keys so a retry can't double‑post, ordering so a dependent write never overtakes its parent, and a dead‑letter path so a permanently failing item stops blocking the eight behind it.
1# Drain in order. One permanent failure must not block the rest.2async function drain(queue, send) {3 for (const item of queue.pending()) {4 if (item.blockedBy && queue.isPending(item.blockedBy)) continue5 try {6 await send(item.payload, { idempotencyKey: item.id })7 queue.settle(item.id)8 } catch (err) {9 if (err.permanent || item.attempts >= 5)10 queue.deadLetter(item.id, "needs a human")11 else queue.backoff(item.id)12 }13 }14}
Fourteen lines, and every one of them is a decision someone will otherwise make by accident at 2am. The deadLetter branch is the one teams leave out and the one that saves you: without it, a single malformed record retries forever and the user never learns that anything is wrong.
So: is it still worth it in 2026?
For a marketing site, no. For a dashboard someone checks from a desk on a fixed line, probably not. For anything a user fills in on a phone, away from their desk, with money or compliance attached to the result — yes, and more so than in 2020, because expectations have risen. A user on a good connection is less forgiving of a lost form than one who assumed the network was bad to begin with.
The reason to build this way was never that Nigerian infrastructure is poor. It is that the cost of designing for interruption is paid once, at the start, in decisions that are cheap while the schema is still soft — and the cost of not doing it is paid repeatedly, by users, in work they have to redo and trust they do not get back.
By Yusuf Tahir, PhD, founder of Fattahlabs. Based in Kaduna, Nigeria.
hello@fattahlabs.com