All insights

Mobile

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.

Ionic offline-first architecture with a local data layer, sync engine and pending sync queue against a remote API.

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

Network-first Ionic app blocked by a failed request compared with an offline-first app reading from a local database.
In a network-first app the connection is a dependency; in an offline-first app it is only what synchronisation needs.

For read operations, three states are more useful than online and offline: fresh data, cached data with a known age, and no available data.

Store the fetch timestamp beside cached data so the UI can represent freshness honestly.
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.

Keep cached data useful while clearly communicating that it is not current.
@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.

Use different storage mechanisms for credentials, structured offline data and disposable UI preferences.
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

Ionic offline write queue: save locally, queue while offline, then sync in the background when connectivity returns.
The write succeeds locally first, so the queue — not the user — carries the retry.

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.

Generate the mutation identity once and preserve it for every retry.
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

Offline-first reconnection flow showing successful sync, retry after temporary failure, and conflict resolution options.
Reconnection has three outcomes, and each one needs a message the user can act on.

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.

Sources

Where this is applied

Keep reading