An offline-first PWA on AWS Amplify, part 1: Dexie, an outbox, and a sync engine you can read
A code-level walkthrough of Waypoints' offline-first data layer: IndexedDB as the source of truth, a payload-free outbox, and idempotent sync to AppSync keyed on client-generated UUIDs.
Part 1 of a three-part walkthrough of Waypoints, an offline-first PWA for marking and mapping GPS waypoints, built on React, Dexie, Leaflet, and AWS Amplify Gen 2. This part covers the data layer. Part 2 covers the map, and part 3 covers the service worker and tile caching.
Waypoints is a field instrument: you stand somewhere, you mark the spot, you sort your marks into groups, and you look at them on a map. The defining constraint is in the job description: “you stand somewhere” frequently means somewhere with zero bars. So the app is built on a rule that inverts the usual serverless client: IndexedDB is the database, and the cloud is a replica. The UI never issues a network request for data. Reads come from the local store; writes land in the local store; a sync engine reconciles with AppSync when it can.
This part walks the code that enforces that rule: the Dexie schema, the outbox pattern, and a sync engine small enough to read in one sitting: flush, pull, and about 180 lines total.
The schema: three tables, one of them an outbox
Everything local lives in src/data/db.ts. Two entity tables keyed by a client-generated string id, plus an outbox keyed by an auto-incrementing number:
export const createDb = (name = "waypoints"): WaypointsDB => {
const db = new Dexie(name) as WaypointsDB;
db.version(1).stores({
waypoints: "id, groupId, syncStatus, markedAt",
groups: "id, syncStatus",
outbox: "++opId, entity, entityId, createdAt",
});
return db;
};
The local record types carry three fields that never leave the device:
export interface LocalWaypoint {
id: string;
label?: string;
latitude: number;
longitude: number;
markedAt: string;
groupId?: string | null;
syncStatus: SyncStatus; // 'pending' | 'synced'
deleted: boolean; // tombstone - hidden from the UI, kept until the delete flushes
updatedAt: string;
}
syncStatus, deleted, and updatedAt are sync bookkeeping, not domain data. The server-side model (we’ll get to the Amplify schema below) knows nothing about them.
The outbox op is the piece worth staring at:
export interface OutboxOp {
opId?: number; // auto-increment - doubles as flush order
entity: OutboxEntity; // 'waypoint' | 'group'
type: OutboxOpType; // 'create' | 'update' | 'delete'
entityId: string;
attempts: number;
createdAt: string;
}
No payload. An op records that something changed, never what changed: the flusher re-reads the current record at send time. That one decision does a surprising amount of work: five offline edits to the same waypoint queue five ops, but every one of them sends whatever the record looks like now, so the server never sees intermediate states, and a stale-payload bug can’t exist because there are no payloads to go stale. It does cost some efficiency: those five edits mean five round-trips for one final state, and the flusher could coalesce them but doesn’t, which is the price of keeping it this readable.
The write path: record and op, one transaction
Every mutation in src/data/localStore.ts has the same shape, writing the entity and enqueuing the op inside a single Dexie transaction:
const addWaypoint = async (input: WaypointInput): Promise<LocalWaypoint> => {
const record: LocalWaypoint = {
id: newId(), // crypto.randomUUID()
label: input.label,
latitude: input.latitude,
longitude: input.longitude,
markedAt: input.markedAt,
groupId: input.groupId ?? null,
syncStatus: "pending",
deleted: false,
updatedAt: nowIso(),
};
await db.transaction("rw", db.waypoints, db.outbox, async () => {
await db.waypoints.add(record);
await enqueue("waypoint", "create", record.id);
});
return record;
};
The transaction is the point. A record can never exist without its queued op, and an op can never point at a record that was never written. If you’ve read my event-sourcing series, this rhymes with writing the event and its side effects atomically: the local store and the outbox are the two things that must agree, so they change together or not at all.
The id deserves its own sentence: crypto.randomUUID(), generated on the client, used as the Dexie key and, later, as the DynamoDB id. That dual use is the entire idempotency strategy, and it pays off in the flush loop below.
Deleting is a decision
The delete path has to answer a question the create path doesn’t: has the server ever heard of this record? removeUnsyncedOrTombstone answers it by checking the outbox:
const removeUnsyncedOrTombstone = async (
entity: OutboxEntity,
id: string,
): Promise<void> => {
const table = entity === "waypoint" ? db.waypoints : db.groups;
await db.transaction("rw", table, db.outbox, async () => {
const hasPendingCreate = await db.outbox
.where("entityId")
.equals(id)
.filter((op) => op.type === "create")
.count();
if (hasPendingCreate > 0) {
// Never reached the server — drop the record and all its queued ops.
await table.delete(id);
await db.outbox.where("entityId").equals(id).delete();
return;
}
// Already synced (or create already flushed) — tombstone + enqueue delete.
await table.update(id, {
deleted: true,
syncStatus: "pending",
updatedAt: nowIso(),
});
await enqueue(entity, "delete", id);
});
};
If the create op is still queued, the server never saw this record, so the whole thing (record plus every queued op for it) evaporates locally and the server is never bothered. If the create already flushed, the record gets a tombstone (deleted: true, which the read path filters out) and a delete op joins the queue. The UI treats both cases as “it’s gone”; the sync engine sends a delete only in the case where there’s something remote to delete.
One narrow window this doesn’t cover: a delete that lands while the create op is mid-flight. If the request has already gone out but its response hasn’t come back, the outbox check still sees a pending create and drops everything locally, while the server commits the create anyway, and the next pull quietly brings the record back. It’s rare, and it fails toward keeping data rather than losing it, which is the right direction for a field tool. The delete just isn’t guaranteed to stick against an in-flight create.
Reads: the UI subscribes to the database
The read path is useLiveQuery from dexie-react-hooks. The component re-renders whenever the underlying tables change, whether that change came from a user tap or a background sync merge:
export const useWaypoints = (): LocalWaypoint[] =>
useLiveQuery(
async () =>
(await db.waypoints.toArray())
.filter((w) => !w.deleted)
.sort((a, b) => a.markedAt.localeCompare(b.markedAt)),
[],
[],
);
There’s no cache-invalidation story because there’s no cache: the UI reads the actual database, reactively. Tombstoned rows are filtered right here, which is why a deleted-but-not-yet-flushed record disappears from every screen instantly while its delete op waits in the outbox.
Flush: drain the outbox, in order, stopping at the first failure
The sync engine lives in src/data/sync.ts. Flush walks the outbox in insertion order and sends each op through Amplify’s generated client:
const flush = async (): Promise<void> => {
const ops = await db.outbox.orderBy("opId").toArray();
for (const op of ops) {
const result =
op.entity === "waypoint" ? await sendWaypoint(op) : await sendGroup(op);
const errors = result.errors;
if (
errors &&
errors.length > 0 &&
!(op.type === "create" && isAlreadyExists(errors))
) {
if (op.opId !== undefined) {
await db.outbox.update(op.opId, { attempts: op.attempts + 1 });
}
return; // preserve order: stop at first failure, retry next run
}
await onSuccess(op);
}
};
Two choices to notice. First, a failure stops the whole flush rather than skipping ahead. That’s deliberate: ops are ordered (orderBy('opId')), and a create followed by an update must land in that order, and skipping the failed create to send the update would 404. The failed op’s attempts counter increments and everything retries next cycle. Second, that odd exception in the error check: a failed create whose error says “already exists” is not a failure. That’s the idempotency clause:
const isAlreadyExists = (errors: ModelError[]): boolean =>
errors.some((e) =>
/conditional|already exists|ConditionalCheckFailed/i.test(
`${e.errorType ?? ""} ${e.message ?? ""}`,
),
);
Here’s the payoff of the client-generated UUID. Suppose a create flushes, the request succeeds server-side, but the connection drops before the response arrives. The op stays queued, and the next flush re-sends the same create, with the same id, because the id was minted on the client. Amplify’s create is a conditional put on that id, so DynamoDB rejects the duplicate with ConditionalCheckFailed, and the engine reads that rejection as success, because the record is already there. It’s the same conditional-write idempotency that made the event store’s append safe: the key is the lock, just pointed at a different problem. No version vectors, no conflict resolution machinery; a UUID and a regex.
onSuccess is transactional, like the write path: flip the record to synced (or hard-delete it, for a delete op) and remove the outbox row in one atomic step.
Pull: merge without clobbering
The pull side lists both models and merges them into Dexie, with one guard doing all the safety work:
const mergeWaypoint = async (remote: RemoteWaypoint): Promise<void> => {
const local = await db.waypoints.get(remote.id);
if (local && local.syncStatus === 'pending') return; // never clobber unsynced edits
await db.waypoints.put({ /* remote fields */, syncStatus: 'synced', deleted: false, updatedAt: nowIso() });
};
The policy is last-write-wins with a local bias: anything you edited offline is untouchable until its op flushes; everything else takes the server’s word. That’s the whole conflict story, and it’s worth being honest about what it doesn’t do: concurrent edits to the same record from two devices are not merged field-by-field; the pending device wins locally and overwrites on flush. For a single-user field tool, that trade is fine. Know that you’re making it.
The DynamoDB detail that bit anyway
Even a clean abstraction leaks once. sendWaypoint has a special case around groupId, and the comment does the explaining:
// groupId is the GSI key for the Group.waypoints relationship. DynamoDB
// rejects a NULL value on an indexed key during create (PutItem), so omit
// it entirely when there is no group. On update, an explicit null is
// translated by Amplify into a REMOVE, which correctly clears the group.
if (op.type === "create") {
if (record.groupId) payload.groupId = record.groupId;
} else {
payload.groupId = record.groupId ?? null;
}
If you’ve worked with Amplify’s hasMany/belongsTo, you’ve met this: the foreign key is a GSI key, GSI keys can’t be NULL on write, and “no group” therefore means omit the attribute on create but send null on update (which AppSync turns into a REMOVE). It’s three lines, it’s the only place the app knows it’s talking to DynamoDB, and it’s exactly the kind of thing an offline queue has to get right because the user isn’t there to see the error.
The backend: two models and an owner rule
The entire Amplify Gen 2 data layer is one small schema in amplify/data/resource.ts:
const schema = a.schema({
Group: a
.model({
name: a.string().required(),
description: a.string(),
waypoints: a.hasMany("Waypoint", "groupId"),
})
.authorization((allow) => [allow.owner()]),
Waypoint: a
.model({
label: a.string(),
latitude: a.float().required(),
longitude: a.float().required(),
markedAt: a.datetime().required(),
groupId: a.id(),
group: a.belongsTo("Group", "groupId"),
})
.authorization((allow) => [allow.owner()]),
});
export const data = defineData({
schema,
authorizationModes: { defaultAuthorizationMode: "userPool" },
});
allow.owner() gives per-user isolation for free: every record is stamped with the Cognito identity that created it, and list() only ever returns your own rows, which is why the pull step can be as naive as “list everything and merge.” The sync engine consumes the generated client through a hand-written SyncClient interface (four methods per model), which is what lets the entire engine run under Vitest against fake-indexeddb and a mock client. The sync tests cover the ordered-flush-stops-at-first-failure behavior, the already-exists swallow, and the never-clobber-pending merge without a network in sight.
When sync runs
The engine starts only after Cognito reports a signed-in user (AuthedApp wires it in a useEffect), and the schedule is minimal:
const start = (): (() => void) => {
const handler = (): void => {
void flush().then(pull);
};
void flush().then(pull); // on app launch
window.addEventListener("online", handler); // and whenever connectivity returns
return () => window.removeEventListener("online", handler);
};
Launch, and the online event. That’s the whole scheduler. The honest caveats: there is no flush-after-every-write trigger, so a change made while online can sit queued until the next online event or relaunch; retries have a counter but no backoff or cap; and cross-device deletes don’t propagate: pull upserts what the server returns but never removes local rows that are absent from the response. Cognito adds one more seam: sign-in needs a network, but the cached session keeps you authenticated offline afterward, so the only thing a dead zone blocks is the initial login.
None of these gaps threaten the core guarantee, which is the one the app was built around: nothing you do in the field is ever lost. It’s in IndexedDB the moment you tap, it’s queued in the same transaction, and it flushes, idempotently and in order, when the network comes back.
The data layer is half the offline story. The other half is the app itself surviving a reload with no network: the service worker, the app shell, and a map that still has tiles. The map comes first: part 2 covers Leaflet, GPS capture, and the convex hull; part 3 covers the PWA shell and tile caching.