Optimizing bitdrift’s Rust mobile SDK for binary size
At bitdrift, we’ve always believed in building SDKs that are powerful, reliable, and lightweight. From day one, we committed to a shared Rust core for our mobile SDKs, which has given us memory safety, performance, and cross-platform parity, but also introduces a well-known challenge: Rust binaries can get big, fast.
For mobile developers, binary size isn’t an abstract concern. Every extra megabyte slows downloads, hurts retention, and can even increase app startup times. Our goal was clear: deliver a full-featured SDK without bloating the apps that use it.
In this post, we’ll walk through the techniques we used to shrink our SDK from several megabytes down to about 1 MB (compressed).

Why Rust, and why size matters
We chose Rust to build a shared core once for iOS and Android, instead of duplicating complex logic across two SDKs. Rust gave us speed and safety — but also larger binaries by default. That left us with a clear challenge: how do we keep our SDK lean enough for mobile apps while still benefiting from Rust?Understanding our binary size challenge
Before diving into specific strategies for managing binary size, it’s important to understand how our SDK is structured and how we measure its size. We ship different artifacts for each platform: on Android, our native code is packaged as a shared library (.so) inside the AAR, while on iOS, we provide a static library (.a) within an XCFramework. These approaches give us different opportunities for size optimization, some of which apply to both platforms, while others are platform-specific. Many of the techniques we’ll discuss are inspired by min-sized-rust, which focuses on reducing Rust executable size. We’ve adapted these strategies to our Bazel-based build system, which adds some extra challenges compared to Cargo-only setups. Now that we’ve established how we build and measure our SDK, the question becomes: how do we make sure every optimization actually works and that no regressions sneak in? Continuous testing and CI automation provide the answer.Testing and CI: keeping binary size under control
When it comes to optimization, the most important rule is simple: measure everything.- Intentional changes: Every time we apply a new flag or optimization, we measure the binary size impact directly. This ensures that we understand the benefit of each optimization.
- Unintentional changes: New dependencies, features, or even compiler defaults can quietly add hundreds of kilobytes. Without guardrails, those regressions slip in unnoticed.
Compile flags
The first thing to do when optimizing for size is to configure the Rust compilation itself in a way that optimizes for size. This can be done by setting the Rust opt-level to s or z and setting the number of codegen units to 1. Reducing the number of code gen units allows for better optimizations at the cost of slower compile times. For us this means setting these as extrarustc
flags like so:
For us there is no significant difference between z and s. Binary size impact: 26%build:release-common --@rules_rust//rust/settings:extra_rustc_flag='-Ccodegen-units=1' build:release-common --@rules_rust//rust/settings:extra_rustc_flag='-Copt-level=s'
Stripping debug info
By default, Rust will include various debug information and symbols into the shared library, which are meant to help with debugging. In order to minimize the binary size felt by the app, we strip the binary for symbols and debug information and make this available separately should users need to debug the binary. Binary size impact: 25%Link-Time Optimizations & dead code stripping
Link-Time Optimization (LTO) allows the compiler and linker to optimize across crate boundaries, potentially eliminating unused code. Dead code stripping takes this further by removing functions and symbols that aren’t referenced at runtime. Together, these techniques can significantly reduce binary size — especially for shared libraries.This requires maintaining a list of symbols to retain, which can be a bit of work, but the size savings are worth it, as the binary size impact is very significant. Binary size impact: 90%# Enable fat LTO (thin is faster, but not as effective for size). build:release-common --@rules_rust//rust/settings:lto='fat' # Explicitly specify which symbols to retain. build:release-android --linkopt=-Wl,--retain-symbols-file=retain-symbols.txt
Abort on panic
One significant source of binary size is creating the unwind tables necessary to implement Rust panics. Similar to C++ exceptions, unwind tables are necessary in order to allow the binary to unwind the stack in the case of a panic/exception, so opting out of this has a significant impact on binary size. A consequence of this is that our SDK will abort() whenever a panic hits, which has led to us taking great care to avoid runtime errors that result in panics. We may cover this in a future blog post, but in short, making heavy use of Result-types everywhere, aggressive clippy linting and careful error handling has made this very manageable. For us this meant settingBinary size impact: 19%build:abort-panic --@rules_rust//rust/settings:extra_rustc_flag=’-Cpanic=abort’
Building the Rust stdlib
By default both Cargo and Bazel will use a set of pre-compiled rlibs to include the standard library, which means that additional steps are necessary in order to size-optimize the stdlib code. With Cargo this can be accomplished by the-Zbuild-std
flag, using Bazel we end up having to maintain a separate build that can produce the pre-compiled rlibs expected by the build.
Here’s an example of one of the build invocations. You can see here that we are having to build the stdlib with many of the same flags we already apply to our main build.
The build can be found here. This step ends up having a big impact on the binary size, as not only does it mean that the code can be optimized for size but we are also able to drop big chunks of the standard library. During our size testing, we noticed that we’d be able to get rid of all the crates related to handling unwinding panics such as gimli. Note that because we want to apply LTO (as mentioned above), we also need to make sure that the stdlib is built with LTO enabled. Without this, the final build will not be able to fully apply all LTO optimizations. Both- name: Build rust std (iOS) env: # Allow using unstable cargo features in the standard library. RUSTC_BOOTSTRAP: 1 # This is required by compiler-builtins RUSTC_SRC: ${{ steps.rust-src.outputs.path }} RUSTFLAGS: "-C panic=abort -C opt-level=s -Clto=fat -Cembed-bitcode -C llvm-args=--inline-threshold=225 -C codegen-units=1 -Zlocation-detail=none -Zforce-unstable-if-unmarked" IPHONEOS_DEPLOYMENT_TARGET: "14.0" run: | cd ${RUSTC_SRC}/library/std cargo build \ --release \ --features "panic_immediate_abort,optimize_for_size" \ --target aarch64-apple-ios \ --target aarch64-apple-ios-sim \ --target x86_64-apple-ios \
-Clto=fat
and -Cembed-bitcode
are necessary for this.
Binary size impact: 15%
Dependent management
No matter how much you play with the build, in the end the code that you are bringing into the binary will eventually lead to binary size increase. As features are added, more code is brought into the binary and the size inevitably increases. A lot of effort has been put into inspecting the dependency tree (via cargo tree) and making smart decisions about what code should be brought in. In general, keeping the dependencies used by the SDK low makes managing the binary size easier to reason about, so we’ve spent time getting rid of larger crates from the dependency tree. For example, prometheus used to be brought in to support metrics types in the SDK, but we found that rewriting our own bd-client-stats crate allowed us to cherry pick the pieces that we needed instead of pulling in a large crate. This is a broad category, but a few notable changes we’ve found helpful include:- using zlib for compression as we get it “for free” since it’s available via dynamic linking to system libraries on iOS and Android
- Writing our own stats library over bringing in prometheus
- disabling crate features that we don’t need that bloat binary size, like some of the ones used by the regex crate
CI automation
Thanks to CI automation that computes the size difference across PRs, we’re able to keep an eye on the size increase caused by each PR and make an educated decision on whether it makes sense to spend more time investigating the size increase. The CI automation automates what we’ve been using in this post to compute the size difference in the .so as well as the size change in our test APK (to also account for the size impact of JVM dependencies). The tooling used for this automation can be found here.Size Comparison Report(x86_64)
Metric | APK (KB) | SO (KB) |
---|---|---|
Baseline | 3414 | 1300 |
Current | 3466 | 1352 |
Difference | 52 | 52 |
Conclusion
Through a combination of smarter build flags, link-time optimizations, custom stdlib builds, and careful dependency management, we’ve kept our Rust-based mobile SDK lean, hovering around 1 MB even as we’ve added more features over time. The payoff is huge: developers get the reliability and safety of Rust without paying the price of an oversized SDK. And for us, every byte saved today is room for innovation tomorrow. We’re continuing to explore new size-reduction techniques, both within Rust itself and in our build system. We’re looking forward to sharing more as we continue down this path! If you’re tackling the same challenge, we’d love to compare notes.Interested in learning more? Check out the sandbox to get a hands-on feel for what working with Capture is like or get in touch with us for a demo. Please join us in Slack as well to ask questions and give feedback!
Author

Snow Pettersen