Threadable isn't live anymore, so there's no app to hand you. This is a tour of the parts I built and the reasoning behind them, taken from the two codebases and their git history. I wrote the first commit of both the iOS app and the Rails API, and everything here predates AI coding tools except where noted at the end.
page turns are table stakes. they took nine months. →01Reading a book together, asynchronously
A circle picks a book. Each member reads on their own schedule. When someone highlights a passage and comments, that thread is pinned to those exact words, and everyone else in the circle finds it when they reach that page. Later the product grew a public layer (book pages, follows, a home feed, reviews), a web app, PDF support and purchases.
Most of the interesting engineering sits under one requirement: a comment has to stay attached to the same words for every reader, on every device, at every font size, across app versions.
02The reader, four ways
The reader had to paginate reflowable EPUBs, let you select across page boundaries, draw highlights and comment avatars, swipe smoothly between chapters, and work offline. I owned it from the first prototype, and each version was dropped for a specific, observed reason.
1. Native text with my own paginator (Nov–Dec 2020)
A PageBuilder that split chapters into pages in React Native, plus a small Objective-C view manager to make text selectable. Reimplementing typesetting, pagination and range selection for arbitrary book HTML would have been a multi-year project. Once we committed to storing anchors as EPUB CFIs, a renderer that already spoke CFI was the better bet.
2. epub.js inside a WebView (Jan 2021)
The book rendered by epub.js, with a message bridge back to React Native. Within two weeks we forked epub.js to fix swipe timing. This became the core of every later version.
3. A localhost server to feed it (Apr–May 2021)
Books and fonts were served from an on-device HTTP server. It failed to start often enough that I added random ports, five retries, Sentry logging and a fallback screen. That was managing a failure mode, so I removed it instead: the book is read as base64, fonts are base64-encoded at build time, and epub.js and JSZip are inlined into the bundle by Babel. The page loads with loadHTMLString, so there's no server and no network.
4. A forked WebView, then my own (Jul–Aug 2021)
We needed a custom selection menu: “Comment” instead of Copy, Look Up and Share. A fork of react-native-webview worked but meant carrying a large third-party diff. A month later I replaced it with about 150 lines of Swift: ThreadableWeb, a WKWebView with a script-message handler in one direction, a view-manager command in the other, and a menu that allows exactly one action. It shipped until the end.
// ios/ThreadableWebManager.swift: React Native → WebView
@objc func callJavascript(_ node: NSNumber, jsToExecute: NSString) {
DispatchQueue.main.async {
let component = self.bridge.uiManager.view(forReactTag: node) as! ThreadableWeb
component.callJavascript(value: jsToExecute)
}
}The epub.js fork carried continuous swiping across chapters (blank spine items between chapters, watched by an IntersectionObserver, so the next chapter loads mid-swipe), support for precomputed locations, and highlight fixes for nested inline elements. We kept it rebased on upstream and later moved it into our own org so the web app could share it.
03Pinning a conversation to a sentence
An EPUB has no pages. Page 112 on a phone is page 87 on an iPad. So every annotation is stored as an EPUB Canonical Fragment Identifier: a path through the book's document tree down to a character offset, which every renderer resolves the same way.
Selections that cross a page break
Readers kept trying to highlight a sentence that wrapped onto the next page. When a drag reaches the edge, the reader turns the page and keeps selecting, then stitches the two partial CFIs into one range by sharing the base path.
const cfiBase = startCfiLocation.replace(/!.*/, '');
const cfiRange = `${cfiBase}!,${cfiStart},${cfiEnd})`;Sorting threads in reading order
The thread list needed to read like the book. CFIs don't sort as strings (/10 comes before /8), and Postgres can't compare them, so I parsed and compared them in Ruby with the epub-cfi gem and handed the ordered IDs back to SQL so pagination still worked. A malformed CFI from an old client sinks to the end instead of failing the request.
# app/domain/annotation/sort.rb
rescue Racc::ParseError
# If we get a malformed CFI, just return as if it is were in the 9999th section
EPUB::CFI('epubcfi(/9999/999!/999/999/9999,/9999:999,/999:999)')
endSorting in application code would be wrong for a feed. Here the set was bounded by one circle reading one book, so I took the simpler design over a precomputed sort key that every client would have had to maintain. When we added PDFs, selection data became polymorphic (CFI for EPUBs, page and rects for PDFs) and the sort grew a second comparator.
04Progress without the cost
Circles wanted to see where everyone was in the book. Computing a percentage for a reflowable book is expensive, and writing progress on every page turn would hammer the API.
epub.js gets percentages by walking the entire book to build a locations index, which is slow on a phone for a long novel. I made the server a cache for it. The first reader of a book generates locations and uploads them; everyone after receives them with the book and the fork skips the walk.
Every page turn writes {cfi, percentage, date} to device storage immediately. The API write goes through a tiny queue that holds only the latest pending call and flushes every five seconds, so a burst of page turns becomes one request. On open, local and server progress are compared and the newer one wins. That can lose a few seconds of progress across two devices, which is fine for a bookmark and never applied to comments.
05Correctness, privacy and access
The API was mine from the first commit. These are the decisions I'd point to as judgment rather than volume.
- Jobs racing their transaction (2022). Notification jobs enqueued in
after_createsometimes ran before the commit and found nothing. I moved every side effect toafter_commit, which became the rule the later pub/sub layer was built on. - Let the database decide (2022). Adding a book to a circle loaded every book to check for duplicates and could still race. A unique index plus one
rescue_from ActiveRecord::RecordNotUniquereturning 422 was faster and correct under concurrency. - An access leak (2022). I found and closed a path where a user could query circles they didn't belong to, then moved all authorization into Pundit policies backed by role capabilities nine days later, instead of patching one endpoint.
- Deleting an account (2022). An anonymization job removes PII and the Firebase identity, but keeps replies that have children as “removed”, so other readers' threads still make sense.
- Tokens in headers (2021–22). The Firebase ID token is exchanged once for an HTTP-only Rails session cookie shared by iOS, admin and web. I shipped the client and server halves together, then removed header auth.
- Real EPUBs ignore the spec (2021–23). I moved table-of-contents parsing from Node into Ruby, then hardened it one real book at a time. When the web reader took over navigation I left the endpoint returning an empty list for old app versions still in the wild.
06A home feed weighted by popularity and relevance
In early 2025 the home screen became two feeds: Following (activity from the people and books you follow) and Trending (what's active across the platform, tilted toward your tastes). I laid the groundwork for the following feed and its caching, and paired with two teammates on the trending model.
Each reader, book and comment gets a score that is the sum of its engagement, with each event fading over time. A reaction or reply on a comment counts heavily and fades over 30 days. A new follower fades over 7, so readers trend on momentum rather than lifetime totals. The weights live in one config module and the decay is generated as SQL, so scoring runs in Postgres.
score = Σ weight × e^(−t / T) # t = age of the event, T = decay periodEngagement only marks a row as needing a rescore, and an hourly job rescores just those rows. Relevance is applied when the feed is read: 1.3–1.5× boosts for people and books in genres you follow but haven't followed yet, and a boost for fresh discussion on books. The query drops your own activity, low scores and stale items, with cutoffs in a settings table so we could tune the feed without a deploy. Precomputing popularity keeps reads cheap; applying relevance at read time means one score table instead of one per user.
07Performance and practice
When circle screens slowed as data grew, I shipped eight PRs in one week of fall 2022: optimized book feeds, side-loaded replies with activity, lazy-loaded relationships, dropped a computed sort and added the missing indexes. Each change was small and could be reverted alone.
- Tests. I started the move from Minitest to RSpec in 2022 and finished it in 2023, file by file alongside feature work. The suite ended at 171 spec files plus Cypress end-to-end runs.
- Release plumbing. fastlane and TestFlight lanes in the app's second month, dev, staging and prod schemes, Heroku deploys in the API's third week, and a staging environment in 2022.
- Shipped clients don't upgrade. A health-check endpoint and a forced-upgrade prompt, so the API didn't have to support every build forever.
- Analytics without touching prod. An anonymized BigQuery export so the team could run analysis without querying the production database.
- Deleting my own work. In 2023 I removed the expert-commentary feature I'd built in 2021 once the product moved away from it.
- The web app (2023). Set up inside Rails to reuse the deploy pipeline, with my epub render hooks, in-book search, server-side rendering and separate public and signed-in bundles so shared links loaded fast.
Credit where due: the pub/sub observers, the Firestore live signal and the Cypress setup were built by teammates. I reviewed and built on them, and left them out of this write-up on purpose.
08What I'd do differently
- Decide sooner on a WebView reader. The native-text prototype cost about six weeks. The first question should have been where anchors live, and the answer (CFI) pointed at epub.js immediately.
- Keep the native test suite. I set up Jest in 2021 and removed it a year later, rather than investing in tests around the WebView bridge, where the hardest reader bugs lived.
- Verify payments on the server. Our 2025 Stripe purchase flow trusted the client's payment result. A webhook check should have shipped with it.
The AI era, briefly: from late 2024 we used LLMs for genre tagging, chapter summaries and in-reader insights anchored to passages. Those sat on the same CFI and selection-data foundation described above.
