RORK LABJP
DEADLINE — Google Play's Android 16 (API level 36) requirement lands tomorrow, August 31. One day left, and it covers new apps and updates to existing ones alikeDECISION — An extension cannot be filed once the deadline passes. So the real cutoff today is not the build itself but deciding whether to make it or to file insteadVISIBILITY — Apps you no longer update are not exempt. Anything still below API level 35 stops appearing for new users on newer Android devicesPITFALL — When a dependency has not caught up to the new API level, the local build can pass while the store pre-check stops you. Reading your own code will not surface thatEXPO — expo@57.0.17 moved React Native to 0.86.3. A pattern is emerging of small non-breaking patches between major SDK releases, which lowers the cost of keeping currentCHOICE — Shipping to Android too points at Rork proper and its React Native output; Apple-specific capabilities at the center of the spec point at Rork Max and Swift. It is a question of what you shipDEADLINE — Google Play's Android 16 (API level 36) requirement lands tomorrow, August 31. One day left, and it covers new apps and updates to existing ones alikeDECISION — An extension cannot be filed once the deadline passes. So the real cutoff today is not the build itself but deciding whether to make it or to file insteadVISIBILITY — Apps you no longer update are not exempt. Anything still below API level 35 stops appearing for new users on newer Android devicesPITFALL — When a dependency has not caught up to the new API level, the local build can pass while the store pre-check stops you. Reading your own code will not surface thatEXPO — expo@57.0.17 moved React Native to 0.86.3. A pattern is emerging of small non-breaking patches between major SDK releases, which lowers the cost of keeping currentCHOICE — Shipping to Android too points at Rork proper and its React Native output; Apple-specific capabilities at the center of the spec point at Rork Max and Swift. It is a question of what you ship
Articles/Getting Started
Getting Started/2026-08-30Intermediate

expo-doctor passes on API level 35. Here is what its 22 checks actually cover

I opened the expo-doctor 1.20.4 bundle and listed every check it registers. Only one looks at store requirements, and its threshold is still API level 34.

expo-doctorExpo SDK 575Google Play31targetSdkVersion3Rork547

Yesterday evening I was going through my published Android apps, recounting the targetSdkVersion of each one. Google Play's API level 36 requirement lands tomorrow, and when you are an indie developer carrying several apps at once, memory is not a reliable inventory system.

After the manual pass, I ran npx expo-doctor as a final sanity check. Everything came back green.

I almost stopped there. Then I paused, because I could not answer a simple question: green on what, exactly?

I had never read the actual list of checks expo-doctor runs. It is all sitting in the published package, so I opened it. What I found about the deadline was not what I expected.

The 22 checks registered in expo-doctor 1.20.4

I wanted to inspect the package without installing anything into a project, so I pulled the tarball straight from the npm registry.

META=$(curl -s https://registry.npmjs.org/expo-doctor)
 
echo "$META" | node -e "
let s=''; process.stdin.on('data',d=>s+=d).on('end',()=>{
  const j=JSON.parse(s); const v=j['dist-tags'].latest;
  console.log('latest =', v);
  console.log('tarball =', j.versions[v].dist.tarball);
});"
# latest  = 1.20.4
# tarball = https://registry.npmjs.org/expo-doctor/-/expo-doctor-1.20.4.tgz
 
curl -sL https://registry.npmjs.org/expo-doctor/-/expo-doctor-1.20.4.tgz -o expo-doctor.tgz
tar xzf expo-doctor.tgz

package/build/index.js is a single 984 KB bundle, and every check sits in it as a class. One command pulls out the class names, their descriptions, and the SDK version range each one applies to.

node -e "
const s = require('fs').readFileSync('package/build/index.js', 'utf8');
const re = /class\s+(\w+Check\w*)\s*\{description=\"([^\"]+)\";sdkVersionRange=\"([^\"]*)\"/g;
let m;
while ((m = re.exec(s))) console.log(m[1].padEnd(46), m[3].padEnd(20), m[2]);
"

Here are the 22 results, grouped by how broadly they apply. A range of * means the check runs regardless of your SDK version.

CheckSDK rangeWhat it looks at
StoreCompatibilityCheck*Version requirements for app store submission
ExpoConfigSchemaCheck*Schema of app.json / app.config.js
ExpoConfigCommonIssueCheck*Common mistakes in the Expo config
AppConfigFieldsNotSyncedToNativeProjectsCheck*Config fields that never reach native in a non-CNG project
PackageJsonCheck*Common package.json problems
PackageManagerVersionCheck*npm / yarn versions
LockfileCheck*Presence of a lock file
EnvLocalFilesCheck*Local env files that got committed
NativeToolingVersionCheck*Native toolchain versions
PeerDependencyChecks*Missing required peer dependencies
ProjectSetupCheck*Common project setup issues
IllegalPackageCheck>=44.0.0Incompatible support packages inside native modules
SupportPackageVersionCheck>=45.0.0 <54.0.0Support package version alignment
GlobalPackageInstalledLocallyCheck>=46.0.0Legacy global CLI installed locally
InstalledDependencyVersionCheck>=46.0.0Packages matching the versions your SDK expects
MetroConfigCheck>=51.0.0Metro config problems
ReactNativeDirectoryCheck>=51.0.0Dependencies validated against React Native Directory
AutolinkingDependencyDuplicatesCheck>=54.0.0Duplicate installed dependencies
DependencyVersionOverrideCheck>=55.0.0Overridden dependency versions
HermesV1VersionCheck>=55.0.0 <58.0.0SDK versions affected by Hermes V1 regressions
VectorIconsCheck>=56.0.0Icon packages conflicting with @expo/vector-icons
ExpoRouterReactNavigationCheck>=56.0.0 <57.0.0@react-navigation installed next to expo-router

Reading down the list, the shape becomes obvious. Almost all of it is a health check on your dependency tree and project layout: package versions, config file syntax, duplicates, lock files. As a tool for confirming your working copy is not quietly broken, it is very good at its job.

And exactly one of those 22 checks touches store requirements at all.

Only one check looks at the store, and its threshold is from 2024

Pull out the definition of StoreCompatibilityCheck and the constants it judges against are right there in plain text.

// extracted from package/build/index.js (expo-doctor 1.20.4)
u.PLAY_STORE_MINIMUM_REQS = {
  effectiveDate: "August 31 2024",
  AndroidSdkVersion: 34,
  ExpoSdkVersion: 50
};

The effective date is 31 August 2024 and the threshold API level is 34. That is Google Play's requirement from two years ago, not the API level 36 requirement that takes effect tomorrow, 31 August 2026.

The logic follows from the constant. A warning is raised only when targetSdkVersion reads below 34, so 34, 35, and 36 are all treated as fine. An app still sitting on targetSdkVersion 35 gets a green result from expo-doctor today.

For a CNG project with no android directory, the check falls back to the android.targetSdkVersion value in the expo-build-properties plugin, and if that is absent, to whether the Expo SDK version is 50 or higher. On SDK 57 that branch passes without hesitation.

The name is plural, but the only constant it consults is PLAY_STORE_MINIMUM_REQS. I could not find any App Store equivalent in the bundle. Minimum iOS version and Xcode requirements live outside these 22 checks entirely, so if you ship to both App Store and Google Play, read this check as covering one of them.

I do not read this as a defect so much as a question of scope. expo-doctor is built to validate the integrity of your project, not to track shifting store policy deadlines. Still, given the name and the shape of the output, I doubt I am the only person who has quietly treated it as a pre-submission gate.

When the value in build.gradle never gets read

Digging one level deeper, I found that in projects with an android directory the check often stops even earlier. Here is the regular expression it uses to read the value.

const E = /^\s*targetSdkVersion\s*=\s*(?:Integer\.parseInt\(findProperty\('android\.targetSdkVersion'\)\s*\?:\s*'(\d+)'\)|'(\d+)')/m;
const w = i.match(E)?.[1];   // only the first capture group is read

There are two capture groups. The first covers the Integer.parseInt(findProperty(...) ?: '35') form; the second covers a plain quoted literal such as targetSdkVersion = '35'. The consuming line reads only the first one. So when the value is written as a plain quoted literal, the regex matches but the extracted value comes back undefined.

I copied the same constant and the same expression into a standalone script and ran a handful of spellings through it.

// check-store-gate.mjs
const PLAY_STORE_MINIMUM_REQS = { AndroidSdkVersion: 34 };
const TARGET_SDK_RE =
  /^\s*targetSdkVersion\s*=\s*(?:Integer\.parseInt\(findProperty\('android\.targetSdkVersion'\)\s*\?:\s*'(\d+)'\)|'(\d+)')/m;
 
function judge(gradleText) {
  const raw = gradleText.match(TARGET_SDK_RE)?.[1];
  const value = raw ? parseInt(raw, 10) : undefined;
  const flagged = !!(value && value < PLAY_STORE_MINIMUM_REQS.AndroidSdkVersion);
  return { matched: TARGET_SDK_RE.test(gradleText), value, verdict: flagged ? 'warns' : 'silent' };
}
 
const samples = [
  ["targetSdkVersion = '33'", "ext {\n  targetSdkVersion = '33'\n}"],
  ["targetSdkVersion = '35'", "ext {\n  targetSdkVersion = '35'\n}"],
  ['targetSdkVersion = 35',   'ext {\n  targetSdkVersion = 35\n}'],
  ["parseInt(... ?: '33')",   "ext {\n  targetSdkVersion = Integer.parseInt(findProperty('android.targetSdkVersion') ?: '33')\n}"],
  ["parseInt(... ?: '35')",   "ext {\n  targetSdkVersion = Integer.parseInt(findProperty('android.targetSdkVersion') ?: '35')\n}"],
  ['(no ext block)',          'apply plugin: "expo-root-project"'],
];
 
for (const [label, text] of samples) {
  const r = judge(text);
  console.log(label.padEnd(26), 'matched=' + String(r.matched).padEnd(6),
              'value=' + String(r.value).padEnd(10), r.verdict);
}

Run on Node.js 22.23.2, this is what came back.

How build.gradle spells itRegex matchesValue readResult
targetSdkVersion = '33'yesundefinedsilent
targetSdkVersion = '35'yesundefinedsilent
targetSdkVersion = 35noundefinedsilent
Integer.parseInt(... ?: '33')yes33warns
Integer.parseInt(... ?: '35')yes35silent
no ext blocknoundefinedsilent

Writing targetSdkVersion = '33' produces no warning at all. The only spelling that yields a usable value is the Integer.parseInt(findProperty(...)) form.

I then pulled the SDK 57 bare template, expo-template-bare-minimum@57.0.20, and looked at its android/build.gradle directly. There is no targetSdkVersion entry in the ext block at all. Line 94 of android/app/build.gradle reads targetSdkVersion rootProject.ext.targetSdkVersion, and that value is resolved by the expo-root-project Gradle plugin. In that layout there is simply no string for the regex to land on.

So on a current standard SDK setup, StoreCompatibilityCheck finds the android directory, fails to read a value, and passes. The green was inevitable.

What you can silence, and what I would leave alone

While reading the bundle I found something else worth keeping: several checks respond to environment variables and to a package.json config block.

SettingEffectHow I use it
EXPO_DOCTOR_SKIP_DEPENDENCY_VERSION_CHECKThe SDK version alignment check is never registeredNot routinely. Only for short windows with a deliberately pinned dependency
EXPO_DOCTOR_ENABLE_DIRECTORY_CHECKForces the React Native Directory check on or off, overriding configOff in CI when I do not want a build depending on an external API
EXPO_DOCTOR_WARN_ON_NETWORK_ERRORSSkips the failing exit code when every failure was a network errorAlways on in CI, so a flaky connection does not stop a build
expo.doctor.reactNativeDirectoryCheck.excludePackages excluded from directory validation; /regex/ strings supportedFor in-house and self-authored modules
expo.doctor.reactNativeDirectoryCheck.listUnknownPackagesfalse suppresses warnings for packages with no metadataOnly when the noise gets in the way
expo.doctor.appConfigFieldsNotSyncedCheck.enabledTurns the non-CNG config sync check on or offI leave it alone

The EXPO_DOCTOR_WARN_ON_NETWORK_ERRORS behaviour is nicely bounded once you read it. It counts the failing checks whose cause was a network error, and only skips the failing exit when that count equals the total number of failures. If even one non-network failure is mixed in, the run fails normally. That is why it is safe to leave on in CI.

EXPO_DOCTOR_SKIP_DEPENDENCY_VERSION_CHECK is the one I avoid making permanent. With it set, the check is not registered at all and you get a single log line saying it has been disabled. The fact is recorded, but the version of me who opens that repository three months from now is not guaranteed to read the log.

What to check instead, starting tomorrow

Three lines, written mostly for my own future reference.

A green run of expo-doctor confirms that your dependencies and project layout are in order. It does not confirm that you meet the store's current requirements. API level 36 needs a separate verification path.

For the effective value, the build artifact or Play Console is the reliable source. I wrote up the places where the configuration you can see diverges from the value that actually reaches the build in The three places I had to fix before a Rork project actually targeted API level 36. For what your dependencies drag along with them, After Bumping targetSdk to 36, Which Part of Your Dependencies Should You Actually Read covers adjacent ground.

There is one thing I would suggest doing today. Open android/app/build.gradle in your project and check whether targetSdkVersion is a literal or comes through rootProject.ext. If it is the latter, expo-doctor's judgement never reaches that value. It takes a couple of minutes. For keeping track of which Expo patch your build is actually carrying, I put the procedure in Nine Expo patches shipped in thirty days. Which one is your app actually running?.

Less a case of doubting the tool, more a case of my having misread what it was responsible for. I am glad I went and looked.

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.

  • Copy-paste ready implementation code
  • New advanced guides published daily
  • $5/mo or $15 for lifetime access
View Membership →

If you found this article helpful, a small tip ($1.50) would mean a lot to us. Your support helps keep this site ad-free and covers server and hosting costs.

Related Articles

Getting Started2026-08-22
The names you can change, and the ones you can't — the 30 minutes before your first Rork release
An app has three names, and only the display name can be changed after release. Here is how I check the identifiers Rork fills in by default, using a dependency-free script, before I hit submit.
Getting Started2026-08-19
You Cannot Shorten Review, But You Can Still Choose the Moment Your App Goes Live
Review time is out of your hands. The moment your app appears in the store is not. Here is how the three App Store Connect release options, Google Play managed publishing, and eas submit fit together.
Getting Started2026-04-18
What Actually Happens When You Ship a Rork App to iOS and Android at the Same Time
A practical account of releasing a Rork app to both the App Store and Google Play simultaneously — covering screenshot specs, privacy manifests, SDK requirements, review timing, and what actually trips you up along the way.
📚RECOMMENDED BOOKS
Build a Large Language Model (From Scratch)
Sebastian Raschka
LLM Dev
Prompt Engineering for LLMs
Berryman & Ziegler
Prompting
AI Engineering
Chip Huyen
AI Eng
* Contains affiliate links
See all →