Shipping a performance-first video feed: How Tendbble built a real-time social app with Expo
Key Points
- One global playback rule cut memory and eliminated audio overlap
- Patched vision-camera to stop AVCaptureSession on view removal
- Reanimated + Skia + React Compiler enabled smooth UI under JS load
Summary
Tendbble is a media-heavy, real-time social video app built by a two-person frontend team on a single Expo/React Native codebase. Performance was treated as the product: smooth 60fps+ scrolling, instant-feeling capture, and reliable memory behavior across iOS and Android. The team hit this by controlling video lifecycle, using native camera primitives, moving animation work off the JS thread, and enforcing platform-specific performance budgets.
Key Points
-
Video playback lifecycle
- Enforce one global-playing video across the entire app (claim-on-enter, auto-release previous).
- Add a 400ms settlement delay before initializing a player to avoid work during fast scrolls; show thumbnail/blurhash placeholder instead.
- Preload one post ahead in scroll direction and zero behind; result: active players dropped from ~20–30 to ~3–4.
-
Camera and capture
- Use react-native-vision-camera for codec control (H.265 iOS, H.264 Android), multi-lens access, and buffer compression.
- Capture UX: tap for photo, hold for video, double-tap to flip, pinch-to-zoom with snapping at 0.5×/1×/2×; Skia progress ring for recording UI.
- Use a native Expo module to composite and burn high-resolution text overlays (avoids screenshot-quality loss).
-
Native resource cleanup
- Found a memory leak: AVCaptureSession kept running when camera view was removed (react-native-vision-camera v4.7.3).
- Patch with hooks (patch-package) to stop sessions on parent removal and on deinit; lesson: audit native resources when using React Navigation.
-
Smooth animations and gestures
- Drive animations on the UI thread with Reanimated v4 worklets and frame callbacks; avoid JS-dependent rendering paths.
- Use Skia for visuals Reanimated can't render efficiently (rotating gradients, squircles, dual-pass text strokes).
- Compose gestures by racing a tap against a long-press+pan with manual activation; rate-limit haptics/audio to avoid overload.
-
Compiler and rendering optimizations
- Adopt React 19 + React Compiler to remove much manual memoization (useMemo/useCallback/React.memo), improving scroll and transition performance.
- Enforce purity rules to get predictable compiler benefits.
-
Platform-specific budgets and memory management
- Maintain separate animation/performance configs: aggressive 120fps tuning for iOS, simplified animations and smaller list windows for lower-end Android.
- Implement priority-based cleanup responding to native memory warnings: clear video buffers first, then image decode caches, then query caches; proactively flush caches on background.
- Handle signed URLs vs persisted cache: invalidate rehydrated cache older than 45 minutes to avoid broken media from 1-hour signed URLs.
Practical takeaways for engineers
- For video feeds, be the arbiter of playback—one active player, delayed init, and tight preloading beats relying on OS heuristics.
- Treat native camera/audio resources as outside React’s lifecycle; add explicit teardown hooks and test navigation/rehydration paths.
- Push animation work to the UI thread (Reanimated worklets + Skia) and adopt React Compiler to reduce JS GC and re-render contention.
- Split performance targets by platform hardware instead of enforcing a single cross-platform budget.
Next areas explored
- Shared element transitions, scroll-velocity based preloading, robust background upload resume, and edge-cached media to reduce signed-URL fragility.