1Summary
Bluesky is not a React Native app that happens to have a few native modules. It is a web, iOS and Android product sharing one TypeScript codebase, with 89 web-specific files against 21 native-specific ones, and it already runs three pieces of pure native code as app extensions. A move to SwiftUI and Jetpack Compose would not replace one codebase with two. It would replace one with three, because the web client cannot come along.
The team also already does the hard native work React Native usually hides: ten local native modules, load-bearing patches to React Native and expo-notifications, and a signed OTA pipeline they operate themselves. That capability is exactly what makes a targeted native move cheap for them, and a full rewrite expensive.
If we were advising this team, we would recommend keeping React Native as the host and moving the two or three surfaces where native gives users something measurable — composer keyboard handling, video, and Android feed scrolling — behind native screens.
2At a glance
- React Native
- 0.86.0
- Expo SDK
- 57 (expo 57.0.8)
- Architecture
- Continuous Native Generation — no committed ios/ or android/
- Navigation
- React Navigation 7, 89 screen registrations, 78 screens plus 5 tab navigators
- Targets
- iOS, Android and web from one codebase
- App source
- 1,625 TypeScript files, ~231,000 lines
- Runtime dependencies
- 164
- Local native modules
- 10, about 47 Swift / Kotlin / Objective-C files
- Patched dependencies
- 17 patches, 1,298 lines, including React Native itself
- OTA updates
- expo-updates against a self-hosted, code-signed update server
- E2E
- 21 Maestro flows, plus a perf test and a setup flow
3Native surface
Grouped by area rather than package by package. Difficulty is how hard the swap is for this app, not for apps in general.
Push and background notifications
Highexpo-notifications (patched), modules/expo-background-notification-handler, BlueskyNSE
- iOS
- UNUserNotificationCenter, APNs, Notification Service Extension
- Android
- FirebaseMessagingService, NotificationCompat, channels
The patch is marked load-bearing: Android background handling routes through a custom module.
OTA updates
Highexpo-updates (patched), updates.bsky.app, code signing
- iOS
- No equivalent for code — phased releases, remote config
- Android
- No equivalent for code — staged rollouts, Play In-App Updates
checkAutomatically is NEVER, so the app controls when updates apply. That release lever disappears.
Video playback and compression
Highexpo-video, @bsky.app/video, @bsky.app/video-compressor, expo-video-thumbnails
- iOS
- AVPlayer, AVAssetExportSession
- Android
- Media3 ExoPlayer, Media3 Transformer
Already partly native via Bluesky's own packages; a good candidate to move first.
Keyboard and composer
Highreact-native-keyboard-controller (patched, used in 15 files), expo-paste-input, @bsky.app/react-native-uitextview
- iOS
- SwiftUI TextEditor / UITextView with keyboard layout guide
- Android
- Compose BasicTextField, WindowInsets.ime
Composer behaviour is where users feel React Native most; also where parity checks matter most.
Lists, pagers and gestures
Mediumreact-native-pager-view (patched), react-native-screens (patched), reanimated + worklets, gesture-handler, scroll-forwarder module
- iOS
- SwiftUI List / ScrollView, TabView paging
- Android
- LazyColumn, HorizontalPager
The screens patch fixes an Android RecyclerView crash during transitions — a sign of real list pain on Android.
Media and camera
Mediumexpo-camera, expo-image, expo-image-picker, expo-image-manipulator, @bsky.app/expo-image-crop-tool, expo-media-library (patched), react-native-view-shot
- iOS
- AVFoundation, PhotosPicker, Core Image
- Android
- CameraX, Photo Picker, Coil
Mostly well-trodden native APIs; the crop tool and view-shot usage need a closer look.
Storage and device
Mediumreact-native-mmkv, @react-native-async-storage/async-storage, expo-file-system, swiss-army SharedPrefs, expo-device, expo-application
- iOS
- UserDefaults / Keychain, FileManager
- Android
- DataStore, Android Keystore, Context.filesDir
A data migration is needed so existing sessions survive the switch.
App integrity and auth
Mediumreact-native-device-attest, expo-web-browser, expo-linking
- iOS
- App Attest / DeviceCheck, ASWebAuthenticationSession, Universal Links
- Android
- Play Integrity API, Custom Tabs, App Links
Used in sign-up's captcha step. The repo's own type note says the Android variant implements Play Integrity only.
Observability and experiments
Low@sentry/react-native, @bitdrift/react-native, @growthbook/growthbook-react
- iOS
- Sentry Cocoa, bitdrift iOS SDK, GrowthBook Swift SDK
- Android
- Sentry Android, bitdrift Android SDK, GrowthBook Kotlin SDK
Native SDKs exist for all three. Event names must match for parity checks.
Small platform utilities
Lowexpo-haptics (patched), expo-clipboard, expo-sharing, expo-localization, expo-blur, expo-glass-effect, expo-screen-orientation, expo-keep-awake, and others
- iOS
- UIKit / SwiftUI built-ins
- Android
- Android framework / Compose built-ins
Individually trivial; together a long tail worth a checklist.
4Custom native code
Ten local modules and three iOS app extensions. This is native code the team already writes and owns, which matters for every recommendation below.
- modules/expo-bluesky-swiss-armyPlatformInfo, Referrer, SharedPrefs, VisibilityView
- modules/bottom-sheetNative bottom sheet
- modules/expo-bluesky-gif-viewGIF rendering
- modules/expo-background-notification-handlerAndroid background push handling
- modules/expo-emoji-pickerNative emoji picker
- modules/react-native-scroll-forwarderScroll event forwarding
- modules/expo-receive-android-intentsAndroid share intents
- modules/BlueskyNSEiOS Notification Service Extension
- modules/Share-with-BlueskyiOS share extension
- modules/BlueskyClipiOS App Clip for starter packs
5Patches
17 patches totalling 1,298 lines. Two are labelled load-bearing in their own notes. The largest:
- react-native@0.86.0.patch448 lines
Marked load-bearing. Fixes an iOS use-after-free in the Fabric scheduler seen in Sentry.
- expo-notifications@57.0.7.patch170 lines
Marked load-bearing. Routes Android background messages to the custom handler and sets channel IDs.
- react-native-pager-view@6.8.0.patch156 lines
Lets iOS 26's full-screen back gesture work on the leftmost pager page, in both the Paper and Fabric implementations.
- expo@57.0.8.patch97 lines
Type-check-only change to a declaration file; no runtime impact.
Changes the chat scroll view's keyboard and extra-padding hooks.
Android: stops RecyclerView children transitioning individually, fixing a crash when leaving a screen.
6The hard parts
- 1
The web client does not migrate
89 web-specific files and a shared component system (ALF) mean native iOS and Android would diverge from web immediately. Every feature would afterwards be built three times.
- 2
Notifications span four codebases
expo-notifications with a load-bearing patch, a custom Android background handler, an Android badge-count workaround, and an iOS Notification Service Extension.
- 3
Losing a self-hosted, signed OTA pipeline
The team controls when updates apply. Replacing that with store releases, remote config and phased rollouts changes how incidents are handled.
- 4
The composer
Keyboard controller in 15 files, custom text view, paste handling. It is the most-used surface and the hardest to prove parity on.
- 5
Seventeen patches encode production knowledge
Each patch is a bug the team already found. A rewrite has to rediscover or deliberately retire every one of them.
7Checkpoint order
- 1Parity harness first: run the existing 21 Maestro flows against a native build as well as the React Native one. Maestro drives both.
- 2Video playback and compression, as native views hosted in the React Native app. Already partly native, measurable, isolated.
- 3Android feed and pager screens, where the screens and pager-view patches point at real list pain.
- 4Composer, once parity checks are routine. Highest user impact, highest risk.
- 5Stop there and measure. Notifications, OTA and auth only move if a full rewrite is chosen.
8Brownfield or rewrite
Recommendation: brownfield
Bluesky already hosts pure native code (App Clip, share extension, notification service extension) next to React Native. Adding native screens inside the existing app keeps web, keeps OTA for everything else, and lets each move be reverted. A rewrite only makes sense if the team decides web should become a separate product.
9Effort estimate
| Area | Engineer-days |
|---|---|
| Parity harness on native builds | 15–25 |
| Video surfaces (both platforms) | 30–45 |
| Android feed and pagers | 25–40 |
| Composer (both platforms) | 45–70 |
| Hosting, navigation bridging, release changes | 20–30 |
| Targeted move | 135–210 |
For comparison, a full native rewrite of iOS and Android: roughly 78 screens × 2 platforms × 0.75–1.5 days, plus 90–140 days for the native surface above and 25 days of harness — about 230–400 engineer-days, before web divergence costs.
Assumptions
- Agent-assisted checkpoint workflow with human review, not unassisted rewriting.
- Engineers already comfortable in Swift and Kotlin; hiring time excluded.
- Day ranges are from route and dependency counts in the repository plus our judgement — an order of magnitude, not a quote.
- No change to the AT Protocol client, backend or web client.
10Stay on React Native if
- Web stays a first-class client built from the same code. For Bluesky it is.
- The team keeps shipping OTA fixes weekly and values that lever.
- Performance problems are concentrated in a few surfaces rather than everywhere.
11Open questions for the team
- How often are OTA updates actually shipped, and how many were incident fixes?
- Which surfaces generate the most performance complaints or crashes, per platform?
- Is there appetite for native-only features (widgets, Live Activities) that would change the calculation?
- Who would own native iOS and Android code long term?
Evidence read: package.json, app.config.js, src/Navigation.tsx, modules/, plugins/, patches/, __e2e__/flows/, AGENTS.md