●APPLE — Apple's event is September 9, with the iOS 27 release candidate landing the same day. General release is September 14, preorders September 12, and retail September 18●TESTING — That leaves five working days between the RC and the public release. For React Native and Expo apps, it is the last clean window to run everything against Xcode 27 and the iOS 27 SDK●SIRI — iOS 27 rebuilds Siri from the ground up, which puts anything using Siri Intents or voice input at the top of the list to verify●PLAY — Google Play's target API level 36 requirement took effect on August 31. If you did not make it, an extension request through November 1 is still available in Play Console●EXPO — expo@57.0.17 moves React Native to 0.86.3 and clears the Hermes V1 memory regression that hit apps importing react-native-worklets or reanimated●RORK — The two Rork products build different things: the original generates React Native via Expo, while Rork Max generates Swift and compiles it on a cloud Mac fleet●APPLE — Apple's event is September 9, with the iOS 27 release candidate landing the same day. General release is September 14, preorders September 12, and retail September 18●TESTING — That leaves five working days between the RC and the public release. For React Native and Expo apps, it is the last clean window to run everything against Xcode 27 and the iOS 27 SDK●SIRI — iOS 27 rebuilds Siri from the ground up, which puts anything using Siri Intents or voice input at the top of the list to verify●PLAY — Google Play's target API level 36 requirement took effect on August 31. If you did not make it, an extension request through November 1 is still available in Play Console●EXPO — expo@57.0.17 moves React Native to 0.86.3 and clears the Hermes V1 memory regression that hit apps importing react-native-worklets or reanimated●RORK — The two Rork products build different things: the original generates React Native via Expo, while Rork Max generates Swift and compiles it on a cloud Mac fleet
I counted how many versions expo install starts recommending after one patch bump
What expo install recommends is tied to the patch version of expo itself. I measured the ledger diff against the npm registry, how far npm install drifts, and the one check worth adding to a generated project.
I wanted a fast list in a generated project, so I added FlashList. Muscle memory took over: npm install @shopify/flash-list. The build passed. The list scrolled. A few days later npx expo-doctor came back red on exactly that one line.
I had 2.3.2 installed. Expo expected 2.0.2 — three minors apart. That gap produces no error and no warning. It only becomes visible on the day you deliberately run the check.
What stuck with me was the other question: where does Expo get 2.0.2 from? The answer lives inside the expo package itself, and it gets rewritten on every patch release.
Where the ledger expo install reads actually lives
npx expo install <package> does not fetch the npm latest. It reads bundledNativeModules.json, shipped inside the installed expo package, and installs whatever version is written there.
# The ledger is a plain JSON file in node_modulescat node_modules/expo/bundledNativeModules.json | head -20
As of 2026-09-02, expo@57.0.19 ships a ledger with 123 entries: 87 Expo-owned modules (expo-* and @expo/*) and 36 third-party packages.
That third-party list is not an afterthought. Sentry, Stripe, FlashList, Skia, Lottie, react-native-maps, react-native-webview, AsyncStorage — the packages you reach for in the first week of a real project. So expo install is not merely resolving Expo's own modules. It carries a statement of the form "for this SDK, we verified this version" for the community libraries too.
npm install never looks at that statement. It installs the registry's latest. That is the fork in the road.
Two patch releases, four days, 30 entries moved
If the ledger were pinned per SDK, this would be a short article. It is not. The contents change with each patch of expo.
I pulled several patch tarballs from the npm registry, extracted just the ledger, and wrote a small script to count the diff.
// drift.mjs — diff the ledgers of two expo patch releasesimport { readFileSync } from "node:fs";const read = (v) => JSON.parse(readFileSync(`x${v}/package/bundledNativeModules.json`, "utf8"));const [from, to] = process.argv.slice(2);const a = read(from), b = read(to);const moved = Object.keys(a).filter((k) => k in b && a[k] !== b[k]);const pinned = Object.entries(b).filter(([, r]) => /^\d/.test(r)); // no ~ and no ^console.log(`ledger entries: ${Object.keys(b).length}`);console.log(`${from} -> ${to}, entries moved: ${moved.length}`);console.log(`exact pins (no range): ${pinned.length}`);
57.0.17 was published on 2026-08-26 (UTC) and 57.0.19 on 2026-09-01. Roughly four days apart, 30 of 123 recommendations moved.
Widen the window and it grows. Comparing 57.0.8 (published July 22) with 57.0.17 gives 54 moved entries — all inside the same SDK 57.
Here is the release cadence of the 57 line, for context.
Version
Published (UTC)
57.0.14
2026-08-17
57.0.15
2026-08-20
57.0.16
2026-08-24
57.0.17
2026-08-26
57.0.18
2026-08-28
57.0.19
2026-09-01
Every three to four days. And since "expo": "~57.0.19" allows any 57.0.x patch, the day you regenerate your lockfile changes the answer expo install gives you.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦You will be able to verify for yourself whether dependencies added after generation still sit inside the combination Expo actually tested
✦You will be able to tell a version-ledger mismatch from a broken lockfile, instead of losing half a day to the wrong suspect
✦You will be able to decide, backed by the measured 30-entry shift across one patch bump, whether an expo patch upgrade counts as a harmless change in your project
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
This is the part that ran against my expectation. I assumed that bumping the patch would pull the third-party recommendations forward as well.
Every one of the 30 entries that moved between 57.0.17 and 57.0.19 was an expo-* or @expo/* package. expo-updates went ~57.0.18 → ~57.0.20, expo-widgets went ~57.0.13 → ~57.0.16, and so on.
Not a single third-party entry moved. react-native stayed at 0.86.3, react at 19.2.3 — and note that neither carries a tilde or a caret. They are exact pins.
Kind
How the ledger writes it
Behavior on a patch bump
Expo-owned modules
ranges like ~57.0.15
moves every few days
react / react-native
exact pin, e.g. 0.86.3
frozen for the SDK major
22 major third-party packages
exact pin
frozen for the SDK major
Which means the recommended versions for Sentry or AsyncStorage will not advance no matter how many expo patches you apply. They move when the next major SDK lands, and not before. "Bump expo and everything else falls into line" only holds for Expo's own modules.
That asymmetry changes how I think about dependency updates. As an indie developer shipping on my own, my default has been to upgrade whatever can be upgraded. Then, on the Android side of one of my wallpaper apps, I bumped an image library on its own — the library was fine, but its combination with the build toolchain crashed launch on older devices only. What broke was not the library. It was the combination. The 22 pinned entries are exactly where Expo has absorbed that combination risk on your behalf.
Measuring how far npm install drifts
So how far does the muscle-memory npm install actually take you? Here are the ledger values next to the npm latest, both measured on 2026-09-02.
Package
Ledger (expo@57.0.19)
npm latest
Drift
@sentry/react-native
~7.11.0
8.24.0
1 major
@react-native-async-storage/async-storage
2.2.0
3.1.1
1 major
react-native-webview
13.16.1
14.0.1
1 major
@shopify/react-native-skia
2.6.2
2.11.2
5 minors
@shopify/flash-list
2.0.2
2.3.2
3 minors
react-native-maps
1.27.2
1.29.0
2 minors
lottie-react-native
~7.3.8
7.5.0
2 minors
react-native-reanimated
4.5.1
4.6.0
1 minor
react-native-svg
15.15.4
15.15.5
1 patch
Sentry, AsyncStorage, and WebView each jump a major. A major bump in a native module almost always asks something of your build config or your init code. npm install brings that request in silently.
This matters most when an AI writes the setup steps for you. Ask for "a fast list with FlashList" and the generated instructions will usually include npm install @shopify/flash-list. Whether it translates that to npx expo install depends on the generation. The person receiving the output has to know the difference.
One check to drop into a freshly generated project
Running npx expo install --check would cover this. In practice, the week I forget to run it is the week three dependencies get added. I would rather have CI fail, so I added a script that compares package.json against the installed ledger.
// scripts/check-bundled.mjs// Compare package.json dependencies against the ledger inside the installed expo.// Anything absent from the ledger (zustand and friends) is outside Expo's// verified set, so we stay quiet about it.import { readFileSync } from "node:fs";import { createRequire } from "node:module";const require = createRequire(import.meta.url);const ledger = require("expo/bundledNativeModules.json");const pkg = JSON.parse(readFileSync("package.json", "utf8"));const deps = { ...pkg.dependencies, ...pkg.devDependencies };const strip = (r) => r.replace(/^[\^~]/, "");const rows = [];for (const [name, range] of Object.entries(deps)) { const want = ledger[name]; if (!want) continue; // not verified by Expo, skip if (range === want) continue; // exact match const pinned = !/^[\^~]/.test(want); // is the ledger value an exact pin? const same = strip(range) === strip(want); if (pinned && !same) rows.push([name, range, want, "ledger pins this exactly"]); else if (!pinned && !same) rows.push([name, range, want, "outside the ledger range"]);}if (rows.length === 0) { console.log("no dependencies conflict with the ledger");} else { console.log(`dependencies conflicting with the ledger: ${rows.length}`); for (const [n, got, want, why] of rows) { console.log(` ${n}\n package.json: ${got}\n ledger: ${want} (${why})`); } process.exitCode = 1;}
Run against a package.json where expo, expo-image, and react-native follow the ledger but FlashList and Sentry were installed at latest:
$ node scripts/check-bundled.mjsdependencies conflicting with the ledger: 2 @shopify/flash-list package.json: ^2.3.2 ledger: 2.0.2 (ledger pins this exactly) @sentry/react-native package.json: ^8.24.0 ledger: ~7.11.0 (outside the ledger range)
Some drift is deliberate, so failing unconditionally would make the check unusable. I pair it with an allow list that records the reason.
// Dependencies we knowingly keep outside the ledger, with the reason attachedconst ALLOW = { "@sentry/react-native": "need v8 Session Replay; temporary until the SDK 58 ledger catches up",};// One line inside the loop is enoughif (ALLOW[name]) continue;
Having a place to write the reason is what makes this survivable six months later. The problem is rarely the drift itself — it is that nobody wrote down why the drift was accepted.
What to do with a dependency that drifted
Once the check goes red, the decision splits three ways. Which one you pick determines how much work follows.
Bring it back to the ledger
The lightest option. Re-run npx expo install <package> and it drops to the ledger version. I take this route by default: if I cannot articulate why latest is required, matching the ledger makes the next SDK upgrade cheaper.
One gotcha. When this means a downgrade, node_modules and the lockfile can disagree and resolution fails outright. Running npm install once, or regenerating the lockfile before retrying, is the reliable workaround.
Keep it outside on purpose
Sometimes the ledger version simply lacks a feature you need. Packages that ship features quickly — Sentry is the usual example — can outpace the ledger by enough that waiting is not an option.
In that case, write the reason into the allow list above and add a rough expiry ("until the next major SDK"). Being outside the ledger is not the problem. Having no record of why is.
Defer the decision
If you cannot decide today, keep the check from failing while still surfacing the drift: treat only the exact pins as errors.
// Fail CI on exact-pin drift; leave range drift as a warningconst fatal = rows.filter(([, , want]) => !/^[\^~]/.test(want));if (fatal.length > 0) process.exitCode = 1;
Exact pins like react-native or Skia are the layer where drift tends to surface as a production build problem rather than a lint complaint. Failing on those alone keeps the check alive while undecided dependencies sit in the warning column. Failing on everything is how a check gets commented out, so this is the split I recommend.
When local and CI disagree
Because the ledger ships inside expo, a different expo patch on CI produces a different expo install --check result. There are essentially two ways to end up there.
package.json says "expo": "~57.0.x" and the lockfile is not shared (or CI runs npm install and rewrites it)
CI restores a cached node_modules where expo is still on the previous patch
Both present as symptoms that look like a corrupted lockfile. What is actually out of sync is the ledger version. One line separates the two.
# Run this locally and on CI; the numbers should matchnode -p "require('expo/package.json').version"
If those differ, align expo before touching anything else. Since making this the first question, I have stopped losing time to the lockfile. The wider set of checks I run after generation is in the acceptance checks I still run after the build turns green.
The ledger tells you how heavy an SDK upgrade will be
There is a second use for this. The ledger diff doubles as a preview of the next SDK upgrade.
When expo@58 lands, I plan to extract both ledgers and run drift.mjs before reading anything else. How many of the 22 exact pins moved tells you how deeply the upgrade reaches into native code. If only Expo's own modules shifted, it is a light upgrade. If Sentry or AsyncStorage jumped a major, block out time for init code and build settings.
The number arrives before the release notes do, which is why I like this order: know how much changed before reading what changed.
If it prints 123, those 123 packages are the combination Expo verified. Count how many of your dependencies appear in that set, and how many of those match the ledger version. The ones added by npm install will stand out immediately.
I treated expo install and npm install as interchangeable until three minors of FlashList taught me otherwise. The difference is not the command name. It is whether a ledger is being consulted at all.
Share
Thank You for Reading
Rork Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.