Last spring, a React Native team came to us with an app that took 8 seconds to become interactive on a mid-range Android device. Their JS bundle was 42MB, and every image, font, and Lottie file was packed right inside it. We'd seen this pattern a dozen times before, and I knew the fix wasn't a faster phone — it was getting those assets off the main bundle and onto a CDN.
Why Bundling Everything Into Your React Native App Hurts Performance
When you run react-native bundle with default settings, Metro pulls all your static assets — PNGs, fonts, JSON configs — into the binary or the JS bundle itself. It feels convenient at build time, but you're forcing every user to download everything, even if they only visit two screens. I've worked with e-commerce apps where 60% of the bundled images were for products the average user never viewed. That's wasted bandwidth and wasted time on first launch, especially on flaky 3G and 4G connections where mobile framework CDN delivery would have been far more efficient.
The other problem is updates. If you fix a single typo in a product image or swap a splash screen, you have to ship a whole new binary through App Store review, or push a heavy OTA update. Your users are stuck waiting, and your React Native CDN strategy is essentially non-existent. A proper Mobile App CDN Caching Strategies: Disk Cache, Memory Cache, and Network Cache Layers approach separates the immutable code from the mutable assets, so you can cache aggressively at the edge and invalidate selectively. The HTTP Working Group Specifications define exactly how Cache-Control and ETag headers should behave for this, and we rely on them heavily.
For RN assets, the key shift is thinking in terms of bundle optimization rather than just bundling. You want a small, core JS bundle that boots fast, with everything else fetched on demand. That means configuring Metro to exclude certain asset types and rewriting the asset resolver to point at CDN URLs. It doesn't take much code, but it changes your load times dramatically.
// metro.config.js - Excluding large assets from the bundle
const { getDefaultConfig } = require('metro-config');
module.exports = (async () => {
const config = await getDefaultConfig();
return {
...config,
transformer: {
...config.transformer,
assetPlugins: ['expo-asset/tools/hashAssetFiles'],
},
resolver: {
...config.resolver,
assetExts: config.resolver.assetExts.filter(ext =>
!['png', 'jpg', 'mp4', 'lottie'].includes(ext)
),
},
// Custom server middleware to rewrite asset URLs to CDN
server: {
rewriteRequestUrl: (url) => {
if (url.endsWith('.png') || url.endsWith('.jpg')) {
return `https://cdn.example.com/assets${url}`;
}
return url;
}
}
};
})();
Routing RN Assets Through the Edge Without Breaking OTA Updates
Once you've split your assets, you need a reliable way to serve them. We don't just dump files on S3 and call it a day. A real mobile framework CDN setup puts those assets on edge nodes close to your users, with proper cache headers, Brotli compression, and HTTP/2 or HTTP/3 prioritization. I've seen teams cut asset load times by 70% simply by moving from a single-origin server in us-east-1 to an edge network with 300+ PoPs. The web.dev Articles on optimizing Largest Contentful Paint apply directly here — your React Native image is that LCP element on mobile.
The trickiest part is handling versioning. You can't have a user on app version 1.4 requesting an asset that was overwritten by 1.5. We solve this by hashing asset filenames and including the bundle version in the CDN path, like /assets/v1.4.2/ab12cd3-hero.png. Your JS bundle requests the exact hash it was built with, so there's no mismatch. If you're using CodePush or Expo Updates, you'll want to tie your CDN purge logic to your OTA release pipeline. Our Mobile CDN SDK Integration Guide: Adding Edge Acceleration to iOS and Android Apps walks through how to wire this up natively so the SDK handles cache validation for you.
On the client side, you also need a smart loader that checks the disk cache first, then the CDN, and never blocks the UI thread. React Native's Image.prefetch and expo-asset are useful, but for true control we often write a small native module that respects Cache-Control headers and falls back gracefully. This is where an Offline-First Architecture with CDN: Building Apps That Work Without Connectivity pattern pays off — you cache aggressively on first load and serve from disk when the network is gone.
// RN asset loader with CDN + disk cache fallback
import { Image } from 'react-native';
import * as FileSystem from 'expo-file-system';
const CDN_BASE = 'https://cdn.example.com/assets/v1.4.2';
const CACHE_DIR = FileSystem.cacheDirectory + 'rn-assets/';
export async function loadCdnAsset(assetName, hash) {
const cdnUrl = `${CDN_BASE}/${hash}-${assetName}`;
const localUri = `${CACHE_DIR}${hash}-${assetName}`;
// 1. Check if we already have it on disk
const fileInfo = await FileSystem.getInfoAsync(localUri);
if (fileInfo.exists) {
return localUri;
}
// 2. Fetch from CDN with conditional request
const result = await FileSystem.downloadAsync(cdnUrl, localUri, {
headers: {
'Accept': 'image/webp,image/*,*/*;q=0.8',
},
cache: true
});
// 3. Prefetch for next launch to warm the memory cache
await Image.prefetch(cdnUrl);
return result.uri;
}
What We Actually See After Moving RN Assets to the Edge
Numbers matter more than theory, so let me share what happened with a recent retail client. They had a 38MB Android bundle and a 44MB iOS IPA, largely due to high-res product photography. We moved all images and videos to the CDN, enabled immutable caching with a one-year max-age, and set up stale-while-revalidate for config JSON. The core JS bundle dropped to under 8MB. They didn't have to rewrite any screens — just the asset pipeline.
The performance gains weren't just on paper. Time-to-interactive on a Moto G7 went from 4.8 seconds to 1.9 seconds on a 3G connection, and subsequent launches were even faster because the disk cache was warm. Perhaps more importantly, their OTA updates shrank from 15MB to under 2MB since they were only shipping JS deltas. That meant they could push bug fixes multiple times a week without annoying users. It's the kind of result that makes bundle optimization feel less like an optimization and more like fixing a fundamental architectural mistake.
If you're considering this for your own app, don't try to optimize everything at once. Start with your largest asset type — usually images — and measure. Use performance traces from Flipper or Sentry to see what's actually blocking your launch. The table below shows the typical ranges we've measured across mid-tier Android devices in production.
| Delivery Strategy | Initial Bundle Size | Cold Start (Slow 3G) | Cache Hit Rate (Day 7) | OTA Update Size |
|---|---|---|---|---|
| All Assets Bundled Locally | 38.4 MB | 4.8s | n/a | 14.7 MB |
| CDN Without Edge Caching (Origin Only) | 8.2 MB | 3.4s | 42% | 3.1 MB |
| CDN With Edge + Disk Cache | 7.9 MB | 1.9s | 88% | 1.8 MB |
| CDN With Edge + Disk + Prefetch | 7.9 MB | 1.4s | 94% | 1.8 MB |
Implementing the Edge Asset Pipeline Without Forking Metro
The cleanest implementation we've shipped doesn't require forking Metro or ejecting Expo. Instead, you intercept at the asset resolution layer and rewrite the URI before it hits the bridge. You keep your JavaScript bundle lean - just the JS and the manifest - and let everything else resolve to a versioned edge URL at runtime. The trick is to make it deterministic: every asset gets a content hash at build time, that hash becomes the CDN path, and your runtime loader just appends it. If you've done it right, your app binary drops by 60-70% and you never ship the same 4MB onboarding video twice.
We typically generate an assets-manifest.json during CI that maps local require('./images/hero.png') calls to their hashed CDN equivalents. That manifest is bundled *with* the JS, so it's covered by your CodePush or Expo Updates diff, but the heavy assets themselves live at something like https://assets.yourapp.com/v2/hero.a3f8c9.png. Set a 1-year immutable cache on those hashed paths and a short 5-minute TTL on the manifest itself. You'll see edge hit ratios jump to 94-96% within 48 hours, and origin traffic drops to near zero except right after a release. For Adaptive Quality Delivery for Mobile: Network-Aware Content Optimization this separation is critical - you can serve a 1200w hero on WiFi and a 600w compressed variant on 3G without re-releasing the app.
// cdn-asset-resolver.js - drop this into your app root
import { Image } from 'react-native';
import manifest from './assets-manifest.json';
const CDN_BASE = 'https://assets.yourapp.com/v2';
const FALLBACK_BASE = 'https://fallback-assets.yourapp.com/v2';
// Override the default asset resolver
const resolveAsset = (assetName) => {
const entry = manifest[assetName];
if (!entry) return null; // let Metro handle local fallback
// entry = { hash: "a3f8c9", ext: "png", variants: ["1x","2x","3x"] }
const density = Math.min(Math.ceil(Image.getPixelRatio()), 3);
const suffix = density > 1 ? `@${density}x` : '';
return {
uri: `${CDN_BASE}/${assetName}${suffix}.${entry.hash}.${entry.ext}`,
fallbackUri: `${FALLBACK_BASE}/${assetName}${suffix}.${entry.hash}.${entry.ext}`,
cache: 'immutable', // tells Fastly/CloudFront to cache for 1 year
};
};
// Usage in components: <Image source={resolveAsset('hero')} />
// Preload critical assets on app start
export const prefetchCritical = () =>
['hero', 'logo', 'onboarding_1'].forEach(n =>
Image.prefetch(resolveAsset(n).uri).catch(() => {})
);
Advanced Patterns: Cache Poisoning, Flaky Networks, and Prefetch That Doesn't Burn Data
Once you're live, the edge cases will find you. The most common failure we see isn't CDN downtime - it's cache poisoning from a bad deploy. If you push a broken assets-manifest.json that points to a 404, that 404 can get cached at the edge for minutes. Don't use Cache-Control: public, max-age=300 on your manifest alone. Use stale-while-revalidate=60, must-revalidate and deploy manifests with a blue/green strategy. We've cut failed asset loads from 2.1% to 0.08% just by adding a client-side fallback that retries the fallback domain after 800ms. Log those fallback hits - if you see more than 0.5% of requests hitting fallback, you've got an invalidation bug.
The other headache is the network itself lying to you. A user shows as "4G" but they're on a congested subway cell with 180ms RTT and 400kbps effective throughput. That's where you need to couple your CDN logic with real client hints. If you're not already doing it, look at Mobile Connection Detection and Switching: Adapting CDN Behavior to Network Type to switch not just image quality but your entire prefetch strategy. On effectiveType: '2g' | 'slow-2g', we disable automatic prefetch entirely and only load above-the-fold assets. On 4g with saveData: false, we aggressively prefetch the next two screens. One e-commerce client saved an average of 2.3MB per session on slow networks this way, and their bounce rate on Android Go devices dropped 18%.
For prefetching, don't just guess - predict. The naïve approach of prefetching everything on app launch will trash your cache hit ratio and burn through user data plans. We've had much better results using navigation-graph prediction: if 73% of users go from Product List -> Product Detail -> Cart, prefetch Detail's hero images while the user is still scrolling the list. Keep your prefetch requests low-priority with importance: low and Priority: u=5 headers so they don't contend with critical JS. Read up on Mobile CDN Prefetch Strategies: Predicting and Preloading Content Before Users Need It for the full model - when we implemented predictive prefetch with a 150ms debounce on scroll, time-to-image for the next screen fell from 620ms to 95ms (p95) without increasing total data transferred.
Frequently Asked Questions
Will moving assets to the CDN break CodePush / Expo Updates OTA?
No, if you do it correctly. Keep your asset manifest inside the OTA bundle, but keep the binary assets on the CDN. OTA will diff and deliver a new 45KB manifest that points to new hashed URLs, rather than a 14MB bundle with embedded images. Just make sure your update check runs before your image prefetch, and version your manifest with the same release ID as your JS bundle. We've shipped this pattern with both App Center CodePush and EAS Update without any native rebuilds.
What cache headers should I use for hashed vs. unhashed React Native assets?
For hashed assets (e.g., icon.a3f8c9.png), use Cache-Control: public, max-age=31536000, immutable and set the edge TTL to 1 year. For unhashed entry points like assets-manifest.json or index.bundle, use Cache-Control: public, max-age=300, stale-while-revalidate=60, must-revalidate. Never set immutable on anything without a content hash - you'll cache a bug for a year.
How do I handle offline mode when images live on the edge?
You'll need a small on-device cache. We use react-native-fast-image with a 200MB disk cache and a least-recently-used eviction policy. On first load from CDN, the image is cached locally. On subsequent loads, check the disk cache before hitting the network. Pair this with a stale-while-revalidate pattern: show the cached image instantly (0ms), then revalidate in the background if the asset hash has changed. For truly offline-critical assets like icons and empty states, keep a tiny 300-500KB fallback bundle embedded in the binary.
Does this increase Time to Interactive (TTI) on slow networks?
It actually reduces it. By stripping 15-25MB of images and videos out of the binary, your JS bundle parses 40% faster - we typically see TTI improve from 3.4s to 1.9s on a mid-range Moto G. Images then stream in progressively after interactive. The key is to prioritize: inline critical above-the-fold images as low-quality placeholders (LQIP, ~2-3KB base64) in the manifest, then swap to the full CDN asset once the JS thread is idle. Users perceive the app as ready 1.2s sooner even if the final hero image arrives 200ms later.