EAS Update not downloading on production build despite matching channels and successful publish
Source: Dev.to
Issue Summary
I’m using EAS Update in a bare React Native project (not the Managed workflow). The update publishes correctly and appears on the Expo Dashboard, but the physical device (installed via TestFlight) never receives the changes.
- The update is listed under the correct branch/channel in the dashboard.
- I’ve restarted the app multiple times.
Project Configuration
app.config.ts
import { ExpoConfig, ConfigContext } from 'expo/config';
import * as dotenv from 'dotenv';
import path from 'path';
import pkg from './package.json';
const APP_VARIANT = process.env.APP_VARIANT || 'prod';
dotenv.config({ path: path.resolve(__dirname, `.env.${APP_VARIANT}`) });
interface CustomExpoConfig extends ExpoConfig {
'react-native-google-mobile-ads'?: {
android_app_id?: string;
ios_app_id?: string;
};
}
const convertVersionToNumber = (version: string) => {
const [major, minor, patch] = version.split('.').map(Number);
return major * 1_000_000 + minor * 1_000 + patch + 2;
};
export default ({ config }: ConfigContext): CustomExpoConfig => ({
...config,
name: 'GoalWith',
slug: 'goalwith',
version: pkg.version,
runtimeVersion: pkg.version,
ios: {
...config.ios,
bundleIdentifier: 'com.goalwith.goalwith',
buildNumber: convertVersionToNumber(pkg.version).toString(),
googleServicesFile: './ios/GoogleService-Info.plist',
},
android: {
package: 'com.goalwith',
versionCode: convertVersionToNumber(pkg.version),
},
extra: {
env: process.env.ENV,
apiUrl: process.env.API_URL,
kakaoAppKey: process.env.KAKAO_APP_KEY,
googleWebClientId: process.env.GOOGLE_WEB_CLIENT_ID,
admobIdAndroid: process.env.ADMOB_ID_ANDROID,
admobIdIos: process.env.ADMOB_ID_IOS,
eas: {
projectId: '8475b304-e536-458b-aa6a-6aea6e3e6939',
},
},
'react-native-google-mobile-ads': {
android_app_id: process.env.ADMOB_ID_ANDROID,
ios_app_id: process.env.ADMOB_ID_IOS,
},
updates: {
url: 'https://u.expo.dev/8475b304-e536-458b-aa6a-6aea6e3e6939',
requestHeaders: {
'expo-channel-name': 'production',
},
},
});
eas.json
{
"build": {
"development": {
"channel": "dev",
"env": {
"APP_VARIANT": "dev"
}
},
"production": {
"channel": "production",
"env": {
"APP_VARIANT": "prod"
}
}
}
}
AppDelegate.swift
import UIKit
import Expo
import React
import React_RCTAppDelegate
import ReactAppDependencyProvider
import GoogleSignIn
import RNBootSplash
import KakaoSDKCommon
import KakaoSDKAuth
import FirebaseCore
import EXUpdates
@main
class AppDelegate: ExpoAppDelegate {
var window: UIWindow?
var reactNativeDelegate: ReactNativeDelegate?
var reactNativeFactory: RCTReactNativeFactory?
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
) -> Bool {
AppController.initializeWithoutStarting()
FirebaseApp.configure()
let delegate = ReactNativeDelegate()
let factory = ExpoReactNativeFactory(delegate: delegate)
delegate.dependencyProvider = RCTAppDependencyProvider()
reactNativeDelegate = delegate
reactNativeFactory = factory
bindReactNativeFactory(factory)
self.window = UIWindow(frame: UIScreen.main.bounds)
factory.startReactNative(
withModuleName: "main",
in: self.window,
launchOptions: launchOptions
)
if let rootView = self.window?.rootViewController?.view {
RNBootSplash.initWithStoryboard("BootSplash", rootView: rootView)
}
GIDSignIn.sharedInstance.restorePreviousSignIn { user, error in
if error != nil || user == nil {
// Show the app's signed-out state.
} else {
// Show the app's signed-in state.
}
}
if let kakaoAppKey = Bundle.main.object(forInfoDictionaryKey: "KAKAO_APP_KEY") as? String {
KakaoSDK.initSDK(appKey: kakaoAppKey)
} else {
print("Warning: KAKAO_APP_KEY not found in Info.plist")
}
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
override func application(
_ app: UIApplication,
open url: URL,
options: [UIApplication.OpenURLOptionsKey : Any] = [:]
) -> Bool {
var handled = false
handled = GIDSignIn.sharedInstance.handle(url)
if handled { return true }
if AuthApi.isKakaoTalkLoginUrl(url) {
handled = AuthController.handleOpenUrl(url: url)
if handled { return true }
}
return super.application(app, open: url, options: options)
}
}
class ReactNativeDelegate: ExpoReactNativeFactoryDelegate {
override func sourceURL(for bridge: RCTBridge) -> URL? {
// needed to return the correct URL for expo-dev-client.
bridge.bundleURL ?? bundleURL()
}
override func bundleURL() -> URL? {
#if DEBUG
RCTBundleURLProvider.sharedSettings().jsBundleURL(forBundleRoot: ".expo/.virtual-metro-entry")
#else
if let updatesUrl = AppController.sharedInstance.launchAssetUrl() {
return updatesUrl
}
return Bundle.main.url(forResource: "main", withExtension: "jsbundle")
#endif
}
}
Expo.plist
<dict>
<key>EXUpdatesEnabled</key>
<true/>
<key>EXUpdatesCheckOnLaunch</key>
<string>ALWAYS</string>
<key>EXUpdatesLaunchWaitMs</key>
<integer>0</integer>
<key>EXUpdatesRequestHeaders</key>
<dict>
<key>expo-channel-name</key>
<string>production</string>
</dict>
<key>EXUpdatesURL</key>
<string>https://u.expo.dev/8475b304-e536-458b-aa6a-6aea6e3e6939</string>
<key>EXUpdatesRuntimeVersion</key>
<string>1.0.5</string>
</dict>
What I’ve Already Verified
- The update appears under the production channel in the Expo Dashboard.
- The app has been force‑quit and relaunched several times on the device.
Goal
Identify why the OTA update isn’t being applied on the TestFlight‑installed device despite the dashboard showing the update as published. Any insight into missing configuration, caching, or required native changes would be appreciated.
If you need to see any other configuration files or specific logs, please let me know and I will update the post immediately.