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.tgzpackage/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.
| Check | SDK range | What 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.0 | Incompatible support packages inside native modules |
| SupportPackageVersionCheck | >=45.0.0 <54.0.0 | Support package version alignment |
| GlobalPackageInstalledLocallyCheck | >=46.0.0 | Legacy global CLI installed locally |
| InstalledDependencyVersionCheck | >=46.0.0 | Packages matching the versions your SDK expects |
| MetroConfigCheck | >=51.0.0 | Metro config problems |
| ReactNativeDirectoryCheck | >=51.0.0 | Dependencies validated against React Native Directory |
| AutolinkingDependencyDuplicatesCheck | >=54.0.0 | Duplicate installed dependencies |
| DependencyVersionOverrideCheck | >=55.0.0 | Overridden dependency versions |
| HermesV1VersionCheck | >=55.0.0 <58.0.0 | SDK versions affected by Hermes V1 regressions |
| VectorIconsCheck | >=56.0.0 | Icon 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 readThere 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 it | Regex matches | Value read | Result |
|---|---|---|---|
targetSdkVersion = '33' | yes | undefined | silent |
targetSdkVersion = '35' | yes | undefined | silent |
targetSdkVersion = 35 | no | undefined | silent |
Integer.parseInt(... ?: '33') | yes | 33 | warns |
Integer.parseInt(... ?: '35') | yes | 35 | silent |
| no ext block | no | undefined | silent |
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.
| Setting | Effect | How I use it |
|---|---|---|
EXPO_DOCTOR_SKIP_DEPENDENCY_VERSION_CHECK | The SDK version alignment check is never registered | Not routinely. Only for short windows with a deliberately pinned dependency |
EXPO_DOCTOR_ENABLE_DIRECTORY_CHECK | Forces the React Native Directory check on or off, overriding config | Off in CI when I do not want a build depending on an external API |
EXPO_DOCTOR_WARN_ON_NETWORK_ERRORS | Skips the failing exit code when every failure was a network error | Always on in CI, so a flaky connection does not stop a build |
expo.doctor.reactNativeDirectoryCheck.exclude | Packages excluded from directory validation; /regex/ strings supported | For in-house and self-authored modules |
expo.doctor.reactNativeDirectoryCheck.listUnknownPackages | false suppresses warnings for packages with no metadata | Only when the noise gets in the way |
expo.doctor.appConfigFieldsNotSyncedCheck.enabled | Turns the non-CNG config sync check on or off | I 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.