

Hermes, FlashList, react-native-fast-image, UI-thread Reanimated, offline-first React Query: the complete guide to a stable 60 fps on a 2 GB Tecno Spark Go.
On the Nexus mobile app, 68% of users have an entry-level Android device under €100 with 2 GB of RAM and a slow 4-core CPU. Here's the playbook that took me to a stable 60 fps on those devices.
When you develop on a €1,200 iPhone Pro, you never see the real problems. Everything is smooth. But the majority of French-speaking West African users — and a substantial slice of emerging markets in general — run on entry-level Android devices: Tecno, Itel, Infinix, Xiaomi A-series. These phones have:
In these conditions, a default React Native app stutters. Not a little — a lot. Scroll frame drops, choppy transitions, 8-second cold starts. The perception: "this app is broken."
This article: what I changed on the Nexus mobile app to go from 18 fps to a stable 58 fps on a Tecno Spark Go.
Without measurement, you optimize blind. Three tools mattered:
Flipper + Performance Monitor: shows UI and JS thread FPS as overlay. It's my reference. If I see the JS thread drop below 30 fps, I have a logic problem. If it's the UI thread, it's a rendering problem.
Android Studio Profiler: for cold start and memory usage. I profile from onCreate to the first interactive screen.
Reactotron: to trace re-renders. If a component re-renders 12 times during a transition, I know.
| 1 | // hooks/useRenderCount.ts — handy in dev |
| 2 | import { useRef } from "react"; |
| 3 | |
| 4 | export function useRenderCount(name: string) { |
| 5 | const count = useRef(0); |
| 6 | count.current += 1; |
| 7 | if (__DEV__) { |
| 8 | console.log(`[render] ${name} — ${count.current}`); |
| 9 | } |
| 10 | } |
That's the user's first glance. On Spark Go, the default app took 7.8 seconds. Target: under 3 seconds. What moved the needle:
Hermes is the JavaScript engine optimized for React Native on Android. It pre-compiles bytecode, shrinks the bundle and starts 30–50% faster. On Spark Go: -2.1 seconds.
| 1 | // android/app/build.gradle |
| 2 | project.ext.react = [ |
| 3 | enableHermes: true, |
| 4 | ] |
The classic JS splash waits for React to mount. Slow on entry-level. The native splash shows from Android boot, before React. Combined with react-native-bootsplash, it creates the illusion of instant startup.
The router loads all screens at init by default. Bad. With React.lazy or a router that supports native lazy loading (React Navigation 6+ with @react-navigation/native-stack):
| 1 | import { createNativeStackNavigator } from "@react-navigation/native-stack"; |
| 2 | import { lazy } from "react"; |
| 3 | |
| 4 | const HomeScreen = lazy(() => import("../screens/HomeScreen")); |
| 5 | const PortfolioScreen = lazy(() => import("../screens/PortfolioScreen")); |
| 6 | |
| 7 | const Stack = createNativeStackNavigator(); |
Initial bundle drops from 4.2 MB to 1.8 MB. Startup: -1.2 seconds.
90% of fluidity problems come from lists. On Nexus, "My investments" displays up to 200 rows. Rules:
FlatList (or FlashList), never ScrollView + mapScrollView renders all children even off-screen. FlatList virtualizes. Shopify's FlashList goes further by using native cell recycling:
| 1 | import { FlashList } from "@shopify/flash-list"; |
| 2 | |
| 3 | <FlashList |
| 4 | data={investments} |
| 5 | estimatedItemSize={88} |
| 6 | renderItem={({ item }) => <InvestmentCard investment={item} />} |
| 7 | keyExtractor={(item) => item.id} |
| 8 | /> |
getItemLayout when height is knownIf each item has the same height, giving it to FlatList avoids measurement and speeds up scrolling.
React.memo on itemsWithout memo, every scroll re-renders all visible items, even if props haven't changed:
| 1 | export const InvestmentCard = React.memo(function InvestmentCard({ investment }: Props) { |
| 2 | return ( |
| 3 | <View style={styles.card}> |
| 4 | <Text>{investment.label}</Text> |
| 5 | <Text>{investment.amount}</Text> |
| 6 | </View> |
| 7 | ); |
| 8 | }, (prev, next) => |
| 9 | prev.investment.id === next.investment.id && |
| 10 | prev.investment.amount === next.investment.amount |
| 11 | ); |
The custom comparator skips the default shallow check that re-renders as soon as a prop changes reference.
A 4 MB image downloaded and decoded blocks the JS thread for seconds. On Spark Go, deadly. Three rules:
Ask the backend for the right size based on PixelRatio.get(). Not 1920×1080 on a 720p screen.
| 1 | import { PixelRatio } from "react-native"; |
| 2 | |
| 3 | const width = 360; |
| 4 | const ratio = PixelRatio.get(); |
| 5 | const imageUrl = `${cdn}/photo.jpg?w=${width * ratio}&q=80`; |
react-native-fast-imageThe default Image component has weak cache and blocks decoding. react-native-fast-image uses SDWebImage (iOS) and Glide (Android) — native off-thread decoding, smart disk and memory cache.
WebP shrinks file size by 30–40% vs JPEG and is natively supported on Android 4.0+. AVIF even more, but limited to Android 12+. Serve AVIF when the client accepts it, fallback WebP.
Animating in JS on entry-level = guaranteed frame drops. The rule: every animation must run on the UI thread.
react-native-reanimated v3 lets you write "worklets" that execute on the UI thread with no round-trip to JS:
| 1 | import Animated, { useSharedValue, useAnimatedStyle, withSpring } from "react-native-reanimated"; |
| 2 | |
| 3 | function SwipeableRow() { |
| 4 | const offset = useSharedValue(0); |
| 5 | |
| 6 | const style = useAnimatedStyle(() => ({ |
| 7 | transform: [{ translateX: offset.value }], |
| 8 | })); |
| 9 | |
| 10 | const onSwipe = (delta: number) => { |
| 11 | offset.value = withSpring(delta); |
| 12 | }; |
| 13 | |
| 14 | return <Animated.View style={style} />; |
| 15 | } |
For heavy screen transitions (animation + data load), defer non-critical work with InteractionManager.runAfterInteractions. It lets the runtime finish the transition before fetching.
On 3G with 600 ms latency, waiting for a network request before showing anything is unbearable. Rule: always show first, refresh later.
| 1 | import { useQuery } from "@tanstack/react-query"; |
| 2 | |
| 3 | export function useInvestments(userId: string) { |
| 4 | return useQuery({ |
| 5 | queryKey: ["investments", userId], |
| 6 | queryFn: () => api.fetchInvestments(userId), |
| 7 | staleTime: 0, |
| 8 | gcTime: 1000 * 60 * 60 * 24, |
| 9 | refetchOnMount: "always", |
| 10 | }); |
| 11 | } |
Combined with an AsyncStorage persister for React Query, the user opens the app and sees their last data immediately, even offline. Fetch follows in background and updates.
On 3G, 1 MB = 16 seconds of download. The bundle must be minimal:
source-map-explorer — visualizes who weighs what. Often an 800 KB library added for a single debounce function.import { debounce } from "lodash-es") not whole libs.core-js configured for Android API 21+, not Android 4.| Pattern | When | Gain |
|---|---|---|
| Server-side gzip compression | Always | -60% payload |
| Multiplexed HTTP/2 | Always | -300 ms per cascading request |
| Idle pre-fetch | Before likely transition | Perceived-instant transition |
| Keep-alive connection | Always | -100 ms per additional request |
| Exponential backoff | On retry | Avoids overload on network failure |
After applying all patterns on Nexus Mobile:
| Metric | Before | After |
|---|---|---|
| Cold start | 7.8 s | 2.4 s |
| 200-item scroll FPS | 18 | 58 |
| Idle memory | 280 MB | 145 MB |
| Initial bundle | 4.2 MB | 1.8 MB |
| Time-to-interactive | 9.1 s | 3.2 s |
The highest-leverage moves were switching to FlashList + memo (+30 fps instantly) and enabling Hermes + lazy loading (-3.2 s startup). The rest is daily rigor.
React Native on entry-level Android isn't a framework problem, it's a discipline problem. Hermes, virtualized lists, UI-thread animations, native images, bundle audit, offline-first. None of these patterns is revolutionary, but their systematic application separates an app that works from an app users keep on their phone.
The metric to watch: time-to-interactive on a defined target device. If it's under 3 seconds on the low end of the market, your app is usable anywhere.
If this topic feels close to a real product problem, I can help on diagnosis, architecture, backend, interface and automations that make a platform usable in production.
Reader reactions
No comment yet
Be the first to share your reaction.