This page is for developers facing a React Native round, from a first mobile job to a senior role owning an app in the stores. Most rounds start with how React Native draws native views and what the new architecture changed, then move to native modules, navigation and state, and spend a long time on performance: lists, animations and startup. Expect questions on platform differences, storage, push notifications, Expo and shipping releases too. Senior rounds add a production story and a judgement call. Each question shows what the interviewer is checking, the shape of a strong answer and a short answer you can say out loud. Practise them, then swap in your own stories.
Search all questions by round, difficulty and level, or save the ones you want to practise.
JavaScript side: components run on a JS engine and React works out the UI tree, as on the web.
Renderer: each host component becomes a real platform view, like UIView on iOS or an Android View.
Layout: Yoga, a Flexbox engine in C++, computes sizes and positions for those native views.
"No, there's no web view. My components run as JavaScript, usually on the Hermes engine, and React works out what the UI tree should look like, just like it does on the web. The difference is the renderer. Instead of creating DOM nodes, React Native turns each host component into a real platform view: a View becomes a UIView on iOS and an Android View on Android, and Text becomes the platform's native text view. Layout isn't done by a browser either. React Native uses Yoga, a Flexbox engine written in C++, to compute sizes and positions, then applies them to the native views. So users get real native controls, native scrolling and native accessibility, while I write the logic once in JavaScript. The trade-off is that the JavaScript side and the native side have to talk to each other, and that's where most performance questions come from."
Saying React Native renders HTML in a web view, or that JavaScript is compiled into Swift or Kotlin code.
UI thread: owns native views, touches, native scrolling and drawing.
JS thread: runs React, your components and business logic; layout can also run off the main thread.
Symptoms: busy JS means ignored taps, frozen JS animations and blank list rows; busy UI means scrolling itself stutters.
Measure: the Perf Monitor shows JS and UI frame rates separately; confirm on a release build with a profiler.
"Two matter most day to day. The main or UI thread owns the native views: it handles touches, native scrolling and drawing. The JavaScript thread runs React, my components and business logic. Layout can also be calculated off the main thread. If the JS thread is blocked, say by parsing a huge response or a heavy re-render, the screen doesn't freeze completely, because native scrolling lives on the UI thread. But taps feel ignored because their handlers are in JS, animations driven from JS stop, and a long list shows blank areas because JS can't render new rows in time. If the UI thread is the busy one, even scrolling stutters. So the first thing I check is which thread is dropping frames. The Perf Monitor in the dev menu shows the JS and UI frame rates separately, which is a quick first look. Debug builds are much slower, though, so I confirm on a release build with a profiler before I trust any number."
Treating the app as single-threaded, or measuring performance only in a debug build.
What: a JavaScript engine built for React Native and the default in current versions.
Why: JavaScript is compiled to bytecode at build time, so no parsing at startup; tuned for low memory.
Watch for: feature support differs from browser engines; release stack traces need source maps.
"Hermes is a JavaScript engine built specifically for React Native, and it's the default in current versions. The big idea is that it compiles JavaScript to bytecode when the app is built. A general-purpose engine has to parse and compile the source each time the app starts. With Hermes, the app ships bytecode, so that work is already done and startup is faster, especially on cheaper Android phones. It's also designed to keep memory use low, with a garbage collector tuned for mobile. In practice I get a faster launch and smaller memory spikes without changing my code. Two things to watch: Hermes doesn't support every language feature a browser engine does, so I check before relying on something new, and crash stack traces point at bytecode, so I upload source maps for release builds to get readable traces."
Describing Hermes as a UI renderer or a replacement for the bridge, rather than the JavaScript engine.
Old bridge: async JSON messages in batches; serialisation cost, no sync calls, all modules set up at startup.
JSI: a C++ interface so JavaScript holds references to native objects and calls them directly.
Fabric: new renderer with the UI tree in shared C++; synchronous layout reads and concurrent React features.
TurboModules: lazily loaded native modules called through JSI, with Codegen building typed glue from a spec.
"With the old bridge, JavaScript and native code only talked through asynchronous messages, serialised to JSON and sent in batches. That worked, but every call paid a serialisation cost, nothing could be synchronous, and native modules were set up at startup even if no screen used them. JSI replaces that with a C++ interface: JavaScript can hold references to native objects and call their methods directly, and calls can be synchronous when that makes sense. Fabric is the new renderer built on it. The UI tree is managed in shared C++, so layout can be measured synchronously, which removes a lot of flicker, and it supports React's concurrent features like transitions. TurboModules are native modules loaded lazily on first use and called through JSI, and Codegen generates typed glue from a TypeScript spec, so a type mismatch fails the build instead of crashing at runtime. For most app code little changes; native module and library work changes the most."
Saying the new architecture makes every app faster automatically, or being unable to say which problem each piece solves.
Spec: a TypeScript file named with a Native prefix, an interface extending TurboModule, fetched from TurboModuleRegistry.
Codegen: configured in package.json; generates native interfaces at build time that each platform implements.
API shape: Promises for slow work, sync only for cheap reads; wrap the raw module in a hook.
"I start with a spec file in TypeScript. The file name starts with Native, it exports an interface that extends TurboModule, and it gets the module from TurboModuleRegistry by name. getEnforcing throws a clear error if the native side isn't there, which beats a silent undefined. I add a codegenConfig entry in package.json, and at build time Codegen reads this spec and generates the native interfaces: an abstract Java class on Android and an Objective-C++ protocol on iOS. I implement those on each platform, and my types have to match the spec or the build fails. Anything slow, like hardware or disk access, returns a Promise so it doesn't block the JS thread. A sync method is only for cheap reads that return straight away. Finally, I wrap the raw module in a small hook so the rest of the app never touches it directly, and I write a mock for tests."
// NativeDeviceInfo.ts
import type { TurboModule } from 'react-native';
import { TurboModuleRegistry } from 'react-native';
export interface Spec extends TurboModule {
getBatteryLevel(): Promise<number>;
isTablet(): boolean;
}
export default TurboModuleRegistry.getEnforcing<Spec>('NativeDeviceInfo');
Making slow hardware calls synchronous, or letting screens call the raw native module everywhere with no typed wrapper.
Signal: what the reports showed, which platform and devices, and why you suspected native.
Reproduce: get the right device and see the crash yourself.
Fix and result: the root cause, the change, and how you confirmed it stopped.
Lesson: what you changed in testing afterwards.
"At my last company, crash reports showed the app closing on some Android phones right after taking a photo, but never on iOS and never on our test devices. The JavaScript error logs were empty, which told me it was native. The reports clustered on a few low-memory models, so I got one of those phones and reproduced it. The camera handed back a full-resolution photo, and we loaded it into an image view at full size, so the app ran out of memory and the OS killed it. The fix had two parts: resize and compress the photo on the native side before it reached the image view, and show a thumbnail instead of the original. The crash disappeared from those devices in the next release. What I took from it is to always test on a cheap, low-memory Android phone, and to check native logs as soon as JavaScript logs come up empty."
A story that ends with a try-catch or a retry that hides the crash, with no root cause found.
Scope: read both platform SDK docs: startup needs, permissions, own screens, platform support.
Wrap: a small native module exposing only the calls you need; check any community wrapper first.
Setup: native config in a config plugin so prebuild applies it every time.
Team: move from Expo Go to a development build; estimate real-device testing on both platforms.
"It's very doable, so I'd start by scoping rather than saying no. First I read the SDK docs for both platforms: what it needs at startup, which permissions, whether it shows its own screens, and whether it's even available on both iOS and Android. If there's a community wrapper, I check how well it's maintained before trusting it. If not, I'd write a small native module with the Expo Modules API, exposing only the handful of calls we actually need, not the whole SDK. Native setup, like adding the dependency and the keys it needs in the iOS and Android config files, goes into a config plugin, so prebuild applies it every time and nobody hand-edits generated folders. It also means the team moves from Expo Go to a development build, which I'd flag as a workflow change. My estimate would include testing on real devices on both platforms, and I'd keep the wrapper in its own module so we can upgrade or replace it cleanly."
Saying Expo makes it impossible and the app must be rewritten, or wrapping the entire SDK surface when only a few calls are needed.
JS stack: transitions and gestures drawn with React Native animations; highly customisable, same on both platforms.
Native stack: uses the platform navigation, UINavigationController on iOS and fragments on Android.
Default: native stack for feel and performance; JS stack when design needs custom transitions.
"Both give me push and pop screens with a header, but they're built differently. The JavaScript stack draws transitions and gestures itself with React Native animations, so it's very customisable: I can build any transition or header I want, and it behaves the same on both platforms. The native stack hands the work to the platform's own navigation, UINavigationController on iOS and fragments on Android. So transitions, swipe back and large titles feel exactly like other apps on the phone, and it's usually lighter because the animation isn't coming from my JavaScript. I default to the native stack because it feels right for free and performs well. I pick the JS stack when design needs a custom transition or header behaviour the native one can't do. Either way, I keep route params small, usually just an ID, and load the data on the screen itself."
Not knowing the two exist, or passing large objects and callbacks through route params as normal practice.
Native side: URL scheme for simple cases; Universal Links on iOS and App Links on Android, verified by files on the website.
JS side: a linking config with prefixes and a map from paths to screens and params.
Edge cases: cold start reads the launch URL; logged-out users go through login and then continue to the target.
"There are two layers. On the native side I register the link: a custom URL scheme for simple cases, but for real web links I set up Universal Links on iOS and App Links on Android. That means adding the associated domain and intent filters in the app, and hosting verification files on the website so the OS trusts that my app owns the domain. On the JavaScript side, React Navigation takes a linking config with the prefixes and a map from paths to screens, like orders slash id going to the order details screen with the id as a param. It handles both cases: when the app is running it listens for incoming URLs, and on a cold start it reads the URL that launched the app. The parts people miss are login and nesting. If the user isn't signed in, I hold the link, show login, then continue to the target. And I test every link from a killed app on real devices."
Only handling links while the app is open, or dropping the target screen when the user has to log in first.
Server data: a fetching cache that handles caching, retries and refetching.
Client state: local state first; a small store once several screens share it.
Mobile lifecycle: screens stay mounted in a stack, the app backgrounds for hours, the network drops.
Response: refetch on app active and screen focus, persist key data, watch connectivity.
"I split it into two kinds. Server data, like a feed or a profile, goes in a data-fetching cache such as TanStack Query, because it gives me caching, retries and refetching without hand-written loading flags. Real client state, like a draft or UI settings, stays local with useState, or in a small store like Zustand or Redux Toolkit once several screens share it. What's different on mobile is the lifecycle. Screens in a stack stay mounted when a new one is pushed, so data can go stale while it's sitting underneath. The app goes to the background and comes back hours later, so I refetch when the app becomes active, using AppState, and when a screen regains focus. The network drops often, so I watch connectivity and persist key data to storage, so the app opens with something useful instead of a spinner."
Putting every API response into one global store by hand, or never considering what happens when the app returns from the background.
ScrollView: renders every child at once; right for short, known content like forms.
FlatList: virtualised; renders items near the viewport, so memory stays flat for long lists.
Trap: a FlatList inside a same-direction ScrollView loses virtualisation; use header and footer props instead.
"A ScrollView renders all of its children straight away, whether they're on screen or not. That's perfect for a settings page or a form with a small, known amount of content. A FlatList is virtualised: it only renders the items near the visible area and drops ones far off screen, so memory stays flat no matter how long the list gets. If I put a few hundred feed items in a ScrollView, the screen is slow to open and memory climbs, because every row and image exists at once. Going the other way, a FlatList for five static fields is just extra complexity. For grouped data with headers I use SectionList, which has the same virtualisation. One trap is nesting a FlatList inside a vertical ScrollView: the inner list renders everything and virtualisation is lost. I use ListHeaderComponent and ListFooterComponent instead to put content around the list."
Mapping a long array inside a ScrollView, or wrapping a FlatList in a ScrollView to make the whole screen scroll.
Confirm: Perf Monitor shows the JS frame rate dropping; reproduce it in a release build on a real phone.
Cheap rows: stable keys, memoised rows, stable renderItem, no heavy work or new objects inside rows.
Tune: getItemLayout for fixed heights; windowSize and batch size trade memory against blanks.
Next step: a list library that recycles row views if tuning is not enough.
"Blank gaps mean the JS thread can't render rows as fast as the user scrolls, so first I confirm that. The Perf Monitor shows whether the JS frame rate drops while I scroll, and I reproduce it in a release build on a real phone, since debug builds are much slower. Then I make each row cheap. I give the list a stable keyExtractor, wrap the row in React.memo, and keep renderItem stable with useCallback so rows don't re-render with the parent. Inside the row I remove heavy work: derived values are prepared outside the list, I don't create new style objects that break the memo, and images are sized close to how they're shown. If rows have a fixed height, getItemLayout lets the list skip measuring. Then I tune: windowSize controls how much stays rendered off screen, and maxToRenderPerBatch how much renders per pass. Smaller values save memory, bigger ones reduce blanks. If that's not enough, I'd try a list library that recycles row views."
Jumping straight to tweaking FlatList props without measuring, or blaming React Native without checking what each row renders.
Rows: a memoised row component that re-renders only when its own data changes.
Stable inputs: keyExtractor from the ID, renderItem in useCallback, a stable onOpen from the parent.
Layout: getItemLayout from the fixed height; a smaller windowSize to limit memory.
"The row is a memoised component, so it only re-renders when its own message or handler changes. renderItem is wrapped in useCallback and depends only on onOpen, so the list isn't handed a new function on every render. keyExtractor uses the message ID, never the index, so when a new message arrives at the top, existing rows keep their identity instead of every row re-rendering with shifted data. Because every row is the same height, getItemLayout tells the list each row's size and position up front. That skips measurement and makes scrollToIndex work straight away. windowSize is set lower than the default to keep less rendered off screen, which helps memory on long lists. One thing I'd check with whoever renders this: onOpen must be stable too, created with useCallback in the parent. Otherwise renderItem changes every render and the memo on the row does nothing."
import { memo, useCallback } from 'react';
import { FlatList, Pressable, Text, type ListRenderItemInfo } from 'react-native';
type Message = { id: string; title: string };
type OnOpen = (id: string) => void;
const ROW_HEIGHT = 72;
const Row = memo(function Row({ item, onOpen }: { item: Message; onOpen: OnOpen }) {
return (
<Pressable style={{ height: ROW_HEIGHT }} onPress={() => onOpen(item.id)}>
<Text numberOfLines={1}>{item.title}</Text>
</Pressable>
);
});
export function Inbox({ items, onOpen }: { items: Message[]; onOpen: OnOpen }) {
const renderItem = useCallback(({ item }: ListRenderItemInfo<Message>) => <Row item={item} onOpen={onOpen} />, [onOpen]);
return (
<FlatList
data={items}
keyExtractor={(m) => m.id}
renderItem={renderItem}
getItemLayout={(_, index) => ({ length: ROW_HEIGHT, offset: ROW_HEIGHT * index, index })}
windowSize={7}
/>
);
}
Using the index as the key, or passing a new inline renderItem and handler every render and then adding memo that never helps.
Measure: release build, real low-end device, cold start, split into native start, bundle load and first real screen.
JS work: Hermes on, inline requires, lazy screens, defer non-critical SDKs.
Native work: slow SDK setup in the Application class or AppDelegate blocks everything.
Data: no request waterfall at launch; show cached data first.
"First I measure properly: a release build on a real low-end phone, cold start, several runs, with the time split into native start, loading the JavaScript bundle, and the first real screen. Guessing without that split wastes days. Then I work through the usual causes. I make sure Hermes is on, so there's no parsing at startup. I cut work before the first render: heavy imports at the top of the entry file, big synchronous setup, and SDKs like analytics that can start a moment later. Inline requires in Metro make modules load when they're first used instead of all at once. I lazy-load screens the user doesn't see first. I check the native side too, because a slow SDK set up in the Application class or AppDelegate blocks everything. And I avoid a waterfall at launch, like reading storage, then fetching the user, then the feed, one after another. I show cached data first and refresh behind it."
Measuring on a fast phone or in a debug build, or hiding a slow start behind a longer splash screen and calling it fixed.
Inspect: APK Analyzer for Android, a source map explorer for the JS bundle.
Sources: native libraries per CPU architecture, images and fonts, native SDKs from packages, the JS bundle.
Android: ship an App Bundle; enable R8 and resource shrinking.
Everywhere: compress assets, load rare assets on demand, drop heavy dependencies for small features.
"I start by looking at what's inside rather than guessing. For Android I open the build in Android Studio's APK Analyzer, and for the JavaScript bundle I use a source map explorer. The size usually comes from four places: native libraries for every CPU architecture, images and fonts, native SDKs pulled in by packages, and the bundle itself. On Android the biggest single win is uploading an Android App Bundle instead of a universal APK, so the store delivers only the architecture and screen density each phone needs. I turn on R8 to shrink and minify the Java and Kotlin code, and resource shrinking to drop unused resources. Then I compress images, move big, rarely used assets to download on demand, and check whether a package drags in a large native SDK for a small feature. iOS trims some of this per device automatically, but unused dependencies still cost size there too."
Only looking at the JavaScript bundle, or never checking which native libraries and assets actually take the space.
Problem: what was slow, for whom, and how you knew.
Measure: release build, low-end device, a clear start and end point.
Changes: the specific bottlenecks you found and fixed.
Proof: same device and steps before and after; how you kept it from regressing.
"At my last company, the home feed took a few seconds to become usable on older Android phones, and store reviews kept mentioning it. I measured first, with a release build on a low-end phone, timing from launch to the first feed item on screen. The profiler showed two problems. At startup we waited for three API calls in a row before rendering anything, and every feed row re-rendered whenever the parent's state changed, because renderItem was recreated each time and the rows weren't memoised. I changed startup to show the cached feed from local storage straight away and refresh behind it, ran the independent requests in parallel, memoised the rows and made the list callbacks stable. Time to a usable feed dropped to about half, and fast scrolling stopped showing blank gaps. I proved it with the same device and the same steps before and after, and we added that timing to our release checklist."
Claiming it felt faster with no before and after measurement, or testing only on a new flagship phone.
Platform.OS: for a single value or small branch.
Platform.select: for a set of values, like shadow styles.
File extensions: .ios.tsx and .android.tsx when a whole component differs; Metro picks the file.
"There are three levels, and I pick the smallest one that fits. For a single value, I check Platform.OS, like a different keyboard type or a slightly different padding. For a group of style differences, Platform.select is cleaner. Shadows are a good example: iOS uses the shadow properties, while Android has traditionally used elevation, so I spread the right set into the style. When a whole component is really different on each platform, I split it into files like DatePicker.ios.tsx and DatePicker.android.tsx. The rest of the app imports it as DatePicker, and Metro picks the right file at build time, so there's no runtime check at all. What I avoid is scattering Platform.OS checks all over a screen. If one component has more than a couple, that's usually a sign it should be split, or wrapped in a shared component with one platform-aware place."
import { Platform, StyleSheet } from 'react-native';
const styles = StyleSheet.create({
card: {
borderRadius: 12,
...Platform.select({
ios: { shadowColor: '#000', shadowOpacity: 0.15, shadowRadius: 8, shadowOffset: { width: 0, height: 2 } },
android: { elevation: 4 },
}),
},
});
// Whole component differs: DatePicker.ios.tsx and DatePicker.android.tsx,
// imported as './DatePicker'. Metro picks the right file.
Only knowing Platform.OS checks, or never testing the other platform and assuming it looks the same.
Defaults: column direction, relative positioning, flexShrink of zero, flex takes one number.
Units and styles: unitless density-independent sizes, percentages allowed, styles are JS objects.
No cascade: no inheritance except text inside Text; all text must be inside a Text component.
Responsive: no media queries; read window size with useWindowDimensions.
"The ideas are the same, but a few defaults change and they catch web developers out. flexDirection defaults to column, not row, because phone screens stack vertically. Elements are positioned relative by default. flexShrink defaults to zero, so children don't shrink on their own the way they do on the web, and flex itself takes a single number. Sizes are plain numbers in density-independent points, with no px or rem, though percentages work. There's also no cascade. Styles don't inherit from parents, except that text nested inside a Text picks up its parent's text styles, and any text has to be inside a Text component or the app throws an error. Styles are JavaScript objects, usually made with StyleSheet.create, and there are no media queries, so for different screen sizes I read the window size with useWindowDimensions and adjust."
Assuming web CSS works unchanged, like expecting row direction by default or trying to set a font on a View.
Without it: each frame is calculated in JavaScript and sent across, so a busy JS thread stutters the animation.
With it: the animation is sent to native once and the UI thread runs every frame.
Limit: only non-layout props, mainly opacity and transforms; no width, height, margin or top.
"Without the native driver, every frame of an Animated animation is calculated in JavaScript and sent to the native side, so if the JS thread is busy, say a screen is rendering or data is being parsed, the animation stutters. With useNativeDriver set to true, React Native sends the whole animation description to the native side once when it starts, and the UI thread runs every frame on its own. So a fade or a slide stays smooth even while JS is busy. The limit is that it only works with properties that don't affect layout, mainly opacity and transforms like translate, scale and rotate. I can't animate width, height, margins or top with it, because those change layout. So I design around transforms where I can: instead of animating height to reveal a panel, I often translate or scale it. For real layout changes I'd look at LayoutAnimation or Reanimated's layout animations."
function FadeIn({ children }: { children: React.ReactNode }) {
const opacity = useRef(new Animated.Value(0)).current;
useEffect(() => {
Animated.timing(opacity, {
toValue: 1,
duration: 250,
useNativeDriver: true, // frames run on the UI thread
}).start();
}, [opacity]);
return <Animated.View style={{ opacity }}>{children}</Animated.View>;
}
Setting useNativeDriver to true on a height animation and not understanding why it errors, or not knowing it exists.
Problem: the card must follow the finger every frame; a round trip through JS lags.
Tools: Gesture Handler recognises the pan natively; Reanimated worklets run on the UI thread.
Flow: finger moves a shared value, useAnimatedStyle maps it to a transform.
Release: use translation and velocity to dismiss or spring back; only then call back into JS.
"With a drag, the view has to follow the finger on every frame. If touch events go to JavaScript and the new position comes back, any hiccup on the JS thread makes the card lag behind the finger, and users notice that instantly. Gesture Handler recognises the pan gesture on the native side, and Reanimated lets me write small functions called worklets that run on the UI thread. So the finger position updates a shared value, useAnimatedStyle turns that into a translate transform, and it all happens on the UI thread without waiting for JavaScript. When the finger lifts, I look at the translation and the velocity. Past a threshold, I animate the card off screen; otherwise I spring it back. Only at the very end, when the card is really gone, do I hop back to the JS thread to update React state or remove the item. That keeps the gesture smooth even when the app is busy."
Updating React state on every touch move to position the card, which re-renders every frame and lags.
Token: secure storage backed by the iOS Keychain and Android Keystore.
Settings: AsyncStorage, or a fast key-value store for synchronous reads at startup.
Records: SQLite or a database built on it, so you can index and query.
Logout: clear the token and the user data.
"They need three different places. The auth token is a secret, so it goes in secure storage backed by the iOS Keychain and the Android Keystore, through a library like react-native-keychain or expo-secure-store. Never plain AsyncStorage, because that's unencrypted, and on a rooted or jailbroken phone it can be read. Small settings, like the theme or whether onboarding was seen, are fine in AsyncStorage, or MMKV if I want fast synchronous reads at startup. For thousands of records that I need to query, filter or update one at a time, a key-value store is the wrong shape. I'd use SQLite, or a database built on it, so I can index and query instead of loading one huge JSON string into memory. I also think about logout: clearing the token and the user's cached data, so the next person on that device doesn't see it."
Keeping tokens in AsyncStorage, or storing a large dataset as one JSON string and parsing it all on every read.
Local first: screens read from a local database; every change is written locally and queued in an outbox.
Sync: on reconnect send queued changes in order with client IDs so retries are safe, then pull changes since a cursor.
Conflicts: a clear rule per field, like last write wins or flag for the worker to resolve.
Mobile limits: separate photo upload queue, visible sync status, limited background time.
"I'd make the local database the source of truth for the UI. Jobs are downloaded into SQLite, screens read from it, and every change the worker makes is written locally first and added to an outbox queue, so the app works the same with or without signal. A sync worker watches connectivity and app state. When the network comes back, it sends queued changes in order, each with a client-generated ID so the server can ignore a retry it has already applied. Then it pulls changes since the last sync cursor. The hard part is conflicts. If the office edited the same job, I need a rule, like last write wins for simple fields, or flagging the job for the worker to resolve for important ones like status. I'd show clear sync status in the UI, keep photos in a separate upload queue because they're large, and remember that iOS limits background work, so most syncing happens while the app is open."
Just caching API responses and hoping, or retrying writes with no idempotency so a flaky network creates duplicate records.
Delivery: APNs on iOS, Firebase Cloud Messaging on Android.
Token: ask permission at a sensible moment, send the token to the backend, handle refresh and logout.
App states: foreground, background and killed each need handling.
Tap: read a deep link from the payload and navigate there.
"Notifications are delivered by the platform services: APNs on iOS and Firebase Cloud Messaging on Android. First I ask for permission at a moment that makes sense, not on first launch. iOS always needs it, and newer Android versions need a runtime permission too. Once allowed, the device gets a push token, and I send it to my backend tied to the signed-in user. Tokens can change, so I listen for refreshes, and I remove the token on logout. The backend sends messages through APNs or FCM, often via a service that wraps both. In the app there are three states. In the foreground, I decide whether to show a banner or just update the UI. In the background, the OS shows it and a tap opens the app. From a fully closed app, I read the notification that launched it at startup. In both tap cases I take a deep link from the payload and navigate there."
Asking for permission on the first screen with no context, or forgetting the case where a tap launches a closed app.
Expo today: maintained native modules, routing and a build and update pipeline.
Custom native code: Expo Go only runs what it bundles; a development build runs any native library.
Prebuild: native folders generated from config, with config plugins for native changes.
Bare: when the team owns heavy native code or embeds React Native in an existing native app.
"These days I start most new apps with Expo. It gives me well-maintained native modules, file-based routing if I want it, and a build and update pipeline, so the team spends time on the product instead of native setup. The old worry was that Expo couldn't do custom native code, and that's mostly outdated. Expo Go, the sandbox app, only runs the native code bundled inside it, but a development build is my own app binary, so I can add any native library. With prebuild, the ios and android folders are generated from the app config, and config plugins apply native changes, which makes upgrades much easier. A bare project, where the team owns and commits the native folders directly, still makes sense when there's a lot of custom native code, or React Native is embedded into an existing native app. Either way, I keep native changes documented and repeatable."
Saying Expo can't use any native code, or treating Expo Go as the way to ship a production app.
Two parts: the native binary through the stores; the JS bundle and assets it loads.
OTA: publish a new bundle; the app downloads it and uses it on next launch or reload.
Limit: no native changes; a runtime version ties each update to a compatible binary.
Safety: gradual rollout, rollback ready, stay within store rules.
"A React Native release has two parts: the native binary that goes through the app stores, and the JavaScript bundle and assets that binary loads. An over-the-air update tool, like EAS Update, lets me publish a new bundle to a server. The app checks for it, downloads it, and uses it on the next launch or when I choose to reload. That's great for fixing a JavaScript bug or a wrong label in hours instead of waiting for review. What it can't do is change native code. A new native library, a new permission, or a native SDK upgrade needs a store release. The key is matching each update to a compatible binary, which is what a runtime version is for. If a bundle calls a native module the installed binary doesn't have, it crashes. I also roll updates out gradually, keep a rollback ready, and stay within store rules: fixes and improvements, not changing what the app does."
Believing any change, including native modules or permissions, can be pushed over the air, or ignoring binary compatibility.
Versioning: versionCode and versionName on Android; version and build number on iOS.
Signing: upload key with Play App Signing; certificate and provisioning profile on iOS.
Testing: release builds on TestFlight and a Play testing track; source maps uploaded.
Rollout and CI: staged or phased release, watch crashes; automate with Fastlane or EAS, secrets out of the repo.
"Each platform has its own versioning and signing. On Android I bump versionCode, which must always go up, and versionName, which users see, and I sign the bundle with an upload key while Play App Signing holds the final key. On iOS I bump the version and build number and sign with a distribution certificate and provisioning profile. I test release builds, not debug, and upload source maps so production crashes are readable. Testing goes through TestFlight on iOS and an internal or closed testing track on Play. Once it's approved, I use a staged rollout on Android and a phased release on iOS, and watch crash-free rates before going to everyone. I automate the whole thing in CI with Fastlane or EAS: version bumps, signing from stored secrets, builds, source map upload and store upload. Signing keys and passwords never live in the repo."
Building releases by hand on one laptop with keys committed to the repo, or never testing the actual release build before upload.
JavaScript: React Native DevTools for breakpoints, console, components and profiler; LogBox and Metro output.
Native: Xcode and Android Studio for native stack traces, logcat and the device console.
Production: a crash reporter with source maps and iOS debug symbols uploaded.
"It depends which side the problem is on. For JavaScript issues I use React Native DevTools, which connects to Hermes: breakpoints, the console, and the React components and profiler views to see props, state and why something re-rendered. LogBox shows warnings and errors in the app itself, and Metro's terminal shows bundling problems. For network issues I log requests or use a proxy tool to inspect real traffic. When the app crashes natively, JavaScript tools won't help, so I open the project in Xcode or Android Studio and read the native stack trace, logcat or the device console. That's usually where missing permissions, linking problems or a native library crash show up. For crashes from real users, I rely on a crash reporting service with source maps and iOS debug symbols uploaded, so I get readable stacks and can see which versions and devices are affected."
Only using console.log for everything, or having no idea how to read a native crash log.
Plan: step through versions with the template diff instead of one big jump.
Audit: check every native dependency for support first; replace abandoned ones.
Breakage: what actually broke, usually native build tooling.
Safety: device testing, internal testers, staged rollout, and a routine so the gap never grows again.
"In my last role we'd fallen several versions behind, and some libraries had stopped supporting our version. I didn't jump straight to the latest. I used the upgrade helper to see the template diff for each step and moved one or two versions at a time on a branch. Before touching anything, I listed every native dependency, checked which ones supported the new architecture, and replaced two that were abandoned. What broke was mostly native: a Gradle and Kotlin version bump, an iOS pod needing a newer minimum iOS version, and one library relying on an old internal API. On the JavaScript side, a few deprecation warnings had become errors. We ran the full test list on real devices for both platforms, gave it to internal testers for a week, then did a staged rollout while watching crash rates. It took longer than a normal release, but nothing reached users broken, and we now upgrade every couple of releases."
Jumping many versions in one commit and fixing errors until it builds, with no plan for testing or rollback.
Get the error: install the release build and read logcat during launch.
Usual causes: R8 stripping a class used by reflection, config missing in release, a native library missing for one architecture, dev-only code paths.
Verify: test the signed release build on real devices.
Deadline: tell people early; move the release rather than ship a crash; add a release smoke test.
"First I get the real error instead of guessing. I install the release build on a device and read logcat while it launches. Release-only crashes come from a small set of causes, so the stack trace narrows it fast. A common one is R8 removing or renaming a class that a library looks up by reflection, which shows up as a class-not-found error, and the fix is a keep rule for that library. Others are config missing in release, like an API URL only set for debug, a native library not packaged for one CPU architecture, or code that only worked in dev mode. Once I've fixed it, I test the actual signed build on a couple of devices, not the debug one. If I can't find a safe fix in time, I tell the team and the product owner early with what I know, and we move the release rather than ship something that crashes. Afterwards I add a release-build smoke test to CI."
Turning off minification for the whole app to make it go away, or shipping on time without ever running the release build.
Contain: halt or pause the rollout so no more users get the build.
Diagnose: crash reports filtered to the new version; one crash or many, which devices and screens.
Fix path: JavaScript crash may be fixable over the air; native needs a hotfix build through review.
Communicate: plain facts to the team and support; afterwards close the testing gap.
"The first move is to stop the damage. I halt or pause the rollout in the store console so no more users get the build. Then I look at crash reports filtered to the new version: is it one crash or several, and which devices, OS versions and screens does it hit. If the crash is in JavaScript and the app uses over-the-air updates, I may be able to ship a fix, or roll the bundle back, quickly for the people who already have it. If it's native, those users need a new store release, so I prepare a hotfix build and ask for an expedited review on iOS if it's serious. Meanwhile I keep the team and support in the loop with plain facts: what's broken, roughly how many users, and when we expect a fix. Once it's resolved, I work out why testing missed it and add a check for that, not just the fix."
Letting the rollout continue while investigating, or pushing a rushed fix to everyone without checking it on the affected devices.
ClapAssist is an AI interview assistant for Mac and Windows. It listens to the interview on your computer and shows you what to say, in short lines you can read while you talk. Your resume and notes are never stored on our servers. It stays out of screen share on every plan; only you can see it.