Ionic Offline-First Architecture with Angular & Capacitor
Reliable mobile apps need more than an offline error message. Cache useful data, preserve its age, queue safe mutations and tell users exactly what happened.
The VNPF member application was built for provident-fund members in Vanuatu using the phones and networks available to them. That environment makes connectivity part of the architecture rather than an error case.
An offline-first Ionic application therefore needs to answer more than whether the device currently has a network connection. It needs to know what data is available, how old that data is and what happens when the user performs an action that cannot yet reach the server.
Model fresh, stale and unavailable data
For read operations, three states are more useful than online and offline: fresh data, cached data with a known age, and no available data.
export interface Cached<T> { value: T; fetchedAt: number; } @Injectable({ providedIn: 'root' }) export class BalanceStore { private readonly cached = signal<Cached<Balance> | null>(null); readonly balance = computed( () => this.cached()?.value ?? null ); readonly freshness = computed(() => { const entry = this.cached(); if (!entry) { return 'none' as const; } const age = Date.now() - entry.fetchedAt; return age < 5 * 60_000 ? ('fresh' as const) : ('stale' as const); }); }A stale balance can still be useful when its age is visible. Hiding cached information behind an indefinite loading state often produces a worse experience than showing the last known value honestly.
@switch (freshness()) { @case ('fresh') { <app-balance [value]="balance()" /> } @case ('stale') { <app-balance [value]="balance()" /> <p class="note"> Showing saved data from {{ updatedAt() | date: 'short' }} </p> } @case ('none') { <app-empty-state message="Your balance will appear here once you are back online." /> } }Choose Capacitor storage based on the data
Not every local-storage option has the same purpose. Small UI preferences can live in a key-value store. Queryable offline records fit a database such as SQLite. Authentication credentials belong in platform-backed secure storage.
await SecureStoragePlugin.set({ key: 'refresh_token', value: token, }); await db.run( `INSERT OR REPLACE INTO statements (id, period, payload) VALUES (?, ?, ?)`, [ statement.id, statement.period, JSON.stringify(statement), ] ); await Preferences.set({ key: 'last_tab', value: 'balance', });Queue offline writes with idempotency keys
Offline reads are relatively straightforward. Offline writes are harder because a failed response does not prove that the server never received the request.
Generate an idempotency key when the user performs the action and reuse that key on every retry. The server can then recognize repeated delivery of the same mutation instead of creating another record.
async submit( claim: ClaimDraft ): Promise<void> { const action: QueuedAction = { key: crypto.randomUUID(), kind: 'claim.submit', payload: claim, queuedAt: Date.now(), }; await this.queue.add(action); void this.flush(); }Design the offline status message as part of the workflow
A user action can be accepted by the server, saved locally for later submission or rejected. Those outcomes have different consequences and should not share the same generic error message.
A queued action should explain that the work has been saved on the device and what will happen when connectivity returns. That gives the user confidence that pressing the button repeatedly is unnecessary.
Why offline-first architecture must be decided early
Offline support changes the data layer: where values come from, whether they have an age, how mutations are represented and what retry means. Retrofitting those concepts after every feature assumes permanent connectivity can approach a rewrite.
Designing those boundaries at the beginning is comparatively inexpensive. That is why offline behaviour belongs in the product and architecture specification rather than a later resilience backlog.

