Most reading apps and spaced-repetition apps are separate products. You highlight in one, export in some format, import into Anki, edit card templates, hope the content survived the round-trip, and then maintain two separate review queues. FreedWise is the bet that those two workflows should be the same workflow. You highlight something worth remembering, and it’s immediately queued for review using the FSRS algorithm. No pipeline, no export, no third service. Your library, highlights, and review schedule all live in an SQLite database on your device.
The constraint I set from the beginning: zero network. No account creation, no sync server, no analytics, no crash reporting. That constraint shaped every decision downstream, from how vendor libraries get deployed to how backups work.
The Stack
| Layer | Technology | What It’s For |
|---|---|---|
| Runtime | React Native 0.83 + Expo 55 | iOS and Android from one codebase |
| Language | TypeScript 5.9 (strict) | Type safety across UI, services, and data layers |
| State | Zustand 5 | Lightweight global state without Redux boilerplate |
| Rendering | epub.js + PDF.js inside WebView | In-browser rendering bridged to native highlight events |
| Scheduling | ts-fsrs 5.3 | Free Spaced Repetition Scheduler algorithm |
| Persistence | Expo SQLite | Versioned migrations, transactional writes |
| Import | expo-document-picker + magic byte validation | Reject corrupt files at import, not at read time |
| Notifications | expo-notifications (local only) | Daily review reminders without a push server |
React Native + Expo was the only reasonable choice for a two-platform app where I wanted a single codebase. Expo specifically makes the SQLite, file system, and notification APIs consistent without digging into native modules. Zustand replaced Redux because the app’s state is manageable — the boilerplate-to-benefit tradeoff for Redux didn’t hold here.
The spaced-repetition piece uses ts-fsrs, the TypeScript port of the FSRS algorithm. FSRS outperforms SM-2 (what Anki uses) in recall accuracy on most content types, and the TypeScript port maps cleanly to a relational schema. Every highlight stores FSRS state directly as columns: stability, difficulty, reps, lapses, state, due_date. No separate card table — the highlight is the card.
One detail worth calling out on the import side: file extensions lie. Rather than trusting that a .pdf file is actually a PDF, the importer reads the first four bytes and checks magic numbers before copying anything to the book library.
const MAGIC_BYTES = {
pdf: [0x25, 0x50, 0x44, 0x46], // %PDF
epub: [0x50, 0x4b, 0x03, 0x04], // PK\x03\x04 — EPUB is a ZIP
}
A misconfigured or truncated file throws a validation error at import time rather than silently breaking the reader later.
Seventeen Iterations to Get One Reader Right
The piece I’m most proud of and most relieved is done: a unified WebView reader that handles both EPUB and PDF through the same code path.
Most apps separate these formats. EPUBs need epub.js and CFI-based positioning; PDFs need PDF.js and page-plus-coordinate positioning. Keeping them separate is simpler — two readers, two highlight bridges, two maintenance surfaces. I wanted one.
The approach: a versioned HTML bundle that lives on the device’s file system (not inside the app binary) loads both epub.js and PDF.js at runtime, exposes a shared JavaScript API that React Native calls via postMessage, and handles all highlight rendering inside the WebView. The React Native side sends commands and receives events. It never touches the DOM directly.
What I underestimated was how many production problems would surface before that was stable. The VendorAssetManager currently ships at LIB_VERSION = '17'. Each version number represents a real failure mode:
v3 — Android WebView rejected the vendored epub.js with MIME type errors because file:// URLs don’t get content-type headers. Fixed by loading all scripts synchronously from a local index.html rather than dynamic <script> injection.
v6 — EPUB CFI positions drifted when users switched between scroll and paginated mode mid-session. Stored a per-book flow mode flag; invalidate and recalculate CFIs on mode switch rather than trying to preserve them across incompatible layout models.
v10 — Tap-to-turn-page overlays built as fixed-position divs inside the WebView stopped registering correctly when Android zoomed the viewport. WebView pan moves the entire viewport, so a tap on a “fixed” overlay hit the wrong position. Replaced with native Pressable zones layered over the WebView in React Native — they stay in screen space regardless of zoom state.
v14 — Highlight rehydration after bookmark restoration was skipping the first few highlights sporadically. Race condition between the WebView signaling readiness and the host sending rehydration messages. Fixed with a handshake: the WebView signals ready, the host waits for that event before sending any rehydrate commands.
v17 — Current version. Stable on both platforms, handles the edge cases I know about.
The versioning solves a real deployment problem. The vendor assets (epub.js, PDF.js, jszip, and the reader runtime) are extracted from the app bundle to the device’s documents directory on first launch, then reused across sessions. That extraction step is expensive, so you only want to do it when something actually changed. The version gate makes reinstall automatic when users update the app, without requiring them to know anything changed.
The highlight system itself: when you highlight text, the WebView sends a message with the selection’s CFI (EPUBs) or page number and normalized bounding rectangle (PDFs), plus the selected text. That data gets persisted to SQLite immediately. When you reopen the book, a rehydrate command sends all existing highlights back into the WebView, which re-renders them via its annotation layer. Highlights survive app restarts without touching the source file.
The Offline Guarantee Is Architectural, Not Configurational
Zero network isn’t a setting that could be turned off — it’s enforced at multiple levels. The WebView’s Content Security Policy forbids all external origins: default-src 'none'; script-src 'self' 'unsafe-eval'.... There’s no fetch() or network import anywhere in the service layer. epub.js and PDF.js are vendored and served from the file system. Review reminders use expo-notifications with local scheduling — no push server, no token registration.
The service layer is structured as a dependency-injected set of modules — BookService, HighlightService, FSRSService, SearchService, NotificationService, DataService — all registered through a ServiceFactory at startup. In tests, that factory swaps in mock implementations. In production, every service talks exclusively to SQLite and the file system. Nothing escapes the device.
This creates a specific relationship with users: uninstalling means their data is gone unless they’ve run a backup. That’s a deliberate tradeoff. Building sync infrastructure, authentication, and a backend would take months and introduce ongoing operational dependencies. For a reading tool, offline-first also means it works on planes, without cell service, and without worrying about what happens when the backend goes down.
Action Tags: Annotating While You Read
One small design decision that turned out more useful than I expected: embedded action tags in highlight text.
If you append .q "What mechanism explains this?" to a highlight, it becomes a flashcard with a question side shown first during review. .discard marks a highlight as excluded from review (useful for passages you highlighted for reference but don’t want to drill). .h1, .h2 mark structural headers for outline views.
The alternative was a button-heavy annotation UI — a modal or popover for each highlight with options for flashcard creation, discarding, and tagging. That interrupts reading flow. The tag approach means you can annotate at the speed of typing, inline, while you’re still in reading mode. Power users use it; casual users ignore it and highlight normally.
What I’d Redo
The EPUB flow mode handling is the roughest part of the codebase. When a user switches between paginated and scroll mode, there’s a CFI invalidation step that works but is inelegant — it stores a per-book mode flag and uses it to decide whether to attempt CFI restoration or fall back to percentage position. I’d redesign the position abstraction to be mode-aware from the start, so the reader can translate between positioning systems rather than choosing one and discarding the other.
The fallback scheduling for FSRS calculation failures is the right call — a crash in the scheduling algorithm shouldn’t block reviews — but the current fallback (1d/3d/7d/14d fixed intervals) loses the personalization that makes FSRS valuable. A smarter fallback would interpolate from the card’s last known stability rather than ignoring it entirely.
Try It Out
Check out the source code on GitHub.