Media Learning App
Offline-First Audio/Video Platform · iOS & Android
A mature cross-platform learning app that streams long-form audio and video, downloads for offline playback, and resumes where you left off. I led a ground-up rewrite of the playback engine for offline-first reliability and a major framework upgrade of the underlying codebase — anonymized engineering case study.
Tech Stack
What I Owned
- ·Ground-up audio playback engine rewrite
- ·Offline-first downloads & playback
- ·Major framework / runtime upgrade
- ·App-wide stability & performance
Engineering Focus
- ·Offline-aware media source selection
- ·Modular playback subsystem (~15 modules)
- ·Furthest-position progress reconciliation
- ·Background-kill & remote-control recovery
Security
- ·Offline content in app-private storage
- ·Backend-authoritative content access
- ·Authenticated API traffic over HTTPS
- ·Telemetry scrubbed of user content
Performance
An anonymized engineering case study. This describes a mature, cross-platform (iOS + Android) media learning app I worked on — at the level of architecture, engineering judgment, and outcomes. No company, product, vertical, source code, internal names, or non-public metrics appear here by design.
Stack at a glance: React Native (RN 0.83) on Expo SDK 55 · React 19 · TypeScript 5.9 · Apollo Client v3 (GraphQL, typed codegen) · React Navigation v6 · react-native-track-player · expo-video · Reanimated v4 + Worklets · FlashList · MMKV / AsyncStorage / SQLite · Sentry · EAS Build + OTA Updates · Jest + Detox
1. Executive summary
A cross-platform learning app that streams long-form audio and video organized into courses and chapters. Users browse a personalized home feed, play content with background audio and lock-screen controls, download for offline playback, resume where they left off, search a large library, take in-app quizzes, and follow push notifications that deep-link straight into content.
The headline engineering work — and the focus of this case study — is a ground-up rewrite of the audio playback engine built for offline-first reliability and error recovery, plus leading a major framework upgrade of a long-lived production codebase. The hard part of a media app isn't playing a file when everything works; it's behaving correctly across flaky networks, app backgrounding, and platform-specific media behavior — and never dropping audio while the user navigates or the OS reclaims the process.
The work was organized around four problems, and the architecture is essentially the set of answers to them:
| Hard problem | Architectural answer |
|---|---|
| Users play content on the move, in low/no connectivity | Offline-first playback: stream when online, validated local fallback offline, clean reconnect |
| Audio must survive navigation, backgrounding, and OS process kills | A modular playback engine with watchdogs, recovery, and a single source of truth for "now playing" |
| Progress must be correct even with intermittent connectivity | A sync engine that queues progress locally and reconciles the furthest-achieved position |
| A mature codebase had to stay maintainable and release-stable | A major framework + runtime upgrade, sequenced behind a branch and shipped through staged channels |
2. My role
Mobile/front-end engineer with broad ownership of the client app, leading the highest-risk subsystems:
- Media playback subsystem — designed and led a ground-up rewrite focused on reliability, offline behavior, and error recovery.
- Offline & downloads — download lifecycle and local-vs-remote source selection.
- App-wide stability — crash reduction and recovery from platform edge cases.
- Framework modernization — a major cross-platform framework and language runtime upgrade on a mature app, kept release-stable.
- End-to-end features — UI, data layer, navigation, and instrumentation.
- Build & release — staged distribution channels and over-the-air updates.
3. Technology stack & why
| Concern | Choice | Why this over alternatives |
|---|---|---|
| Framework | React Native 0.83 on Expo SDK 55 (with committed native projects) | Expo's module ecosystem, EAS, and OTA updates, while retaining native ios/+android/ projects for full control of the native surface. |
| Language/runtime | React 19 + TypeScript 5.9, Node 24 toolchain | A strict, modern baseline; the upgrade brought the whole stack onto current, supported versions. |
| Data layer | Apollo Client v3 (GraphQL) + typed code generation | Generated types keep client and server in contract; Apollo cache persistence enables offline reads. |
| Navigation | React Navigation v6 — native stack + bottom tabs | Native-feeling transitions; deep linking with custom URL/slug resolution into specific content. |
| State | React Context + hooks (no Redux) | Cohesive, local state per feature; the playback engine owns its own state rather than leaking it into a global store. |
| Local storage | MMKV + AsyncStorage + SQLite | The right tool per data class: fast key/value (MMKV), simple persistence (AsyncStorage), structured/queryable data (SQLite). |
| Audio | react-native-track-player | Background playback, lock-screen/remote controls, and a queue with auto-advance — the contract a media app lives and dies by. |
| Video | expo-video | Full-screen video lessons with a modern, maintained player surface. |
| Lists & images | FlashList + expo-image | Virtualized recycling for content-heavy screens; native image caching and progressive decode. |
| Animation | Reanimated v4 + Worklets, Moti | Animations driven on the UI thread, immune to JS-thread stalls. |
| Forms/validation | react-hook-form / Formik + Yup / Zod | Schema validation shared across forms and payloads. |
| Observability | Crash/error reporting (Sentry), analytics, custom playback telemetry | Field-only playback bugs are invisible without purpose-built instrumentation. |
| Flags & messaging | A feature-flag service + push notifications (FCM + Notifee + a notifications-feed SDK) | Decouple rollout from release; deep-link notifications into content. |
| Build/release | EAS Build (development / preview / production channels) + expo-updates OTA + Fastlane | Cloud builds, staged channels, JS-only hotfixes without a store round-trip. |
| Testing | Jest (unit) + Detox (E2E) | Logic invariants in Jest, critical journeys end-to-end in Detox. |
Third-party SDKs are named here only as a skills list. Where they touch the product's specifics, this write-up stays at the category level by design.
4. App architecture
The codebase is feature-organized — screens/, reusable components/,
hooks/, services/, and navigation/, over a typed GraphQL data layer. Shared
domain types are consumed from a common monorepo package (Yarn workspaces + Lerna)
so the mobile client and a companion web app stay in contract.
navigation/ native stack + bottom tabs; deep-link/slug resolution
│
screens/ feed, player, library, search, downloads, quizzes, account
│
components/ reusable + feature UI (player chrome, cards, lists, sheets)
│
hooks/ + services/ shared logic
├─ playback engine ~15 single-responsibility modules (see §5)
├─ downloads download lifecycle + local-vs-remote selection
├─ data (Apollo) typed GraphQL ops + cache persistence (offline reads)
└─ storage MMKV / AsyncStorage / SQLite by data class
Why this separation: routes change for navigation reasons, screens for UX reasons, components for design reasons, and services for business-logic reasons. Keeping them in distinct layers means a change in one rarely ripples into the others — and the playback engine, the riskiest subsystem, sits behind a clean service boundary that the rest of the app talks to but doesn't reach into.
5. The offline-first playback engine (the centerpiece)
The legacy player worked when the network was healthy and the app stayed in the foreground. The real world is neither. The rewrite split a monolithic player into ~15 small, single-responsibility modules, each independently testable and reasoned about. Shipping them as a parallel "V2" — alongside the legacy engine — let me validate side-by-side and contain the risk of a big-bang swap.
┌──────────────────────────┐
│ State manager │ single source of truth
│ ("currently playing") │ for now-playing state
└────────────┬─────────────┘
│
┌──────────────┐ ┌──────────────▼─────────────┐ ┌──────────────────┐
│ Network │───▶│ Media source select │◀───│ Downloads / │
│ monitor │ │ stream online · local off │ │ local files │
└──────────────┘ └──────────────┬─────────────┘ └──────────────────┘
│
┌─────────────────┬──────────┼───────────┬─────────────────────┐
▼ ▼ ▼ ▼ ▼
┌────────────┐ ┌──────────────┐ ┌──────────┐ ┌──────────────┐ ┌──────────────┐
│ Retry mgr │ │ Buffering │ │ Prefetch │ │ Background- │ │ Telemetry / │
│ + error │ │ watchdog │ │ / batch │ │ kill recovery│ │ metrics │
│ classifier │ │ (stall recover)│ │ manager │ │ │ │ │
└────────────┘ └──────────────┘ └──────────┘ └──────────────┘ └──────────────┘
│
┌────────────▼─────────────┐
│ Sync engine │ queue progress locally;
│ furthest-position reconcile│ reconcile on reconnect
└──────────────────────────┘
The modules, and what each one owns:
- State manager — the single source of truth for what's playing, so navigation, the mini-player, the lock screen, and the full player never disagree.
- Network monitor + online/offline lifecycle — drives source selection off real connectivity, not just "an interface exists."
- Media source selection — stream from remote when online; validate and fall back to local files when offline; reconnect cleanly when the network returns.
- Sync engine — queues playback progress locally and reconciles it with the backend, sending the furthest-achieved position rather than noisy latest values (so a brief offline scrub-back can't clobber real progress).
- Retry manager + error classifier — categorize failures and apply the right retry/backoff strategy instead of blind retries.
- Buffering watchdog — detect and recover from stalled playback rather than spinning forever.
- Prefetch / batch manager — warm upcoming tracks in a queue while avoiding concurrent-write races.
- Background-kill recovery — restore playback after the OS reclaims the background process (notably on Android).
- Telemetry / metrics — custom playback instrumentation for field debugging, because these bugs almost never reproduce on a developer's desk.
The throughline: the queue keeps playing. Audio shouldn't stutter because the user opened another screen, the train went into a tunnel, or the OS decided to reclaim memory. Every module above exists to protect that one invariant.
6. Selected challenges
Each is framed as Problem → Approach → Outcome.
Offline-first correctness
Problem. When a device drops offline mid-session, a naive player keeps firing remote requests that fail, retry, and reconnect in a loop — burning battery and stalling audio — even though a perfectly good downloaded copy sits on disk.
Approach. The network monitor drives media source selection directly: on going offline, the engine switches to the validated local file immediately rather than letting remote requests cycle. Progress is queued locally; on reconnect, the sync engine reconciles the furthest-achieved position with the backend instead of replaying every noisy intermediate value.
Outcome. Retry/reconnect loops were eliminated, offline playback became seamless, and progress stayed correct across connectivity changes — at the cost of more explicit logic, which is the right trade for reliability users can feel.
Background-audio reliability across platforms
Problem. Background execution, foreground/background transitions, connectivity changes, and remote media controls all behave differently on iOS and Android. The worst case: the OS terminates the background process and silently kills audio.
Approach. Each platform edge case got explicit, tested handling rather than a shared happy path. A buffering watchdog recovers stalled playback; a background-kill recovery module restores the session after the OS reclaims it; lock-screen/remote-control events are treated as first-class inputs to the state manager, not afterthoughts.
Outcome. Background audio survived the platform edge cases that previously surfaced as field-only crashes and dropouts — hardened by telemetry that finally made those bugs visible.
A major framework upgrade on a mature app
Problem. A long-lived production app had drifted behind on its framework, runtime, and UI library. Falling further behind threatened maintainability, but a large native-module surface (media, notifications, storage, animation, gestures) meant one bump rippled across the whole stack — and releases couldn't stop while the work was in flight.
Approach. The upgrade (framework + UI library + JS runtime, ~80 native and JS dependencies aligned, plus the Node toolchain) was sequenced behind a feature branch and validated module-by-module, then shipped through staged distribution channels so day-to-day releases kept flowing on the side.
Outcome. The app landed on a modern, supported baseline with zero release disruption — a longer-lived branch traded for protected production stability.
7. Tech-stack summary
| Group | Technologies |
|---|---|
| Core | React Native 0.83 · Expo SDK 55 · React 19 · TypeScript 5.9 · Yarn workspaces + Lerna monorepo · Node 24 |
| Navigation | React Navigation v6 (native stack + bottom tabs) · deep linking with slug resolution |
| Data & state | Apollo Client v3 (GraphQL) + typed codegen · Apollo cache persistence · React Context + hooks · MMKV / AsyncStorage / SQLite |
| Media | react-native-track-player (audio) · expo-video (video) · background audio, lock-screen/remote controls, queue auto-advance |
| UI & animation | Reanimated v4 + Worklets · Moti · FlashList · expo-image · gesture-handler · bottom-sheet · SVG |
| Forms & validation | react-hook-form · Formik · Yup · Zod |
| Observability | Sentry · analytics events · custom playback telemetry |
| Flags & messaging | Feature-flag service · push notifications (FCM + Notifee + notifications-feed SDK) |
| Build / release / QA | EAS Build (dev / preview / prod channels) · expo-updates OTA · Fastlane · Jest + Detox |
8. Outcomes
Stated qualitatively — no non-public metrics here by design:
- A more reliable, offline-capable playback experience that holds audio steady across navigation, backgrounding, and connectivity changes.
- A modernized framework baseline on a mature app, delivered without disrupting the release train.
- A reduced crash/error surface, with defensive state handling and classified retries replacing blind ones.
- A clearer architecture — small, single-responsibility playback modules behind a clean boundary — that's far easier to extend and debug.
- Field visibility through crash reporting and custom playback telemetry, so the bugs that only happen in the wild are finally observable.
9. Key decisions & tradeoffs
| Decision | Why | Tradeoff accepted |
|---|---|---|
| Parallel V2 rewrite (not in-place patching) | Contain risk; validate side-by-side against the legacy engine | Short-term duplication during the migration |
| Switch to local files immediately on going offline | Kill retry/reconnect loops; seamless offline audio | More connectivity-aware logic to reason about |
| Sync the furthest-achieved position | Tolerate intermittent connectivity without clobbering progress | A reconciliation step instead of naive last-write |
| Stage the framework upgrade behind a branch | Protect ongoing releases during foundational work | A longer-lived branch to keep in sync |
| Invest in playback telemetry early | Field-only bugs are otherwise invisible | Some instrumentation overhead and signal-to-noise tuning |
10. Lessons learned
- Isolating risky subsystems behind clean boundaries makes big rewrites shippable. A parallel V2 you can validate side-by-side beats a brave in-place swap.
- Platform background and connectivity differences need explicit, tested handling — the shared happy path is exactly where the field crashes hide.
- Early telemetry pays for itself on bugs that never reproduce locally.
- Large upgrades are survivable when sequenced, staged, and validated module-by-module rather than attempted all at once.