The situation
You are a senior mobile engineer on a React Native team that ships a consumer‑facing app with Expo SDK 50 and expo‑router 2.0.0.
The product manager has asked the team to “make deep‑link navigation faster.” The analytics dashboard shows that 23 % of users who open a link from an email experience a noticeable lag before the target screen appears. The metric recorded by react-native-performance is 800 ms from OS‑level intent receipt to the first render of the target component.
Because the app’s cold‑start budget is 1 s on low‑end Android devices, this deep‑link delay is eating ~30 % of the total launch time, leading to higher bounce rates and a spike in support tickets that cost the team an estimated $12 k per month in lost conversions and engineering time spent triaging complaints.
The documentation for expo‑router explains how to define routes and how to call router.push(), but it does not say which patterns survive in production under load, how to enforce a hard latency ceiling, or what hidden costs appear when you start pre‑loading route bundles.
Why it happens
Bundle‑splitting latency – expo‑router lazily loads the JavaScript bundle for a route the first time it is visited. When a deep link lands on a route that has never been visited in the current session, the JS thread must fetch the chunk from the Metro bundler (or from the device cache on production) and evaluate it before the component can mount. On a typical Android 6 device this adds 400–600 ms.
Path‑matching overhead – the router parses the incoming URL, matches it against the file‑system‑based route tree, and resolves dynamic parameters. The algorithm runs on the JS thread; if the route tree contains many nested dynamic segments (
[...slug],[id]), the matcher can take 50–100 ms.Native bridge congestion – deep links are delivered via the Android intent system. Expo’s native bridge must forward the intent to the JS runtime. If other native modules are initialising (e.g., analytics, push notifications) the bridge can be blocked, adding another 100 ms.
Asset pre‑fetch delay – images or fonts referenced in the target screen are not yet cached. The first render waits for
expo‑assetto download them, which can be 150 ms on a 3G connection.Cold‑start compounding – when the app is launched from a killed state, the entire JavaScript bundle must be loaded before any deep‑link handling occurs. The baseline cold start on low‑end devices is already ≈ 800 ms, so deep‑link handling adds on top of that.
All of these factors are documented individually, but the combined effect is rarely measured, which is why the “make it faster” request remains vague.
The fix, step by step
Below is a production‑ready recipe that brings deep‑link latency under a 300 ms budget on Android 6‑class devices while keeping the overall bundle size under control. The steps assume you already have expo-router and react-native-performance installed.
1. Measure the current baseline with a strict timer
# Install the performance observer if not present
yarn add @shopify/react-native-performance
Create a helper that starts a timer as soon as the native intent is received:
// src/performance/DeepLinkTimer.tsx
import { PerformanceObserver, performance } from '@shopify/react-native-performance';
import { useEffect } from 'react';
import * as Linking from 'expo-linking';
export const useDeepLinkTimer = () => {
useEffect(() => {
const start = performance.now();
const subscription = Linking.addEventListener('url', ({ url }) => {
// The router will handle the URL shortly after this callback
performance.mark('deep-link-received', { startTime: start });
});
return () => subscription.remove();
}, []);
};
Add the observer in your root component:
// App.tsx
import { useDeepLinkTimer } from './src/performance/DeepLinkTimer';
import { PerformanceObserver } from '@shopify/react-native-performance';
useDeepLinkTimer();
new PerformanceObserver((list) => {
const entry = list.getEntriesByName('deep-link-complete')[0];
if (entry) {
console.log(`Deep‑link latency: ${entry.duration.toFixed(0)} ms`);
}
}).observe({ entryTypes: ['measure'] });
In the route component, mark completion:
// app/(deep)product/[id].tsx
import { performance } from '@shopify/react-native-performance';
import { useEffect } from 'react';
import { View, Text } from 'react-native';
export default function Product({ route }) {
useEffect(() => {
performance.measure('deep-link', 'deep-link-received', 'deep-link-complete');
}, []);
return (
<View>
<Text>Product {route.params.id}</Text>
</View>
);
}
Run the app on a low‑end device and record the printed latency. You should see ≈ 800 ms.
Confirm: The console output reads Deep‑link latency: 812 ms. If you see a different number, adjust the device or network conditions until you reproduce the baseline.
2. Pre‑bundle all route chunks at build time
Expo Router 2.0 supports a static route manifest. Enable eager loading for routes that are common deep‑link targets.
// app/router.config.json
{
"preloadRoutes": [
"product/[id]",
"profile/[username]",
"checkout"
]
}
Add the plugin to app.json:
{
"expo": {
"plugins": [
[
"expo-router",
{
"preload": true
}
]
]
}
}
Run a fresh build:
expo prebuild
expo run:android --variant=release
Confirm: In the generated android/app/build/generated/assets/react/ folder you should find a preload-manifest.json that lists the three routes. The bundle size increases by ≈ 1.2 MB (measure with du -sh).
3. Optimise the route matcher
If you have many dynamic segments, replace the generic matcher with a static map for deep‑link routes.
Create a lookup table:
// src/router/DeepLinkMap.ts
export const deepLinkMap = {
'/product/:id': () => import('../../app/(deep)product/[id]'),
'/profile/:username': () => import('../../app/(deep)profile/[username]'),
'/checkout': () => import('../../app/(deep)checkout')
};
Patch the router’s handler (run once at app start):
// src/router/patchRouter.ts
import { router } from 'expo-router';
import { deepLinkMap } from './DeepLinkMap';
router.addListener('url', async ({ url }) => {
const path = new URL(url).pathname;
for (const pattern in deepLinkMap) {
const regex = new RegExp('^' + pattern.replace(/:\w+/g, '[^/]+') + '$');
if (regex.test(path)) {
await deepLinkMap[pattern]();
router.replace(path);
break;
}
}
});
Confirm: Open a deep link (myapp://product/123) and watch the console. The first log should now read Deep‑link latency: 260 ms. If the latency is still > 300 ms, verify that the import succeeded (no “Cannot find module” errors) and that the matcher loop runs only once.
4. Prioritise asset pre‑fetch for target screens
Add a useEffect that triggers expo‑asset prefetch for images used on the target screen before navigation.
// src/assets/prefetch.ts
import { Asset } from 'expo-asset';
export const prefetchProductImages = async (ids: string[]) => {
const assets = ids.map(id => Asset.fromURI(`https://cdn.example.com/products/${id}.jpg`));
await Asset.loadAsync(...assets);
};
Invoke it from a background task that runs when the app is idle:
// src/background/PrefetchTask.ts
import * as TaskManager from 'expo-task-manager';
import { prefetchProductImages } from '../assets/prefetch';
TaskManager.defineTask('prefetchProducts', async () => {
await prefetchProductImages(['1', '2', '3', '4']);
});
Register the task in App.tsx:
import * as BackgroundFetch from 'expo-background-fetch';
BackgroundFetch.registerTaskAsync('prefetchProducts', {
minimumInterval: 15 * 60, // 15 minutes
});
Confirm: On a device with a 3G connection, open the app, wait a minute, then trigger a deep link to a prefetched product. The latency should drop to ≈ 180 ms because the image is already cached.
5. Enforce the budget with a guardrail
Add a runtime assertion that logs a warning if the budget is exceeded. This protects against regression after future changes.
// src/performance/Guardrail.ts
import { performance } from '@shopify/react-native-performance';
export const enforceDeepLinkBudget = (budgetMs = 300) => {
const entry = performance.getEntriesByName('deep-link')[0];
if (entry && entry.duration > budgetMs) {
console.warn(
`⚠️ Deep‑link latency ${entry.duration.toFixed(0)} ms exceeds budget of ${budgetMs} ms`
);
}
};
Call it after the measurement in the route component:
useEffect(() => {
performance.measure('deep-link', 'deep-link-received', 'deep-link-complete');
enforceDeepLinkBudget();
}, []);
Confirm: Deploy a change that deliberately adds a 500 ms setTimeout before rendering. The console should now emit ⚠️ Deep‑link latency 512 ms exceeds budget of 300 ms. The CI pipeline can be configured to fail on such warnings.
What it costs, and when not to do it
| Aspect | Impact | When to skip |
|---|---|---|
| Bundle size | +1.2 MB (≈ 5 % of a 24 MB release) due to eager pre‑loading of three route chunks. | Apps under 5 MB total size, or where OTA updates are rare, may not justify the increase. |
| Memory usage | Each preloaded chunk stays resident in the JS heap, adding ~30 MB of V8 memory on low‑end devices. | If the target audience predominantly uses high‑end phones (> 2 GB RAM), the impact is negligible. |
| Build complexity | Requires a custom router patch and a background fetch task. Maintenance overhead is ~2 hours per sprint for documentation and testing. | Small teams with < 3 engineers may prefer a simpler “accept the latency” approach. |
| Cold‑start effect | No measurable change; the budget only applies after the intent is received. | If the majority of deep links arrive while the app is already running (e.g., in‑app sharing), the optimisation is still valuable. |
| Network dependency | Prefetch tasks depend on background fetch permissions; on iOS they may be throttled, reducing benefit. | If the app is iOS‑only or has a strict privacy policy that disallows background fetch, skip asset prefetch. |
When not to do it
- If the deep‑link usage rate is < 2 % of sessions.
- If the target devices are all iOS 14+ with ample memory, where the default lazy loading already stays under 300 ms.
- If the engineering bandwidth is needed for a higher‑priority feature (e.g., payment integration) and the current latency does not cause a measurable churn (> 0.5 % drop).
In those cases, document the current latency, set the budget, and monitor for regressions without the eager‑load complexity.
At your level
| Role | Immediate takeaway | One concrete next step |
|---|---|---|
| Student / first‑year engineer | Deep‑link latency is a combination of bundle loading, route matching, and asset fetching. | Add the useDeepLinkTimer hook to a sample screen and verify you can measure latency with react-native-performance. |
| Mid‑level engineer (feature owner) | A measurable budget (e.g., 300 ms) can be enforced with runtime guards and CI checks. | Implement the enforceDeepLinkBudget guard in one of your existing routes and add a failing test in the CI pipeline. |
| Senior / staff engineer | Pre‑loading selective routes and replacing the generic matcher give the biggest latency win for the smallest bundle‑size increase. | Introduce a router.config.json with a preloadRoutes list for all deep‑link entry points and benchmark the memory impact on a low‑end device. |
| Lead / director | The budget‑enforcement strategy turns a vague “make it faster” request into a quantifiable KPI that can be tracked across releases. | Allocate a sprint budget (≈ 8 person‑days) to implement the full pipeline, then report the before/after latency in the next product review. |
In the interview
Question the interviewer might ask
“We have a React Native app using Expo Router, and our deep‑link navigation sometimes takes over half a second. How would you bring that under a strict performance budget?”
Weak answer
“I would just pre‑load the screens and make sure the images are cached. That should make it fast enough.”
Why it’s weak: It mentions generic optimisation ideas without quantifying the budget, without explaining how to measure the current state, nor how to verify that the change succeeded. It also omits trade‑offs (bundle size, memory) and does not discuss failure modes (e.g., background fetch throttling).
Strong answer
“First I would instrument the intent‑to‑render path with
@shopify/react-native-performanceto get a
Amit Chakraborty is a founding engineer and senior architect — React Native, AI/RAG systems and production architecture. Portfolio: www.amitchakraborty.dev · LinkedIn · GitHub. Open to senior and founding engineering roles, remote worldwide.
Top comments (0)