DEV Community

Cover image for I Built a Serverless, Offline-First Exam Simulator for the Google Cloud Architect Cert (Beta)
Thien Nhan (Jay)
Thien Nhan (Jay)

Posted on

I Built a Serverless, Offline-First Exam Simulator for the Google Cloud Architect Cert (Beta)

๐Ÿงช ArchiCloud PCA is currently in BETA. Everything below is live and working, but expect rough edges, new questions every week, and the occasional breaking change in how progress is stored. Feedback is very welcome.

Studying on a train with zero bars

I'm preparing for the Google Professional Cloud Architect (PCA) exam. Most of my study time happens in places with bad or no connectivity: trains, cafรฉs, the 20 minutes before a meeting.

Every tool I tried failed at least one of these:

  • It needed a connection.
  • Its mock exams didn't follow the real exam's section weights.
  • It said which answer was right but never why the others were wrong.
  • It was a pile of leaked "exam dumps" (which break Google's certification agreement).

So I built my own: ArchiCloud PCA. It's an installable PWA that:

  • works 100% offline after the first visit (notes, quizzes, timed mock exams),
  • syncs progress across devices when you sign in,
  • has no server I have to write or run: Firebase is the whole backend,
  • and costs $0/month on Firebase's free Spark plan.

This post covers the architecture and the decisions behind it, plus the bugs that cost me the most time.


The constraints

Constraints made most of the decisions for me:

Constraint Consequence
Solo developer, evenings only No server to operate. No API layer to version.
Must work offline Content ships inside the bundle; the client owns the state.
$0 budget Firebase Spark: 50k reads / 20k writes per day, no billing account.
No copyrighted course material Only metadata + links to the official courses; every note and question is self-written.

What's in the app today (beta):

  • 24 courses from the official learning path, tracked as To do / Doing / Done
  • 35 study notes in English, each with a Vietnamese translation
  • 462 original questions, each tagged to an official exam-guide objective, with a rationale for every option
  • 4 case studies (Altostrat Media, Cymbal Retail, EHR Healthcare, KnightMotives Automotive)
  • Practice quizzes, a timed 50-question mock exam, a "review what I got wrong" pile, and a progress dashboard

Tech stack and why

Layer Choice Why (and what I rejected)
Build Vite 8 + React 19 + TypeScript 6 A pure SPA. No SSR needed, and a PWA is simpler without Next.js.
Routing React Router 8 (data mode, lazy routes) Every page except home is its own chunk.
UI Tailwind CSS v4 + shadcn/ui (Radix) Fast, accessible primitives, dark mode for free.
State Zustand 5 with persist Tiny API. subscribe(next, prev) turned out to be the key to the whole sync design.
Validation Zod 4 One schema validates content at build time and backups at runtime.
Auth + DB Firebase Auth + Cloud Firestore (Spark) The client SDK talks to the DB directly; Security Rules act as the API layer. The offline cache is built in.
PWA vite-plugin-pwa + Workbox (injectManifest) A custom service worker so I control exactly what is (and isn't) intercepted.
Tests Vitest + Testing Library + Firebase Emulator + Playwright One tool per layer (more on that later).
Hosting Firebase Hosting Same origin as the Auth handler (/__/auth), which matters for sign-in on mobile.

I rejected MongoDB Atlas + an API. It would mean a server to run and hand-written offline sync. Firestore gives you an IndexedDB cache and an offline write queue out of the box.


Architecture at a glance

Browser (installed PWA)
 โ”œโ”€ Service Worker (Workbox)
 โ”‚    โ”œโ”€ precache: app shell + every page chunk + every note & question
 โ”‚    โ””โ”€ runtime: avatar images (CacheFirst)
 โ”œโ”€ React app (feature folders)
 โ”‚    โ”œโ”€ features/learn     โ†’ Markdown notes, course path
 โ”‚    โ”œโ”€ features/quiz      โ†’ pure TS engine + Zustand stores
 โ”‚    โ”œโ”€ features/progress  โ†’ insights, JSON backup/restore
 โ”‚    โ”œโ”€ features/custom    โ†’ imported questions (NotebookLM)
 โ”‚    โ”œโ”€ features/auth      โ†’ Firebase Auth (lazy-loaded)
 โ”‚    โ””โ”€ features/sync      โ†’ local-first sync (lazy-loaded)
 โ”‚
 โ”‚   Zustand stores (localStorage)  โ† the source of truth for the UI
 โ”‚            โ‡…  ProgressSync (pull delta / debounced push)
 โ”‚   Firestore SDK (IndexedDB cache + offline write queue)
 โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ‡… when online
Firebase (Spark): Hosting ยท Auth ยท Firestore + Security Rules
Enter fullscreen mode Exit fullscreen mode

The rule that shapes everything: the UI never reads from Firestore. It reads from Zustand stores persisted to localStorage. Firestore is a replication target. Today (beta) you can still study as a guest, but sign-in is becoming required: Firestore will hold your imported and generated questions, so they need an owner.

The Firebase SDK (~630 kB) is never on the critical path. It is loaded with a dynamic import() only after the app has rendered:

// src/features/auth/auth-bootstrap.tsx
void import('./actions').then((m) => m.initSession())
Enter fullscreen mode Exit fullscreen mode

Content as code (validated like code)

All study content lives in the repo as JSON (questions) and Markdown (notes). That gives me version control, code review for content, and offline support for free, because it's bundled.

Every question goes through a Zod schema with cross-field rules:

// src/content/schemas.ts (trimmed)
export const Question = z
  .object({
    id: z.string().regex(/^(ct-\d+|gd-[a-z0-9-]+)-q\d{2,}$/),
    domain: DomainId,
    objective: ObjectiveId, // e.g. "1.3" from the official exam guide
    type: z.enum(['single', 'multi']),
    options: z.array(z.object({ id: OptionId, text: z.string().min(1) })).min(3).max(6),
    correct: z.array(OptionId).min(1),
    rationale: z.string().min(1),
    /** Why each option is right or wrong โ€” one entry per option. */
    optionRationales: z.record(OptionId, z.string().min(1)),
    difficulty: z.union([z.literal(1), z.literal(2), z.literal(3)]),
  })
  .superRefine((q, ctx) => {
    if (q.type === 'single' && q.correct.length !== 1)
      ctx.addIssue({ code: 'custom', message: 'single-choice needs exactly 1 correct answer' })
    const explained = Object.keys(q.optionRationales).sort()
    if (explained.join() !== q.options.map((o) => o.id).sort().join())
      ctx.addIssue({ code: 'custom', message: 'optionRationales must explain every option' })
  })
Enter fullscreen mode Exit fullscreen mode

Then a build-time validator (npm run prebuild) checks what a schema can't see on its own:

  • every question's objective belongs to its domain,
  • section weights sum to exactly 1,
  • each Vietnamese note has the same heading structure as its English original,
  • and my favorite, the "longest answer is correct" tell:
// src/content/validate.ts
/** A correct option this much longer than every distractor gives the answer away. */
export const LENGTH_TELL_RATIO = 1.15

if (q.type === 'single') {
  const longestWrong = Math.max(
    ...q.options.filter((o) => !q.correct.includes(o.id)).map((o) => o.text.length),
  )
  const ratio = length(q.correct[0]!) / longestWrong
  if (ratio > LENGTH_TELL_RATIO)
    warnings.push(`${q.id} correct option is ${ratio.toFixed(2)}ร— the longest distractor`)
}
Enter fullscreen mode Exit fullscreen mode

When you write your own questions, you tend to make the correct answer the most precise one, and therefore the longest. Test-takers learn to spot that. The validator caught a lot of them, and I rebalanced every one.

At runtime, questions and notes are code-split per course with import.meta.glob. The service worker then precaches them all:

// src/content/loader.ts
const questionModules = import.meta.glob<unknown>('../../content/questions/*.json', {
  import: 'default',
})
Enter fullscreen mode Exit fullscreen mode

A pure, deterministic quiz engine

The quiz engine is plain TypeScript with no React and no I/O. Every function takes a session and returns a new one, so it's trivial to unit-test and to persist.

Seeded randomness

A quiz is fully described by its seed. Reload the page mid-quiz and the same seed rebuilds the same question order and option shuffle:

// src/features/quiz/engine/random.ts
/** Deterministic PRNG (mulberry32): the same seed always gives the same quiz. */
export function createRng(seed: number): () => number {
  let a = seed >>> 0
  return () => {
    a = (a + 0x6d2b79f5) >>> 0
    let t = a
    t = Math.imul(t ^ (t >>> 15), t | 1)
    t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296
  }
}
Enter fullscreen mode Exit fullscreen mode

Math.random() can't be seeded, so tests would be flaky and a reload would reshuffle the options under the learner's cursor.

A mock exam that respects section weights

The real exam weights its six sections from 12.5% to 25%. With 50 questions, the shares don't come out as whole numbers. A section might also have fewer questions in the bank than its share. allocate() uses the largest-remainder method and moves any shortfall to sections that still have questions:

// src/features/quiz/engine/exam.ts (core loop)
while (remaining > 0 && open.length) {
  const weight = open.reduce((n, d) => n + d.weight, 0)
  const shares = open.map((d) => ({ id: d.id, exact: (remaining * d.weight) / weight }))
  const add = new Map(shares.map((s) => [s.id, Math.floor(s.exact)]))
  let left = remaining - [...add.values()].reduce((a, b) => a + b, 0)
  // hand out the leftover seats to the biggest fractional parts
  for (const s of [...shares].sort((a, b) => frac(b.exact) - frac(a.exact))) {
    if (left-- <= 0) break
    add.set(s.id, add.get(s.id)! + 1)
  }
  for (const [id, n] of add) {
    const take = Math.min(n, cap(id) - (counts[id] ?? 0))
    counts[id] = (counts[id] ?? 0) + take
    remaining -= take
  }
  open = open.filter((d) => cap(d.id) - (counts[d.id] ?? 0) > 0) // full sections drop out
}
Enter fullscreen mode Exit fullscreen mode

A timer that survives a reload

The exam stores startedAt and durationMs, not a countdown. The remaining time is always derived:

export const deadline = (exam: ExamSession) => exam.startedAt + exam.durationMs
export const remainingMs = (exam: ExamSession, now = Date.now()) =>
  Math.max(0, deadline(exam) - now)
Enter fullscreen mode Exit fullscreen mode

The session is persisted with Zustand. If you close the tab, kill the app, or lose power, the clock is still right when you come back. Submitting after the deadline counts as a time-out at the deadline, so a sleeping laptop can't buy you extra minutes.


Local-first sync without writing a server

This is the most interesting part of the project.

Goal: progress (course status, per-question stats, exam history, imported questions) should follow you across devices. Offline edits must never be lost, and Firestore reads and writes must stay well under the free quota.

1. Server-stamped deltas

Every document carries a syncedAt field set with serverTimestamp(). Each device remembers how far it has pulled, so a sign-in only fetches what changed since then:

// src/features/sync/sync.ts
const since = where('syncedAt', '>', Timestamp.fromMillis(sinceMs))
const [courses, stats, attempts, custom] = await Promise.all(
  ['courseProgress', 'questionStats', 'attempts', 'customQuestions'].map((name) =>
    getDocs(query(collection(this.db, 'users', this.uid, name), since)),
  ),
)
Enter fullscreen mode Exit fullscreen mode

Why a server timestamp and not the client clock? Device clocks drift. A phone set 5 minutes behind would otherwise miss changes forever.

2. Merge: last-write-wins per key

Merging is a pure function, unit-tested without Firebase:

// src/features/sync/merge.ts
/** Last-write-wins per key on `updatedAt`. Ties keep the local value. */
export function mergeByUpdatedAt<T extends { updatedAt: number }>(
  local: Record<string, T>,
  remote: Record<string, T>,
) {
  const merged = { ...local }
  const pulled: string[] = []
  const toPush: string[] = []
  for (const [key, r] of Object.entries(remote)) {
    const l = local[key]
    if (!l || r.updatedAt > l.updatedAt) {
      merged[key] = r
      pulled.push(key)
    } else if (l.updatedAt > r.updatedAt) {
      toPush.push(key)
    }
  }
  for (const key of Object.keys(local)) if (!(key in remote)) toPush.push(key)
  return { merged, pulled, toPush }
}
Enter fullscreen mode Exit fullscreen mode

Exam and quiz attempts are immutable, so they don't need LWW. They're merged as a union by id.

3. Push: watch the stores, debounce, batch

After the first pull, ProgressSync subscribes to each Zustand store and diffs prev against next by reference. Because the store updates are immutable, a changed key always has a new object:

useProgress.subscribe((next, prev) => {
  if (this.applyingRemote) return // don't echo what we just pulled
  for (const id of changedKeys(prev.courses, next.courses)) this.queueCourse(id)
  this.scheduleFlush()
})
Enter fullscreen mode Exit fullscreen mode

Writes are keyed in a Map, so ten flag toggles on the same question collapse into one write. They are flushed after a 1.5 s debounce, in writeBatches of up to 450. They are also flushed immediately on visibilitychange โ†’ hidden and pagehide, because mobile browsers kill background tabs without warning.

The honest trade-off

LWW on questionStats means that if you answer the same question on two devices while both are offline, one seen increment can be lost. For a study app that's fine. A CRDT counter would fix it, but it isn't worth the complexity in beta.


Security Rules are the API layer

With no server code of my own, firestore.rules is the API contract. Every collection has its own schema validation:

// firestore.rules (excerpt)
function stamped() {
  return request.resource.data.syncedAt == request.time;
}

match /questionStats/{questionId} {
  allow read: if isOwner(uid);
  allow create, update: if isOwner(uid)
    && request.resource.data.keys().hasOnly(
         ['seen', 'correctCount', 'lastResult', 'lastSeenAt', 'flagged', 'updatedAt', 'syncedAt'])
    && isCount(request.resource.data.seen)
    && request.resource.data.correctCount <= request.resource.data.seen
    && stamped();
}

// Finished quizzes and exams. Immutable once written.
match /attempts/{attemptId} {
  allow create: if isOwner(uid) && /* schema checks */ stamped();
}

match /{document=**} {
  allow read, write: if false; // deny by default
}
Enter fullscreen mode Exit fullscreen mode

Three details worth stealing:

  • syncedAt == request.time forces clients to use serverTimestamp(). Nobody can backdate a write to hide it from another device's delta pull.
  • keys().hasOnly([...]) stops clients from stuffing extra fields into documents.
  • Attempts only allow create, so exam history can't be rewritten after the fact.

The rules are covered by tests that run against the Firebase Emulator with @firebase/rules-unit-testing (npm run test:rules).


Offline PWA, done carefully

The service worker is small, but every line exists for a reason:

// src/sw.ts
// The whole app โ€” shell, every page chunk, and all notes and questions โ€” is precached at install.
precacheAndRoute(self.__WB_MANIFEST)
cleanupOutdatedCaches()

// Client-side routes load the app shell. Firebase Hosting's reserved /__/ URLs (the auth handler)
// must always reach the network.
registerRoute(
  new NavigationRoute(createHandlerBoundToURL('/index.html'), { denylist: [/^\/__\//] }),
)

// A new version waits until the learner accepts the "update" prompt, so a quiz is never cut off.
self.addEventListener('message', (event) => {
  if (event.data?.type === 'SKIP_WAITING') void self.skipWaiting()
})
Enter fullscreen mode Exit fullscreen mode
  • Precache everything. The content is small (it's text), so after one visit every note and question is available offline.
  • The /__/ denylist. Without it, the SW answers Firebase's /__/auth/handler with the SPA shell, and Google sign-in redirects silently break.
  • registerType: 'prompt'. An auto-updating SW could reload the page in minute 87 of a 120-minute mock exam. Updates wait for a click.

A Playwright test proves it end to end. It goes offline, reloads, deep-links to a course, starts a quiz, and checks that /__/auth/handler is not served by the service worker:

const handler = await page.goto('/__/auth/handler')
expect(handler?.fromServiceWorker()).toBe(false)
Enter fullscreen mode Exit fullscreen mode

War stories: bugs that cost me the most time

1. Deploys that could break the app for up to an hour.
Firebase Hosting served the SPA-rewritten index.html with its default max-age=3600. After a deploy, a browser could keep the old HTML for up to an hour, and that HTML pointed at hashed JS files the new deploy had removed. The fix: Cache-Control: no-cache on everything except /assets/**, which is content-hashed and stays immutable.

2. await batch.commit() never returns offline.
With Firestore's offline cache, a write promise resolves only when the server acknowledges it. The write already sits in the local cache, but awaiting it offline blocked the sync loop. The fix:

// Offline, the promise resolves only once the server has the batch; the cache already has it.
if (navigator.onLine) await batch.commit()
else void batch.commit()
Enter fullscreen mode Exit fullscreen mode

3. Firestore rejects undefined.
Firestore refuses to write a document that contains an undefined field, and optional fields on imported questions are often undefined. The cheapest fix is to round-trip the object through JSON before writing: JSON.parse(JSON.stringify(question)).

4. One device, two accounts.
If you study as a guest (still possible in beta), then sign in as A, sign out, and sign in as B, B must never receive A's data. Local progress is tagged with an ownerUid. A different account triggers a wipe and a full pull, and signing out clears the device copy.

5. Google sign-in on an installed PWA.
signInWithPopup is unreliable in standalone mode and on phones, so the app picks the strategy at runtime:

const preferRedirect = () =>
  window.matchMedia('(display-mode: standalone)').matches ||
  window.matchMedia('(pointer: coarse)').matches
Enter fullscreen mode Exit fullscreen mode

It also falls back to redirect on auth/popup-blocked.


Smaller features worth a mention

  • NotebookLM import. NotebookLM has no public quiz API. The app gives you a copyable prompt that makes NotebookLM reply in a strict plain-text format, and a parser that reads it (plus CSV and JSON). It resolves answers written as "B", "b)", "2", "A, C" or the option's own text. Imported questions join your quizzes, the review pile and, optionally, mock exams.
  • Typed i18n (EN/VI) without a library. en.ts is the source of truth and vi: Messages must provide every key, so a missing translation fails tsc. Study notes have Vietnamese versions, but questions stay in English, like the real exam.
  • Progress insights. Per-section mastery, the five weakest exam objectives (each with a one-click practice quiz), and a mock exam trend chart against the 80% target. Progress can be exported to and restored from a JSON backup, because Spark has no automatic backups.

Testing strategy

Layer Tool What it covers
Pure logic Vitest Quiz engine, allocate(), merge, NotebookLM parser, content validator
UI Testing Library + jsdom Pages and routes, Vietnamese rendering, translation key parity
Backend Firebase Emulator Security Rules (allowed/denied writes) and the full sync flow
PWA Playwright Offline reload, deep links, manifest, /__/auth passthrough

Keeping the engine and the merge logic pure made this easy. Most of the important behavior is tested in milliseconds, with no browser and no Firebase.


What I learned

  1. Local-first is a data-ownership decision, not a library. Once the client store is the source of truth, offline mode and a fast UI follow from it, whether or not the user is signed in.
  2. Security Rules can be a real API layer if you treat them like one: schema validation, server timestamps, immutability, deny by default, and tests.
  3. Validate content like code. A build that fails on a bad question is much better than a learner finding it on exam day.
  4. Write down your trade-offs. LWW loses an increment in a rare edge case. I know it and I've documented it, and that's enough for now.

What's next (beta roadmap)

  • More questions, driven by feedback from early users
  • Sign-in required. Imported and generated questions will live in Firestore under each learner's account, so every learner needs an account
  • Vietnamese translations of the questions (optional, with an EN toggle)
  • Automated tests for the "new version available" flow
  • Leaving beta once the question bank and sync have been tested by more learners

Try the beta: archicloud-pca.web.app. Install it, turn on airplane mode, and take a mock exam. If you find a wrong answer, a confusing explanation or a bug, please leave a comment. That kind of report is exactly what the beta is for. ๐Ÿ™

ArchiCloud PCA is an independent study project. It is not affiliated with or endorsed by Google. All notes and questions are original; no exam dumps.

Top comments (0)