How a missing compiler flag adds megabytes of dead code to every React Native Android app — confirmed on Mattermost, Coinbase, and Discord
I've been deep in React Native's native internals for months — building performance tools, poking at Hermes's C ABI, profiling JSI dispatch. Last week I had an idea for a novel binary compression format that could shrink .so files by 30-40%. I spent days designing a post-link optimizer, a domain-specific bytecode codec, a scheme for lazy-loading native code from compressed archives.
Then I ran nm -D on a production APK.
The answer wasn't a new algorithm. It was a missing compiler flag. And as I'd later discover, it was a missing compiler flag on a binary that had already been through four layers of sophisticated optimization — including Meta's own post-link binary optimizer.
What I Found
I pulled the Mattermost APK — a popular, actively maintained, open-source React Native app — directly from the Google Play Store. This is the exact binary that millions of users install. Production release, stripped, signed.
I unzipped it and looked at libreactnative.so — the core of React Native on Android. Fabric, Yoga, TurboModules, JSI, the event system. It ships in every React Native Android app.
nm -D libreactnative.so | grep " T " | wc -l
2,826 functions exported. Plus 3,936 weak symbols (vtables, RTTI, template instantiations). That's 6,762 total dynamic symbol exports.
Then I asked: how many of these does anything in the APK actually need?
The Audit
I cross-referenced every .so in the APK — checking which of libreactnative.so's exports appear as undefined (imported) symbols in other libraries, which are callable via JNI from Java, and which might be resolved at runtime via dlsym().
Cross-referenced by other .so files: 45
JNI-callable from Java: 213
Potential dlsym targets: 265
Weak symbols referenced by anything: 0 out of 3,936
───────────────────────────────────────────
Upper bound needed (with overlap): ~420
Total exported: 6,762
Waste: 94%
94%. Out of 6,762 dynamic symbol exports, at most ~420 are actually needed. The other 6,342 are internal implementation details leaking through the symbol table.
What's Leaking?
I broke down the 2,826 strong exports by namespace:
1,609 facebook::react::* (internal class methods)
703 other C++ (vtable thunks, RTTI, templates)
193 facebook::react::jsinspector
135 Yoga C API
64 folly::*
55 facebook::yoga (C++ internals)
33 facebook::react::Fabric*
1 SampleCxxModule
193 Chrome DevTools inspector symbols. The entire jsinspector_modern subsystem — InspectorPackagerConnection, ReactInstanceManagerInspectorTarget, FallbackRuntimeTargetDelegate — exported from a production release build. Not debug code that happens to be compiled in. Exported symbols that anchor the inspector's entire code in the binary, making it impossible for the linker to strip.
SampleCxxModule. A literal demo module. In a production build. With its full implementation — save(), load(), hello(), concat(), repeat(), except() — plus the facebook::xplat::samples::Sample class backing it. Compiled and shipped to every user.
135 Yoga C API functions. YGNodeNew, YGNodeCalculateLayout, YGNodeStyleSetFlexDirection — the entire public C API for the Yoga layout engine. Yoga is an internal implementation detail of Fabric's layout system, called only from within libreactnative.so itself. No other library in the APK references a single Yoga symbol.
And the 45 symbols that ARE consumed by other .so files? They're exactly what you'd expect: JSIExecutor, TurboModule constructor, CallInvokerHolder, folly::dynamic basics, JNI bootstrap. That's the real API surface.
Confirming Across Multiple Apps
One app could be a misconfiguration. So I pulled two more production APKs from the Play Store — Coinbase (a finance app, 270 MB) and Discord (social/gaming, 34 MB of native code). Completely different companies, different teams, different native module stacks.
libreactnative.so across all three:
| Mattermost | Coinbase | Discord | |
|---|---|---|---|
| Size | 6.0 MB | 6.3 MB | 3.9 MB |
| Strong exports | 2,826 | 2,872 | 3,073 |
| Waste | 87% | 83% | 84% |
| Inspector symbols | 279 | 321 | 355 |
| Yoga C API | 135 | 156 | 158 |
| SampleCxxModule | YES | YES | no |
Three apps, three companies, nearly identical numbers. The libreactnative.so symbol table is virtually the same everywhere because it's the same prebuilt library that React Native distributes. Two out of three ship SampleCxxModule in production. All three export 250+ Chrome DevTools inspector symbols and 130+ Yoga C API functions.
It's Not Just libreactnative.so
Every .so in every APK showed the same pattern. Some highlights:
| Library | App | Size | Exports | Waste |
|---|---|---|---|---|
libappmodules.so |
Coinbase | 2.8 MB | 217 | 99% |
libappmodules.so |
Discord | 2.0 MB | 482 | 99% |
liblibdiscore-rn-jsi-module.so |
Discord | 5.4 MB | 7,308 | 99% |
libkv_storage.so |
Discord | 1.6 MB | 707 | 99% |
librive-android.so |
Discord | 3.6 MB | 5,883 | 95% |
libreanimated.so |
Discord | 465 KB | 184 | 94% |
libreanimated.so |
Coinbase | 755 KB | 168 | 93% |
libNativeBridge.so |
Coinbase | 7.9 MB | 2,835 | 73% |
libtensorflowlite_jni.so |
Coinbase | 4.1 MB | 280 | 80% |
The libappmodules.so pattern is especially telling — that's each app's own custom native code, where the only external entry point is JNI_OnLoad. One function needs to be visible. They export hundreds.
Discord's liblibdiscore-rn-jsi-module.so is 5.4 MB of Rust code exporting 7,308 symbols at 99% waste. Every serde deserializer implementation, every internal store method, every trait implementation — all visible in the dynamic symbol table. Rust has the same default visibility problem as C++.
The grand totals across all three apps:
Native Code Total Symbols Needed Waste
─────────── ───────────── ────── ─────
Mattermost 34 MB 14,587 2,478 66%
Coinbase 74 MB 31,600 6,205 56%
Discord 34 MB 30,860 5,830 77%
─────────── ───────────── ────── ─────
TOTAL 142 MB 77,047 14,513 69%
142 MB of native code across three production apps. 77,047 dynamic symbol exports. Conservatively 69% unnecessary — and that's with generous dlsym heuristics inflating the "needed" count. The real waste is likely 80-85%.
Why This Happens
The root cause is a single missing compiler flag: -fvisibility=hidden.
But what makes this truly remarkable is what I found when I checked what optimization flags ARE set. Buried in the .comment section of libreactnative.so:
Android (12285214, +pgo, +bolt, +lto, +mlgo, based on r522817b)
clang version 18.0.2
Read that list of flags: PGO (profile-guided optimization), BOLT (Meta's own post-link binary optimizer), LTO (link-time optimization), and MLGO (machine-learning-guided optimization). This is the most advanced compilation pipeline in the industry. Four layers of sophisticated optimization, including a tool Meta specifically built to optimize binary layout.
And none of it can fully do its job.
On Linux and Android, C and C++ symbols are exported from shared libraries by default. Every non-static function, every global variable, every vtable — unless you explicitly mark it as hidden, it goes into the dynamic symbol table.
When the linker runs --gc-sections (dead code elimination) on a shared library, it treats every exported symbol as a "root" — a starting point that must be kept along with everything reachable from it. If every function is exported, every function is a root. The linker can't strip anything. LTO can't eliminate dead code across translation units if that code is exported. BOLT is optimizing the layout of code that shouldn't be in the binary at all. PGO is profiling and tuning functions that will never execute in production.
It's a race car engine with the parking brake on.
React Native's CMakeLists.txt has --gc-sections. It has --icf=safe. Exception handling tables are already eliminated (no .eh_frame section — good). The optimization infrastructure is world-class. It just can't do its job because every symbol is exported. One missing flag undermines four layers of optimization.
This is standard best practice for shared libraries. It's in Apple's documentation. It's in the GCC manual. Chromium does it. Qt does it. Every well-optimized shared library does it. React Native doesn't. Neither do most of the libraries in the React Native ecosystem.
There are also 2,004 RTTI (Run-Time Type Information) symbols in the dynamic table — typeinfo structs and type name strings for C++ classes. With -fvisibility=hidden, these would no longer be exported, and those that aren't referenced internally could be stripped entirely.
The Impact
Native code is the single largest component of a React Native APK — and the only one stored uncompressed. Here's the breakdown from the Mattermost APK:
Component On disk In APK (ZIP'd) Notes
───────────────────────── ─────── ────────────── ─────
Native .so files 34 MB 34 MB Stored UNCOMPRESSED (mmap requirement)
HBC bundle 21 MB ~10 MB ZIP compressed
DEX bytecode 35 MB ~15 MB ZIP compressed
Resources + assets 17 MB ~7 MB ZIP compressed
Other ~5 MB
───────
Total APK 71 MB
Every byte stripped from .so files comes directly off the APK. There's no compression safety net. This is why native code visibility matters more than optimizing any other component.
I haven't yet rebuilt libreactnative.so with -fvisibility=hidden to measure the exact size delta. But when 84-94% of exported symbols are unnecessary GC roots, the linker is retaining all code reachable from those roots:
- The entire inspector/debugger subsystem (250-355 symbols per app)
- The full Yoga C API (only the C++ internals are called within libreactnative)
-
SampleCxxModuleand the completefacebook::xplat::samplesnamespace - folly template instantiations used only internally
- Vtable and RTTI data for every C++ class with virtual methods
- All code transitively reachable from any of the above
A conservative estimate: 20-40% of libreactnative.so's code would be stripped with proper symbol visibility. That's 1.2-2.4 MB off the core library alone — code that PGO profiled, BOLT reordered, and MLGO tuned, all for nothing. Apply the same fix across all libraries in a typical app and the savings multiply.
Because native code is stored uncompressed in the APK, these savings translate byte-for-byte to smaller downloads and installs. No other component offers that direct relationship between optimization and APK size.
Multiply by every React Native Android app on the Play Store. Multiply by every version ever shipped.
How to Check Your Own App
This takes 60 seconds:
# Unzip your APK (or split APK for native libs)
unzip -o your-app.apk -d apk_contents
# Count exports for any .so
SO=apk_contents/lib/arm64-v8a/libreactnative.so
echo "Strong exports: $(nm -D $SO | grep ' T ' | wc -l)"
echo "Weak exports: $(nm -D $SO | grep ' W ' | wc -l)"
# Check for the telltale signs
nm -D $SO | grep " T " | awk '{print $3}' | c++filt | grep -i "sample\|inspector\|^YG"
If you see SampleCxxModule, jsinspector symbols, or the Yoga C API in a production build, your library has the same problem.
I've published the full audit script that cross-references every .so in an APK and produces a complete waste report: [TODO: link to audit.sh]
The Fix
For library authors, it's a build configuration change:
# In CMakeLists.txt
set(CMAKE_CXX_VISIBILITY_PRESET hidden)
set(CMAKE_C_VISIBILITY_PRESET hidden)
set(CMAKE_VISIBILITY_INLINES_HIDDEN YES)
Then mark your actual public API:
#define RN_EXPORT __attribute__((visibility("default")))
RN_EXPORT void JNI_OnLoad(JavaVM* vm, void* reserved);
// ... the ~50 functions that ARE the public API
Or use a linker version script that explicitly lists exported symbols.
For Rust libraries: use #[no_mangle] pub extern "C" only on functions that genuinely need to be public. Everything else stays hidden by not marking it pub extern.
The hard work is auditing which symbols need to be public. The approach I used — cross-referencing nm -D output across all .so files in the APK plus JNI and dlsym analysis — gives you the answer mechanically.
What I'm Building
I'm building slimbin — a tool that automates this entire analysis. Point it at an APK, a set of .so files, a Docker image, or any collection of ELF/Mach-O binaries:
slimbin audit ./apk_contents/lib/arm64-v8a/
╔══════════════════════════════════════════════╗
║ libreactnative.so — 6.0 MB ║
║ Exports: 6,762 (2,826 T + 3,936 W) ║
║ Consumed: ~420 ║
║ Waste: 94% ║
║ -fvisibility=hidden: NO ║
║ Estimated savings: 1.2 – 2.4 MB ║
╚══════════════════════════════════════════════╝
It generates the version script that exports only what's consumed. It works on any shared library, not just React Native.
But the bigger point is: before you reach for novel compression algorithms, post-link optimizers, or custom binary formats — check if your libraries are even built correctly. React Native runs its binaries through PGO, LTO, BOLT, and MLGO. It still ships SampleCxxModule and 355 Chrome DevTools inspector symbols in production. The most sophisticated optimization pipeline in the world can't outperform releasing the parking brake.
This analysis was performed on production Android APKs downloaded directly from the Google Play Store via apkeep: Mattermost (chat, 71 MB), Coinbase (finance, 270 MB), and Discord (social/gaming). All builds are confirmed production: ELF binaries are stripped, no .debug sections are present, no debug libraries are included, no sanitizer instrumentation was detected. These are the exact binaries that users install. All commands shown can be reproduced by anyone with an APK and standard Android NDK tools (nm, readelf, strings). Versions analyzed were current as of April 2026.
I'm a Solutions Architect and React Native developer with 25+ years of development experience and two stints as Developer Success Engineer at Expo. I have four pending computer vision patents and build tools for the React Native ecosystem. You can find me at https://linkedin.com/in/kimbrandwijk.
Top comments (0)