Jacob’s Tech Tavern

Jacob’s Tech Tavern

Fitting the Lapse experience into 15 MegaBytes

How we optimised the world's most complex app clip

Jacob Bartlett's avatar
Jacob Bartlett
Apr 21, 2025

Today we’re going to explore how we optimised the Lapse app clip to fit into 15MB. Once we got past the low-hanging fruit, there were trickier size optimisations to work through. To get over the finish line, we had to apply some more hacky tricks.


Subscribe to Jacob’s Tech Tavern for free to get ludicrously in-depth articles on iOS, Swift, tech, & indie projects in your inbox every week.

e8f4ec50-752f-4ed5-a756-ac1e30109e40_1680x1200.png.webp

At Lapse (the series A social media startup I work for), we’re constantly evaluating new iOS technologies to make our social media app more fun, more engaging, and more viral.

The app clip we built for our Rolls feature contains the core experience of our social photo-sharing app, and is almost certainly the most feature-rich app clip ever created.

The challenge?

The app clip was limited to just 15MB.

We achieved this via a 5-stage optimisation process:

  • An architectural overhaul to fix our dependency graph.

  • Nice, easy optimisations to pick low-hanging bundle-size fruit.

  • Painful, difficult optimisations to grind our app clip towards the size limit.

  • Awful, hacky optimisations to narrowly inch our app clip size over the line.

  • Setting up automation to catch size regressions before they block a release.

Today we’re going to look in detail at how we applied these optimisations to bring our app clip down to 15MB.

These techniques aren’t limited to app clips—you can apply the same approach to achieve size efficiency to your own apps, and potentially win a dramatic increase in the conversion rate from app store listing to download.


Wait a second, what’s an App Clip?

An app clip is a fast-loading lightweight iOS experience that offers features without requiring a full app install—they can be downloaded via hyperlinks, QR codes, or NFC tags.

App clips are increasingly popular for single-use quick tasks like paying at gas stations, using car parks, renting an e-bike, or ordering from restaurant menus.

They can be created in your existing iOS projects by creating an App Clip Target. Usually this target will import the modules corresponding to features you want to make available.

Rolls

Rolls is a shared photo album you create with your friends. The twist with Lapse is that you are kept in the moment: the photos you take aren’t visible until a time period has Lapsed (hence the name).

The cornerstone of this feature is a viral growth mechanism: the QR code on the lock-screen widget. You can create a Roll, then ask your friends to scan the QR code from your lock screen. Scanning this presents the app clip installation, where your friend can download the Rolls app clip and immediately contribute to the shared album, without having to install or onboarding to the whole app. This mechanism was designed as a low-friction way to introduce Lapse to friends at parties, holidays, and events.

This QR code led to the biggest technical challenge in building Rolls: Apple decrees that, to work via QR code, the app clip install size must to be under 15MB.

If we decided to use hyperlinks, this limit jumps to 50MB—a much looser constraint. But in the social media game, a tiny bit of friction is the difference between a few dozen shares and explosive viral growth. We were going with QRs.

Optimising the Dependency Graph

There was an elephant in the room of our app clip dream: Realm.

Lapse wants to replace your default camera, so everything needs to work offline. Therefore, we have a strong dependency on this persistence framework for data-related tasks.

Unfortunately, the Realm library alone was 16MB, meaning we had to re-architect our modules to decouple from a specific persistence framework.

Our Architecture

At Lapse, our app’s modular architecture is loosely based on The Modular Architecture from our friends at Tuist. In short, each feature module is actually 2 modules: an API contract, then the feature implementation itself.

This allows features to import each other and navigate between screens without creating circular dependencies. For example, when a screen inside uRollsFeature wants to navigate to the camera screen in uCameraFeature, it only needs to import uCameraAPI. This API contract contains a screen factory interface, which is implemented in uCameraFeature.

These Feature modules are dependency-injected at the top level of our dependency graph. This architecture keeps our build times fast because changes to an implementation module like uCameraFeature doesn’t invalidate uRollsFeature, or other parts of your dependency graph—since they only depend on the API.

If you aren’t familiar with interface targets and build times, this is a great primer from SwiftRocks.

Decoupling From Realm

The problem is that our feature implementation modules all imported the Realm libraries, creating a tight coupling to this hefty dependency.

To square this circle, we needed to introduce complexity to our dependency graph: splitting our Feature modules into a UI layer (FeatureUI) and a data access layer (FeatureImpl).

This allows us to use the same screens in the UI module for the main app & the app clip, but swap out the Impl modules to replace Realm with Core Data—a system framework with zero impact on bundle size.

Both the UI and Impl modules import the same API, so we can add data access layer protocols to the API (like CameraDataService and RollsDataService)—therefore our UI module can still contain all the view logic—only the data is swapped out.

Now, the modular structure for our Rolls features looked like this:

Our app clip mirrored this structure, with swapped-out AppClipImpl modules:

This means all our API protocols, UI code, and view logic is shared. The only difference between the app and app clip lies in the implementation files that manage the data access layer—either Realm or Core Data.

The APIs and Impl modules eventually included our navigation logic as well, allowing us to implement different screen navigation on the app clip. This allowed us to present a StoreKit overlay prompting a user to download the full app whenever an app clip user tried to access a feature outside Rolls.

While this dependency graph has become pretty complicated, the process of wiring the modules together became fairly straightforward with the help of Tuist—we could template this dependency graph structure to automatically set it up for any new module.

The Initial App Clip

Flash forward a few weeks. We’ve built the core Rolls experience with this novel modular architecture, and we’re ready to start optimising.

The initial analysis shows things aren’t too bad to start with: our app clip bundle clocks in at 21MB. For an unoptimised app, a 40% bundle size haircut isn’t totally crazy. We should be able to do this.

The 15MB limit is for install size, not download size.

This was a little annoying, because app clip size limits are designed to facilitate a speedy download. Setting the limit for install size (usually at least 2x larger than the compressed .ipa download size) felt a bit arbitrary.

Oh well.

I performed a simple analysis of some potential opportunities for bundle size optimisations, including:

  • What we might be able to optimise

  • How much space each change will save

  • The effort level (both for the initial optimisation and any maintenance)

I brought it back to the team, we agreed on priorities, then I got to work.

Nice, Easy Optimisations

Assets & Fonts

The biggest opportunity for bundle size optimisation is almost always assets:

  • Minimise use of images and videos.

  • Offload what you can to the network.

  • Compress the rest.

These usually involve negotiations with your product and design teams. We can’t offload everything to a CDN, because what if users are onboarding somewhere with spotty internet? Some compression is great, but we don’t want our onboarding videos full of distracting compression artefacts.

We’d been pretty careful when building Rolls, so we didn’t have many big image or video assets remaining in the bundle.

Fonts were a different story: our app clip (and our main app, since they shared our UI library) had several sparsely-used font files from old design systems. This was a pretty simple negotiation with design, deleting several and occasionally substituting with a more commonly-used font.

Deleting several fonts from the UI library, we shaved off 600kB.

Binary Symbols & Metadata

If you look closely at each framework in our bundle, they all contain a String Table. These are binary symbols, metadata used for debugging. Since Xcode 14, this metadata is left bloating release builds by default.

This app clip includes hefty string tables for each dynamic framework. We can add a build script that iterates through each framework and removes these symbols using the UNIX strip command.

strip -rSTx Apollo.framework

Binary symbol metadata is a similar piece of cruft that shows up in your binary. It’s more metadata to help facilitate linking modules together dynamically. This isn’t necessary in app binaries, because nothing will link against them. This binary symbol metadata can be striped using Xcode linker flags:

-Wl,-exported_symbols_list,/dev/null

All in all, clearing out this cruft saves us a stonking 2.8MB.

Subscribe for free to continue reading

Enter your email and get every post from Jacob’s Tech Tavern.
Already have an account? Sign in
© 2026 Jacob Bartlett · Privacy ∙ Terms ∙ Collection notice
Start your SubstackGet the app
Substack is the home for great culture