Real-Time Content Filtering on iOS: Architecture for Processing Books at Scale
How we built a content filtering engine that processes EPUB and Kindle books in real-time on iOS without modifying the original files.
BookBuddy filters objectionable content from ebooks in real-time — profanity, violence, sexual content, substance abuse — without modifying the original files. The user sees a clean version while reading; the original stays untouched.
Building this required solving three hard problems: processing EPUB content efficiently, applying filters in real-time during reading, and doing it all on-device with no cloud dependency. Here’s how the architecture works.
The Core Constraint
Content filtering for text sounds simple until you think about it carefully. You can’t just search-and-replace words because:
- Context matters. “Hell” in dialogue is different from “hell” in a theological discussion. “Bloody” is profanity in British English and a medical term everywhere.
- Original files must stay intact. Users own their books. Modifying the file is destructive and potentially illegal (DRM, publisher agreements).
- Performance is non-negotiable. Users are reading. Any lag between page turn and content display breaks the experience.
- Categories must be granular. “I want to filter profanity but not violence” is a valid preference. So is “filter strong profanity but allow mild.”
Architecture: The Filter Pipeline
The system has three stages, each operating on a different phase of the reading experience:
Stage 1: Import & Analysis
When a user imports a book (EPUB or Kindle-sourced content), the import pipeline runs:
- Parse the EPUB/content structure into chapters
- Run the content analyzer across all text
- Build a filter map — a data structure that records the location, category, severity, and suggested replacement for every filterable element
- Store the filter map alongside the book metadata (SwiftData)
This stage runs once per book. It’s the expensive operation, and it happens in the background while the user sees a progress indicator.
Stage 2: Filter Map Construction
The filter map is the core data structure. It’s essentially a sparse overlay on the book’s content:
struct FilterEntry {
let chapterIndex: Int
let range: Range<String.Index>
let category: FilterCategory
let severity: Severity
let original: String
let replacement: String
}
Each entry records where a filterable element exists, what category it belongs to, how severe it is, and what to replace it with. The filter map is indexed by chapter for O(1) lookup during reading.
The replacement strategy varies by category:
- Profanity: Replaced with clean alternatives (“damn” → “darn”) or redacted (“f***”)
- Violence: Graphic descriptions are summarized (“The detailed fight scene” → a shortened, less graphic version)
- Sexual content: Explicit passages are replaced with fade-to-black markers
- Substance abuse: References can be flagged or softened depending on user preference
Stage 3: Real-Time Rendering
When the user reads, the rendering pipeline applies filters in real-time:
- Load the chapter’s raw content
- Look up the chapter’s filter entries
- Apply only the entries matching the user’s active filter categories and severity threshold
- Render the filtered content in the reader view
Because the filter map is pre-computed, this stage is a simple array traversal with string replacements — fast enough that the user never perceives a delay.
The Dictionary Problem
Early versions hardcoded word lists in every service that needed them. The content analyzer had a list. The filter engine had a list. The alignment service had a list. Three copies of 146+ words, drifting apart.
The fix was a FilterDictionary — an actor-based singleton that loads from JSON configuration files and provides O(1) lookup:
actor FilterDictionary {
static let shared = FilterDictionary()
func lookup(_ word: String) -> FilterMatch? {
// Normalized lookup: lowercased, stripped of punctuation
// Returns category, severity, and replacement options
}
}
Every service that needs word matching calls FilterDictionary.shared.lookup(). One source of truth. Updates propagate everywhere.
Kindle Integration: The Pivot
The original Kindle integration used JavaScript injection into a WKWebView displaying the Kindle Cloud Reader. This was clever but fragile — Amazon changes their web reader frequently, breaking the injection scripts.
We pivoted to an EPUB download pipeline:
- User authenticates with their Kindle account
- The app downloads the book’s content through the Kindle content API
- Content is converted to our internal EPUB-like representation
- The standard filter pipeline processes it
This is more work upfront but dramatically more reliable. The filter pipeline doesn’t care where the content came from — EPUB, Kindle, or any other source. It operates on a normalized text representation.
Performance Characteristics
On an iPhone 15 Pro:
- Import analysis: ~2 seconds for a 300-page novel
- Filter map construction: ~500ms
- Real-time render with filters: <5ms per page (imperceptible)
- Memory overhead: ~2MB per book’s filter map (stored in SwiftData, loaded on demand)
The import analysis is the bottleneck, and it runs once. Everything after that is effectively instant.
What We Got Wrong
Over-engineering the filter engine initially. The first version tried to do NLP-level context analysis for every word. It was slow and produced worse results than a well-curated dictionary with severity levels. Simple lookup with a good dictionary beats complex ML for this use case.
Trying to filter in the WebView. JavaScript injection for real-time content modification in WKWebView is a maintenance nightmare. Every Kindle reader update broke something. The EPUB pipeline pivot was painful but correct.
Not building the dictionary singleton first. We duplicated word lists across three services before realizing they needed to be unified. If we’d started with the dictionary as the source of truth, we’d have saved weeks of debugging inconsistencies.
The Privacy Angle
Everything runs on-device. No content is sent to any server. No reading habits are tracked. No filter preferences are shared. The user’s data stays on their phone.
This isn’t just a feature — it’s a constraint that drives better architecture. When you can’t phone home to a cloud API for content analysis, you build something that works locally. And local-first systems are simpler, faster, and more reliable than their cloud-dependent alternatives.
The tradeoff is that our filter accuracy depends on the dictionary and rules we ship, not a cloud ML model that improves continuously. We mitigate this with regular dictionary updates and user-submitted feedback that gets incorporated into the next release.
For a reading app where users are trusting you with the content they and their families consume, privacy isn’t optional. It’s the product.