Skip to main content

Search

Search problems, hooks, and pages

Back to About

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

The schema is intentionally a single v1 with no migration chain, because this database was created on a fresh origin. If it ever reuses an origin that already shipped a higher IndexedDB version, the version chain has to be restored or Dexie will refuse to open.

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