I moved to Hong Kong in 2025 and committed to learning Mandarin from zero. I wanted an HSK tool I would use every day. Four criteria:
- Followed the official November 2025 HSK 3.0 list, every level
- Native-speaker audio on every word and every example sentence
- Ran on my phone and my laptop with synced progress
- No subscription
Nothing out there checked all four. So I built it.
Mandarin Deck is live at mandarindeck.com. It is free to use, and I pay the hosting costs myself. This is what I learned across 25 build sessions.
Quick jargon note: spaced repetition shows each word right before you forget it. A PWA is a web app you install to your home screen and use offline, without an app store.
HSK 3.0, the November 2025 final
Old HSK was six levels and about 5,000 words. HSK 3.0 is nine levels and 10,902 words. Levels 1 through 6 cover everyday fluency. Levels 7 through 9 push toward professional and near-native. I started on an earlier draft and had to migrate once the November 2025 final list dropped.
I also added 1,379 supplementary words: food, restaurants, Hong Kong terms, transport, and everyday filler. Real conversations need those words long before HSK 4.
The architecture I did not design
Mandarin Deck started as a 10.8 MB HTML file with inline JavaScript, CSS, and word data. No build step. No backend. No API calls. Progress lived in localStorage.
I did not design that architecture. It happened because I wanted the shortest path from idea to working browser app: one file to debug, one file to ship, zero toolchain.
Then I wanted my progress on my phone without emailing myself a JSON export. The real work began.
Cloud sync on a file:// app
The question was how to add cloud sync without rewriting the app. The answer: keep localStorage as the fast mirror and make Supabase authoritative only when signed in. Google OAuth, row-level security per user, one table per concern.
Conflict handling mattered. I did not want three-way merges. When local and cloud disagree on reload, the side with more words wins by default, and a banner lets the user switch. Study sessions flush immediately. Mid-session writes debounce to 30 seconds. Tab-close events go through a local pending-flush queue because sendBeacon cannot attach Supabase auth headers.
Every one of those design choices exists because of a specific bug. The next two sections are the two I remember the clearest.
The reset that would not reset
“Reset all progress” broke in the worst possible way. A user tapped it, saw “done,” came back the next day, and found all their old progress still there.
The app kept progress in two browser stores. Reset cleared one. The other restored everything on the next load. The app undid my undo.
The fix was not a patch. Every storage read and write now flows through one helper layer that knows both stores. No shortcuts. The root cause was not a typo. It was a discipline problem.
For the curious. The two layers are localStorage and an IndexedDB mirror. The reset code called localStorage.removeItem directly. On reload, the wrapper repopulated localStorage from IndexedDB. The reset might as well have never happened.
Lesson: if you add a safety net around one part of the app but not the whole app, you are carrying two different products in one codebase. Close that gap.
The button that looked alive but wasn’t
A user reported that the Check button stopped working. I tried my phone, laptop, signed in, signed out. All fine.
The bug only appeared in private browsing. Sign-in could not persist, the cloud save failed, and the app got stuck halfway through processing the tap. It thought it was still saving, so every next tap was ignored.
The fix was boring: catch the error, clear the busy state, show a toast. Rule written down: any code that enters a busy state before risky work needs an exit path when that work fails.
For the curious. submitAnswer mutated phase='submitting' before awaiting cloudPush(), which threw because Supabase sessions can’t persist in incognito. Every subsequent Check tap bailed early in the phase !== 'answering' guard. Fix: wrap the transition in try and catch, restore the phase on error, surface the failure via a non-blocking toast.
The update that trapped itself on people’s phones
The scariest bugs came from the service worker. Once it ships, it runs on users’ devices. If it breaks, I cannot simply take it back from the server.
I learned this by shipping a broken update prompt. It worked on my computer and broke on iPhones. Worse, the broken copy installed itself on devices and blocked the good copy from loading.
The fix was two parts: a simpler update check that avoids service-worker lifecycle tricks, and a small reset page I can send to anyone whose app gets stuck. It clears the bad browser pieces without erasing progress.
For the curious. Take 1 was a service-worker statechange listener. Take 2 is a <meta name="app-version"> tag baked at build time plus a data/version.json poll and a reload toast when they disagree. No service-worker lifecycle changes. The panic button is /reset.html, which clears caches and unregisters service workers while preserving localStorage and IndexedDB.
Lesson: any piece of code that runs on your users’ devices and cannot be rolled back from the server deserves staging-grade paranoia. Ship the escape hatch before you need one.
The most useful file I have
The most useful thing I built is not in the app. It is CHANGELOG.md. Every session, I write what I did, what broke, how I fixed it, and what I would tell myself next time.
Every bug story above is in that file. Without it, the same bug shape would come back three weeks later in a new outfit, and I would fight it from scratch.
If you are building alone, write down what you learn. Not a polished retrospective. A scruffy log. It is the only memory you have.
Where it is going
Next: expand reading mode through HSK 4 to 6, decide whether handwriting input belongs, and keep improving offline behavior. The Supabase custom-domain upgrade is also on the list because Google OAuth currently shows the raw Supabase redirect.
Try it
mandarindeck.com. Free. Sign in with Google so progress follows you everywhere. If you hit a bug, every drill card has a “Report a Problem” button.
The project page is the short version. This post is the build story.