Offline-First Job Tracker
built with: Dexie + IndexedDB
IndexedDB via Dexie, behind a repository layer, with live queries that make cross-tab sync fall out for free.
Why IndexedDB and not localStorage
localStorage is synchronous, string-only, and capped at a few megabytes. A job tracker holds structured records with nested arrays of rounds, contacts, documents, and notes — serialising all of that to JSON on every keystroke blocks the main thread.
IndexedDB is asynchronous, stores structured objects directly, and supports indexes. Dexie makes it usable without the raw API's callback ceremony.
One database serves the whole app, with one table per feature and a single version declaring them all. Adding a feature means adding a table to that same `stores({...})` call and bumping the version — not spinning up a second Dexie instance.
// src/db/index.ts
class QuickRecallDB extends Dexie {
jobs!: Table<JobApplication, string>;
speakUpQAs!: Table<SpeakUpQA, string>;
bookmarks!: Table<Bookmark, string>;
reviews!: Table<ReviewState, string>;
attempts!: Table<PracticeAttempt, string>;
practiceSessions!: Table<PracticeSessionState, string>;
mockInterviews!: Table<MockInterview, string>;
constructor() {
super('quickrecall');
this.version(1).stores({
jobs: 'id, status, favorite, createdAt',
speakUpQAs: 'id, sourceId, jobId, createdAt',
bookmarks: 'id, kind, createdAt',
reviews: 'id, dueAt',
attempts: 'id, refId, startedAt',
practiceSessions: 'refId',
mockInterviews: 'id, status, startedAt'
});
}
}The schema string is indexes, not columns
A common misreading of `jobs: "id, status, favorite, createdAt"` is that those are the fields being stored. They are not — Dexie stores the entire object regardless. That string declares the primary key and which fields get indexes for querying and sorting.
So `orderBy("createdAt")` is fast because `createdAt` is indexed, while a field like `company` is still persisted and readable, just not indexable without a schema bump.
Repository layer, and why it is async
Components never touch the `db` object. Each feature owns a repository module that is the only code allowed to persist for that table, exposing plain `getAll` / `create` / `update` / `remove` functions.
Those functions are async-shaped even where Dexie could resolve faster, so swapping IndexedDB for an HTTP backend later means rewriting the bodies of four functions and nothing else.
Reads are normalised on the way out. A record written by an older build might be missing an array field, and a single `undefined.map()` would take down the board — so `normalizeJob` coerces the nested arrays defensively.
// src/db/jobs.ts
function normalizeJob(raw: JobApplication): JobApplication {
return {
...raw,
rounds: Array.isArray(raw.rounds) ? raw.rounds : [],
contacts: Array.isArray(raw.contacts) ? raw.contacts : [],
documents: Array.isArray(raw.documents) ? raw.documents : [],
notes: Array.isArray(raw.notes) ? raw.notes : []
};
}
export async function getAll(): Promise<JobApplication[]> {
const rows = await db.jobs.orderBy('createdAt').reverse().toArray();
return rows.map(normalizeJob);
}Cross-tab sync for free
The UI reads through `useLiveQuery` from `dexie-react-hooks`. Dexie observes the tables a query touched and re-runs it whenever any of them change — including changes made in another browser tab.
That removes an entire category of code. There is no optimistic patching, no cache invalidation, no manual state sync, no refetch-after-mutate. A mutation just writes, and every subscribed view in every open tab updates itself.
Important
Gotcha
Where it lives in the repo
- src/db/index.ts
- src/db/jobs.ts
- src/components/job-tracker/use-jobs.ts
- src/types/job-tracker.ts