Building offline-first: lessons from Numera's architecture
When we started building Numera, we made a decision that shaped every technical choice afterwards: it had to work without internet. Not "offline mode available". Not "works better with connection". Truly, completely, functionally offline. Here's how we built a PWA that serves thousands of users without a single server call after first visit.
Why offline-first
The decision wasn't technical. It was practical.
We were building for Ontario teachers and teacher candidates, and Ontario is big. Rural schools have unreliable connections. Urban schools have shared bandwidth that crawls during peak hours. GO Train tunnels kill cell service. Not everyone has unlimited data plans. Some students don't have home internet at all. An app that requires constant connectivity excludes the people who need it most.
Once you commit to that as a hard constraint, every architectural choice downstream becomes simpler. You stop debating whether to add an offline mode and start asking which features can survive without one — and the answer turns out to be: most of them, if you design from day one.
The architecture stack
Numera's frontend stack is intentionally boring: React + Vite + Tailwind for the UI, Zustand + IndexedDB for state and storage, a custom math engine (20+ deterministic solvers) for problem solving, a service worker for caching and background sync, and the standard browser APIs (IndexedDB, Cache Storage, Web App Manifest) underneath. The interesting choice isn't any single layer — it's the rule that every layer must function without network access.
The PWA foundation
Not all Progressive Web Apps are offline-first. Many are just websites with a service worker that caches assets — and the moment you ask them to do something the cache didn't anticipate, they fail. True offline-first requires more.
Our service worker uses a cache-first, network-fallback strategy. The app loads from cache first, network second. Users see content instantly even when offline; if a request can be served from cache, we never touch the network at all. New requests go to the network and get cached on the way back, so the next visit is instant.
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.match(event.request).then((response) => {
if (response) return response; // serve from cache
return fetch(event.request).then((res) => {
caches.open('numera-v1').then((cache) => {
cache.put(event.request, res.clone());
});
return res;
});
})
);
});Pair that with a precached app shell, a manifest that lets the user "Add to Home Screen", and runtime caching strategies for fonts/images — and the app behaves like a native install rather than a website.
The data storage architecture
We store three categories of data locally, each in the right place. Static assets (JS bundles, CSS, HTML, icons, images) live in the Cache API — that's what it's for. Structured data (the question bank with 10,000+ problems, user progress and history, settings) lives in IndexedDB. Session state (current session data, UI preferences, temporary calculations) lives in localStorage because it's simple and the data is small.
We evaluated other options for the question bank. localStorage was disqualified by the 5MB limit and the strings-only constraint. WebSQL is deprecated. The Cache API is designed for assets, not structured queries. IndexedDB was the only option that gave us the 50MB+ storage we needed (browser-dependent), structured data with proper indexes for fast queries, and asynchronous operations that don't block the UI. Yes, the API is verbose — we wrap it with idb and move on.
The question bank schema is straightforward — each question is a record with id, grade, strand, difficulty, the question text, the answer, an array of solution steps, and tags. The whole bank is about 15MB compressed for 10,000+ questions, downloaded on first visit and never refetched after.
The math engine: running locally
The most important architectural decision after "offline-first" was deterministic, not AI-based. Most math apps send problems to servers because solving math at scale is hard. We rejected this for three reasons: latency (200ms round-trip vs 10ms local), privacy (problems should stay on the device), and reliability (local works during outages).
The engine is a modular solver system. A classifier looks at the input, decides what kind of problem it is, and routes to a specialized solver. Each solver runs entirely in JavaScript, uses deterministic algorithms (not AI), returns step-by-step explanations, and completes in under 100ms.
function solve(problem) {
const type = classify(problem);
const solvers = {
'linear': solveLinear,
'percent': solvePercent,
'geometry': solveGeometry,
// ... 20+ solvers
};
return solvers[type](problem);
}For education specifically, deterministic beats AI on every axis that matters. AI math solvers take 1–5 seconds; deterministic takes <100ms. AI may hallucinate plausible-looking wrong answers; deterministic is always correct (or it errors and tells you). AI varies by prompt; deterministic gives the same input the same output every time. AI sends problems to a server; deterministic stays local. AI costs API fees; deterministic costs nothing per use. AI requires connection; deterministic works anywhere.
The trade-off is coverage. AI can attempt anything; deterministic only handles problem types we've explicitly built solvers for. For Ontario MPT prep, that's exactly the right trade — the curriculum is bounded, so we can hit it precisely.
State management without servers
Traditional apps sync state with servers: user action → API call → server update → response → UI update. Offline apps can't do this, so we needed a different model.
Our solution is local-first state with optional sync. We use Zustand with a custom IndexedDB persistence layer. State changes update the store immediately; the persist middleware writes to IndexedDB asynchronously. Nothing waits for a network round-trip. There's no manual sync code in the components — they just update state and trust the persistence layer.
When a user practices on two devices, we use last-write-wins with timestamps. Simple, predictable, and offline-first. We considered CRDTs but rejected them as over-engineering for a primarily single-user app — the conflict cases are rare enough that a simple resolution rule is fine.
The build process
Vite handles the heavy lifting. We split bundles by route so first load is small (~180KB gzipped), use aggressive Terser minification with drop_console: true for production, and configure VitePWA with Workbox to handle the service worker registration and runtime caching for fonts. The question bank itself (~15MB) downloads on first visit and lives in IndexedDB after.
The result: ~3 seconds for first load on 4G, <50ms for subsequent loads from cache, and time-to-interactive under 1 second after the initial install.
Lessons we'd want to tell ourselves earlier
Design offline-first, not offline-also. Don't build a web app and then add offline support. Design for offline from day one. Every feature must work offline; online features are enhancements, not requirements. Test with network disabled during development — your future self will catch the assumptions before they ship.
Cache strategically. Not everything needs to be cached forever. The app shell goes in precache and stays. The question bank uses cache-first with a 7-day TTL. User progress uses network-first because freshness matters. Analytics uses background sync because the user shouldn't wait for it. Pick the right strategy per asset class.
Handle storage limits gracefully. Browsers limit storage. Check navigator.storage.estimate() periodically; when usage crosses 80%, clear old cached questions. Failing to do this means your app eventually breaks for power users in ways that are very hard to debug.
Provide feedback. Users need to know when they're offline. A subtle banner ("Offline mode — changes will sync when you reconnect") is enough. Without it, they assume something is broken.
Test in real conditions. Development with fast wifi doesn't reflect reality. Throttle to 3G. Disconnect entirely. Test on actual mobile devices. The best feedback we got came from sending early builds into real schools and watching what broke.
What we gave up
Offline-first isn't free. We gave up real-time collaboration (not needed for solo math practice), cloud-based AI features (deterministic solvers are better for education anyway), instant cross-device sync (last-write-wins is good enough for our use case), rich centralized analytics (privacy beats data), and easy server-pushed updates (users get updates on next visit, which is acceptable).
Every one of those trade-offs was the right call for our users. They'd be the wrong calls for, say, a real-time collaborative whiteboard.
When offline-first is the right choice
Build offline-first if your users have unreliable connectivity (rural schools, transit, intermittent wifi), privacy is a core requirement (education, healthcare, legal), you want zero per-feature server costs, your app is primarily single-user, or you need guaranteed availability regardless of your infrastructure.
Don't build offline-first if real-time collaboration is the core experience, you genuinely need centralized data control (multi-user invariants, server-side billing logic), the app is inherently social/multiplayer, or your users expect instant cross-device sync as table stakes.
Where the web platform is heading
The browser is becoming increasingly capable for offline-first work. The Background Fetch API enables proactive content updates. Periodic Background Sync schedules sync jobs when online. The File System Access API lets users import and export their data without a server. Web Locks prevent concurrent modifications across tabs. Each of these used to require a backend; each now runs in the browser.
If you haven't looked at the offline-capable web in a few years, it's worth another look. The capabilities have moved.
The starter checklist
Choose a PWA framework (Vite + VitePWA is our recommendation). Add a service worker via Workbox. Set up IndexedDB for structured data (use idb to wrap it). Implement cache-first fetching as your default strategy. Add an offline indicator to the UI. Test with network disabled — repeatedly. Handle storage quota limits before you ship. Plan your sync strategy if you need one (and skip it if you don't).
Closing
Building offline-first was the right choice for Numera. It gave us reliability that cloud-dependent apps can't match, privacy that's built-in rather than bolted on, performance that feels instant, and accessibility for users with limited connectivity.
The web platform has evolved enough that offline-first isn't a compromise anymore — it's a competitive advantage. If you're building for education, ask honestly whether your users really need constant connectivity. Often they don't. They just need tools that work, wherever they are.
Numera is a free, offline-first math practice platform for Ontario teachers and teacher candidates. Try it at app.numeracode.com — no internet required after first visit.
Related Educational Resources
Enhance your learning with these complementary resources
Comments (0)
The views in comments are those of the author and do not necessarily reflect the views of Numera. We reserve the right to remove inappropriate content.