How We Improved the Startup Time of Our App by 50%
Introduction
Recently, we had started to realize that the startup time of the Pump.fun app was starting to drag on (in multiple seconds), and it was getting flagged internally as a pain point. If it was starting to become frustrating for us, it was certainly also for our users.
We had tracked some telemetry for this, but it only told us more or less a single number, and that number seemed too long for what’s accepted as an industry standard. We hypothesized that we would see better product metrics if we were able to reduce the startup time.
To combat this, we decided to invest in better startup telemetry, broken down into specific milestones between native and JS as a starting point, so we can better understand where the time was spent and see where regressions occurred.
Checking the code, it was also evident that the startup time wasn’t being held sacred and lots of work had been accumulating during this critical phase, so in addition to fixing it, we needed to add safeguards to stop it coming back in the future.
This post is about how we identified and fixed the issues, and made it as hard as possible for it to regress in the future.
1. Why Startup Time Matters
Startup time is the first UX signal users receive from a mobile application. The app should be ready to help the user as quickly as possible.
Research consistently shows that people become more sensitive to launch delays when they open an app many times throughout the day. In our case, this is especially important because our users return to the app frequently, and every additional 100ms is noticeable.
That is why we treat startup performance as a critical product metric. In this article, we want to share the techniques, tooling, and lessons that helped us significantly improve startup time in our React Native application.
2. Measurement and Observability
Before optimizing anything, it is important to understand exactly how startup time is measured and what your current baseline looks like.
Android recommends the following launch-time targets:
- Cold startup: under 5 seconds
- Warm startup: under 2 seconds
- Hot startup: under 1.5 seconds
Source: https://developer.android.com/topic/performance/vitals/launch-time
On iOS, Apple recommends keeping cold-start p50 below one second.
To get a complete picture, we collect startup metrics from several different sources.
Native Platform Metrics
Android
On Android, we use Firebase and Android Vitals through Google Play Console. This gives us launch analytics for cold, warm, and hot starts broken down by:
- app version
- Android SDK version
- device model
- country
- and other dimensions
iOS
On iOS, Xcode Organizer provides launch-time metrics including TTF (time to first frame) for p50 and p90. The data is grouped by native release version and can be analyzed per device model.
These native metrics are useful, but they do not tell the whole story for a React Native application.
A React Native app starts in multiple stages:
- Native application startup
- React Native runtime initialization
- JavaScript bundle loading and execution
- OTA update processing
Native analytics usually only measure part of that flow.
Custom Startup Analytics
To get end-to-end visibility, we built additional startup analytics using Datadog and react-native-performance.
The Datadog React Native SDK allows us to collect cold-start information on both platforms.
Using react-native-performance, we measure:
nativeLaunchrunJsBundlecontentAppeared- custom performance marks
This gives us precise timing for:
- JS bundle loading
- time to first content
- deep-link startup scenarios
- biometric-auth flows
- and other startup variants
Instead of relying on a single number, we can now see exactly where time is spent.
3. React Native Startup Under the Hood
Before discussing profiling and optimization strategies, it is useful to understand what actually happens during startup in a React Native application.
A React Native app consists of several major parts:
- the host native application
- the React Native framework core
- native modules such as Turbo Modules and Expo Modules
- the JavaScript bundle
- assets such as images, fonts, and other resources
The startup flow usually looks like this:
- Native host application initialization
- Root activity or root view creation
- React runtime initialization
- Fabric, JSI, Turbo Modules, and related systems startup
- JS bundle loading
- Execution of the JavaScript entry point
Every stage can introduce bottlenecks.
4. Profiling Tools and Workflows
One important rule: always profile release builds.
Debug builds behave very differently and can completely distort startup measurements.
Native Startup Profiling
iOS
On iOS, we primarily use the Instruments App Launch template with additional network and HTTP traffic instrumentation enabled.
This profile helps us identify:
- slow React Native host initialization
- expensive native modules
- blocking network requests
- work that could potentially be deferred
More information: https://developer.apple.com/documentation/xcode/reducing-your-app-s-launch-time#Profile-your-apps-launch-time
Android
On Android, we use Perfetto traces through either:
- Perfetto CLI
- Android Performance Analyzer
Perfetto allows us to inspect the entire startup pipeline in detail and identify:
- expensive native modules
- slow resource loading
- thread contention
- blocking operations
More information: https://developer.android.com/topic/performance/vitals/launch-time
APK Analysis
To inspect what actually ends up inside the production build, we use APK Analyzer from Android Studio.
https://developer.android.com/studio/debug/apk-analyzer
This helps us understand:
- binary growth
- bundled native libraries
- asset sizes
- transitive dependencies
JavaScript Bundle Analysis
The next step is analyzing the React Native bundle itself.
We use Expo Atlas:
Expo Atlas visualizes:
- application code
- imported JS libraries
- dependency relationships
- bundle composition
This makes it easier to find:
- oversized dependencies
- duplicated logic
- dead code
- unexpected imports
Hermes CPU Profiling
For JavaScript runtime profiling, we use:
https://github.com/margelo/react-native-release-profiler
react-native-release-profiler allows us to record Hermes stack traces from release builds and inspect them using:
It is important to initialize the profiler as early as possible in index.ts.
We built a small wrapper module that starts profiling during app launch and automatically stops after a timeout, saving the results to disk for later analysis.
import { startProfiling, stopProfiling } from "react-native-release-profiler"; import { useDevToolsStore } from "@/stores/devToolsStore";
import { getSafeErrorMessage } from "@/utils/errors";
import { logger } from "@/utils/logger"; const STARTUP_PROFILE_DURATION_MS = 7000; const startedAt = Date.now(); try {
startProfiling();
logger.info("[startupCpuProfile] Hermes sampling profiler started");
} catch (error) {
logger.warn("[startupCpuProfile] startProfiling failed", {
error: getSafeErrorMessage(error),
});
} setTimeout(() => {
stopProfiling(true)
.then((path: string) => {
const stoppedAt = Date.now();
logger.info(`[startupCpuProfile] Saved to: ${path}`); useDevToolsStore.getState().addReleaseProfilerSavedProfile({
path,
createdAt: stoppedAt,
startedAt,
stoppedAt,
});
})
.catch((error) => {
logger.warn("[startupCpuProfile] stopProfiling failed", {
error: getSafeErrorMessage(error),
});
});
}, STARTUP_PROFILE_DURATION_MS);
To symbolize the profile, run:
npx react-native-release-profiler \
--local {YOUR_CPUPROFILE_PATH} \
--sourcemap-path {SOURCEMAP_PATH}
On Android, sourcemaps are usually located at:
android/app/build/generated/sourcemaps/react/release/index.android.bundle.map
On iOS, sourcemap generation is disabled by default. To enable it, add:
export SOURCEMAP_FILE="$DERIVED_FILE_DIR/main.jsbundle.map"
to your ios/.xcode.env configuration.
5. Optimizations That Made a Real Impact
Once we had reliable measurements and proper profiling workflows, we started optimizing the startup path itself.
Some of these changes produced surprisingly large wins.
Native Runtime Optimization
Always stay close to the latest React Native and Expo releases.
Recent versions contain a huge number of startup-related improvements affecting:
- binary size
- initialization overhead
- rendering performance
- native-module startup cost
Here are a few examples:
- RN 82 — Removal of the Legacy Architecture https://github.com/react-native-community/discussions-and-proposals/blob/nc/legacy-arch-removal/proposals/0929-legacy-architecture-removal.md
- RN 83 — Compile out the Legacy Architecture on iOS with: RCT_REMOVE_LEGACY_ARCH=1 bundle exec pod install
- RN 84 — Hermes V1 by default and additional Legacy Architecture removals
- Expo 55 — Expo Modules Core improvements that fixed slow startup caused by Kotlin reflection: https://github.com/expo/expo/pull/39389
- Expo 56 — New Kotlin compiler plugin with:
- 40% faster cold starts
- 33% faster first render
Enable R8 on Android
On Android, enable R8 in release builds to:
- obfuscate Java code
- shrink binaries
- remove unused code
With Expo, this can be enabled through expo-build-properties:
https://docs.expo.dev/versions/latest/sdk/build-properties/
Using: enableMinifyInReleaseBuilds: true
Remove Expensive Native Dependencies
Analyze native dependencies with APK Analyzer and aggressively remove unused libraries.
Some dependencies are included through autolinking even if your application never directly uses them.
We also use: https://knip.dev/ to identify dead dependencies.
Example: Lottie
We discovered that some animations still relied on lottie-react-native, which added roughly 300 KB of Java code.
At the same time, we were already using Skia, which can also render Lottie animations.
Removing the extra dependency reduced native footprint without sacrificing functionality.
Example: react-native-svg
If you do not need advanced SVG animation or transformations, consider replacing react-native-svg rendering with regular image rendering.
The library can add nearly 1 MB of native .so binaries.
More details: https://swmansion.com/blog/you-might-not-need-react-native-svg-b5c65646d01f/
Asset Optimization
Optimize every image and SVG asset automatically during CI.
Use modern formats such as:
- WebP
- AVIF where supported
Move non-critical assets to a CDN whenever possible to reduce bundle size.
Fonts
Configure Expo Fonts correctly so fonts become part of the native bundle rather than the JavaScript bundle: https://docs.expo.dev/develop/user-interface/fonts/
If you use Skia, prefer native system fonts through matchFont: https://shopify.github.io/react-native-skia/docs/text/text/#system-fonts
Also pay attention to assets bundled indirectly through third-party libraries.
For example, we recently discovered Material fonts included through Expo Router even though we did not use them: https://github.com/expo/expo/issues/43614
JavaScript Bundle Optimization
To reduce JS bundle size, we rely on several tools:
- Knip for dead code and dependency detection
- Expo tree shaking
- dependency analysis through Expo Atlas
We also moved the majority of localization files to a CDN (bundling only critical path chunks) as well as image assets automatically.
Critical JS Path Optimization
Using react-native-release-profiler, we collected startup traces showing which functions and modules executed during app launch.
That led to several impactful changes.
Splash screen Animation Simplification
We rewrote splash-screen animations to use useNativeDriver instead of Reanimated.
This allowed us to avoid initializing the much more expensive Reanimated runtime during the critical startup path.
Consolidating Deferred Work
We found multiple competing implementations of deferred background work scattered throughout the codebase.
We consolidated everything into a single utility:
deferUntilAppReady
The utility introduces:
- high-priority queue
- medium-priority queue
- low-priority queue
Each queue drains in order once the application becomes interactive.
Implementation example:
import { logger } from "@/utils/logger"; const FALLBACK_TIMEOUT_MS = 5000; export type AppReadyReason =
| "splash_hidden"
| "force_update"
| "fallback_timeout"; export type DeferPriority = "high" | "medium" | "low"; const PRIORITY_ORDER: readonly DeferPriority[] = ["high", "medium", "low"]; let isReady = false;
let readyReason: AppReadyReason | null = null;
const queues: Record<DeferPriority, (() => void)[]> = {
high: [],
medium: [],
low: [],
}; const drainQueue = () => {
for (const priority of PRIORITY_ORDER) {
const queue = queues[priority];
while (queue.length > 0) {
const cb = queue.shift();
if (!cb) continue;
try {
cb();
} catch (error) {
logger.warn("[appReady] queued callback threw", {
priority,
error: error instanceof Error ? error.message : String(error),
});
}
}
}
}; const fallbackTimer: ReturnType<typeof setTimeout> = setTimeout(() => {
if (!isReady) {
logger.warn("[appReady] fallback timeout fired before any explicit mark", {
timeoutMs: FALLBACK_TIMEOUT_MS,
});
markAppReady("fallback_timeout");
}
}, FALLBACK_TIMEOUT_MS); if (typeof fallbackTimer === "object" && fallbackTimer !== null) {
const maybeUnref = (fallbackTimer as { unref?: () => void }).unref;
if (typeof maybeUnref === "function") {
maybeUnref.call(fallbackTimer);
}
} export function markAppReady(reason: AppReadyReason): void {
if (isReady) return;
isReady = true;
readyReason = reason;
clearTimeout(fallbackTimer); logger.info("[appReady] marked ready", { reason }); drainQueue();
} export function deferUntilAppReady(
cb: () => void,
priority: DeferPriority = "medium",
): void {
if (isReady) {
queueMicrotask(() => {
try {
cb();
} catch (error) {
logger.warn("[appReady] microtask callback threw", {
priority,
error: error instanceof Error ? error.message : String(error),
});
}
});
return;
}
queues[priority].push(cb);
}
Killing Module-Time Work
Many modules were doing hidden work at import time.
These modules performed expensive operations immediately during evaluation, even when their functionality was not required during startup.
We identified those modules through Hermes traces and replaced them with lazy loading.
Example: Lazy require
const { parsePhoneNumberFromString } = require("libphonenumber-js")
Example: React Lazy
import { lazy, Suspense } from "react";
import { View } from "react-native"; const InviteLinkQrCodeContent = lazy(
() => import("@/components/pages/chat/InviteLinkQrCodeContent"),
); export default function GroupInviteQrBottomSheet() {
return (
<Suspense fallback={<View className="flex-1 bg-bg-primary" />}>
<InviteLinkQrCodeContent />
</Suspense>
);
}
Moving Work From Runtime to Build Time
One particularly expensive hotspot came from repeated calls to convertIdlToCamelCase in .@coral-xyz/anchor.
That function performs two expensive operations:
structuredClone(idl)— deep cloning the entire IDL JSON- recursive traversal converting snake_case keys to camelCase
Our IDLs were around 600 KB of JSON and could take up to two seconds on slower Android devices during app startup.
The solution was simple in principle:
Camel-case the IDLs once during build time and skip runtime conversion entirely.
That required three pieces working together:
- A Babel plugin that finds Anchor IDLs in the Metro graph and rewrites them into camelCase form
- A
patch-packagepatch on .@coral-xyz/anchorsoconvertIdlToCamelCaseshort-circuits when it sees our sentinel - Babel configuration enabling the plugin for non-test builds
Moving that work out of runtime eliminated one of the largest startup bottlenecks we had.
Before JS optimisation:
After JS optimisation:
6. How We Protect Performance From Regressions
Startup optimization is not a one-time project.
Without guardrails, regressions slowly creep back in.
To prevent that, we built several layers of protection.
Documenting the Critical Startup Path
We documented the critical startup path and marked key files with .@requires-approval annotations so changes receive additional review attention (this pragma forces the owning team to approve the change).
CI Bundle Size Checks
On every pull request, CI compares the size of the production bundle against the baseline.
Unexpected increases are surfaced immediately. We also send the bundle and native size to our dashboard on each merge back to main, so we can easily see how the size scales over time and address any unexpected inflations.
Automated Asset Optimization
CI automatically:
- optimizes images
- validates asset budgets
- detects oversized resources
Dead File Detection
We also run dead-file detection in CI to prevent unused code from accumulating over time. Since stacked PRs often implement something which is consumed later in the stack, we have a budget of 10 files, which once passed will block merging.
Dead Package Detection
We block PRs now which orphan an unused package, which when initially implemented highlighted many dead packages in our codebase which can contribute to bigger bundles and slower app startup (particularly in the native phase).
Dangerfile Potential Startup Impacting Code
We use a dangerfile to detect when a function is called or new constructor performed during module time, which is work that is often paid at startup time. Due to limitations of static analysis, it just informs the PR author, but as reminder to verify if we need to initialize something at this point or can it be deferred/lazily done at a later time.
Workshops
We ran a workshop with every team to explain the tools available in RN and how they can be used to profile parts of the app.
We're hiring!
We're hiring Reactive Native Engineers! Apply now to help build one of the biggest consumer apps of our generation: https://jobs.ashbyhq.com/batoncorporation/d8362327-ef6a-4200-93d8-f258d0f870ba
View other roles: https://jobs.ashbyhq.com/batoncorporation
Posted Jun 8, 2026, 4:32 PM UTC