Table of Contents

    Book an Appointment

    How did we discover the iOS linkage crash in our Kotlin Multiplatform project?

    While working on a high-security cross-platform FinTech application, our team was tasked with unifying the Android and iOS codebases using Kotlin Multiplatform (KMP) and Compose Multiplatform. The goal was to share not just business logic, but also the entire UI layer and navigation routing logic to accelerate feature delivery. Everything worked flawlessly during local Android testing. The screens transitioned smoothly, state was preserved, and performance was excellent.

    However, during a routine deployment to our iOS test devices, we realized we had a critical issue. The app launched, but the moment the initial routing graph was invoked, the application crashed instantaneously. There were no graceful degradation or caught exceptions—just a hard runtime native crash. This situation immediately halted our iOS testing pipeline. When companies hire software developer teams for cross-platform delivery, resolving deep platform-specific anomalies like this quickly is what separates robust engineering from endless debugging cycles. This challenge inspired the following technical breakdown so other teams can avoid the same dependency trap when scaling their multiplatform architectures.

    Why does dependency alignment matter in Kotlin Multiplatform routing?

    In a standard Android environment, Jetpack Navigation Compose is a mature, tightly coupled library that relies heavily on Android-specific lifecycle and saved state components. For our FinTech platform, the business use case required deep linking, complex authentication flows, and secure state preservation across multiple screens. To achieve this in KMP, we utilized the JetBrains port of Navigation Compose.

    The problem arises in how Kotlin Native compiles and links these dependencies for the iOS target. Unlike the JVM, where classpath conflicts often surface as easily identifiable version clashes, Kotlin/Native compiles code into intermediate representation (IR). If the version of the Navigation library expects a specific function signature in the Lifecycle or SavedState library, and that signature has been altered or is missing in the ported version you are using, the compiler might still generate a binary that ultimately fails upon execution.

    What caused the IrLinkageError runtime crash on iOS?

    Upon inspecting the iOS crash logs via Xcode and our crash reporting tools, we found the following stack trace:

    Uncaught Kotlin exception: kotlin.native.internal.IrLinkageError: Function 'performRestore' can not be called: No function found for symbol 'androidx.savedstate/SavedStateRegistryController.performRestore|performRestore(androidx.core.bundle.Bundle?){}[0]'

    This IrLinkageError is a classic symptom of binary incompatibility in Kotlin Multiplatform. The JetBrains fork of Navigation Compose we were using (version 2.8.0-alpha10) was trying to invoke the performRestore function on the SavedStateRegistryController. However, the specific version of the Lifecycle and SavedState libraries pulled into the dependency tree either lacked this function on the iOS target or had a completely different signature.

    In our initial libs.versions.toml, we had aggressively updated certain libraries while lagging on others. We were mixing Kotlin 2.1.x with an older Compose Multiplatform version and mismatched AndroidX Lifecycle versions. The iOS binary was attempting to link an intermediate representation that fundamentally did not align with the provided native stubs.

    How did we diagnose and approach the Compose Multiplatform version mismatch?

    To diagnose the issue, we first generated a dependency tree for both our Android and iOS targets. We needed to map out exactly which transitive versions of androidx.lifecycle and androidx.savedstate were being resolved. We noticed that while Gradle was successfully resolving the dependencies for the JVM, the iOS source sets were resolving a fractured mix of JetBrains-ported artifacts and standard AndroidX stubs.

    Our reasoning process focused on establishing a strict version matrix. Because JetBrains ports Jetpack libraries in synchronized waves, picking an arbitrary version of Navigation and pairing it with a different era of Compose Multiplatform almost always guarantees an IrLinkageError.

    What alternative routing solutions did we consider?

    Before strictly enforcing the version matrix, we evaluated a few architectural pivots. We considered these solutions as well:

    • Reverting to native iOS routing: We explored decoupling the UI and using SwiftUI for iOS navigation while keeping Compose for Android. This was rejected as it defeated the core business value of a unified UI layer.
    • Switching to third-party KMP routers: We evaluated libraries like Voyager and Decompose. While excellent tools, migrating our already complex Jetpack-based navigation graph would have severely delayed the release.
    • Manually patching the SavedState registry: We considered writing expect/actual implementations to bypass the broken restore call. This was deemed too hacky and a maintenance nightmare for future updates.

    What is the stable version combination for cross-platform navigation?

    After systematic testing, we identified that the root fix required stepping back from cutting-edge alpha Kotlin compilers and aligning strictly with the JetBrains compatibility matrix. The solution involved harmonizing Kotlin, Compose Multiplatform, and the JetBrains forks of Lifecycle and Navigation.

    Here is the proven, stable configuration we implemented in our version catalog:

    [versions]
    agp = "8.2.2"
    kotlin = "2.0.20"
    composeMultiplatform = "1.7.0-rc01"
    androidx-lifecycle = "2.8.2"
    navigationCompose = "2.8.0-alpha10"
    [libraries]
    jetbrains-lifecycle-viewmodel = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel", version.ref = "androidx-lifecycle" }
    jetbrains-lifecycle-runtime-compose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose", version.ref = "androidx-lifecycle" }
    jetbrains-navigation-compose = { module = "org.jetbrains.androidx.navigation:navigation-compose", version.ref = "navigationCompose" }
    

    Validation Steps:

    • We ensured all navigation dependencies used the org.jetbrains.androidx group ID rather than the standard androidx group ID, which lacks the iOS native implementations.
    • We downgraded Kotlin from 2.1.x to the stable 2.0.20 release, ensuring the IR compiler matched the Compose compiler plugin natively.
    • We performed a full clean build and invalidated caches: ./gradlew clean iosX64Binaries.
    • The iOS app successfully booted, and deep-link routing functioned identically to the Android implementation.

    What actionable lessons can engineering teams apply for Kotlin Multiplatform?

    When organizations decide to hire Kotlin developers for multiplatform architecture, it is crucial that the team understands the nuances of native compilation. Here are the actionable insights from this experience:

    • Strictly Follow the Matrix: Never arbitrarily bump KMP libraries. Kotlin, Compose Multiplatform, and JetBrains AndroidX ports are tightly coupled. Always verify compatibility in the official JetBrains release notes.
    • Beware of the Group IDs: Ensure you are using org.jetbrains.androidx and not the standard androidx for cross-platform shared UI modules. Mixing them will cause linkage errors.
    • Test iOS Targets Early: Do not rely solely on JVM/Android testing. KMP logic should be built and executed on iOS simulators continuously in your CI/CD pipeline to catch IrLinkageError crashes at the PR level.
    • Understand IR Linkage: Realize that KMP dependency resolution on iOS is fundamentally different from standard Gradle JVM resolution. A successful Gradle sync does not guarantee a successful iOS native binary build.
    • Manage Transitive State: Libraries like Navigation rely on SavedState and ViewModel. If you force a specific version of Navigation, use Gradle constraints to enforce the corresponding versions for its transitive dependencies.

    How do these insights help you scale your cross-platform engineering?

    Dependency management in Kotlin Multiplatform goes beyond simple version bumps; it requires a deep architectural understanding of how intermediate representations link across disparate operating systems. By diagnosing the IrLinkageError and establishing a strict version catalog, our team stabilized the FinTech application, allowing for rapid, unified feature rollouts across both platforms. When enterprise leaders look to hire mobile app developers for cross-platform deployment, they need assurance that the team can navigate these deep framework complexities without compromising delivery timelines. If you are facing similar architectural bottlenecks or want to ensure your next KMP project is built on a solid foundation, contact us.

    Social Hashtags

    #KotlinMultiplatform #ComposeMultiplatform #Kotlin #KMP #iOSDevelopment #AndroidDevelopment #JetpackCompose #ComposeUI #TechBlog #MobileDevelopment #CrossPlatform #SoftwareEngineering #AppDevelopment #KotlinNative #NavigationCompose #AndroidX #iOS #FinTech #DeveloperTips #Debugging

     

    Frequently Asked Questions

    Success Stories That Inspire

    See how our team takes complex business challenges and turns them into powerful, scalable digital solutions. From custom software and web applications to automation, integrations, and cloud-ready systems, each project reflects our commitment to innovation, performance, and long-term value.