We Are Going Native: Why Vwaza Is Migrating from Flutter to Kotlin Multiplatform
ENGINEERING

We Are Going Native: Why Vwaza Is Migrating from Flutter to Kotlin Multiplatform

Joel Fickson Ngozo

Joel Fickson Ngozo

The very first version of our flagship product, Vwaza Music, was built with Flutter. When I started building Vwaza in 2020, the vision was vertical growth: start with music streaming, then expand into TV, sports, books, podcasts, and more. Flutter helped us get to the App Store fast. A single codebase let a lean team ship an entire music streaming platform - audio playback, offline downloads, payments, search, artist profiles - to both iOS and Android from one Dart codebase.

In Q1 2025, when we announced our expansion into TV streaming and creator tools, it was time to rethink the technical foundation. We needed a technology that could share business logic across three distinct apps (Music, TV, Studio) while delivering truly native experiences on six platform targets: iPhone, iPad, Android phone, Android tablet, Android TV, and Apple TV.

We landed on Kotlin Multiplatform with native SwiftUI and Jetpack Compose UIs. This is the story of why, and a deep technical walkthrough of how we built it.

Where Flutter Excelled

Credit where it is due. Flutter gave us real advantages during the first four years:

Speed to market. A single Dart codebase meant one engineer could build a feature end-to-end for both platforms. Hot reload made iteration cycles measured in seconds, not minutes. We went from zero to a production music streaming app with 865 Dart files, covering audio playback, offline downloads, payments, search, artist discovery, and social features.

Widget composition. Flutter's widget tree model made it straightforward to build complex UIs. Our player screens, discovery feeds, and artist profile pages were all composed from reusable widget primitives. The declarative approach scaled well as the app grew.

Ecosystem for common tasks. Packages like dio for networking, riverpod for state management, and cached_network_image for media loading solved real problems quickly. We built 281 Riverpod providers spanning auth, player, audio, downloads, playlists, payments, and more.

Flutter was the right choice for us in 2020. However, as we approached 2025, it became clear that our needs and our codebase were evolving.

Where Flutter Hit the Wall

Dependency Version Lock-In

Seven of our major dependencies were pinned to old versions because upgrading would require significant rewrites. This is not just version pinning - it is compounding technical debt. Each pinned package blocks newer versions of the Flutter SDK and platform improvements. The audio stack was especially painful: just_audio 0.9.42 with audio_session 0.1.25 kept us on audio playback code without upstream improvements for over a year.

Incomplete State Management Migration

Our original architecture used GetX for state management. As the app grew, we migrated to Riverpod for its better testability and type safety. But by 2025, this migration was only about 60% complete. We had 281 Riverpod providers implemented, but screens across the app were still being converted. Two state management paradigms coexisting in a single codebase create confusion for every engineer who touches it.

Workarounds Everywhere

A grep through the codebase revealed 68 instances of Future.delayed() - artificial delays inserted as workarounds for timing issues, simulated validations, and race conditions. Beyond the delays, we had 28 unfinished TODOs for features that never shipped: offline album playback, HLS authentication token generation, queue reordering, favorites integration with Riverpod, and more.

No TV Platform Support

This was the deal-breaker. Flutter does not support tvOS or Android TV. When we decided to build Vwaza TV, Flutter simply could not be part of the conversation. TV interfaces require focus-based navigation (D-pad and remote control), 10-foot UI patterns, and platform-specific media playback integration. None of these maps to Flutter's touch-oriented widget system.

We needed a solution that could target iPhone, iPad, Android phone, Android tablet, Android TV, and Apple TV from a single piece of shared business logic. Flutter could cover four of those six. We needed all six.

Service Layer Sprawl

The codebase had grown to include 25+ services, some with overlapping concerns. We had five separate notification services. The MethodChannel bridge for device security validation referenced native code that did not actually exist on either platform - a phantom dependency that could cause runtime crashes.

Why Kotlin Multiplatform

KMP solves a specific problem well: sharing business logic across platforms while keeping UI native.

Shared everything except UI. Authentication, networking, models, dependency injection, caching, encryption - all written once in Kotlin and compiled for both JVM (Android) and Native (iOS/tvOS). The UI layer uses Jetpack Compose on Android and SwiftUI on iOS, each playing to its platform's strengths.

The expect/actual pattern. KMP lets you define an interface in common code and provide platform-specific implementations. Token storage is an interface in common code. Android implements it with SharedPreferences and a ContentProvider. iOS implements it with NSUserDefaults. The consuming code never knows which implementation it is using.

SKIE for Swift interop. Kotlin's sealed classes, coroutines, and flows do not map cleanly to Swift by default. SKIE (Swift Kotlin Interface Enhancer) bridges this gap by generating Swift-friendly APIs from Kotlin code, so our iOS engineers can work with idiomatic Swift types.

SQLDelight for offline caching. Type-safe SQL compiled to both Android SQLite and iOS native drivers. Our music domain uses 10 SQLDelight schemas for offline content, download records, and discovery caching. No ORM magic - just SQL that generates Kotlin data classes.

Six platform targets from one shared layer. The TV app alone targets four platforms: iOS, tvOS, Android phone, and Android TV. Each gets native UI tuned to its form factor, but all four share the same auth layer, networking client, and data models.

The KMP Directory Structure

The mobile directory is organized into four top-level modules. The shared module contains all cross-platform foundations: authentication, networking, models, configuration, cryptography, security, and dependency injection. It compiles to framework outputs for Apple platforms - VwazaShared.framework, VwazaMusicDomain.framework, and VwazaStudioDomain.framework.

The tv module contains the Vwaza TV app with Jetpack Compose for Android and Android TV, plus SwiftUI for iOS and tvOS. The music module holds the music domain layer with 13 API services, SQLDelight caching, and platform apps. The studio module contains 12 API services for creator tools, 8 repositories, and chunked file upload support.

The critical architectural constraint: each app depends on shared plus its own domain module. No app depends on another app.

All dependency versions live in a single Gradle version catalog using Kotlin 2.2.0, Compose Multiplatform 1.8.2, Ktor 3.1.3, Koin 4.1.0, SQLDelight 2.0.2, and SKIE 0.10.11.

The Shared Auth Layer: A Code Deep Dive

Authentication is the perfect example of code that should be written once. Every Vwaza app - Music, TV, Studio - authenticates against the same Core service using the same JWT tokens.

Our Core service runs on Fastify with TypeScript. The backend uses RS256 JWT tokens with 1-hour access token expiration and 30-day refresh tokens. Roles include user, creator, and admin. Rate limiting enforces 5 login attempts per minute and 10 refresh attempts per minute.

Every auth request and response is modeled as a Serializable Kotlin data class in commonMain. These models are used identically by the TV, Music, and Studio apps. When the backend adds a field, we add it here once and it is available everywhere.

Token storage is where KMP's platform abstraction shines. The TokenStorage interface lives in common code with methods for saving tokens, getting access and refresh tokens, clearing tokens, and managing cached user data. The Android implementation uses SharedPreferences locally and a ContentProvider for cross-app token sharing - if a user logs into Vwaza TV on their Android device, the Music app can discover those tokens without requiring a second login. The iOS implementation uses NSUserDefaults with the Apple Keychain for secure storage.

AuthApi wraps all HTTP calls to the Core service's auth endpoints using Ktor. The AuthRepository manages authentication state as a StateFlow that all UI layers observe, with states for Unauthenticated, Loading, Authenticated, and Error.

The VwazaHttpClient factory creates an HTTP client that automatically attaches bearer tokens, refreshes them on 401 responses, skips auth for public endpoints, and sends device metadata with every request. In the Flutter codebase, the equivalent required a token refresh queue to prevent race conditions, a separate auth interceptor class, a request queue interceptor limiting concurrent requests to 6, and manual coordination among all of these. Here, Ktor's Auth plugin handles it natively.

Everything is wired together through Koin dependency injection modules. Each app provides its own VwazaConfig and platformModule at startup.

Shared Code Strategy: Going Deeper

Three-Tier Architecture

Every domain module follows the same pattern: API Layer (Ktor HTTP) to Repository Layer (StateFlow + cache) to UI Layer (Compose / SwiftUI). The Music domain illustrates this at scale with 13 API services. The SongsApi class inherits the full auth/refresh/device-header behavior from the shared HttpClient without any additional configuration. It just makes HTTP calls and deserializes responses.

SQLDelight: Offline-First Caching

The Music domain uses SQLDelight for type-safe offline caching with 10 schema files covering content caches, discovery feed caching, offline download tracking, and pagination with TTL management. The cache layer follows a cache-aside pattern with TTL and stale-while-revalidate. Repository methods check the cache first, return the cached data to the UI immediately, and then fetch fresh data from the network in the background. If the network fails, the cached data remains available.

DRM and Content Security

The Music domain includes a full content security stack for offline downloads: EncryptionService for AES-encrypted downloaded audio files, LicenseManager for offline playback licenses, ContentIntegrity for verifying file integrity, and SegmentDecryptor for HLS segment decryption. All of this is shared code - written once, compiled for both platforms.

What Gets Shared, What Stays Native

The principle is simple: share logic, keep UI native. Shared in KMP: authentication, HTTP networking, data models, repository layer, dependency injection, encryption and security, file download management, and push notification token lifecycle. Native per platform: all UI (SwiftUI views, Compose screens), navigation, media playback (AVFoundation on iOS, Media3/ExoPlayer on Android), platform permissions, OAuth UI flows, and accessibility integration.

What We Have Learned So Far

KMP's shared layer genuinely eliminates duplicated business logic, but it is not free. Kotlin 2.2.0 with SKIE has its own friction: default parameter values not exposed to Swift, Result types erased to Any?, and SKIE compatibility constraints with newer Kotlin versions. Solvable, but worth knowing upfront.

The expect/actual pattern scales well, and native UI is worth the effort. SwiftUI with NavigationStack, focus management on tvOS, D-pad navigation on Android TV - these are things a cross-platform UI layer cannot provide without compromise.

The TV app is shipping. The music domain layer is built. The hard part - replacing a production music streaming app - is ahead of us. But every line of shared code we write serves three apps instead of one.

This is a living document. We will update it as the migration progresses through 2026.

ENGINEERING
Joel Fickson Ngozo

Written by

Joel Fickson Ngozo

Founder & CEO