Field teams are the use case nobody plans for. An inspection app, a delivery app, a warehouse app: the browser loses the network in a basement or a rural yard, and the Angular client throws HttpErrorResponse: 0 Unknown Error on every write. The user retypes the form later, or doesn't.
This tutorial makes a MEAN app work offline end to end: the Angular 22 service worker serves the shell, IndexedDB holds an outbound queue of mutations, and an Express 5 + MongoDB 8 API accepts that queue idempotently and resolves conflicts with a version check. Versions are current as of August 2026: Angular 22, Express 5, Mongoose 8, MongoDB 8, Node 24 LTS.
What "offline-first" actually has to solve
Installing @angular/pwa is about 10% of the job. The remaining 90% is data:
- Shell and assets must load with no network. That is the service worker's job, and it is nearly free.
- Reads must return the last known data instead of an error.
- Writes must be accepted locally, survive a page reload or a killed tab, and replay later in order.
- Replay must be idempotent. A queue that retries on flaky networks will send the same mutation twice. The server must create one document, not two.
- Conflicts must have a rule. Two devices edit the same record offline. You need an answer that is not "whichever request arrived last wins silently."
Points 3 to 5 are where most home-grown offline modes fail.
Step 1: add the service worker
ng add @angular/pwa
That writes ngsw-config.json, registers the worker in app.config.ts, and adds a manifest. Two changes matter. First, register the worker as soon as the app is stable rather than after 30 seconds, so a field user who opens the app once is protected immediately:
// app.config.ts
import { provideServiceWorker } from '@angular/service-worker';
import { isDevMode } from '@angular/core';
provideServiceWorker('ngsw-worker.js', {
enabled: !isDevMode(),
registrationStrategy: 'registerWhenStable:5000',
})
Second, do not try to make the service worker cache your API writes. Angular's dataGroups can cache GET responses (performance for reference data, freshness for lists), and that is worth configuring:
"dataGroups": [
{
"name": "reference-data",
"urls": ["/api/sites", "/api/checklists"],
"cacheConfig": { "strategy": "performance", "maxSize": 200, "maxAge": "7d" }
},
{
"name": "inspections-read",
"urls": ["/api/inspections"],
"cacheConfig": { "strategy": "freshness", "maxSize": 500, "maxAge": "1d", "timeout": "3s" }
}
]
POST, PATCH and DELETE stay out of the service worker entirely. Mutations belong in an explicit queue you control, because you need retry policy, ordering and user-visible status — none of which a cache gives you.
Remember the deployment rule: the service worker cache is keyed to the ngsw.json hash table, so ng build output must be deployed atomically. If Express serves half the old bundle and half the new one, clients hit hash mismatches and get stuck reloading. Serve builds from a versioned directory and switch a symlink, or put the browser bundle behind a CDN with immutable filenames.
Step 2: an IndexedDB outbox in the client
localStorage is synchronous, size-limited and string-only. Use IndexedDB. A thin wrapper with idb keeps it readable:
npm install idb uuid
// src/app/data/outbox.ts
import { Injectable, signal } from '@angular/core';
import { openDB, type IDBPDatabase } from 'idb';
import { v4 as uuid } from 'uuid';
export interface Mutation {
id: string; // client-generated, also the idempotency key
method: 'POST' | 'PATCH' | 'DELETE';
url: string;
body?: unknown;
baseVersion?: number; // document version the edit was based on
createdAt: number;
attempts: number;
lastError?: string;
}
@Injectable({ providedIn: 'root' })
export class Outbox {
private db?: Promise<IDBPDatabase>;
readonly pending = signal(0);
private open() {
return (this.db ??= openDB('mean-offline', 1, {
upgrade(db) {
db.createObjectStore('outbox', { keyPath: 'id' });
db.createObjectStore('docs', { keyPath: '_id' });
},
}));
}
async enqueue(m: Omit<Mutation, 'id' | 'createdAt' | 'attempts'>): Promise<Mutation> {
const db = await this.open();
const mutation: Mutation = { ...m, id: uuid(), createdAt: Date.now(), attempts: 0 };
await db.put('outbox', mutation);
await this.refreshCount();
return mutation;
}
async all(): Promise<Mutation[]> {
const db = await this.open();
const rows: Mutation[] = await db.getAll('outbox');
return rows.sort((a, b) => a.createdAt - b.createdAt); // FIFO matters
}
async update(m: Mutation) {
(await this.open()).put('outbox', m);
await this.refreshCount();
}
async remove(id: string) {
(await this.open()).delete('outbox', id);
await this.refreshCount();
}
async cacheDoc(doc: { _id: string }) { (await this.open()).put('docs', doc); }
async cachedDocs<T>(): Promise<T[]> { return (await this.open()).getAll('docs'); }
private async refreshCount() {
this.pending.set((await this.all()).length);
}
}
The id is generated on the client. That single decision is what makes the whole design safe: it is the mutation's identity, the idempotency key sent to the server, and — for creates — the _id of the document itself, so the UI can show the new record with its final URL before the server has ever heard of it.
Step 3: optimistic writes in the store layer
A store service wraps reads and writes. Reads try the network and fall back to IndexedDB. Writes update the local signal immediately and enqueue.
// src/app/data/inspections.store.ts
import { Injectable, inject, signal, computed } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';
import { Outbox } from './outbox';
import { v4 as uuid } from 'uuid';
export interface Inspection {
_id: string;
siteId: string;
notes: string;
status: 'draft' | 'submitted';
version: number;
pendingSync?: boolean;
}
@Injectable({ providedIn: 'root' })
export class InspectionsStore {
private http = inject(HttpClient);
private outbox = inject(Outbox);
readonly items = signal<Inspection[]>([]);
readonly unsynced = computed(() => this.items().filter(i => i.pendingSync).length);
async load() {
try {
const fresh = await firstValueFrom(this.http.get<Inspection[]>('/api/inspections'));
this.items.set(fresh);
for (const doc of fresh) await this.outbox.cacheDoc(doc);
} catch {
this.items.set(await this.outbox.cachedDocs<Inspection>());
}
}
async create(input: Pick<Inspection, 'siteId' | 'notes'>) {
const doc: Inspection = { _id: uuid(), status: 'draft', version: 1, pendingSync: true, ...input };
this.items.update(list => [doc, ...list]);
await this.outbox.cacheDoc(doc);
await this.outbox.enqueue({ method: 'POST', url: '/api/inspections', body: doc });
}
async edit(id: string, patch: Partial<Pick<Inspection, 'notes' | 'status'>>) {
const current = this.items().find(i => i._id === id);
if (!current) return;
const next = { ...current, ...patch, pendingSync: true };
this.items.update(list => list.map(i => (i._id === id ? next : i)));
await this.outbox.cacheDoc(next);
await this.outbox.enqueue({
method: 'PATCH',
url: `/api/inspections/${id}`,
body: patch,
baseVersion: current.version,
});
}
}
Note that a create uses a UUID string _id, not a MongoDB ObjectId. Let the client own the identifier; the server accepts it. Mixing ObjectId and client-generated ids in one collection is the usual mistake, so declare _id: String in the Mongoose schema and be consistent.
In the template, show sync state rather than hiding it. Users trust an app that admits what it has not sent yet:
@if (sync.online()) {
@if (store.unsynced() > 0) { <p class="badge">Syncing {{ store.unsynced() }} change(s)…</p> }
} @else {
<p class="badge warn">Offline — {{ store.unsynced() }} change(s) saved on this device</p>
}
Step 4: the sync loop
Trigger a flush when the app starts, when the browser fires online, and on a slow interval as a backstop. Keep it strictly sequential: an out-of-order replay can apply a patch before the create it depends on.
// src/app/data/sync.service.ts
import { Injectable, inject, signal } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';
import { Outbox, type Mutation } from './outbox';
@Injectable({ providedIn: 'root' })
export class SyncService {
private http = inject(HttpClient);
private outbox = inject(Outbox);
readonly online = signal(navigator.onLine);
readonly conflicts = signal<Mutation[]>([]);
private running = false;
constructor() {
addEventListener('online', () => { this.online.set(true); void this.flush(); });
addEventListener('offline', () => this.online.set(false));
setInterval(() => void this.flush(), 60_000);
void this.flush();
}
async flush() {
if (this.running || !navigator.onLine) return;
this.running = true;
try {
for (const m of await this.outbox.all()) {
const keepGoing = await this.send(m);
if (!keepGoing) break; // stop on first retryable failure; preserve order
}
} finally {
this.running = false;
}
}
private async send(m: Mutation): Promise<boolean> {
try {
const headers = {
'Idempotency-Key': m.id,
...(m.baseVersion ? { 'If-Match': `"${m.baseVersion}"` } : {}),
};
await firstValueFrom(this.http.request(m.method, m.url, { body: m.body, headers }));
await this.outbox.remove(m.id);
return true;
} catch (err: any) {
if (err.status === 409 || err.status === 412) {
await this.outbox.remove(m.id);
this.conflicts.update(list => [...list, { ...m, lastError: 'conflict' }]);
return true; // a conflict is resolved by the user, not by retrying
}
if (err.status >= 400 && err.status < 500) {
await this.outbox.remove(m.id); // permanently invalid; do not retry forever
console.error('dropping unrecoverable mutation', m, err.status);
return true;
}
await this.outbox.update({ ...m, attempts: m.attempts + 1, lastError: String(err.status ?? 'offline') });
return false; // network error or 5xx: try again on the next flush
}
}
}
The error triage is the important part. Three outcomes, three behaviours: retry (network, 5xx), surface to the user (409/412 conflict), discard with a log (400/422 validation — replaying it will never succeed). A queue that retries everything forever is how offline apps end up permanently wedged behind one bad record.
For background sync while the tab is closed, the Background Sync API exists but is still Chromium-only in 2026 and needs a custom service worker. Do not depend on it. online events plus a flush on app start covers the realistic field workflow, where the user reopens the app when they get back into signal.
Step 5: make the Express 5 API idempotent
The server now has two new obligations: honour Idempotency-Key and enforce optimistic concurrency.
// api/src/idempotency.js
import { Schema, model } from 'mongoose';
const keySchema = new Schema({
_id: String, // the Idempotency-Key
userId: { type: String, required: true },
status: Number,
body: Schema.Types.Mixed,
createdAt: { type: Date, default: Date.now, expires: '30d' },
});
export const IdemKey = model('IdemKey', keySchema);
export async function idempotent(req, res, next) {
const key = req.get('idempotency-key');
if (!key || req.method === 'GET') return next();
const existing = await IdemKey.findById(key);
if (existing) {
if (existing.userId !== req.user.sub) return res.status(403).end();
if (existing.status) return res.status(existing.status).json(existing.body);
return res.status(409).json({ error: 'request already in flight' });
}
await IdemKey.create({ _id: key, userId: req.user.sub });
const json = res.json.bind(res);
res.json = (body) => {
IdemKey.updateOne({ _id: key }, { status: res.statusCode, body }).catch(() => {});
return json(body);
};
next();
}
This is an async middleware, which Express 5 handles natively — a rejection goes to the error handler without a try/catch wrapper. The TTL index (expires: '30d') keeps the collection from growing forever; pick a window longer than your worst realistic offline stretch.
Optimistic concurrency uses a version field and a conditional update. Mongoose's built-in __v works, but an explicit field is clearer across an API boundary:
app.patch('/api/inspections/:id', requireAuth, idempotent, async (req, res) => {
const patch = InspectionPatch.parse(req.body);
const ifMatch = req.get('if-match')?.replace(/"/g, '');
const filter = { _id: req.params.id, owner: req.user.sub };
if (ifMatch) filter.version = Number(ifMatch);
const updated = await Inspection.findOneAndUpdate(
filter,
{ $set: patch, $inc: { version: 1 } },
{ new: true },
);
if (!updated) {
const exists = await Inspection.findOne({ _id: req.params.id, owner: req.user.sub }).lean();
if (exists) return res.status(409).json({ error: 'version conflict', current: exists });
return res.status(404).json({ error: 'not found' });
}
res.json(updated);
});
findOneAndUpdate with the version in the filter is a single atomic operation — no read-then-write race even when two devices sync in the same millisecond. A 409 returns the server's current document so the client can show a real diff instead of a shrug.
Creates need one extra guard. Because the client supplies _id, a duplicate create that somehow arrives without its idempotency key must not become a 500:
app.post('/api/inspections', requireAuth, idempotent, async (req, res) => {
const body = InspectionCreate.parse(req.body); // _id: z.string().uuid()
try {
const doc = await Inspection.create({ ...body, owner: req.user.sub, version: 1 });
res.status(201).json(doc);
} catch (err) {
if (err.code === 11000) {
const existing = await Inspection.findOne({ _id: body._id, owner: req.user.sub });
return res.status(200).json(existing); // same create, already applied
}
throw err;
}
});
Keep owner: req.user.sub in every filter. Client-supplied ids make server-side ownership checks more important, not less — and validating the body with Zod before it reaches Mongoose is still your first line against NoSQL injection.
Step 6: resolve conflicts in the UI, not in a heuristic
Last-write-wins is a decision, not a default — and for inspection notes or stock counts it is usually the wrong one. Surface conflicts:
@for (c of sync.conflicts(); track c.id) {
<div class="conflict">
<p>This record changed on another device while you were offline.</p>
<pre>Your edit: {{ c.body | json }}</pre>
<button (click)="keepMine(c)">Keep my version</button>
<button (click)="discard(c)">Use the server version</button>
</div>
}
"Keep my version" re-enqueues the patch with the server's current version as baseVersion. "Use the server version" drops the mutation and refreshes from the server document. For counters and append-only lists you can avoid conflicts structurally — send $inc or $push semantics ("add 3 units", "append this note") instead of whole documents — and that is worth doing wherever the data allows it.
Step 7: test it honestly
- Playwright offline runs.
await context.setOffline(true), drive the form, thensetOffline(false)and assert the queue drains and exactly one document exists. This is the single highest-value test in an offline app. - Double-submit test. Fire the same mutation twice with the same
Idempotency-Keyand assert one document plus two identical responses. - Conflict test. Patch with a stale
If-Matchand assert a 409 carryingcurrent. - Chrome DevTools. The Application panel shows service worker state and IndexedDB contents; throttle to "Offline" and reload to confirm the shell boots from cache.
- Reload mid-queue. Kill the tab with items in the outbox, reopen, confirm they still flush. This is the test that catches teams who kept the queue in memory.
The short version
Offline-first on the MEAN stack is four pieces: a service worker for the shell and GET caching, an IndexedDB outbox for mutations, a strictly ordered sync loop with proper error triage, and a server that treats Idempotency-Key and a version field as first-class. Once those are in place, "the network dropped" stops being a bug report and becomes a badge in the corner of the screen.
If field users are fighting with your Angular app, our MEAN Stack app development and performance tuning teams do this work regularly — get in touch and tell us what your users lose when the signal drops.