From Flutter to KMP
We build payment products for small businesses. Two of them are apps. One runs on the card terminals merchants use to take payments at the counter, the other on their phones. Both exist to help a merchant run the same business, which is why they kept needing so many of the same features.
That overlap had a cost, and different tech stacks made it worse. The terminal app was built in Kotlin, the mobile app in Flutter, so a feature wanted in both had to be written twice, once in each stack, with no code shared between them. Our engineers had also been wanting to try Kotlin Multiplatform, which lets you share code across platforms from a single codebase. The duplicated work and that curiosity pushed in the same direction, so we ran a small experiment to see whether one feature, built once in KMP, could run in both apps.
The experiment: embedding KMP inside Flutter
We borrowed the idea from Coinbase, who did the same thing with React Native. Rather than commit their whole app up front, they embedded one real feature in the native app they already shipped and measured the result. We followed that playbook and dropped a single Kotlin Multiplatform feature into the Flutter app we already ran in production.
That feature came from the terminal team, who had built it in Kotlin Multiplatform because they needed the same functionality inside their own Android app. To share it, they published it to Maven as an artifact, which let the Flutter app pull it in as a dependency and embed it too. The same feature, built once, now ran in both apps.
Embedding KMP in a Flutter app is straightforward enough. A Flutter package pulls
in the right Gradle Kotlin Multiplatform plugin, and voize’s
flutter-kmp shows what that wiring looks like. From there you have
two ways to run the KMP code. You can reach it over a method
channel to launch a separate native screen, an Activity on
Android or a UIViewController on iOS, or you can embed it inline as a native UI
view dropped straight into the Flutter widget tree.
We took the first route, because the second one is where the cost hides. Running Compose UI inline inside Flutter UI is where we expected performance to suffer, so we kept the two apart. When the user taps the feature’s entry point, we launch the native screen and hand control to KMP from there.
What the experiment told us
In production the feature ran fine. The friction was all in development, in the constant plumbing to get Flutter and KMP talking to each other. The user’s preferred currency was one small piece of it. The KMP side also needed the auth token, and it had to hand analytics events and logs back to Flutter, with every one of those crossings wired by hand over the method channel. Pulling KMP into the Flutter app pushed its build time up, too.
That plumbing aside, the experiment still left us confident about running with Kotlin Multiplatform, because it validated three things:
- The tooling for KMP and Compose UI is stable enough.
- The team has more expertise with Kotlin Multiplatform than with Flutter.
- We burned a lot of hours just connecting the two frameworks together.
Deciding on a full rewrite
Almost all of the pain came from making the two frameworks coexist during
development. Wiring up method channels and sharing composeResources with
Flutter, for example, was consistently painful, while the KMP code itself never
gave us trouble in production. That told us the real risk in a rewrite was never
the technology, but the integration between the two frameworks.
Was the method channel the whole problem? No. The deeper tax was structural. Doing something as ordinary as moving from one screen to the next meant crossing from Flutter into KMP, going through a publish step, and keeping two separate stacks in sync, all for an interaction that is trivial inside a single app. That overhead came from running two technologies side by side, not from any one bridge between them.
That realization shaped the proposal. A brownfield migration, keeping Flutter alive and moving features into KMP one at a time, would have stretched that integration pain across the entire project. Every shared piece would need a workaround to keep the two frameworks in sync, and that tax, paid in engineer time, would compound feature after feature. A full greenfield rewrite avoided all of it by starting clean in a single framework, with no coexistence cost to pay.
Greenfield carried a second benefit, this one for design. A clean app let the design team rethink the experience rather than decorate the old one. On a brownfield path they would have had less room, because a consistent product means every new screen has to sit next to the old screens still shipping, which quietly pushes design toward the safe, conservative choice. Starting over removed that constraint.
Rewriting in KMP was never really in question. Most of our developers already wrote Kotlin to build apps, and even among the Flutter app’s contributors the ones who knew Kotlin were the majority, so Kotlin Multiplatform was a natural evolution rather than a leap. Timing helped too. When Nubank wrote about scaling with Flutter, KMP was still an early, emerging option. By the time we made our call, it had matured into something stable and well established.
Planning the rewrite
Planning was the chaotic part. An old app hides features nobody remembers the reason for anymore, so scoping the rewrite meant rediscovering our own product first. A rewrite is also not a decision engineering can make alone, and bringing the product team along took time. To make the case concrete, we built the foundation of the new app and put three options on the table:
- KMP with native UI.
- Compose UI on some screens, native on others.
- KMP with Compose Multiplatform.
We went with option 1. The other two lean on Compose Multiplatform, which gives you a single UI shared across platforms. That is exactly what Flutter already did, so rewriting into it would change almost nothing about the UI. Option 2, mixing native and Compose screen by screen, would leave us maintaining two UI paradigms in one app without committing to either.
If we were going to rewrite at all, the one upgrade worth the effort was a genuinely native experience on each platform, SwiftUI on iOS and Compose on Android, each following its own conventions. That was also the easiest case to make to product. An app that speaks the language of the device it runs on is something people can see and feel right away, so the value never needed explaining. Most of our users are on iOS, and a first-class iOS experience was worth the real cost of the native path, which is maintaining two UIs.
With the path settled, we wrote down what the rewrite had to deliver:
- Best Android and iOS experience, by delegating the UI to each platform.
- A stable app.
- Heavy use of AI.
- Faster time to delivery than the old app.
- A shared data layer with web.
Results
The rewrite took six months. Here is what came out of it.
Best Android and iOS experience
We built a design system on top of SwiftUI and Compose. It leans on each platform’s own design system and customizes it for our needs. On iOS that means bringing in Liquid Glass. On Android we adopted Material, plus a small set of Material Expressive.
Adoption reflects it too. The old Flutter app used around 20% of our design system, while the new one uses all of it.
A stable app
We measured everything below against the old Flutter app, unless noted otherwise.
| Metric | Before | After |
|---|---|---|
| Time to home screen | 6 to 8s | around 2s, sometimes less |
| Crash-free sessions | 97% | above 99.7% |
| R8 optimization coverage | 22% | around 70% |
| App size | 65 MB | 44 MB |
| Startup time | in line with competitors |
Crash-free holds on both platforms, and R8 coverage is still climbing.
Testing
The old app already had high test coverage, and we kept it that way through the rewrite. End-to-end tests still run on Maestro. The one real change was in how we write the rest of them. We moved away from mocks and toward fakes, which made the tests less brittle and closer to real behavior. We are also investing in headless testing, which we’ll cover in a separate post.
Heavy use of AI
Once we had the architecture and the rules between modules nailed down, we built our mobile harness mainly on top of the model. Its pillars were:
- CLI and skills to build the data layer.
- Skills for the do’s and don’ts of writing a presenter, a use case, a test.
- Skills for our module boundaries and how features are allowed to depend on each other.
- Figma MCP plus design-system skills to build the UIs correctly.
Strict module boundaries were the backbone. Features depend only on each other’s public contracts, never their implementations, so the build graph stays a set of contracts instead of a web of internals. Defining them was not the hard part, and they held up very well. The boundary that did take judgment was Kotlin to Swift. Because the iOS screens are native, everything we export from Kotlin to Swift carries a cost in build time and developer experience, so deciding what to expose took real care. Holding dozens of features to all of this by hand is where teams drift, which is why we let the harness enforce it instead of the reviewer.
That enforcement lives in the skills. Each one captures a recipe, covering where a given kind of work lives, what it may depend on, and the conventions reviewers used to repeat on every pull request. Building a feature’s data layer, writing a presenter, wiring a test, each turned into a guided, repeatable path instead of tribal knowledge. Getting a new feature onto the established rails no longer depended on how long someone had been on the team.
Faster time to delivery
The old app had no real separation between features. They were tangled enough that one team could change something and break another team’s work, often without either noticing until it shipped. The rewrite fixed that at the structure level. Each team got clear ownership of its features and worked independently, served by a mobile platform layer we built out to match what the teams actually needed. Breakages where one team quietly broke another became far less common.
With teams no longer stepping on each other, releases got smoother and went out on the planned day instead of slipping. We moved to weekly releases instead of biweekly, which gave every team more chances to put work in production. The path to get a feature into main got simpler too, backed by an automated workflow that carried the routine steps.
It showed up in the numbers. Features we had scoped at 20 weeks landed in around 8, and we shipped feature parity with the old app plus a big chunk of the year’s roadmap.
Build and CI
Getting builds and CI to a workable state took real effort. We run CI on a fleet of self-hosted machines, and a full run of our checks now lands in around 10 minutes. The bigger win was the production build. It used to average about an hour, and it now finishes in roughly 25 minutes. One of the largest levers was counterintuitive. Disabling Kotlin’s incremental native cache cut the native build time by around 70%.
iOS is still the sore spot. That build stays slow and limiting, and the constraint comes from Kotlin’s native toolchain rather than anything in our own code. It is the part of the pipeline we most want to fix next.
A shared data layer with web
The goal was consistency. The app and the web portal kept showing numbers that didn’t match, and reconciling them by hand had worn thin, so we pulled the shared data layer out and delivered it to the web portal as well. Both surfaces now read from the same source. One piece of that work is open source, kotlinx-locale, a small library that keeps locale-sensitive data like currencies and dates consistent across platforms. We’ll cover how we pulled the rest off in a separate post.
The team that could ship it
The most surprising result of the rewrite was who ended up contributing. Product managers and designers started delivering features on their own, going from a Figma screen to a build they could install and try. That turned into a story of its own, and we cover it in a follow-up post.
The cost, and what we’d do differently
None of this came for free. To rewrite the app while still moving the product forward, we had to freeze functionality on the old one, and the stretch that followed was heavy. The roadmap we had committed to was large, and delivering it on top of a full rewrite asked a lot of the team.
The pace also worked against us in places. With so many features to ship, some of them didn’t get the refinement they needed before we started building. More than once, what felt like moving fast turned into building the same feature two or three times over. The time we thought we were saving by rushing, we paid back in rework.
If we were starting over, we wouldn’t change much about the direction. Going fully greenfield instead of brownfield is still the call we would make. What we would watch more closely is our module boundaries. A few modules ended up carrying more responsibility than they should have. Our selection-and-filters module, for instance, grew large and pulled in dependencies on nearly every feature module, which turns it into exactly the kind of bottleneck a modular setup is supposed to avoid, hard to change in isolation and easy to break.
Closing
The rewrite paid off. Six months in, the app is more stable and faster, and it shares a data layer with the web portal.