Skip to main content

Overview

Smaller bundle sizes mean faster app startup, reduced memory usage, and better user experience. This guide shows you how to measure and optimize your app’s bundle size.

Measuring Bundle Size

Expo bundle analyzer

# Generate bundle visualization
npx expo export --platform ios
npx expo export --platform android

# Install analyzer
npm install -g expo-atlas

# Analyze the bundle
npx expo-atlas
This opens an interactive visualization showing:
  • Module sizes
  • Dependency tree
  • Duplicate packages
  • Largest contributors

Manual bundle inspection

# Generate production bundle
npx expo export:web

# Check bundle sizes
ls -lh dist/_expo/static/js/

Source map explorer

npm install -g source-map-explorer

# Analyze bundle
source-map-explorer dist/_expo/static/js/*.js

Reducing Bundle Size

Remove unused dependencies

Check for unused packages:
npm install -g depcheck
depcheck
Remove unused packages:
npm uninstall package-name

Analyze dependency sizes

npm install -g bundle-phobia-cli
bundle-phobia [package-name]
Or check online: bundlephobia.com

Replace large dependencies

# Before: ~230KB
npm uninstall moment

# After: ~12KB (with tree shaking)
npm install date-fns
// Before
import moment from 'moment';
const formatted = moment(date).format('YYYY-MM-DD');

// After
import { format } from 'date-fns';
const formatted = format(date, 'yyyy-MM-dd');

Tree Shaking

Import only what you need

// ❌ Bad: Imports everything
import * as Icons from '@expo/vector-icons';
const Icon = Icons.MaterialIcons;

// ✅ Good: Imports only what's needed
import { MaterialIcons } from '@expo/vector-icons';

// ❌ Bad: Entire library
import _ from 'lodash';

// ✅ Good: Specific function
import debounce from 'lodash-es/debounce';

Configure Metro for tree shaking

metro.config.js
const { getDefaultConfig } = require('expo/metro-config');

const config = getDefaultConfig(__dirname);

config.transformer = {
  ...config.transformer,
  minifierConfig: {
    keep_classnames: true,
    keep_fnames: true,
    mangle: {
      keep_classnames: true,
      keep_fnames: true,
    },
  },
};

module.exports = config;

Dynamic Imports

Code splitting with React.lazy

app/_layout.tsx
import { lazy, Suspense } from 'react';
import { ActivityIndicator } from 'react-native';

// Lazy load heavy components
const ProfileScreen = lazy(() => import('./profile'));
const SettingsScreen = lazy(() => import('./settings'));

export default function Layout() {
  return (
    <Suspense fallback={<ActivityIndicator />}>
      <Stack>
        <Stack.Screen name="profile" component={ProfileScreen} />
        <Stack.Screen name="settings" component={SettingsScreen} />
      </Stack>
    </Suspense>
  );
}

Conditional imports

// Only import when needed
async function handleExport() {
  const { exportToPDF } = await import('./utils/pdf-export');
  await exportToPDF(data);
}

Platform-specific code

utils/analytics.ts
import { Platform } from 'react-native';

export async function initAnalytics() {
  if (Platform.OS === 'web') {
    const { initWebAnalytics } = await import('./analytics.web');
    return initWebAnalytics();
  } else {
    const { initNativeAnalytics } = await import('./analytics.native');
    return initNativeAnalytics();
  }
}

Image Optimization

Use WebP format

// Smaller file size, good quality
<Image source={require('./image.webp')} />

Image compression

Compress images before adding to your app:
# Using ImageOptim (macOS)
imageoptim assets/**/*.{png,jpg}

# Using sharp-cli
npm install -g sharp-cli
sharp -i input.jpg -o output.jpg -q 80

Responsive images

components/ResponsiveImage.tsx
import { Image } from 'expo-image';
import { useWindowDimensions } from 'react-native';

type Props = {
  sources: {
    small: string;
    medium: string;
    large: string;
  };
};

export function ResponsiveImage({ sources }: Props) {
  const { width } = useWindowDimensions();

  const source = width < 400 ? sources.small :
                 width < 800 ? sources.medium :
                 sources.large;

  return <Image source={{ uri: source }} />;
}

Font Optimization

Load fonts selectively

app/_layout.tsx
import { useFonts } from 'expo-font';

export default function Layout() {
  const [fontsLoaded] = useFonts({
    // Only load weights you actually use
    'Inter-Regular': require('../assets/fonts/Inter-Regular.ttf'),
    'Inter-Bold': require('../assets/fonts/Inter-Bold.ttf'),
    // Don't load: Light, Medium, SemiBold, ExtraBold, Black
  });

  if (!fontsLoaded) return null;

  return <Slot />;
}

Use system fonts

import { Platform, StyleSheet } from 'react-native';

const styles = StyleSheet.create({
  text: {
    fontFamily: Platform.select({
      ios: 'System',
      android: 'Roboto',
      default: 'system-ui',
    }),
  },
});

Optimizing Vector Icons

Use only needed icon sets

// ❌ Loads all icon fonts (~2MB)
import { Ionicons, MaterialIcons, FontAwesome } from '@expo/vector-icons';

// ✅ Load only what you use
import { Ionicons } from '@expo/vector-icons';

Create custom icon sets

# Generate custom icon font with only icons you use
npm install -g @expo/vector-icons
generate-icon-set icons.json

Use SVG instead

npm install react-native-svg
import Svg, { Path } from 'react-native-svg';

function CustomIcon() {
  return (
    <Svg width="24" height="24" viewBox="0 0 24 24">
      <Path d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z" fill="currentColor" />
    </Svg>
  );
}

Remove Development Code

Use DEV flag

if (__DEV__) {
  // Development-only code (removed in production)
  console.log('Debug info');
  require('./devtools');
}

Conditional imports

const debugTools = __DEV__ ? require('./debug-tools') : null;

if (__DEV__ && debugTools) {
  debugTools.init();
}

Hermes Optimization

Hermes provides better performance and smaller bundle size:
app.json
{
  "expo": {
    "jsEngine": "hermes",
    "ios": {
      "jsEngine": "hermes"
    },
    "android": {
      "jsEngine": "hermes"
    }
  }
}
Benefits:
  • Faster startup time
  • Lower memory usage
  • Bytecode compilation
  • Better optimizations

Production Build Optimization

EAS Build configuration

eas.json
{
  "build": {
    "production": {
      "env": {
        "NODE_ENV": "production"
      },
      "android": {
        "buildType": "app-bundle",
        "enableProguardInReleaseBuilds": true,
        "enableShrinkResourcesInReleaseBuilds": true
      },
      "ios": {
        "buildConfiguration": "Release"
      }
    }
  }
}

Minification

Metro automatically minifies in production:
metro.config.js
module.exports = {
  transformer: {
    minifierPath: 'metro-minify-terser',
    minifierConfig: {
      compress: {
        drop_console: true, // Remove console.log in production
        passes: 3,
      },
    },
  },
};

Analyzing Dependencies

Find duplicate packages

npm ls [package-name]

Check for multiple versions

npm dedupe

Use yarn resolutions

package.json
{
  "resolutions": {
    "package-name": "1.2.3"
  }
}

Bundle Size Budget

Set size limits to prevent regressions:
bundle-size-check.js
const fs = require('fs');
const path = require('path');

const MAX_BUNDLE_SIZE = 5 * 1024 * 1024; // 5MB

const distDir = path.join(__dirname, 'dist/_expo/static/js');
const files = fs.readdirSync(distDir);

let totalSize = 0;

files.forEach(file => {
  const filePath = path.join(distDir, file);
  const stats = fs.statSync(filePath);
  totalSize += stats.size;
});

console.log(`Total bundle size: ${(totalSize / 1024 / 1024).toFixed(2)}MB`);

if (totalSize > MAX_BUNDLE_SIZE) {
  console.error('Bundle size exceeds maximum!');
  process.exit(1);
}
Add to CI:
package.json
{
  "scripts": {
    "check-bundle-size": "node bundle-size-check.js"
  }
}

Best Practices

Import strategy

// ✅ Good: Named imports (tree-shakeable)
import { useState, useEffect } from 'react';

// ❌ Bad: Default import of entire module
import React from 'react';
const { useState, useEffect } = React;

// ✅ Good: Specific imports
import debounce from 'lodash-es/debounce';

// ❌ Bad: Entire library
import _ from 'lodash';

Lazy loading strategy

// Heavy dependencies
const VideoPlayer = lazy(() => import('./VideoPlayer'));
const MapView = lazy(() => import('./MapView'));
const Charts = lazy(() => import('./Charts'));

// Load on demand
function Dashboard() {
  const [showCharts, setShowCharts] = useState(false);

  return (
    <View>
      <Button onPress={() => setShowCharts(true)} title="Show Charts" />
      {showCharts && (
        <Suspense fallback={<Spinner />}>
          <Charts />
        </Suspense>
      )}
    </View>
  );
}

Monitoring

Track bundle size over time

.github/workflows/bundle-size.yml
name: Bundle Size

on: [pull_request]

jobs:
  bundle-size:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - uses: actions/setup-node@v3
      - run: npm ci
      - run: npx expo export:web
      - uses: andresz1/size-limit-action@v1
        with:
          github_token: ${{ secrets.GITHUB_TOKEN }}

Bundle size dashboard

Use tools like Bundlewatch or RelativeCI to track bundle size across builds.

Troubleshooting

  • Run npx expo-atlas to analyze
  • Check for duplicate dependencies with npm ls
  • Look for large packages in analysis
  • Verify tree shaking is working
  • Use named imports, not default imports
  • Check package.json has "sideEffects": false
  • Ensure using ES modules, not CommonJS
  • Verify Metro config is correct
  • Check if new dependencies are tree-shakeable
  • Look for lighter alternatives
  • Consider lazy loading the feature
  • Analyze the dependency with bundle-phobia

Size Optimization Checklist

  • Run bundle analyzer to identify large modules
  • Remove unused dependencies
  • Replace large libraries with smaller alternatives
  • Use tree-shakeable imports
  • Implement code splitting with lazy loading
  • Optimize images (WebP, compression)
  • Load only required fonts and icon sets
  • Enable Hermes engine
  • Remove console.log in production
  • Enable ProGuard (Android)
  • Monitor bundle size in CI
  • Set bundle size budgets