How We Improved the Startup Time of Our App by 50%

https://preview.redd.it/rps1tba6436h1.png?width=2092&format=png&auto=webp&s=0e7b31c1231ea324ff5a1883ecd91446a24d840a

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

https://preview.redd.it/hrmo20g8436h1.png?width=1290&format=png&auto=webp&s=f965a0a139eb31c50e7d8749a60bd8bc1fdf0060

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:

  1. Native application startup
  2. React Native runtime initialization
  3. JavaScript bundle loading and execution
  4. 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:

  • nativeLaunch
  • runJsBundle
  • contentAppeared
  • 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:

  1. Native host application initialization
  2. Root activity or root view creation
  3. React runtime initialization
  4. Fabric, JSI, Turbo Modules, and related systems startup
  5. JS bundle loading
  6. 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:

https://github.com/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:

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:

  1. structuredClone(idl) — deep cloning the entire IDL JSON
  2. 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:

  1. A Babel plugin that finds Anchor IDLs in the Metro graph and rewrites them into camelCase form
  2. A patch-package patch on .@coral-xyz/anchor so convertIdlToCamelCase short-circuits when it sees our sentinel
  3. 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:

https://preview.redd.it/81iik1mz436h1.png?width=1415&format=png&auto=webp&s=d99384a69a4de7b296010f8fb92d432b0dfe3161

After JS optimisation:

https://preview.redd.it/ubhrqhr0536h1.png?width=1402&format=png&auto=webp&s=64f28c656819789e549cb66d32d09abdc62892fe

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

4416
Loading comments…