
Deep linking in Expo Router can be a game-changer for app navigation, but many developers struggle with slow loading times and inconsistent behavior. Whether you're building a new app or maintaining an existing one, these issues can frustrate users and derail their experience.
In this post, you’ll learn how to streamline deep linking in your Expo Router app. We’ll tackle common bottlenecks like route preloading, optimizing the native bridge, and reducing asset load times. This guide is perfect for developers who want practical, actionable steps to make their deep linking implementation faster and more reliable.
🚀 Step 1: Understanding the Problem
Recognize the typical performance bottlenecks associated with deep linking
Deep linking in Expo Router often encounters issues when transitioning from development to production builds. These bottlenecks can significantly affect the user experience, especially on low-end devices or slower networks. Below are some common performance challenges:
- Scheme registration on Android: Missing `` configuration in the AndroidManifest.xml causes links to open the app but land on the wrong screen.
- Associated Domains on iOS: Universal links fail due to unconfigured entitlements or missing Apple App Site Association (AASA) file.
- Bundle-time route hashing: New route files added after the build process may not be included in the route manifest, leading to unmatched deep links.
- JS runtime readiness: The JavaScript bundle takes time to initialize, causing the router to drop the deep link if the URL is forwarded before
router.isReadyresolves. - Expo Updates caching: Cached bundles may not contain updated route files, resulting in deep links pointing to non-existent routes.
How these bottlenecks manifest during app usage
In real-world scenarios, these issues can lead to unpredictable behavior in production:
- On Android, deep links often open the app but land on the home screen instead of the desired route.
- On iOS, universal links may lead to a blank white screen due to race conditions between the native bridge and the JavaScript runtime.
- Users may experience long delays as the app fetches and evaluates route bundles for the first time.
- Cold starts exacerbate the issue, especially on low-end devices with slower boot times.
Why optimizing these aspects is pivotal for user experience
Deep linking is a critical feature for many apps, especially those relying on external links for user acquisition, notifications, or seamless navigation. When deep linking fails or lags, users experience frustration, leading to higher bounce rates, lower engagement, and potential revenue loss. For apps targeting lower-end devices or global markets with slower internet speeds, these performance bottlenecks can become even more pronounced.
| Constraint | Manifestation | Impact |
|---|---|---|
| Scheme Registration (Android) | Links open the app but land on the wrong screen | Router fails to process initial URL |
| Associated Domains (iOS) | Universal links show a blank screen | Router receives the URL too late |
| Bundle-Time Route Hashing | New routes missing from manifest | Deep links resolve to a missing route |
| JS Runtime Readiness | Deep links dropped before initialization | Intermittent failures, perceived as flaky |
| Expo Updates Caching | Outdated route manifest served | Links work inconsistently post-update |
By understanding these bottlenecks, you can take the first step toward building a robust deep linking solution with Expo Router that works seamlessly in production.
⚡ Implementing Preloading for Common Routes
When dealing with deep linking, one common bottleneck is the time it takes to load route-specific JavaScript bundles. Expo Router supports route preloading, which allows frequently accessed routes to be bundled ahead of time, reducing latency and improving the user experience. Let's set up route preloading step-by-step.
1. Configure Route Preloading
Expo Router provides a router.config.json file for route-specific configurations. Here’s how you enable preloading:
// app/router.config.json
{
"preloadRoutes": [
"invite/[code]",
"profile/[username]",
"checkout"
]
}
In this example, we preload routes for "invite", "profile", and "checkout". These are common targets for deep links, ensuring they load faster without waiting for lazy evaluation.
2. Update app.json to Enable Preloading
Next, modify your app.json or app.config.js to include the Expo Router plugin with the preload option enabled:
{
"expo": {
"plugins": [
[
"expo-router",
{
"preload": true
}
]
]
}
}
This ensures that the preloading configuration is applied during the build process.
3. Build and Verify Preloaded Routes
Run a fresh build to generate the preloaded route bundles:
expo prebuild
eas build --profile production --platform android
Once the build is complete, verify the generated preload manifest. On Android, you can locate it in the android/app/build/generated/assets/react/ folder:
cat android/app/build/generated/assets/react/preload-manifest.json
The file should list the preloaded routes, confirming that the setup is successful.
4. Organize Files for Efficient Preloading
To keep the bundle size manageable, optimize how you structure your route files. Group related routes under logical folders and avoid unnecessary imports. For example:
// app/invite/[code].tsx
import { View, Text } from "react-native";
export default function InviteScreen({ route }) {
const { code } = route.params;
return (
<View>
<Text>Invite Code: {code}</Text>
</View>
);
}
Ensure that each route file only imports what it needs. This reduces the size of individual route bundles.
5. Understand Trade-offs
Preloading routes increases the overall bundle size because routes are bundled upfront rather than lazily loaded. While this improves deep-link performance, it can increase the app's initial load time slightly. Balance preloading with your app's cold-start budget, especially for low-end devices.
Carefully decide which routes are worth preloading based on analytics data. Focus on routes frequently accessed via deep links or those critical for user engagement.
🛠Fine-Tuning Native Bridge Handling
Understand the role of the native bridge in handling deep links
The native bridge is the communication layer between the device's OS and the JavaScript runtime. When a deep link is triggered (e.g., myapp://invite/abc123 or https://myapp.com/invite/abc123), the OS passes the URL to the native side, which forwards it to the JS runtime for processing. Delays in this handoff can lead to race conditions or dropped links, especially during cold starts.
Identify potential delays in bridge communication
Common causes of latency include:
- Native modules initializing concurrently (analytics, push notifications).
- JS runtime not being ready when the bridge forwards the URL.
- Expo Updates serving a stale bundle missing the latest routes.
To diagnose, use Linking.addEventListener to measure when the URL is received versus when the corresponding screen renders.
Implement measures to reduce native bridge latency
Here’s how you can optimize the bridge handling:
-
Delay URL forwarding until the JS runtime is ready:
Use the
AppStatemodule to check the app state and defer deep-link handling until the runtime is initialized.import { AppState } from 'react-native'; import * as Linking from 'expo-linking'; import { useEffect } from 'react'; export const useDeferredDeepLinkHandling = () => { useEffect(() => { const handleDeepLink = (url: string) => { console.log('Handling deep link:', url); // Push the URL to the router or handle it }; const subscription = Linking.addEventListener('url', ({ url }) => { if (AppState.currentState === 'active') { handleDeepLink(url); } else { const appStateListener = AppState.addEventListener('change', (nextAppState) => { if (nextAppState === 'active') { handleDeepLink(url); appStateListener.remove(); } }); } }); return () => subscription.remove(); }, []); }; -
Ensure native bridge readiness: Add a listener for the
router.isReadystate before processing the deep link. This guarantees the router can handle the URL.import { useEffect } from 'react'; import { useRouter } from 'expo-router'; export const useRouterReadyDeepLink = () => { const router = useRouter(); useEffect(() => { const subscription = Linking.addEventListener('url', ({ url }) => { if (!router.isReady) { const interval = setInterval(() => { if (router.isReady) { router.push(url); clearInterval(interval); } }, 100); } else { router.push(url); } }); return () => subscription.remove(); }, [router]); };
Common errors
-
Forwarding URLs too early: If the JS runtime is still initializing, the router may drop URLs passed by the bridge. Always check
router.isReadybefore pushing a URL. - Ignoring app state: If the app is backgrounded, deep links may be processed while the app is inactive, resulting in unexpected behavior.
-
Stale route manifest: Ensure your route manifest is regenerated during every build by running
expo prebuildor using the Expo Router plugin.
💾 Reducing Asset Load Time
Recognize assets that impact page load times
Deep-link navigation can stall if the target screen requires uncached assets like images, fonts, or JSON files. These resources are fetched asynchronously during the first render, leading to noticeable delays. To identify such assets, inspect the target screen and note any dependencies loaded via expo-asset or require() statements.
Implement resource prefetching for expected pages
Prefetching assets ensures they are downloaded and cached before the deep link is processed. Use Expo's Asset module to preload required resources during app initialization:
import { Asset } from 'expo-asset';
import { useEffect } from 'react';
export const preloadAssets = async () => {
const assets = [
require('./assets/image1.png'),
require('./assets/image2.png'),
require('./assets/fonts/Roboto-Regular.ttf'),
];
await Promise.all(assets.map((asset) => Asset.loadAsync(asset)));
};
export const useAssetPreloader = () => {
useEffect(() => {
preloadAssets().catch((error) =>
console.error('Asset preloading failed:', error)
);
}, []);
};
Attach the useAssetPreloader hook in your app's root component:
import { useAssetPreloader } from './src/assets/preloader';
export default function App() {
useAssetPreloader();
return (
<View>
<Text>Welcome to MyApp</Text>
</View>
);
}
Leverage caching to improve subsequent load times
Expo automatically caches assets after they are downloaded. To ensure maximum cache utilization, avoid modifying asset file paths between app builds. Additionally, verify that your CDN or hosting configuration enables proper caching headers (e.g., Cache-Control: max-age=31536000).
For assets fetched dynamically, consider using AsyncStorage or a similar local storage mechanism to cache JSON or API responses:
import AsyncStorage from '@react-native-async-storage/async-storage';
export const fetchAndCacheData = async (url: string) => {
const cachedData = await AsyncStorage.getItem(url);
if (cachedData) {
return JSON.parse(cachedData);
}
const response = await fetch(url);
const data = await response.json();
await AsyncStorage.setItem(url, JSON.stringify(data));
return data;
};
Comparison of Preloading Options
Below is a summary of common approaches for asset preloading:
| Approach | Pros | Cons |
|---|---|---|
Asset.loadAsync |
Simple, built into Expo, supports images/fonts | Requires manual asset listing |
| CDN caching | Automatic, scalable for large assets | Dependent on server configuration |
| Custom caching (e.g., AsyncStorage) | Flexible, works for API responses | Requires manual implementation |
Pitfalls and Common Errors
- Incorrect asset paths: Ensure paths match the build output structure.
- Missing cache headers: Double-check your CDN configuration.
- Prefetch timing: Prefetch assets early, ideally during app initialization.
- Large asset sizes: Optimize images and fonts to reduce download overhead.
🧩 Conclusion
Streamlining deep linking with Expo Router can significantly enhance your app's performance and user experience. By addressing key challenges and implementing the right optimizations, you can ensure seamless navigation for your users. Now that you’ve gained insights into these strategies, why not try integrating them into your own projects and see the difference firsthand?
Key Takeaways
- Identify and address deep linking performance bottlenecks in Expo Router.
- Leverage static route preloading to reduce deep linking latency.
- Optimize custom routing and API logic for smoother deep link resolution.
- Implement caching and asset preloading to cut down load times.
📖 Additional Resources
- Unexpected behaviour of Expo Router deep links in production
- Deep‑link navigation in Expo Router exceeding the performance budget