Fixing Android 16KB Page Size Without Upgrading AGP
Google Play now rejects apps that don’t support 16KB memory page sizes. The official fix is upgrading to AGP 8.5+, but that’s not always practical. Here’s how I fixed it with post-build patching scripts — no toolchain changes required.
What’s Happening#
Starting November 1, 2025, all new apps and updates submitted to Google Play that target Android 15 (API 35) or higher must support 16KB memory page sizes on 64-bit devices. This isn’t optional — your AAB will be rejected if it’s not compliant.
The issue surfaces the moment you bump targetSdkVersion to 35+. Your app might have been building and deploying fine for years, but once you target API 35, Google Play starts checking whether your native .so libraries are aligned to 16KB boundaries. If they’re not, you get:
Your app does not support 16 KB memory page sizes.
Why 16KB#
Android is transitioning from 4KB to 16KB memory page sizes. This affects any app that bundles native .so libraries — which includes all React Native apps. The shift is about hardware efficiency:
- TLB efficiency — Larger pages mean the CPU manages 4x fewer TLB entries, reducing cache misses
- Fewer page faults — Each fault loads 4x more data, cutting startup overhead
- Less kernel overhead — The OS manages less metadata per mapped page
Google reports up to 3.16% faster app launch times on average (up to 30% for some apps), 4.56% reduction in power draw during launch, and 8% faster system boot times.
Which Devices Run 16KB Mode#
Several Pixel devices already support 16KB page sizes through Developer Options (“Boot with 16KB page size”):
- Pixel 8 / 8 Pro / 8a (Android 15 QPR1+)
- Pixel 9 / 9 Pro / 9 Pro XL (Android 15 QPR2+)
- Pixel 9a (Android 16+)
You can check a device’s page size with:
adb shell getconf PAGE_SIZE
# 16384 = 16KB mode, 4096 = 4KB mode
If your app’s .so files are compiled with 4KB alignment (the previous standard), the dynamic linker on a 16KB device can’t load them. The app crashes with dlopen errors.
Why “Just Upgrade” Doesn’t Work#
The standard advice is to upgrade to AGP 8.5+, which handles 16KB alignment natively through bundletool. But at work, we maintain React Native apps across different versions — some as old as RN 0.59. Upgrading AGP means upgrading Gradle, Kotlin, the Android SDK, React Native itself, and every native dependency. For a working production app, that’s a multi-week migration with real risk of destabilizing things.
I needed a fix that works with the existing build toolchain.
The Two Parts of the Problem#
The 16KB requirement has two layers:
1. ELF alignment — Each .so file has PT_LOAD segments in its ELF header. These declare a p_align value that tells the OS how to align the segment in memory. Most pre-built libraries ship with p_align = 0x1000 (4KB). Google now requires p_align >= 0x4000 (16KB).
2. ZIP/AAB alignment — The .so files inside the AAB must be positioned at 16KB-aligned offsets so that Play Store’s bundletool generates compliant split APKs. Older versions of bundletool don’t know about this requirement.
The Fix: Post-Build Patching#
I wrote two Python scripts that hook into Gradle’s build pipeline and patch both layers after the build, without touching the toolchain.
How It Works#
patch_elf_16kb.py walks all .so files in the merged native libs directory, reads each ELF header, and rewrites any PT_LOAD segment with p_align < 0x4000 to 0x4000. It handles both 32-bit and 64-bit ELF files and is safe to run on already-aligned files (no-op).
patch_bundleconfig_16kb.py opens the AAB as a ZIP, finds BundleConfig.pb, and adds alignment = PAGE_ALIGNMENT_16K to the UncompressNativeLibraries protobuf message. This is the same thing AGP 8.5+ does natively — we’re just doing it after the fact.
Setup#
Copy both scripts to a scripts/ folder at your project root, then add Gradle hooks in android/app/build.gradle:
tasks.whenTaskAdded { task ->
// Hook 1: Patch ELF PT_LOAD alignment from 4KB to 16KB
if (task.name.startsWith('merge') && task.name.endsWith('NativeLibs')) {
task.doLast {
def raw = task.name.substring('merge'.length(),
task.name.length() - 'NativeLibs'.length())
def variant = raw.substring(0, 1).toLowerCase() + raw.substring(1)
def mergedDir = new File(buildDir,
"intermediates/merged_native_libs/${variant}/${task.name}/out/lib")
if (!mergedDir.exists()) {
mergedDir = new File(buildDir,
"intermediates/merged_native_libs/${variant}/out/lib")
}
if (mergedDir.exists()) {
exec {
commandLine 'python3',
"${rootProject.projectDir}/../scripts/patch_elf_16kb.py",
mergedDir.absolutePath
}
}
}
}
// Hook 2: Patch AAB BundleConfig.pb + re-sign
if (task.name == 'bundleRelease') {
task.doLast {
def bundleDir = new File(buildDir, "outputs/bundle/release")
def aabFile = new File(bundleDir, "app-release.aab")
if (aabFile.exists()) {
def patchScript =
"${rootProject.projectDir}/../scripts/patch_bundleconfig_16kb.py"
def patchedTmp = new File(aabFile.parent, "_patched_tmp.aab")
exec {
commandLine 'python3', patchScript,
aabFile.absolutePath, patchedTmp.absolutePath
}
aabFile.delete()
patchedTmp.renameTo(aabFile)
// Re-sign (patching invalidates the JAR signature)
if (project.hasProperty('MYAPP_RELEASE_STORE_FILE')) {
exec {
commandLine 'jarsigner',
'-keystore', file(MYAPP_RELEASE_STORE_FILE).absolutePath,
'-storepass', MYAPP_RELEASE_STORE_PASSWORD,
'-keypass', MYAPP_RELEASE_KEY_PASSWORD,
aabFile.absolutePath,
MYAPP_RELEASE_KEY_ALIAS
}
}
}
}
}
}
You also need to add android:extractNativeLibs="false" to your <application> tag in AndroidManifest.xml so native libraries are loaded directly from the APK.
Build and Verify#
./gradlew clean assembleRelease bundleRelease
You should see these in the build log:
16KB ELF patch: scanning /path/to/merged_native_libs/...
patched: libhermes.so
patched: libreactnative.so
patched: libjsi.so
16KB AAB: patching BundleConfig.pb...
patched BundleConfig.pb (+PAGE_ALIGNMENT_16K)
16KB AAB: re-signing with jarsigner...
16KB AAB: done
Upload to Google Play. The 16KB error should be gone.
Things I Learned#
The merged native libs path varies by AGP version. AGP 4.x uses intermediates/merged_native_libs/release/out/lib, while AGP 8.x adds the task name to the path. The script tries both.
Product flavors change the task name. If you have a “production” flavor, the task becomes bundleProductionRelease instead of bundleRelease. Update the hook accordingly.
extractNativeLibs="false" requires API 23+. If your minSdkVersion is below 23, you may need to handle this conditionally.
Re-signing is required. Modifying the AAB’s ZIP contents invalidates the JAR signature. Use the same keystore from your original signing config.
16KB-aligned binaries are backward compatible. They work fine on older 4KB devices. You’re not breaking anything for existing users.
Where It’s Been Tested#
I’ve deployed this fix across three production apps with different setups:
| React Native | AGP | Gradle | Result |
|---|---|---|---|
| 0.59.9 | 4.1.0 | 6.5 | Passed Google Play |
| 0.63.3 | 4.2.2 | 6.9 | Passed Google Play |
| 0.76.5 | 8.6.0 | 8.10.2 | Passed Google Play |
Verifying Compliance#
After building, you can verify your APK is properly aligned:
zipalign -c -P 16 -v 4 app-release.apk
# Should return: "Verification successful"
You can also use APK Analyzer in Android Studio (Build > Analyze APK) to check if your app bundles any .so files under lib/. If you see .so files there, they need to be 16KB-aligned.
When You Don’t Need This#
If you’re on AGP 8.5+, bundletool handles 16KB alignment natively — you don’t need any of this. Same if you’re using NDK r28+, which compiles with 16KB alignment by default. If your app is pure Java/Kotlin with no native .so files, you’re not affected either. And if you’re only doing internal testing, Google Play only enforces this for production track releases.
Open Source#
I’ve published the scripts and Gradle snippets as an open-source repo: android-16kb-fix. Drop them into your project, add the Gradle hooks, and you’re done.