Reducing the Size of Cash App for iOS

Cash App strives to be an excellent platform citizen everywhere our app is available, and a key part of that is respectfully using our customer’s bandwidth and device storage. Over the last year we’ve made significant progress in that area, shrinking the size of our iOS app to half its original size, and I want to share what we’ve learned along the way.

What To Measure

Apple has optimized the App Store with device-specific downloads, a system that delivers each user the smallest possible app binary. Unfortunately, this makes it hard to choose a good data point for comparison. Since the most popular iOS device among Cash App customers is the iPhone 13, we’ll use its measurements wherever possible. When a customer gets Cash App from the App Store, there are two distinct ways app size is felt:

Download size is paramount when the customer is out in the world using a cellular connection and needs fast access to complete a store checkout or split a bill with a friend. When an app’s download size is larger than 200MB, iOS will prompt the user to use Wi-Fi instead of cellular to complete the download, which will deter some customers from downloading the app at all.

Install size directly impacts all customers because space used for Cash App can’t be used for their other apps, photos, or videos. It can also hurt adoption of new Cash App versions if there isn’t enough available storage to install App Store updates.

Our immediate goal was to get Cash App beneath the 200 MB threshold across all devices. Although in order to achieve this, we actually set our goal as 180 MB, to ensure we had some breathing room once we reached that point before binary size efforts would need to be prioritized again. Before we began investigating though, we needed to ensure we had tracking in place to understand the impact of changes we made and ensure going forward those changes wouldn’t regress.

Identify Impactful Fixes

Cash App uses Emerge Tools to visualize the components packaged in the app and identify duplicative or unnecessary resources. Duplication is our largest source of bloat because Cash App functionality is actually implemented in four separate binaries: the iOS app, an iOS widget, a Siri integration extension, and a Siri UI extension. Each of these targets uses static frameworks for optimal runtime performance, which means that code & resources from dependencies are copied directly into the binary. Cash App’s modular codebase re-uses code whenever possible, and therefore an incorrectly configured dependency could be packaged up to four times in the same App Store release.

As part of the size reduction effort, Cash App engineers contributed 35 distinct fixes that fall into 3 categories: eliminating dependencies, minimizing included resources, and applying code optimizations.

1. Dependency Pruning

An easy win across all 4 of our binaries is to completely eliminate unused code from shipping in the first place, but our iOS team already guards against unused modules & SDKs so there wasn’t any low-hanging fruit here.

Our next goal was to eliminate code only used in the iOS app from also being bundled with the three extension targets (yielding a 3× size win). Emerge’s size visualization combined with Bazel build graph queries made it easy to spot issues and refactor modules to move heavy dependencies higher in the tree. A representative example was a refactor to our logging system (used by all 4 targets) to remove CashKotlin, our entry point to the Kotlin Multiplatform runtime:

  1. Use a Bazel query (or similar tool) to determine the dependency chain to CashKotlin:
     $ bazel query 'somepath(//Code/Widget, //Pods/CashKotlin)'
    
     //Code/Widget ->
     //Code/ExtensionUtilities ->
     //Code/Logging ->
     //Pods/CashKotlin
    

    In the visual representation, the problematic dependency link is shown in red:

    Graph representation of modules used by Cash App and its extensions before pruning dependencies

  2. Examine how Logging uses the dependency. In this case the classes importing CashKotlin were only referenced in the Cash App app target.
  3. Refactor Logging to move the app-specific code into a separate module and remove the CashKotlin dependency.
  4. Confirm all uses of the dependency are gone:
     $ bazel query 'somepath(//Code/Widget, //Pods/CashKotlin)'
    
     INFO: Empty results
    

    In the visual representation, note that Logging no longer has an arrow to CashKotlin:

    Graph representation of modules used by Cash App and its extensions after pruning dependencies

  5. Open a pull request with the change and measure its impact with Emerge:

    Install Size: -32MB

    Download Size: -8.1MB

Another notable example was UI-focused code inserted too low in the dependency tree where it was packaged in extensions transitively. Some extensions don’t even display any UI and those that do were not using these particular UI elements.

Dependency pruning led to a total reduction of 60MB for download size and 140MB for install size.

2. Resource Management

A side effect of removing unused UI code from our extension targets was that the associated fonts, icons, and marketing images were also removed. The remaining images were compressed with ImageOptim to yield the smallest possible PNG and SVG files without quality loss. We first attempted this with ASSETCATALOG_COMPILER_OPTIMIZATION = space but didn’t see meaningful reductions in size.

Another notable example was re-implementing a multi-megabyte pattern PNG with SceneKit at runtime instead to avoid shipping the file to all customers.

Resource management led to a total reduction of 11MB for download size and 44MB for install size.

3. Code Optimizations

The most straightforward size fix is applying the -internalize-at-link compiler flag to allow internalizing public symbols at link time, which saved 6MB of download size.

Cash App also pre-compiles common third-party dependencies, and we adjusted our processing pipeline to ensure they are compiled with optimizations and stripped of any debugging symbols.

Code-level choices for enums and structs also can have negative impacts on binary size in certain edge cases. Specifically, we used bloaty to identify enums that were good candidates to become indirect to trade a small runtime performance penalty for large reductions in the code emitted at compile time. Several view model structs with 20+ properties were also converted to classes for similar reasons.

Code optimizations led to a total reduction of 18MB for download size and 86MB for install size.

How To Stay Small

The contribution process to Cash iOS now includes two pre-merge and one post-merge check to ensure our binary size doesn’t grow unexpectedly in the future.

Emerge’s size checks are used pre-merge to verify a given pull request doesn’t introduce a size regression in isolation. We also run a size analysis post-merge between App Store uploads to verify there isn’t a cumulative effect over our size threshold from hundreds of individual code changes in a release cycle.

The entire Emerge pipeline can be the longest job in our CI pipeline, so we’ve also implemented a fast Starlark unit test using the Bazel build graph to quickly diagnose common dependency tree mistakes before they even get to CI. You’ll notice that these forbidden dependencies directly map to our most impactful changes discussed above.

# To keep the Cash.app binary size as small as possible,
# these deps are forbidden from being statically linked in
# extension targets because they include SDKs, fonts, colors,
# and image assets that are relatively large and shouldn't be
# duplicated many times in one binary.
NOT_ALLOWED_IN_EXTENSIONS = [
    "//Code/DesignSystem",
    "//Pods/GooglePlaces",
    "//Pods/GoogleMaps:GoogleMapsBase",
    "//Pods/CashKotlin",
]

forbidden_deps_test(
    name = "BinarySize-WidgetExtension-UnitTests",
    forbidden_deps = NOT_ALLOWED_IN_EXTENSIONS,
    target = "//Code/Widget",
)

forbidden_deps_test(
    name = "BinarySize-SiriExtension-UnitTests",
    forbidden_deps = NOT_ALLOWED_IN_EXTENSIONS,
    target = "//Code/Siri",
)

Each of these pre- and post-merge checks has individually prevented size regressions from shipping to customers, and a defense-in-depth strategy makes sure we comprehensively detect them without inconveniencing our iOS engineers as they quickly ship new features.

As of version 4.45, we’ve brought our download size all the way down to 126 MB. This is a massive improvement from our peak download size of 245 MB and well below our target of 180 MB. Likewise our install size has reduced from 608 MB all the way down to 303 MB.

Of course, our binary size won’t remain perfectly stable. Normal feature development and additions to the app will naturally increase our binary size over time. But by tracking our size and identifying any significant changes, we’re able to appropriately prioritize bringing the most value to our customers while acting as good platform citizens and getting plenty of warning before our binary size becomes a problem again.

Thanks to Nick Entin, Luis Padron, and Eric Firestone for supporting the project and providing feedback on this post.