Expo Interview Questions and Answers

Last updated:

Check out 35 of the most common Expo interview questions, then take an AI-powered practice interview

React NativeJavaScriptEASManaged WorkflowPush Notifications
35+
Questions
15
Basic
13
Intermediate
7
Advanced
Q1

What does Expo add on top of React Native, and what is Continuous Native Generation?

BasicFundamentals

Answer

Expo is a framework and a set of services layered on React Native. The framework part gives you the expo package, a versioned library set (expo-camera, expo-notifications, expo-file-system, expo-sqlite and around a hundred others) that is guaranteed to compile together for a given SDK version, plus expo-router for navigation and a CLI that wraps Metro. The services part is EAS: EAS Build compiles your iOS and Android binaries in the cloud, EAS Submit uploads them to the stores, and EAS Update ships JavaScript and asset changes over the air.

Continuous Native Generation, usually shortened to CNG, is the idea that the ios and android directories are build artefacts, not source. You describe what you need in app.json or app.config.ts, and npx expo prebuild regenerates the native projects from that description plus any config plugins you have declared. The practical consequence is that upgrading React Native stops being a merge conflict exercise across hundreds of native files: you bump the SDK, delete the native folders, and regenerate.

Interviewers ask this to find out whether you understand that 'managed' versus 'bare' is no longer a hard fork. Every Expo project can run prebuild; the real question is whether you keep the generated folders in git or regenerate them in CI.

// app.config.ts
import type { ExpoConfig } from 'expo/config';

const config: ExpoConfig = {
  name: 'Kirana',
  slug: 'kirana',
  scheme: 'kirana',
  version: '2.4.0',
  orientation: 'portrait',
  ios: { bundleIdentifier: 'in.kirana.app', supportsTablet: false },
  android: { package: 'in.kirana.app', edgeToEdgeEnabled: true },
  plugins: [
    'expo-router',
    ['expo-build-properties', { android: { compileSdkVersion: 36 } }],
  ],
  experiments: { typedRoutes: true },
};

export default config;

Key Points

  • expo package plus a version-locked native library set
  • EAS Build, EAS Submit, EAS Update are the service layer
  • CNG treats ios/ and android/ as regenerable output of prebuild
  • Managed vs bare is a spectrum now, not two separate templates
Q2

When does Expo Go stop being usable, and what exactly is a development build?

BasicDevelopment Builds

Answer

Expo Go is a pre-built sandbox app on the App Store and Play Store that contains a fixed set of Expo modules for one SDK version. It is excellent for the first week of a project and for teaching, and it breaks the moment you add any native code that is not already inside it: react-native-mmkv, a payments SDK like Razorpay or PhonePe, Google Maps with a custom API key, react-native-firebase, a custom Expo module, or any config plugin that touches AndroidManifest.xml or Info.plist. You will typically discover this with a red screen saying the native module cannot be found, or a silent no-op.

Push notifications are another cliff: Expo Go dropped remote push support on Android from SDK 53, so anything notification-related needs a real build. A development build is your own app binary, built with the expo-dev-client package included, which gives you the same fast refresh and dev menu experience as Expo Go but with your exact native dependency set. You build it once per native dependency change with eas build --profile development, install the resulting .apk or simulator build, then run npx expo start --dev-client and load your JavaScript into it.

The rule most teams adopt is simple: use Expo Go until the first native dependency lands, then switch the whole team to development builds and never look back. Interviewers ask this to check whether you have shipped anything beyond a tutorial app.

# eas.json
{
  'cli': { 'version': '>= 12.0.0' },
  'build': {
    'development': {
      'developmentClient': true,
      'distribution': 'internal',
      'android': { 'buildType': 'apk' },
      'ios': { 'simulator': true }
    }
  }
}

# build it once, then iterate on JS
npx expo install expo-dev-client
eas build --profile development --platform android
npx expo start --dev-client
💡 Pro Tip: If a teammate says 'it works on my machine but crashes in Expo Go', the answer is almost always that they installed a native dependency and never rebuilt the dev client.
Q3

Why should you use npx expo install instead of npm install, and what do --check and --fix do?

BasicTooling

Answer

Every Expo SDK release pins a known-good version of React Native, React, and each expo-* package. npx expo install reads the SDK version from your package.json, asks the Expo versions endpoint which release of the requested package is compatible, and installs that version instead of whatever npm considers latest. If you run npm install react-native-reanimated you will very likely pull a major version that expects a different React Native, and the failure will not appear at install time. It will appear as a native build failure on EAS, or worse, as a runtime crash that only reproduces on one platform. npx expo install --check compares your installed versions against the SDK's expected set and lists mismatches without changing anything, which is what you wire into CI. npx expo install --fix rewrites those dependencies to the expected versions.

Both are also surfaced by npx expo-doctor, which additionally flags duplicated React copies, unsupported package.json fields, native folders that are out of sync with your config, and packages that ship their own native code without a config plugin. On a real team, expo-doctor plus install --check running on every pull request catches most of the upgrade pain before it reaches a build machine. Interviewers ask this because dependency drift is the single most common reason an Expo project stops building.

# install a compatible version for the current SDK
npx expo install expo-image react-native-reanimated

# CI guard: fail the pipeline on version drift
npx expo install --check
npx expo-doctor

# repair local drift after a bad merge
npx expo install --fix

Key Points

  • expo install resolves versions against the installed SDK, npm does not
  • --check reports drift, --fix rewrites package.json
  • expo-doctor also catches duplicate React and out-of-sync native folders
  • Run both in CI so drift never reaches EAS Build
Q4

How does Expo resolve app.json versus app.config.js versus app.config.ts?

BasicConfiguration

Answer

Expo builds one final config object from up to three sources, in a defined order. app.json (or app.config.json) is static JSON and is read first; the interesting content lives under the expo key. If app.config.js or app.config.ts exists, it is evaluated after the static file and receives the static result as the config argument, so it can spread and override it. If both a .js and a .ts version exist, the TypeScript one wins.

The dynamic form is what you use whenever the config needs to vary: pointing at a staging API, swapping the bundle identifier and app icon so QA can install the internal build alongside production, or reading a value from process.env at build time. Two gotchas come up constantly. First, app.config.ts runs in Node at build time, not on the device, so process.env there refers to the machine or the EAS Build worker, and anything you place under the extra key gets baked into the binary and is readable by anyone who unzips your APK.

Never put a private key there. Second, if you have committed ios and android folders, editing app.config.ts alone does not change them; you have to re-run npx expo prebuild, otherwise the app name or icon you just changed silently stays the same. You read the merged result at runtime with expo-constants.

// app.config.ts
import type { ConfigContext, ExpoConfig } from 'expo/config';

const IS_DEV = process.env.APP_VARIANT === 'development';

export default ({ config }: ConfigContext): ExpoConfig => ({
  ...config,
  name: IS_DEV ? 'Kirana (dev)' : 'Kirana',
  ios: {
    ...config.ios,
    bundleIdentifier: IS_DEV ? 'in.kirana.app.dev' : 'in.kirana.app',
  },
  extra: {
    apiUrl: process.env.API_URL ?? 'https://api.kirana.in',
    eas: { projectId: 'f0b1...' },
  },
});

// runtime
import Constants from 'expo-constants';
const apiUrl = Constants.expoConfig?.extra?.apiUrl;
💡 Pro Tip: Run npx expo config --type public to print the exact config that will be embedded, which is the fastest way to debug 'my change did not apply'.
Q5

How does file-based routing work in expo-router, and what belongs in _layout.tsx?

BasicExpo Router

Answer

expo-router maps files inside the app directory to routes, in the same spirit as Next.js but rendering React Navigation underneath. app/index.tsx is the root route, app/orders/[id].tsx is a dynamic route, app/(tabs)/ is a group whose parentheses keep it out of the URL, and app/+not-found.tsx catches unmatched paths. A _layout.tsx file defines the navigator for everything inside its directory, so app/_layout.tsx typically renders a Stack while app/(tabs)/_layout.tsx renders Tabs. Layouts are also where global providers belong: your query client, theme provider, auth context, and the splash screen hide call all live in the root layout, because that component mounts once and stays mounted across navigations.

Entry point matters: your package.json main must be expo-router/entry, and the plugins array must include expo-router. Two behaviours surprise newcomers. First, every file in app is a route, so putting a helper or a component file there creates a phantom screen; keep non-route code in a sibling components or src folder.

Second, the initial route of a stack is not the first file alphabetically, it is controlled by unstable_settings.initialRouteName, which matters for correct back behaviour when a deep link opens a nested screen directly. Enable experiments.typedRoutes to get autocompleted, type-checked href values, which catches broken links at compile time instead of on a QA device.

// app/_layout.tsx
import { Stack } from 'expo-router';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';

const queryClient = new QueryClient();

export const unstable_settings = { initialRouteName: '(tabs)' };

export default function RootLayout() {
  return (
    <QueryClientProvider client={queryClient}>
      <Stack screenOptions={{ headerShown: false }}>
        <Stack.Screen name='(tabs)' />
        <Stack.Screen name='orders/[id]' options={{ presentation: 'modal' }} />
      </Stack>
    </QueryClientProvider>
  );
}

Key Points

  • app/ directory files become routes; (group) folders do not appear in the URL
  • _layout.tsx declares the navigator and hosts global providers
  • package.json main must be expo-router/entry
  • unstable_settings.initialRouteName controls back behaviour on deep links
Q6

What is the difference between useLocalSearchParams and useGlobalSearchParams, and between router.push and router.replace?

BasicExpo Router

Answer

useLocalSearchParams returns the parameters for the route that the calling component belongs to, and only re-renders that component when its own route is focused. useGlobalSearchParams returns the parameters of the currently active URL anywhere in the tree, so it keeps updating even when the component sits on a screen that is no longer on top of the stack. That difference is a real bug source: a screen deep in a stack that uses useGlobalSearchParams will re-render every time the user navigates elsewhere, refetching data for a screen nobody is looking at. Default to useLocalSearchParams and reach for the global variant only in analytics or a layout that genuinely needs to track the active URL.

On navigation, router.push adds a new entry to the history so the back gesture returns to where you were; router.replace swaps the current entry, which is what you want after login so the user cannot swipe back into the auth screen; router.back pops one entry; router.dismissTo and router.dismissAll unwind modals. The declarative Link component is preferred for anything the user taps because it renders a real anchor on web and supports prefetching, while the imperative router object is for side effects like redirecting after a mutation. Remember that all params arrive as strings or arrays of strings, so an id you push as a number comes back as '42' and needs parsing before you use it in a query key.

// app/orders/[id].tsx
import { Link, router, useLocalSearchParams } from 'expo-router';
import { Text, Pressable } from 'react-native';

export default function OrderScreen() {
  const { id, ref } = useLocalSearchParams<{ id: string; ref?: string }>();
  const orderId = Number(id);

  return (
    <>
      <Text>Order {orderId} from {ref ?? 'direct'}</Text>
      <Link href={{ pathname: '/orders/[id]/invoice', params: { id } }}>
        <Text>View invoice</Text>
      </Link>
      <Pressable onPress={() => router.replace('/(tabs)/home')}>
        <Text>Done</Text>
      </Pressable>
    </>
  );
}
💡 Pro Tip: Params are always strings. Parse and validate them at the top of the screen with Zod or Number(), never pass them straight into a query.
Q7

What does npx expo prebuild do, and should the ios and android folders be committed?

BasicPrebuild

Answer

npx expo prebuild reads your app config plus the plugins array and generates the ios and android native projects: Podfile, Info.plist, AndroidManifest.xml, build.gradle, icons, splash assets, entitlements, everything. It runs automatically inside EAS Build if those folders are absent. Adding --clean deletes and regenerates them, and --platform android limits it to one side.

There are two valid strategies. Strategy one, the CNG path, is to gitignore ios and android entirely and express every native change as a config plugin. Upgrades then become a version bump plus a regenerate, and there is exactly one source of truth.

Strategy two is to commit the folders and edit them directly, which is what teams do when they need something no plugin covers or when they inherited a legacy native codebase. The trap is the middle ground: committing the folders and also relying on app.config.ts. From that point onward prebuild will happily overwrite your hand edits, and the person who ran it will not notice until a release is missing a permission string.

If you must edit native code by hand, delete the config keys that would regenerate it, or write the change as a plugin so both paths agree. Interviewers probe this because it reveals whether you have actually run a release train rather than only a dev server.

# regenerate native projects from the config
npx expo prebuild --clean

# one platform only
npx expo prebuild --platform ios

# .gitignore for the CNG strategy
/ios
/android

# then build entirely in the cloud
eas build --profile production --platform all

Key Points

  • prebuild generates ios/ and android/ from app config plus plugins
  • EAS Build runs it automatically when the folders are absent
  • Either gitignore the folders or commit them, never half of each
  • --clean is destructive; hand edits are lost without a plugin
Q8

How are EAS Build profiles structured in eas.json, and what do distribution and channel control?

BasicEAS Build

Answer

eas.json holds named build profiles under the build key, and profiles can extend one another so shared settings are declared once. Three profiles are conventional: development (developmentClient true, internal distribution, an APK on Android and a simulator build on iOS), preview (a release-mode build with internal distribution so QA and stakeholders can install it via a link or TestFlight), and production (store distribution, an AAB on Android because Play requires it, and a signed archive on iOS). distribution internal means EAS hosts an install page and, on iOS, registers the tester devices in an ad hoc provisioning profile, which is why a new tester's iPhone needs eas device:create before their build works. distribution store produces an artefact intended for App Store Connect or Play Console. The channel key ties the build to an EAS Update channel so the binary knows which branch of JavaScript updates to pull.

Getting channel wrong is a classic incident: a production build labelled preview will happily download QA JavaScript to real users. env inside a profile sets build-time environment variables, and secrets configured in the EAS dashboard are injected without landing in git. Also set the cli.version constraint so everyone on the team builds with a compatible EAS CLI rather than whatever they installed last year.

{
  'cli': { 'version': '>= 12.0.0', 'appVersionSource': 'remote' },
  'build': {
    'base': { 'node': '20.19.0' },
    'development': {
      'extends': 'base',
      'developmentClient': true,
      'distribution': 'internal',
      'channel': 'development'
    },
    'preview': {
      'extends': 'base',
      'distribution': 'internal',
      'channel': 'preview',
      'env': { 'APP_VARIANT': 'preview' }
    },
    'production': {
      'extends': 'base',
      'channel': 'production',
      'autoIncrement': true
    }
  },
  'submit': { 'production': {} }
}
💡 Pro Tip: Set appVersionSource to remote so EAS owns buildNumber and versionCode. Manual increments in app.config.ts are how duplicate-version rejections happen.
Q9

What can and cannot be shipped through EAS Update, and what is runtimeVersion for?

BasicEAS Update

Answer

EAS Update ships a new JavaScript bundle plus assets (images, fonts, JSON) to installed apps without a store review. It cannot change anything native: adding a library with native code, changing a permission string, bumping the target SDK, adding an entitlement, or upgrading the Expo SDK all require a new binary. runtimeVersion is the contract that keeps those two worlds apart. Every binary is stamped with a runtime version, every published update carries one, and the client only downloads an update whose runtime version matches exactly.

Get it wrong in the loose direction and you push JavaScript that calls a native module the installed binary does not contain, which crashes on launch for everyone who receives it. The policies are the interesting part. The appVersion policy derives the runtime version from your app version, so every version bump cuts a new update lane.

The nativeVersion policy uses version plus build number, which is usually too strict since every build orphans previous installs. The fingerprint policy hashes the actual native inputs (dependencies, config, plugins) using @expo/fingerprint, so the runtime version changes only when the native layer genuinely changes, which is what most teams want in 2026. Whatever you pick, publish with eas update --branch and let the channel mapping decide which builds see it, and keep in mind that the first launch after install always runs the update embedded in the binary.

// app.config.ts
export default {
  runtimeVersion: { policy: 'fingerprint' },
  updates: {
    url: 'https://u.expo.dev/f0b1...',
    fallbackToCacheTimeout: 0,
    checkAutomatically: 'ON_LOAD',
  },
};

// publish JS to the production branch
// eas update --branch production --message 'fix checkout crash'
//
// inspect what a build will match
// npx expo-updates fingerprint:generate --platform android

Key Points

  • OTA covers JS and assets only, never native code or permissions
  • runtimeVersion must match exactly between binary and update
  • fingerprint policy tracks real native changes; appVersion is the simple default
  • The embedded update always runs on the very first launch
Q10

How do you set up push notifications with expo-notifications, and what breaks on Android?

BasicNotifications

Answer

The flow is: ask for permission with requestPermissionsAsync, get a token, send the token to your backend, then deliver messages. getExpoPushTokenAsync returns an ExponentPushToken that you send through Expo's push service, which fans out to FCM and APNs for you; getDevicePushTokenAsync returns the raw FCM or APNs token if you would rather talk to those providers directly. You must pass the EAS projectId when fetching an Expo token, otherwise it throws in production builds. Android needs a FCM v1 service account JSON uploaded through eas credentials or the dashboard, because the legacy FCM server key API is dead.

Android also requires notification channels: on Android 8 and later, a notification without a channel simply does not appear, and channel importance is immutable after creation, so shipping a channel with default importance and later wanting heads-up alerts means creating a new channel id. Physical device only; simulators and emulators cannot register for remote push. Expo Go on Android dropped remote push support from SDK 53, so a development build is mandatory.

On iOS remember the aps-environment entitlement is handled by the config plugin but silent background pushes still need the background modes capability. For foreground behaviour, set a notification handler, otherwise nothing is displayed while your app is open, which people routinely report as a bug.

import * as Notifications from 'expo-notifications';
import * as Device from 'expo-device';
import { Platform } from 'react-native';

Notifications.setNotificationHandler({
  handleNotification: async () => ({
    shouldShowBanner: true,
    shouldPlaySound: true,
    shouldSetBadge: false,
  }),
});

export async function registerForPush(projectId: string) {
  if (!Device.isDevice) return null;
  if (Platform.OS === 'android') {
    await Notifications.setNotificationChannelAsync('orders', {
      name: 'Order updates',
      importance: Notifications.AndroidImportance.HIGH,
    });
  }
  const { status } = await Notifications.requestPermissionsAsync();
  if (status !== 'granted') return null;
  const token = await Notifications.getExpoPushTokenAsync({ projectId });
  return token.data;
}
💡 Pro Tip: Push tokens rotate. Re-register on every cold start and de-duplicate server side, or your delivery rate quietly decays over months.
Q11

How do you configure the app icon and splash screen in recent Expo SDKs?

BasicAssets

Answer

The top-level splash key was replaced by the expo-splash-screen config plugin, so in recent SDKs you configure the splash inside the plugins array with image, imageWidth, resizeMode, backgroundColor, and a dark variant. On iOS this generates a launch storyboard; on Android it maps to the Android 12+ SplashScreen API, which is why your image is masked into a circle unless you account for it. Icons are separate: ios.icon takes a square PNG with no transparency and no rounded corners (iOS applies the mask), while android.adaptiveIcon takes foregroundImage plus backgroundColor or backgroundImage and composites them, keeping the safe zone in mind because launchers crop aggressively.

Recent SDKs also accept a themed dark icon on both platforms. At runtime, SplashScreen.preventAutoHideAsync() at module scope keeps the splash up while fonts and the auth session load, and you call hideAsync once the first real screen is ready. Forget the hide call and the app appears frozen on the splash forever, which is a genuine production incident people ship.

The safe pattern is to hide it from the root layout's onLayout callback so it disappears exactly when content paints, and to wrap the hide in a try/catch since calling it twice rejects. Also run prebuild after changing any of these, since icons live in the generated native projects.

// app.config.ts (plugin form)
plugins: [
  ['expo-splash-screen', {
    image: './assets/splash-icon.png',
    imageWidth: 200,
    resizeMode: 'contain',
    backgroundColor: '#0B1120',
    dark: { backgroundColor: '#000000' },
  }],
],

// app/_layout.tsx
import * as SplashScreen from 'expo-splash-screen';
import { useFonts } from 'expo-font';

SplashScreen.preventAutoHideAsync();

export default function RootLayout() {
  const [loaded] = useFonts({ Inter: require('../assets/Inter.ttf') });
  if (!loaded) return null;
  return <Stack onLayout={() => SplashScreen.hideAsync().catch(() => {})} />;
}
Q12

How do environment variables work in Expo, and why is EXPO_PUBLIC_ dangerous for secrets?

BasicConfiguration

Answer

Expo's CLI loads .env files and exposes any variable prefixed with EXPO_PUBLIC_ to your application code. Metro inlines these at bundle time, meaning process.env.EXPO_PUBLIC_API_URL is literally replaced by the string in the compiled JavaScript. That has two consequences interviewers want you to say out loud.

First, the value is shipped inside the bundle, so anyone can extract it by unzipping the APK or pulling the update payload from the CDN. A Razorpay key secret, a database URL, or an admin API token placed there is a leaked credential, not a configuration value. Publishable keys, the API base URL, and feature flags are fine.

Second, because inlining happens at bundle time, you cannot read a variable that was not present when the bundle was built, and you cannot do dynamic access like process.env['EXPO_PUBLIC_' + name] because there is nothing to statically replace. For build-time-only values, such as a Sentry auth token used to upload source maps, define them as EAS environment variables or secrets in the dashboard; they exist on the build worker and never enter the bundle. Anything genuinely secret belongs on your server behind an authenticated endpoint. A useful team convention is a checked-in .env.example plus a CI assertion that every EXPO_PUBLIC_ variable in the codebase is declared, so a missing value fails the build rather than producing an app that silently points at localhost.

# .env (committed .env.example, real values via EAS env vars)
EXPO_PUBLIC_API_URL=https://api.kirana.in
EXPO_PUBLIC_SENTRY_DSN=https://abc@o1.ingest.sentry.io/1
SENTRY_AUTH_TOKEN=only-on-the-build-worker

// usage: must be a static member access
const api = process.env.EXPO_PUBLIC_API_URL;

# store a build-time secret
eas env:create --name SENTRY_AUTH_TOKEN --scope project --visibility secret

Key Points

  • EXPO_PUBLIC_ variables are inlined into the bundle at build time
  • Anything in the bundle is public; treat it as printed on the app store page
  • Dynamic property access defeats inlining and yields undefined
  • Use EAS env vars or secrets for build-time-only values
Q13

When should you use expo-secure-store versus AsyncStorage, and what are the limits?

BasicStorage

Answer

AsyncStorage is an unencrypted key-value store: SQLite-backed on Android and a plain file on iOS. Anything written there is readable on a rooted or jailbroken device and can end up in device backups, so it is right for cached lists, onboarding flags, and the last selected city, and wrong for tokens. expo-secure-store writes to the iOS Keychain and, on Android, to an encrypted store backed by the hardware-protected Android Keystore. It supports keychainAccessible options such as WHEN_UNLOCKED_THIS_DEVICE_ONLY, which prevents the value from moving to a new phone through an iCloud restore, and it can require biometric authentication before the value is released.

Two practical limits matter. SecureStore is meant for small values, with a documented soft ceiling around two kilobytes per entry, so storing a large JWT with many claims or a serialised session object will warn or fail; store the token only and keep profile data elsewhere. It is also noticeably slower than AsyncStorage because each read touches the platform keystore, so do not call it inside a render or a scroll handler.

The common production layout is refresh token in SecureStore, short-lived access token in memory only, and everything else in AsyncStorage or MMKV. For high-frequency synchronous reads teams reach for react-native-mmkv, which is fast and supports encryption but needs a development build since it is not part of Expo Go.

import * as SecureStore from 'expo-secure-store';

const REFRESH_KEY = 'auth.refresh';

export async function saveRefreshToken(token: string) {
  await SecureStore.setItemAsync(REFRESH_KEY, token, {
    keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
  });
}

export async function readRefreshToken() {
  return SecureStore.getItemAsync(REFRESH_KEY);
}

export async function signOut() {
  await SecureStore.deleteItemAsync(REFRESH_KEY);
}
💡 Pro Tip: SecureStore is unavailable on web. Guard with Platform.OS or provide a localStorage shim, otherwise your Expo web build throws on the auth path.
Q14

How do deep links work in Expo, and what is required for iOS universal links and Android app links?

BasicDeep Linking

Answer

There are two kinds of links and interviewers want you to distinguish them. A custom scheme link like kirana://orders/42 is declared with the scheme key in app config and works immediately after a prebuild, but any app can register the same scheme and the OS shows no ownership guarantee. HTTPS links, called universal links on iOS and app links on Android, open your app from a normal web URL and require proving domain ownership.

On iOS you add the associated domains entitlement with applinks:kirana.in and host an apple-app-site-association file at /.well-known/ on that domain, served as application/json with no redirect. On Android you add an intent filter with autoVerify and host assetlinks.json containing the SHA-256 fingerprint of the signing certificate. That last part is the recurring production bug: when Play App Signing re-signs your upload, the fingerprint that matters is the one Play shows, not your local keystore, so links verify in internal testing and break in production. expo-router wires links to routes automatically, since the URL path maps to the file tree, and you use Linking.createURL for building links that work across dev and production. For testing, npx uri-scheme open on Android and xcrun simctl openurl on iOS both work without publishing anything.

// app.config.ts
scheme: 'kirana',
ios: { associatedDomains: ['applinks:kirana.in'] },
android: {
  intentFilters: [{
    action: 'VIEW',
    autoVerify: true,
    data: [{ scheme: 'https', host: 'kirana.in', pathPrefix: '/orders' }],
    category: ['BROWSABLE', 'DEFAULT'],
  }],
},

// building and testing links
import * as Linking from 'expo-linking';
const url = Linking.createURL('/orders/42', { queryParams: { ref: 'sms' } });

// npx uri-scheme open kirana://orders/42 --android
Q15

How do you inspect what actually shipped in a build, and which debugging tools replaced Flipper?

BasicDebugging

Answer

Remote JS debugging in Chrome and Flipper are both gone. The current tool is React Native DevTools, a Chrome DevTools frontend that attaches to Hermes over the Chrome DevTools Protocol; you open it by pressing j in the Expo CLI terminal or from the dev menu, and it gives you a real console, breakpoints in your source through source maps, a network tab, and the React Components and Profiler panels. The dev menu itself opens by shaking the device, pressing m in the terminal, or Cmd+D on the iOS simulator, and it exposes Fast Refresh toggles, the performance monitor overlay, and the element inspector.

For build-time inspection, npx expo config --type public prints the exact resolved config, npx expo-doctor validates dependency health, and EXPO_ATLAS=1 npx expo start records a bundle breakdown you can open with npx expo-atlas to see which package is contributing the most bytes. On the update side, npx eas update:list and eas update:view show which JavaScript is live on each branch, and Updates.updateId at runtime tells you exactly which payload a device is running, which is the only reliable way to answer 'has this user received the fix'. For release builds where the dev menu is unavailable, ship a hidden diagnostics screen that prints Updates.channel, Updates.runtimeVersion, Updates.updateId, and the app version. Support teams in India lean on that screen constantly, because users cannot reliably report which build they have.

import * as Updates from 'expo-updates';
import Constants from 'expo-constants';
import { Text } from 'react-native';

export function DiagnosticsPanel() {
  return (
    <Text selectable>
      {[
        'version: ' + Constants.expoConfig?.version,
        'runtime: ' + Updates.runtimeVersion,
        'channel: ' + Updates.channel,
        'updateId: ' + (Updates.updateId ?? 'embedded'),
        'createdAt: ' + (Updates.createdAt?.toISOString() ?? 'n/a'),
      ].join('\n')}
    </Text>
  );
}

Key Points

  • React Native DevTools over CDP replaced Flipper and Chrome remote debugging
  • expo config --type public shows the config that actually ships
  • expo-atlas explains bundle size by module
  • Ship an in-app diagnostics screen exposing updateId and channel
Q16

How do you write a custom config plugin, and when do you need withDangerousMod?

IntermediateConfig Plugins

Answer

A config plugin is a plain JavaScript function that receives the Expo config object, mutates it, and returns it, and it runs during npx expo prebuild. It is the supported way to make a native change survive regeneration. expo/config-plugins exports typed mods grouped by platform: withAndroidManifest, withStringsXml, withGradleProperties, withProjectBuildGradle and withMainApplication on Android, withInfoPlist, withEntitlementsPlist, withXcodeProject and withPodfile on iOS. Each mod hands you a parsed structure in config.modResults, you edit that structure, and you return the config.

Mods run in the order you register them and always after the base template has been written, so you are editing generated files, never templates. withDangerousMod is the escape hatch: it gives you raw filesystem access at a named point in the pipeline with no parsing and no merge safety, which is exactly why it is named that way. Reach for it only when no typed mod exists, for example copying a vendor .aar into android/app/libs or patching a Podfile line a vendor SDK requires. Wrap your plugin in createRunOncePlugin so it does not apply twice when two packages pull it in.

Debug by running npx expo prebuild --clean and diffing the output, or npx expo config --type prebuild to see the config after plugins resolve. Interviewers use this question to separate people who only consume Expo from people who can keep a project on Continuous Native Generation when a payments or analytics vendor ships setup instructions written for a bare React Native project.

// plugins/withUpiIntents.js
const {
  withAndroidManifest,
  withInfoPlist,
  createRunOncePlugin,
} = require('expo/config-plugins');

const withUpiIntents = (config, { merchantId }) => {
  config = withAndroidManifest(config, (cfg) => {
    const app = cfg.modResults.manifest.application[0];
    app['meta-data'] = app['meta-data'] ?? [];
    app['meta-data'].push({
      $: {
        'android:name': 'in.kirana.MERCHANT_ID',
        'android:value': merchantId,
      },
    });
    return cfg;
  });

  config = withInfoPlist(config, (cfg) => {
    cfg.modResults.LSApplicationQueriesSchemes = [
      ...(cfg.modResults.LSApplicationQueriesSchemes ?? []),
      'upi',
      'phonepe',
      'tez',
    ];
    return cfg;
  });

  return config;
};

module.exports = createRunOncePlugin(withUpiIntents, 'withUpiIntents', '1.0.0');

// app.config.ts
// plugins: [['./plugins/withUpiIntents', { merchantId: 'kirana_live' }]]

Key Points

  • Plugins run during prebuild and edit generated native files
  • Typed mods expose parsed modResults; withDangerousMod gives raw fs access
  • createRunOncePlugin prevents double application
  • npx expo config --type prebuild shows the post-plugin config
Q17

How do you stage and roll back an EAS Update when a bad bundle reaches production?

IntermediateEAS Update

Answer

Branches hold updates, channels are what binaries are stamped with, and the channel-to-branch mapping is the lever you pull in an incident. The safe publish flow is a staged rollout: eas update --branch production --rollout-percentage 10 makes the new group visible to roughly ten percent of eligible devices, then eas update:edit raises it to 50 and 100 once your crash-free rate holds. If something is wrong, you have three recovery options and interviewers want you to know the difference. eas update:republish takes an older update group and publishes it again as a new group on the branch, so devices that already downloaded the bad bundle move forward onto known-good JavaScript. eas update:roll-back-to-embedded publishes a directive telling clients to discard downloaded updates and run the bundle compiled into the binary, which is the right move when you are not sure any recent update is safe. eas channel:edit lets you repoint the production channel at a different branch entirely, which is the fastest switch because it needs no new publish.

Two constraints bite in practice. Nothing takes effect until each device performs an update check, so with the default ON_LOAD behaviour a user sees the fix on the next cold start, not instantly. And rollback only helps for JavaScript problems: if the crash comes from native code you are back to a store submission, which is exactly why the runtime version fingerprint and staged rollouts exist in the first place.

# publish, but only to 10% of devices on the production branch
eas update --branch production --rollout-percentage 10 \
  --message 'retry UPI callback on timeout'

# widen once metrics look clean
eas update:edit --rollout-percentage 50
eas update:edit --rollout-percentage 100

# incident: put the previous good group back on the branch
eas update:list --branch production
eas update:republish --group 4c1f8e2a-... --branch production

# nuclear option: send everyone back to the JS inside the binary
eas update:roll-back-to-embedded --branch production

# fastest switch of all: repoint the channel
eas channel:edit production --branch production-hotfix
eas channel:view production
💡 Pro Tip: Rehearse a rollback on the preview channel before you need it. The first time you run update:roll-back-to-embedded should not be during an outage.
Q18

What do Apple and Google actually allow you to change over the air, and where is the line?

IntermediateEAS Update

Answer

Both stores permit JavaScript updates, and both put conditions on them. Apple's Developer Program License Agreement section 3.3.2 allows downloading and running interpreted code as long as it does not change the app's primary purpose, does not create a store or marketplace inside your app, and does not circumvent signing or sandboxing. App Review Guideline 2.5.2 says the same thing from the review side: apps should be self-contained, with interpreted code as the carved-out exception.

Google Play's Device and Network Abuse policy forbids an app updating or replacing itself outside Play's update mechanism, again with an explicit exception for code in interpreted languages loaded at runtime, provided the delivered code still complies with policy. So bug fixes, copy changes, layout tweaks, pricing display, feature flags and A/B variants are all fine. The line is crossed when the update changes what the app is or what it declared.

Shipping a whole new product surface the reviewer never saw, turning a content app into a payments app, enabling tracking that your App Tracking Transparency prompt and privacy nutrition label do not cover, adding data collection your Play Data safety form omits, routing purchases of digital goods away from in-app purchase, or moving content past your declared age rating are all violations even though technically they are only JavaScript. The engineering-side line is separate and harder: no update can add native code, a permission string, an entitlement, or a new dependency, which is what runtime version enforcement protects you from.

Key Points

  • Apple DPLA 3.3.2 and Review Guideline 2.5.2 permit interpreted code that keeps the app's primary purpose
  • Play's Device and Network Abuse policy carves out interpreted-language updates
  • Privacy labels, Data safety, age rating and IAP rules still bind OTA content
  • Technically, OTA never covers native code, permissions or entitlements
💡 Pro Tip: Keep a short written policy in the repo listing what may go out via EAS Update. It stops a well-meaning product manager from OTA-shipping a feature that needed a review.
Q19

How do you control update download and reload behaviour at runtime with expo-updates?

IntermediateEAS Update

Answer

By default expo-updates checks on load, and with fallbackToCacheTimeout set to zero the app launches immediately on the cached bundle while the new one downloads in the background, so the user gets the update on the following cold start. That is the right default for most apps and the wrong one for a hotfix you need applied today. The runtime API gives you control: Updates.checkForUpdateAsync() asks the server whether a newer update matches this runtime version, Updates.fetchUpdateAsync() downloads it, and Updates.reloadAsync() restarts the JavaScript runtime onto the new bundle.

The useUpdates() hook exposes the same state declaratively through isUpdateAvailable, isDownloading, isUpdatePending, currentlyRunning and lastCheckForUpdateTime, which is what you bind a diagnostics screen or an in-app banner to. The usual production pattern is to check when AppState returns to active rather than only at launch, download silently, then either reload at a safe moment (never mid-checkout, never with unsaved form state) or show a soft prompt. Set checkAutomatically to ON_ERROR_RECOVERY if you want the client to look for a new bundle only after a crash loop, which pairs well with a manual check on foreground. Two things to guard: none of this works in development, so wrap calls in a __DEV__ check or they throw, and reloadAsync destroys in-memory state, so anything the user typed must be persisted first.

import { useEffect } from 'react';
import { AppState, Alert } from 'react-native';
import * as Updates from 'expo-updates';

export function useOtaUpdates() {
  const { isUpdatePending, currentlyRunning } = Updates.useUpdates();

  useEffect(() => {
    const sub = AppState.addEventListener('change', async (state) => {
      if (state !== 'active' || __DEV__ || currentlyRunning.isEmbeddedLaunch === undefined) return;
      try {
        const result = await Updates.checkForUpdateAsync();
        if (result.isAvailable) await Updates.fetchUpdateAsync();
      } catch {
        // offline or no matching runtime version: ignore silently
      }
    });
    return () => sub.remove();
  }, [currentlyRunning]);

  useEffect(() => {
    if (!isUpdatePending) return;
    Alert.alert('Update ready', 'Restart now to apply the latest fix?', [
      { text: 'Later', style: 'cancel' },
      { text: 'Restart', onPress: () => Updates.reloadAsync() },
    ]);
  }, [isUpdatePending]);
}
💡 Pro Tip: Never call reloadAsync from a screen that holds unsaved input. Persist to storage first, or gate the reload behind a navigation event.
Q20

How does EAS manage signing credentials, and what does EAS Submit need for each store?

IntermediateEAS Submit

Answer

On iOS, EAS can generate and hold a distribution certificate and provisioning profiles for you, or you can supply your own. eas credentials is the interactive tool for inspecting, rotating and downloading them, and credentials.json lets you keep them local if your security team insists. Apple limits you to a small number of distribution certificates per account, so 'certificate limit reached' during a build usually means an old CI machine still holds one; revoke it from the Developer portal rather than creating another. Internal distribution builds embed an ad hoc profile listing specific device UDIDs, which is why a new tester needs eas device:create and a fresh build before they can install.

On Android, EAS holds the upload keystore, and losing it is only recoverable if Play App Signing is enabled, which it is for anything published in the last several years. For submission, iOS needs an App Store Connect API key (issuer id, key id and the .p8) plus the ascAppId, and Android needs a Google Play service account JSON with the Play Developer API enabled and the account invited to the Play Console with release permissions. Both go in the submit block of eas.json, and both should come from EAS secrets rather than the repo. The productivity win is eas build --profile production --platform all --auto-submit, which builds, waits, and pushes to TestFlight and the chosen Play track in one command.

// eas.json (submit block)
{
  "submit": {
    "production": {
      "ios": {
        "appleId": "release@kirana.in",
        "ascAppId": "6478123456",
        "appleTeamId": "9A8B7C6D5E"
      },
      "android": {
        "serviceAccountKeyPath": "./credentials/play-service-account.json",
        "track": "internal",
        "releaseStatus": "draft"
      }
    }
  }
}

# inspect or rotate signing material
eas credentials --platform ios

# register a tester device for internal distribution
eas device:create

# build and push to both stores in one shot
eas build --profile production --platform all --auto-submit

Key Points

  • eas credentials manages certificates, profiles and the Android keystore
  • App Store Connect API key (.p8, key id, issuer id) beats an Apple ID password
  • Play needs a service account JSON with Play Developer API access
  • Internal distribution on iOS requires eas device:create plus a rebuild
Q21

How do you build an authenticated route tree in expo-router without a login-screen flash?

IntermediateExpo Router

Answer

The failure everyone hits first is redirecting before the router has mounted, which throws or silently does nothing, or rendering the tab tree for a split second before the session check finishes so the user sees a flash of the home screen and then the login screen. The fix has three parts. First, keep the splash screen up while you resolve the session: call SplashScreen.preventAutoHideAsync() at module scope and return null from the root layout until your auth context reports isLoading false.

Second, express access as a guard in the route tree rather than as an effect. Recent expo-router versions give you Stack.Protected with a guard prop, which unmounts the protected screens entirely when the guard is false and pushes the user to the first available route, so there is no window where a protected screen exists. Third, use router.replace rather than push when moving between the auth and app trees, so the back gesture cannot return to a screen the user has logged out of.

Keep the session itself in a context backed by expo-secure-store for the refresh token and memory for the access token, and expose a signOut that clears storage and lets the guard flip. If you are on an older version without Stack.Protected, the equivalent is a useEffect in the root layout that waits for both isLoading false and useRootNavigationState()?.key to be defined before calling replace. Interviewers ask this because nearly every real app has it and nearly every tutorial gets it wrong.

// app/_layout.tsx
import { Stack } from 'expo-router';
import * as SplashScreen from 'expo-splash-screen';
import { SessionProvider, useSession } from '../ctx/session';

SplashScreen.preventAutoHideAsync();

function RootNavigator() {
  const { session, isLoading } = useSession();
  if (isLoading) return null; // splash stays up
  SplashScreen.hideAsync().catch(() => {});

  return (
    <Stack screenOptions={{ headerShown: false }}>
      <Stack.Protected guard={!!session}>
        <Stack.Screen name='(tabs)' />
        <Stack.Screen name='orders/[id]' />
      </Stack.Protected>

      <Stack.Protected guard={!session}>
        <Stack.Screen name='(auth)/login' />
      </Stack.Protected>
    </Stack>
  );
}

export default function RootLayout() {
  return (
    <SessionProvider>
      <RootNavigator />
    </SessionProvider>
  );
}
💡 Pro Tip: Test the cold-start deep link case: open kirana://orders/42 while logged out. The guard should land the user on login and then on the order, not on the home tab.
Q22

Why use expo-image over the core Image component, and how do you keep long lists alive on a 3 GB Android phone?

IntermediatePerformance

Answer

expo-image wraps SDWebImage on iOS and Glide on Android, which buys you a real two-tier cache, progressive and animated formats including WebP and AVIF, blurhash and thumbhash placeholders, and a transition prop, none of which the core Image gives you. The properties that matter in a list are cachePolicy (memory-disk is the usual choice, disk when you have a lot of large images and limited RAM), contentFit instead of resizeMode, and recyclingKey. recyclingKey is the one people miss: when a row is recycled, the view briefly shows the previous item's image unless you tell expo-image that the identity changed, which is the flicker users report as 'wrong photo for a second'. On memory, the decoded bitmap is what costs you, not the file.

A 2000 by 2000 JPEG that is 180 KB on disk becomes roughly 16 MB in memory when decoded at ARGB_8888, so twenty of them off-screen is an out-of-memory crash on a 3 GB device. Serve images at display size from your CDN, cap the rendered dimensions, and let the cache do the rest. For the list itself, FlashList recycles views instead of mounting new ones the way FlatList does, and version 2 no longer needs estimatedItemSize because it measures automatically. Keep row components memoized with stable keys, avoid inline arrow props that break memoization, and move any per-row formatting out of render.

import { Image } from 'expo-image';
import { FlashList } from '@shopify/flash-list';
import { memo } from 'react';

const BLURHASH = 'L6PZfSi_.AyE_3t7t7R**0o#DgR4';

const Row = memo(function Row({ item }: { item: Product }) {
  return (
    <Image
      source={{ uri: item.thumbUrl }}
      recyclingKey={item.id}
      placeholder={{ blurhash: BLURHASH }}
      contentFit='cover'
      cachePolicy='memory-disk'
      transition={120}
      style={{ width: 96, height: 96, borderRadius: 8 }}
    />
  );
});

export function ProductList({ data }: { data: Product[] }) {
  return (
    <FlashList
      data={data}
      keyExtractor={(item) => item.id}
      renderItem={({ item }) => <Row item={item} />}
    />
  );
}

Key Points

  • expo-image adds real caching, blurhash placeholders and modern formats
  • recyclingKey stops the recycled-row flicker in virtualised lists
  • Decoded bitmap size, not file size, drives out-of-memory crashes
  • FlashList v2 measures rows itself; estimatedItemSize is no longer required
Q23

What has to change in metro.config.js to run an Expo app inside a monorepo, and what breaks on EAS Build?

IntermediateTooling

Answer

Metro resolves from the project root and does not walk up into a workspace by default, so a fresh app inside apps/mobile cannot see packages/ui and cannot see hoisted dependencies at the repo root. Three settings fix it. watchFolders must include the workspace root so Metro watches and bundles shared packages. resolver.nodeModulesPaths must list both the app's node_modules and the root's, in that order. disableHierarchicalLookup set to true stops Metro from silently climbing the tree and picking up a second copy of React or React Native, which is the cause of the 'Invalid hook call' and 'more than one copy of React' errors that only appear in a monorepo. If you use pnpm, add node-linker=hoisted to .npmrc, because the default symlinked layout breaks the autolinking step in Gradle and CocoaPods.

On EAS Build, the workspace root is detected from your package manager's workspace config and the whole repo is uploaded, so the practical failure modes are different: an oversized upload because build outputs are not in .easignore, a lockfile at the wrong level, and shared TypeScript packages that were never compiled because the app expects source and the build expects dist. Keep shared packages as source consumed through Metro rather than as built artefacts, and set the working directory or use eas build inside apps/mobile so eas.json and app.config.ts resolve correctly.

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

const projectRoot = __dirname;
const workspaceRoot = path.resolve(projectRoot, '../..');

const config = getDefaultConfig(projectRoot);

// 1. watch shared packages at the repo root
config.watchFolders = [workspaceRoot];

// 2. resolve from the app first, then the hoisted root
config.resolver.nodeModulesPaths = [
  path.resolve(projectRoot, 'node_modules'),
  path.resolve(workspaceRoot, 'node_modules'),
];

// 3. never climb the tree and pick up a duplicate React
config.resolver.disableHierarchicalLookup = true;

module.exports = config;

# .npmrc when using pnpm
# node-linker=hoisted
💡 Pro Tip: Add an .easignore with build output, fixtures and other apps' artefacts. Monorepo uploads regularly hit hundreds of megabytes and slow every build.
Q24

How do you run periodic work in the background with expo-background-task and expo-task-manager?

IntermediateBackground Work

Answer

expo-background-task replaced expo-background-fetch and sits on WorkManager on Android and BGTaskScheduler on iOS, which is the important detail: you are asking the operating system to run your task eventually, not scheduling a timer. You define the task with TaskManager.defineTask at module scope, not inside a component, because the OS can launch your app headless and the definition must exist before any React tree renders. Import the file that calls defineTask from your entry point or root layout so the module is always evaluated.

Registration is separate: BackgroundTask.registerTaskAsync with a minimumInterval in minutes, where anything below fifteen is clamped on Android and iOS treats the number as a hint it may ignore entirely. Return BackgroundTaskResult.Success or Failed so the scheduler can back off. Reality checks you should say out loud in an interview: Android Doze and per-manufacturer battery managers, which are aggressive on Xiaomi, Oppo, Vivo and Samsung devices common in India, will suppress tasks for users who have not whitelisted the app; iOS runs tasks based on usage patterns, so a user who opens the app rarely gets almost no executions; and nothing runs after force-quit on iOS.

Background location is a different module (expo-location with startLocationUpdatesAsync), needs the always permission plus a Play Console declaration, and is reviewed hard. Design for the task being an optimisation and always reconcile on foreground.

// tasks/syncOrders.ts (imported from app/_layout.tsx)
import * as BackgroundTask from 'expo-background-task';
import * as TaskManager from 'expo-task-manager';

export const SYNC_TASK = 'kirana.sync-orders';

TaskManager.defineTask(SYNC_TASK, async () => {
  try {
    await flushQueuedOrders();
    return BackgroundTask.BackgroundTaskResult.Success;
  } catch {
    return BackgroundTask.BackgroundTaskResult.Failed;
  }
});

export async function registerSyncTask() {
  const status = await BackgroundTask.getStatusAsync();
  if (status !== BackgroundTask.BackgroundTaskStatus.Available) return;

  if (await TaskManager.isTaskRegisteredAsync(SYNC_TASK)) return;
  await BackgroundTask.registerTaskAsync(SYNC_TASK, {
    minimumInterval: 15, // minutes; Android clamps anything lower
  });
}

Key Points

  • defineTask must run at module scope for headless launches
  • WorkManager on Android, BGTaskScheduler on iOS: best effort, not guaranteed
  • minimumInterval is clamped to 15 minutes on Android and is a hint on iOS
  • Indian OEM battery managers suppress tasks; reconcile on foreground
Q25

What does enabling the New Architecture change, and how do you find libraries that will break?

IntermediateNew Architecture

Answer

The New Architecture replaces the asynchronous JSON bridge with JSI, TurboModules for native modules and Fabric for the view layer. Native methods become directly callable through C++ bindings, view creation and layout are synchronous on the shadow tree, and module and component interfaces are generated by Codegen from your TypeScript spec files. By 2026 this is the only supported path: Expo enabled it by default for new projects in SDK 52, turned it on for all projects in SDK 53, and recent React Native versions have removed the legacy renderer, so the question is not whether to migrate but what will break.

The interop layer keeps most older native modules working, but it does not cover everything. The usual casualties are libraries that call findNodeHandle or UIManager.dispatchViewManagerCommand directly, custom view managers that were never run through Codegen, setNativeProps on Fabric components, code that assumed the bridge existed via a global, and anything that measured layout on the next tick and now gets it synchronously. Check reactnative.directory, which flags New Architecture support per library, run npx expo-doctor which reports known-incompatible packages, and grep your own code for the APIs above.

At runtime, global.nativeFabricUIManager being defined tells you Fabric is live. Toggle with newArchEnabled in app config or expo-build-properties, and remember it is a native change, so it needs a prebuild and a new binary, never an OTA update.

// app.config.ts
export default {
  newArchEnabled: true,
  plugins: [
    ['expo-build-properties', {
      android: { newArchEnabled: true, compileSdkVersion: 36 },
      ios: { newArchEnabled: true, deploymentTarget: '15.1' },
    }],
  ],
};

// runtime check
const isFabric = global?.nativeFabricUIManager != null;

# audit before flipping the switch
npx expo-doctor
npx expo prebuild --clean
eas build --profile preview --platform android
💡 Pro Tip: Migrate on a branch with a preview build and a device matrix. Fabric bugs are usually visual (measurement, keyboard avoidance, modals) and never show up in unit tests.
Q26

How do you declare permissions correctly across Android 13/14 and iOS, and what is blockedPermissions for?

IntermediatePermissions

Answer

iOS needs a purpose string in Info.plist for every sensitive API: NSCameraUsageDescription, NSPhotoLibraryUsageDescription, NSLocationWhenInUseUsageDescription and so on. A missing string is not a warning, it is an immediate crash the first time you call the API, and a vague string is a common App Store rejection. In Expo you set these through the relevant config plugin's props (expo-camera's cameraPermission, expo-image-picker's photosPermission, expo-location's locationAlwaysAndWhenInUsePermission) so they land in the generated plist.

Android has moved a lot recently. From API 33, READ_EXTERNAL_STORAGE no longer grants media access; you need READ_MEDIA_IMAGES, READ_MEDIA_VIDEO or READ_MEDIA_AUDIO, and POST_NOTIFICATIONS became a runtime permission, so a notification permission prompt is now mandatory rather than automatic. API 34 added READ_MEDIA_VISUAL_USER_SELECTED for partial photo access, which means your gallery picker must handle the user granting only three photos, and it requires foreground services to declare a type.

The reason blockedPermissions exists is that autolinking merges every permission any dependency declares into your manifest. An audio library you use only for playback can add RECORD_AUDIO, Play then shows 'Microphone' on your store listing, and installs drop. android.blockedPermissions strips them at manifest merge. Always inspect the merged manifest after prebuild rather than trusting your config, and request permissions at the moment of use with an explanatory screen, never all at launch.

// app.config.ts
export default {
  plugins: [
    ['expo-camera', {
      cameraPermission: 'Kirana uses the camera to scan product barcodes at billing.',
    }],
    ['expo-image-picker', {
      photosPermission: 'Kirana attaches photos to delivery complaints.',
    }],
    ['expo-location', {
      locationWhenInUsePermission: 'Kirana finds stores near your delivery address.',
      isAndroidBackgroundLocationEnabled: false,
    }],
  ],
  android: {
    permissions: ['CAMERA', 'POST_NOTIFICATIONS', 'READ_MEDIA_IMAGES'],
    blockedPermissions: ['android.permission.RECORD_AUDIO'],
  },
};

# verify what actually shipped
npx expo prebuild --clean --platform android
# then read android/app/src/main/AndroidManifest.xml

Key Points

  • Missing iOS purpose strings crash on first use, they do not warn
  • API 33 split media access and made POST_NOTIFICATIONS runtime-granted
  • API 34 adds partial photo access your picker must handle
  • blockedPermissions strips permissions autolinked dependencies inject
Q27

How do you manage version, buildNumber and versionCode so store submissions never collide?

IntermediateRelease Management

Answer

There are three numbers and they do different jobs. version is the user-facing string such as 2.4.0 and is shared by both platforms. ios.buildNumber and android.versionCode are the internal counters the stores use for uniqueness: App Store Connect rejects a build whose buildNumber has already been used for that version string, and Play rejects an AAB whose versionCode is not strictly higher than anything previously uploaded to that track. Maintaining these by hand across a team is how you get a 'version code 47 has already been used' failure after a twenty-minute build. Set appVersionSource to remote in the cli block of eas.json and EAS becomes the source of truth: it stores the counters server side, ignores whatever is in app.config.ts, and with autoIncrement true in the profile it bumps them for every build. eas build:version:get shows the current remote values, eas build:version:set fixes them when you are migrating an existing app that already has builds in the stores, and eas build:version:sync writes the remote values back into committed native folders so a local Xcode build agrees with EAS. Two interactions to remember: if your runtimeVersion policy is appVersion, then bumping version cuts a new OTA lane and existing installs stop receiving updates until they upgrade, and autoIncrement does not touch version, so semantic releases still need a deliberate bump in config or through your release script.

// eas.json
{
  "cli": { "version": ">= 12.0.0", "appVersionSource": "remote" },
  "build": {
    "production": {
      "channel": "production",
      "autoIncrement": true,
      "android": { "buildType": "app-bundle" }
    }
  }
}

# what will the next build be numbered?
eas build:version:get --platform android

# migrating an app that already has 46 uploads in Play
eas build:version:set --platform android

# write remote values into committed ios/ and android/ folders
eas build:version:sync
💡 Pro Tip: Never hand-edit versionCode once appVersionSource is remote. EAS ignores the local value and the mismatch only surfaces when someone builds locally.
Q28

How do you get readable stack traces from a minified production bundle shipped through EAS Update?

IntermediateObservability

Answer

A release bundle is Hermes bytecode built from minified JavaScript, so an unsymbolicated crash gives you frames like index.android.bundle:1:284915, which is useless. You need source maps uploaded and correctly keyed to the exact bundle the device is running. With @sentry/react-native, add its Expo config plugin with your organization and project, put SENTRY_AUTH_TOKEN in EAS environment variables as a secret so it exists on the build worker but never in the app, and EAS Build uploads maps automatically at the end of a build.

The part teams get wrong is OTA updates: every eas update publishes a new bundle that the binary's source maps do not describe, so unless you upload maps per update, every OTA crash is unreadable. Key the artefacts the way the SDK reports them, with the release derived from the app version and the dist set to the update id, then run an export with source maps and upload before or immediately after publishing. Set the same values in Sentry.init from expo-updates so runtime events carry matching tags, and wrap the root layout with Sentry.wrap to capture navigation and render errors.

Add Updates.channel and Updates.updateId as tags too, because the first question during an incident is always whether the reporting users are on the new bundle. The same discipline applies to any vendor: Crashlytics, Bugsnag and Datadog all need the update id in the artefact key.

// app.config.ts
plugins: [
  ['@sentry/react-native/expo', {
    organization: 'kirana',
    project: 'kirana-mobile',
  }],
],

// app/_layout.tsx
import * as Sentry from '@sentry/react-native';
import * as Updates from 'expo-updates';
import Constants from 'expo-constants';

Sentry.init({
  dsn: process.env.EXPO_PUBLIC_SENTRY_DSN,
  tracesSampleRate: 0.1,
  release: Constants.expoConfig?.version,
  dist: Updates.updateId ?? 'embedded',
});
Sentry.setTag('ota.channel', Updates.channel ?? 'none');

function RootLayout() { /* ... */ }
export default Sentry.wrap(RootLayout);

# publish an update, then upload its maps under the same release/dist
# eas update --branch production --message 'fix cart total'
# npx expo export --dump-sourcemap --platform android

Key Points

  • EAS Build uploads maps for binaries; OTA bundles need their own upload
  • Key maps by release (app version) and dist (update id) or they will not match
  • Keep SENTRY_AUTH_TOKEN as an EAS secret, never an EXPO_PUBLIC_ variable
  • Tag every event with channel and updateId for incident triage
Q29

When do you write an Expo module with the Expo Modules API instead of a config plugin, and what does the Kotlin side look like?

AdvancedNative Modules

Answer

A config plugin changes configuration: manifest entries, plists, Gradle properties, files on disk. It cannot expose new native behaviour to JavaScript. The moment you need to call platform code that no existing package wraps, for example launching a UPI intent and reading the result, talking to a bank's SDK, integrating an OEM SDK, or doing work on a native thread for performance, you need a native module.

The Expo Modules API is the modern way to write one: npx create-expo-module --local scaffolds a module inside your project's modules directory, which prebuild autolinks without publishing anything to npm. You write Kotlin and Swift against a declarative DSL rather than the old bridge annotations. Inside ModuleDefinition you declare Name, Function and AsyncFunction for the methods, Events plus sendEvent for pushing data to JavaScript, Property for simple getters, View for a Fabric-compatible native view, and OnCreate or OnActivityResult lifecycle hooks.

Arguments are converted automatically from typed signatures, including records and enums, so you are not hand-unpacking a ReadableMap, and throwing a CodedException surfaces a typed error in JavaScript. Because these modules are built on JSI and Codegen, they are TurboModule and Fabric compatible from the start, which matters now that the legacy architecture is gone. On the JavaScript side you load it with requireNativeModule and hand-write the TypeScript types. The trade-off to state in an interview: a local module means every change is a new binary, so keep the native surface thin and the logic in JavaScript wherever you can.

// modules/upi-intent/android/src/main/java/in/kirana/upi/UpiIntentModule.kt
package `in`.kirana.upi

import android.content.Intent
import android.net.Uri
import expo.modules.kotlin.exception.Exceptions
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition

class UpiIntentModule : Module() {
  override fun definition() = ModuleDefinition {
    Name("UpiIntent")
    Events("onPaymentResult")

    AsyncFunction("isAppInstalled") { packageName: String ->
      val ctx = appContext.reactContext ?: throw Exceptions.ReactContextLost()
      ctx.packageManager.getLaunchIntentForPackage(packageName) != null
    }

    AsyncFunction("pay") { deepLink: String ->
      val activity = appContext.currentActivity ?: throw Exceptions.MissingActivity()
      activity.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(deepLink)))
    }
  }
}

// modules/upi-intent/index.ts
import { requireNativeModule } from 'expo-modules-core';

type UpiIntent = {
  isAppInstalled(packageName: string): Promise<boolean>;
  pay(deepLink: string): Promise<void>;
};

export default requireNativeModule<UpiIntent>('UpiIntent');

Key Points

  • Config plugins configure; modules add callable native behaviour
  • create-expo-module --local autolinks without publishing to npm
  • ModuleDefinition DSL gives typed args, events, views and lifecycle hooks
  • Built on JSI and Codegen, so TurboModule and Fabric ready
Q30

How would you self-host expo-updates, and what does update code signing protect against?

AdvancedEAS Update

Answer

expo-updates speaks a documented HTTP protocol, so EAS is one implementation of it rather than a requirement. The client requests the URL in updates.url and sends Expo-Platform, Expo-Runtime-Version, Expo-Protocol-Version and the current update id in headers. Your server answers with a multipart response containing a manifest that lists the launch asset and every other asset with its key, content type and URL, or with a no-update-available directive, or with a roll-back-to-embedded directive.

Assets are content-addressed, so the client downloads only what it does not already have. Teams self-host for data residency, for air-gapped enterprise distribution, to run custom cohorting the hosted rollout percentage cannot express, or to serve updates from a CDN inside India for latency. The cost is that you now own asset storage, atomicity of publishes, and the rollback path.

Code signing is the security half and applies whether or not you self-host. Without it, the client trusts whatever the update URL returns over TLS, so anyone who can compromise the server, the CDN, or a device's DNS can serve arbitrary JavaScript into your app. With it, you generate a keypair and an embedded certificate, the binary carries the public certificate, the update payload carries an expo-signature header, and a payload that does not verify is refused at launch. Generate with expo-updates codesigning:generate, wire it up with codesigning:configure, keep the private key out of git, and pass it at publish time so EAS or your own pipeline signs each update.

# generate a keypair and a self-signed certificate
npx expo-updates codesigning:generate \
  --key-output-directory keys \
  --certificate-output-directory certs \
  --certificate-validity-duration-years 10 \
  --certificate-common-name 'Kirana Technologies'

# writes updates.codeSigningCertificate + codeSigningMetadata into app config
npx expo-updates codesigning:configure \
  --certificate-input-directory certs \
  --key-input-directory keys

# publish signed (private key stays in a secret store, never in git)
eas update --branch production --private-key-path ./keys/private-key.pem

# self-hosted config
// app.config.ts
// updates: {
//   url: 'https://updates.kirana.in/api/manifest',
//   requestHeaders: { 'expo-channel-name': 'production' },
// }
💡 Pro Tip: Rotating a code-signing certificate requires a new binary, because the old certificate is compiled in. Choose a long validity and store the key in a proper secret manager on day one.
Q31

An Expo app takes six seconds to reach first paint on a mid-range Android device. How do you find and fix it?

AdvancedPerformance

Answer

Split the six seconds into native process start, JavaScript bundle load and execution, and first render, because the fixes are different. Measure first: Play Console Android vitals reports cold start times across your real device mix, and locally you can log timestamps from application start to the point where you call SplashScreen.hideAsync. On bundle size, EXPO_ATLAS=1 npx expo start followed by npx expo-atlas gives a per-module breakdown, which usually shows one or two surprises: an entire icon font set, a date library with all locales, an analytics SDK pulled in at the root, or a barrel file that imports every screen.

Expo's Metro config enables inline requires by default, so a module is evaluated on first use rather than at bundle load, but a top-level import in your root layout defeats that, and so does a barrel index that re-exports everything. Move heavy initialisation out of the root layout, lazy import screens, and defer analytics, remote config and update checks until after first interaction. On the native side, enable R8 and resource shrinking through expo-build-properties, ship an AAB so Play delivers only the needed ABI and density, and check that Hermes is on since bytecode skips the parse step entirely.

Then look at the first screen itself: a list that renders two hundred rows before paint, a synchronous read of a large AsyncStorage value, or a blocking auth network call will dominate everything else. Fix the blocking call by rendering a skeleton and letting data arrive.

// app.config.ts
plugins: [
  ['expo-build-properties', {
    android: {
      enableProguardInReleaseBuilds: true,
      enableShrinkResourcesInReleaseBuilds: true,
    },
  }],
],

# 1. where do the bytes go?
EXPO_ATLAS=1 npx expo start
npx expo-atlas

# 2. inspect the real production bundle
npx expo export --platform android --dump-sourcemap

# 3. defer non-critical startup work
// import { InteractionManager } from 'react-native';
// InteractionManager.runAfterInteractions(() => {
//   initAnalytics();
//   checkForUpdateAsync();
// });

Key Points

  • Separate native start, bundle evaluation and first render before optimising
  • expo-atlas shows which module owns the bytes
  • Top-level imports in the root layout defeat Metro inline requires
  • R8, resource shrinking and AAB delivery cut install and start cost
Q32

You inherit a three-year-old bare React Native app with committed native folders and eight patch-package patches. How do you get it onto modern Expo and EAS?

AdvancedMigration

Answer

Do it in stages, each of which ships, rather than as one branch that never merges. Stage one is EAS Build with the native folders exactly as they are. EAS builds bare projects happily, so you get reproducible cloud builds, managed credentials and eas submit without touching a line of native code, and that alone removes the 'only Rahul's laptop can build the release' problem.

Stage two is adopting the Expo modules runtime with npx install-expo-modules, which adds expo to an existing bare app so you can start using expo-* packages and later expo-updates. Stage three is the SDK ladder: upgrade one version at a time with npx expo install expo@^NN followed by npx expo install --fix, reading each release's breaking-change notes, and running npx expo-doctor after each step. Skipping versions is how you end up unable to tell which of four upgrades broke the build.

Stage four is the actual CNG move, and the patches are the work. Audit every patch and every hand edit in ios and android: some become unnecessary because the library caught up, some become a config plugin, and a couple genuinely need a fork or a local Expo module. The test is mechanical: run npx expo prebuild --clean into a scratch copy and diff the generated ios and android against the committed ones.

Everything in that diff is something you must reproduce as a plugin before you can delete the folders. Stage five is the New Architecture and expo-router, both of which are much easier once the native layer is generated rather than hand-maintained.

# stage 1: build the bare app on EAS with zero native changes
eas build:configure
eas build --profile preview --platform android

# stage 2: bring in the Expo modules runtime
npx install-expo-modules@latest

# stage 3: climb the SDK ladder one version at a time
npx expo install expo@^54.0.0
npx expo install --fix
npx expo-doctor

# stage 4: find out exactly what your hand edits contain
git switch -c cng-audit
npx expo prebuild --clean
git diff --stat ios android   # every line here needs a config plugin

# stage 5: only then delete the folders
# echo '/ios' >> .gitignore && echo '/android' >> .gitignore
💡 Pro Tip: Keep a plugins/README.md listing each patch you converted and why. Six months later nobody remembers that withPodfileProperties line existed to satisfy a payments SDK.
Q33

How do you orchestrate build, test, submit and update in one pipeline with EAS Workflows?

AdvancedCI/CD

Answer

Before EAS Workflows, teams wired GitHub Actions to call the EAS CLI, which works but means you maintain runners, secrets and a lot of glue for something Expo already knows how to do. EAS Workflows are YAML files under .eas/workflows that run on Expo's infrastructure, triggered by pushes, pull requests, a schedule, or manually with eas workflow:run. The unit is a job, and jobs come in prebuilt types (build, submit, update, maestro_test, fingerprint) or as custom jobs made of steps that run shell commands on the worker.

Jobs declare needs to form a dependency graph, so a submit job consumes the build id output by the build job rather than polling, and independent platform builds run in parallel. The patterns worth describing in an interview are these. On a pull request, run a fingerprint job and a lint or type-check job, and build a preview only when the fingerprint changed, which avoids paying for a binary when the change was JavaScript only.

On merge to your release branch, decide between an update job and a build job on the same fingerprint signal: unchanged native layer means publish an OTA, changed native layer means build and submit. Add a Maestro end-to-end job between build and submit so a broken checkout never reaches TestFlight. Secrets come from EAS environment variables, and every run is visible next to the builds it produced.

# .eas/workflows/release.yml
name: Release

on:
  push:
    branches: ['production']

jobs:
  typecheck:
    steps:
      - uses: eas/checkout
      - uses: eas/install_node_modules
      - run: npx tsc --noEmit

  build_android:
    needs: [typecheck]
    type: build
    params:
      platform: android
      profile: production

  build_ios:
    needs: [typecheck]
    type: build
    params:
      platform: ios
      profile: production

  submit_android:
    needs: [build_android]
    type: submit
    params:
      platform: android
      build_id: ${{ needs.build_android.outputs.build_id }}

# run it by hand
# eas workflow:run release.yml

Key Points

  • Workflows live in .eas/workflows and run on Expo infrastructure
  • Job types: build, submit, update, fingerprint, maestro_test, plus custom steps
  • needs wires build ids into submit without polling the CLI
  • Use a fingerprint job to choose between an OTA update and a full build
Q34

Play Console shows a rising ANR rate and native crashes with no readable frames. How do you debug that in an Expo app?

AdvancedDebugging

Answer

ANRs and native crashes are the two classes your JavaScript error boundary and Sentry's JS handler never see, and Play measures them against published bad-behaviour thresholds that affect store visibility, so they are a real business issue rather than a nice-to-have. Start with readability. R8 minifies your Java and Kotlin, so you must upload mapping.txt, and native frames from Hermes and third-party .so files need debug symbols, which you get by setting the NDK debug symbol level in the release build type through a config plugin on app/build.gradle so the AAB carries them.

Play then symbolicates automatically. For the JavaScript layer, keep source maps keyed to the update id as usual. For ANRs specifically, remember an ANR means the main thread was blocked for roughly five seconds, and in React Native the JavaScript thread is separate, so the culprit is almost always native: a module doing IO on the main thread, a huge synchronous Fabric commit from rendering thousands of views at once, or work in Application.onCreate.

Two Expo-specific causes come up repeatedly. A non-zero fallbackToCacheTimeout makes expo-updates block launch while it checks the update server, which on a weak network turns into an ANR at startup, so keep it at zero. And a config plugin that adds a heavy SDK initialisation to onCreate moves that cost into the launch path. Reproduce with adb shell am start timings and read the stack dumps in the Play Console ANR clusters, which name the blocking method directly.

// plugins/withNativeDebugSymbols.js
const { withAppBuildGradle } = require('expo/config-plugins');

module.exports = (config) =>
  withAppBuildGradle(config, (cfg) => {
    cfg.modResults.contents = cfg.modResults.contents.replace(
      'android {',
      "android {\n    buildTypes { release { ndk { debugSymbolLevel 'FULL' } } }"
    );
    return cfg;
  });

// app.config.ts: never block launch on the update check
// updates: { url: '...', fallbackToCacheTimeout: 0 }

# measure a cold start on a real device
adb shell am start -W -S in.kirana.app/.MainActivity

# watch for main-thread stalls while reproducing
adb logcat -s Choreographer:* ActivityManager:E
💡 Pro Tip: Wire a native crash reporter (Crashlytics NDK or Sentry's native integration) at build time. A JavaScript-only reporter shows a clean dashboard while Play shows a rising crash rate.
Q35

How do you customise what happens on an EAS Build worker, and when is a custom build config the right answer?

AdvancedEAS Build

Answer

There are three levels, and picking the smallest one that works is the answer interviewers want. Level one is npm lifecycle hooks in package.json: eas-build-pre-install, eas-build-post-install, eas-build-on-success, eas-build-on-error and eas-build-on-complete run at named points in the standard build. Fetching a private font, generating a GraphQL client, or writing a google-services.json from an EAS secret all belong here.

The worker exposes EAS_BUILD_PROFILE, EAS_BUILD_PLATFORM, EAS_BUILD_RUNNER and EAS_BUILD_GIT_COMMIT_HASH, so a hook can behave differently per profile. Level two is targeted eas.json keys: prebuildCommand to run your own prebuild variant, node and pnpm versions, image to pin the worker OS and Xcode version, env for build-time variables, and cache with a key and paths list to persist directories such as a Gradle or CocoaPods cache between builds. Bumping the cache key is how you force a clean run when a stale cache is the suspect.

Level three is a custom build config, a YAML file referenced by the config key in a profile, where you assemble the build from steps yourself using functions like eas/checkout, eas/install_node_modules, eas/prebuild and eas/build. That gives you full control and full responsibility, and it is the right choice only when you need something the standard flow cannot express, such as building two artefacts in one run or injecting a licensed SDK. Reach for eas build --local first when you are debugging a worker failure, because iterating on a hook through the cloud queue is painfully slow.

// package.json
{
  "scripts": {
    "eas-build-pre-install": "echo \"$GOOGLE_SERVICES_JSON\" > ./google-services.json",
    "eas-build-post-install": "npx graphql-codegen",
    "eas-build-on-error": "node ./scripts/notify-slack.js failed"
  }
}

// eas.json
{
  "build": {
    "production": {
      "image": "latest",
      "node": "20.19.0",
      "prebuildCommand": "expo prebuild --clean --template ./template.tgz",
      "cache": {
        "key": "gradle-v3",
        "paths": ["~/.gradle/caches"]
      }
    }
  }
}

# debug worker failures without waiting in the queue
eas build --profile production --platform android --local

Key Points

  • package.json hooks cover most needs: pre-install, post-install, on-success, on-error
  • EAS_BUILD_PROFILE and friends let one hook serve every profile
  • cache.key plus cache.paths persist Gradle and CocoaPods between builds
  • Custom build YAML is powerful and rarely necessary; try --local first

Companies Hiring Expo

Flipkart
Meesho
Groww
Zomato
Swiggy
Cars24
Khatabook
Urban Company

Salary Insights

Average in India
₹5-18 LPA

Frequently Asked Questions

What salary should I expect as an Expo or React Native developer in India in 2026?

Ranges vary a lot by city and by whether the employer is a services company or a product company. Freshers with a couple of published apps typically land ₹4-7 LPA, developers with two to four years and real release experience sit around ₹8-15 LPA, and senior engineers who own the release train, EAS pipelines and native debugging commonly see ₹18-30 LPA at funded product companies in Bengaluru, Gurugram and Pune. The single biggest multiplier is whether you have shipped to the stores rather than only built screens. Candidates who can explain credential rotation, an OTA rollback they actually ran, and a crash they symbolicated get materially higher offers than candidates who only know the React side.

Do companies hire for Expo specifically, or is React Native the real requirement?

Job descriptions almost always say React Native, and Expo shows up as a line item or is simply assumed once you are in the room. In practice most new Indian mobile teams start on Expo because EAS removes the need for a Mac build machine and a dedicated release engineer, so the interview drifts into EAS Build profiles, config plugins and EAS Update within twenty minutes. Treat React Native as the language of the job posting and Expo as the language of the actual work. Being able to discuss both, including when a team should stay bare, reads as seniority rather than tool loyalty.

How do I prove production Expo experience if my apps are all side projects?

Ship one app all the way through, because the last ten percent is what interviews probe. Put it on the Play Store, even on internal testing, so you have handled a keystore, a versionCode collision, a Data safety declaration and a review rejection. Set up a preview and a production channel, publish an EAS Update, then deliberately break it and practise a rollback. Write one config plugin, even a small one. Then document all of it in a README with the eas.json you used. A candidate who can screen-share a real eas.json and explain each key beats a candidate with three unpublished portfolio apps.

Do I need to know Kotlin and Swift to get hired for an Expo role?

Not for most mid-level roles, but you need to stop being afraid of native code. The realistic bar is reading a Gradle error and knowing whether it is a version conflict or a missing permission, understanding what prebuild generated and why, and being able to follow a vendor SDK's native setup instructions well enough to turn them into a config plugin. Senior roles do expect you to write a small Expo module in Kotlin or Swift when nothing on npm fits. A practical path is to write one local module that wraps a single platform API, which teaches the Expo Modules API, autolinking and the build cycle in one weekend.

What does a typical Expo interview loop look like in India?

Expect three or four rounds. A screening round on React and React Native fundamentals: hooks, re-render behaviour, lists, navigation. A practical round that is either a take-home (build two screens with an API, offline handling and a loading state) or live coding in an existing repo. A tooling round that is where Expo knowledge actually decides the outcome: eas.json, runtime versions, OTA limits, permissions, debugging on device. Then a design round on offline sync, auth token storage, push architecture or app size. Startups compress this into two rounds and weight the tooling one heavily, because they need someone who can own releases from week one.

How do I keep my Expo skills current when the SDK ships several times a year?

Read the release notes for every SDK even when you are not upgrading, because interviewers ask about recent changes and the notes are short. Keep one side project on the newest SDK and upgrade it the week it lands, which gives you a real story about what broke. Follow the changelogs for expo-router, expo-updates and expo-notifications specifically, since those three change most and are the ones production teams care about. Finally, run npx expo-doctor and npx expo install --check on your work repo regularly, so the difference between the SDK you are on and the one everyone is discussing stays small.

Introduction

Expo stopped being 'React Native with training wheels' several SDK cycles ago. In 2026 it is the default way most teams ship React Native, because it owns the parts of mobile development nobody enjoys: native project generation, build infrastructure, code signing, over-the-air updates, store submission, and a versioned library set that actually compiles together. The pieces an interviewer will name are Continuous Native Generation (prebuild plus config plugins), expo-router for file-based navigation, EAS Build and EAS Submit for CI, and EAS Update for shipping JavaScript fixes without a store review. Knowing the React side is table stakes; knowing how a build is produced and signed is what gets an offer.

Indian product teams lean on Expo hard because the constraints here are unforgiving. A large share of installs land on Android devices with 2-4 GB of RAM on patchy networks, so APK size, cold start time, image decode memory, and list recycling are not academic topics. At the same time, release velocity matters: a payments or delivery app that has to wait two days for an App Store review to fix a crash is a business problem, which is exactly why EAS Update and runtime version policy come up in almost every senior interview. Expect questions on the New Architecture migration too, since Fabric and TurboModules became the only supported path in recent SDK releases.

This guide covers 35 Expo interview questions asked in 2026, ordered from fundamentals to advanced production topics. Each answer explains how the tooling actually behaves, the failure modes that show up only after release, and what the interviewer is really probing. Most questions carry a runnable code or CLI example. Work through the basic block to lock down config, routing, and build profiles, then spend real time on the intermediate and advanced sections: config plugins, update rollbacks, Expo Modules API, memory on low-end Android, and credential management are the topics that separate a ₹8 LPA offer from an ₹18 LPA one.

Ready to practice Expo interviews?

Don't just read, practice these Expo questions live with an AI interviewer that asks follow-ups and scores your answers.

AI-powered practice
Instant feedback
Free to start
Start Free Mock Interview