AI-powered crash analysis is now available on all plans — including Free.Read the crash analysis guide

Mobile Dynamic Module Loading Crash Debugging Guide

NFNourin Mahfuj Finick··8 min read

When your production app suddenly throws a ClassNotFoundException on a feature that worked perfectly during development, you've likely encountered one of mobile development's most frustrating crash categories: dynamic module loading failures. Modern mobile apps increasingly rely on on-demand feature delivery — Android App Bundles with Play Feature Delivery split APKs, and iOS on-demand resources with NSBundleResourceRequest — to keep initial download sizes small while shipping rich functionality. But when these dynamic loading mechanisms fail in production, the resulting crashes are notoriously difficult to reproduce, debug, and fix because they depend on runtime conditions that don't exist in your local development environment.

This guide covers the complete landscape of dynamic module loading crash debugging across Android and iOS, from the architecture fundamentals that cause these failures to production monitoring strategies that catch them before users do.

Why Dynamic Module Delivery Crashes Are Different

Dynamic module crashes differ from typical application crashes in three critical ways. First, they're conditional — the crash only manifests when a user navigates to a feature that triggers module loading, meaning your standard smoke tests may never encounter them. Second, they're environment-dependent — split APK availability depends on Google Play's delivery pipeline, network conditions, and device storage state, none of which you control during development. Third, the stack traces are often misleading — a ClassNotFoundException or NSBundleResourceRequest error may point to the symptom (a missing class or resource) rather than the root cause (a failed delivery, corrupted download, or timing issue).

According to Google's Android App Bundle documentation, Play Feature Delivery supports three distribution modes: install-time, on-demand, and conditional. Each mode introduces different failure points. Install-time modules are delivered at app install but can fail if the device is low on storage. On-demand modules require explicit API calls to request and download, introducing network dependency failure modes. Conditional modules add device feature checks such as country, API level, and device capabilities that can silently exclude modules from delivery.

On iOS, Apple's On-Demand Resources guide describes how asset packs tagged in Xcode are downloaded via NSBundleResourceRequest. The system manages downloading, purging, and prioritizing these packs, but the opaque nature of this management creates crash scenarios that are hard to anticipate.

Android: Debugging Play Feature Delivery Crashes

The ClassNotFoundException Nightmare

The most common Android dynamic module crash is the dreaded ClassNotFoundException or NoClassDefFoundError when accessing code from a split APK that was not properly delivered or installed. This typically manifests with stack traces like:

java.lang.ClassNotFoundException: com.example.features.PaymentActivity
    at dalvik.system.BaseDexClassLoader.findClass(BaseDexClassLoader.java:218)

The root cause is almost always one of three things: the split APK was never downloaded, the download completed but the installation failed silently, or ProGuard and R8 rules stripped classes that the dynamic module needs. To diagnose which one, instrument your SplitInstallManager request flow with comprehensive state tracking:

val manager = SplitInstallManagerFactory.create(context)
val request = SplitInstallRequest.newBuilder()
    .addModule("feature_payment")
    .build()
 
manager.registerListener { state ->
    when (state.status()) {
        SplitInstallSessionStatus.DOWNLOADING -> {
            val pct = state.bytesDownloaded().toFloat() /
                state.totalBytesToDownload().toFloat() * 100
            Log.d("DynamicModule", "Downloading: %.1f%%".format(pct))
        }
        SplitInstallSessionStatus.INSTALLED -> {
            Log.d("DynamicModule", "Module installed successfully")
        }
        SplitInstallSessionStatus.FAILED -> {
            Log.e("DynamicModule", "Install failed: ${state.errorCode()}")
            // Error codes: ACCESS_DENIED, INSUFFICIENT_STORAGE,
            // NETWORK_ERROR, API_NOT_AVAILABLE, MODULE_UNAVAILABLE
        }
        SplitInstallSessionStatus.CANCELED -> {
            Log.w("DynamicModule", "Install canceled")
        }
        SplitInstallSessionStatus.REQUIRES_USER_CONFIRMATION -> {
            manager.startConfirmationDialogForResult(
                state, activity, REQUEST_CODE)
        }
    }
}
 
manager.startInstall(request)
    .addOnFailureListener { e ->
        BugsPulse.logException(e, mapOf("module" to "feature_payment"))
    }

Pay special attention to SplitInstallErrorCode.MODULE_UNAVAILABLE — this indicates the module is not available on Google Play for the current device configuration, which often happens when conditional delivery rules exclude the device based on country, API level, or hardware capabilities.

Corrupted Split APK Downloads

Even when Play Feature Delivery reports success, the downloaded split APK can be corrupted. This produces cryptic crashes that look like native code failures or dex verification errors. The SplitCompat library, which bridges dynamic modules into the main app's classloader, is particularly sensitive to APK integrity. Google's Play Core library documentation recommends always verifying installation state before attempting to use a module:

val installedModules = manager.installedModules
if (!installedModules.contains("feature_payment")) {
    retryWithBackoff(manager, "feature_payment")
}

For release builds, ProGuard and R8 rules must account for dynamic modules. The keep rules for classes accessed via reflection in split APKs need to be in the base module's proguard-rules.pro, not the dynamic module's — a common mistake that causes ClassNotFoundException only in release builds. As discussed in our ProGuard crash debugging guide, consumer ProGuard rules in AARs do not propagate to dynamic feature modules automatically, so you must explicitly include them in the base module configuration.

Storage Constraints and Module Purging

Android's system can purge split APKs when the device is low on storage, creating a situation where a feature that worked yesterday suddenly crashes today. The SplitInstallManager.deferredUninstall() API lets you request module removal, but the system can also remove modules unilaterally without warning. Always check installed modules on app startup and log state to your crash reporting platform:

class BugspulseApplication : Application() {
    override fun onCreate() {
        super.onCreate()
        val manager = SplitInstallManagerFactory.create(this)
        val installed = manager.installedModules
        BugsPulse.setCustomKey(
            "installed_dynamic_modules", installed.joinToString())
        if (installed.isEmpty() && hasDynamicFeatures()) {
            BugsPulse.logMessage(
                "All dynamic modules purged — possible storage pressure")
        }
    }
}

iOS: Debugging On-Demand Resource Crashes

NSBundleResourceRequest Failures

On iOS, on-demand resources crash when NSBundleResourceRequest.beginAccessingResources() throws an error. Unlike Android's clearly documented error codes, iOS on-demand resource errors are often opaque NSError objects with domains such as NSCocoaErrorDomain or NSBundleOnDemandResourceRequestErrorDomain. Proper error handling requires checking the specific error code:

let tags: Set<String> = ["premium_assets"]
let request = NSBundleResourceRequest(tags: tags)
 
request.conditionallyBeginAccessingResources { available in
    if available {
        self.loadPremiumAssets()
    } else {
        request.beginAccessingResources { error in
            if let error = error {
                // NSBundleOnDemandResourceOutOfSpaceError = -4
                // NSBundleOnDemandResourceExceededMaximumSizeError = -2
                // NSBundleOnDemandResourceInvalidTagError = -1
                BugsPulse.logError(error, metadata: [
                    "tags": tags.joined(separator: ",")])
                self.showFallbackUI()
            } else {
                self.loadPremiumAssets()
            }
        }
    }
}

The most insidious iOS on-demand resource crash pattern is the timing race: beginAccessingResources completes successfully, but between that callback and your actual resource access, iOS purges the asset pack due to memory pressure. Apple's guide on optimizing on-demand resources notes that the system may purge resources with zero access count at any time. The defense is to access resources immediately after the callback and hold a strong reference:

// Dangerous: resource may be purged between callback and access
request.beginAccessingResources { error in
    guard error == nil else { return }
    DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
        self.loadTexture() // CRASH: resource already purged
    }
}
 
// Safe: access and retain immediately within the callback
request.beginAccessingResources { error in
    guard error == nil else { return }
    self.retainedBundle = request.bundle  // Hold strong reference
    self.loadTexture()  // Safe: bundle retained in memory
}

Asset Pack Download Corruption

iOS asset packs can become corrupted during download, especially on unreliable networks. When corruption occurs, NSBundleResourceRequest may not report an error until you actually try to load the corrupted resource — often inside a C function, producing a SIGABRT that crash reporters capture without useful context. Apple's NSBundleResourceRequest documentation suggests using progress objects to monitor download integrity:

let request = NSBundleResourceRequest(tags: ["video_assets"])
request.loadingPriority = NSBundleResourceRequest.LoadingPriorityUrgent
 
let progress = request.progress
progress.cancellationHandler = {
    BugsPulse.logMessage("ODR download canceled for: video_assets")
}
 
request.beginAccessingResources { error in
    if let error = error {
        let nsError = error as NSError
        if nsError.code == NSBundleOnDemandResourceOutOfSpaceError {
            self.retryWithoutLargeAssets()
        }
    }
}

Cross-Platform Monitoring Strategies

Dynamic module crashes require specialized monitoring because they are conditional on runtime state that standard crash reports do not capture. You need context about module installation state, network conditions, and storage availability at the time of the crash.

For every dynamic module crash, capture these data points as custom breadcrumbs with your monitoring platform:

  • Module or asset pack name and version: Identify exactly which module failed
  • Installation state: Was the module installed, downloading, or never requested?
  • Error code: The platform-specific error code (SplitInstallErrorCode on Android, NSError code on iOS)
  • Network state: Was the device online? WiFi or cellular connection?
  • Available storage: Free space at crash time using Android's StatFs or iOS volumeAvailableCapacity
  • Download progress: Bytes downloaded versus total for in-flight downloads

With BugsPulse crash analytics, you can build a dynamic module health dashboard that tracks crash-free rates per module rather than per app. This reveals whether one feature module is disproportionately responsible for crashes, helping you prioritize fixes. A module with 98.7 percent crash-free rate while all others sit above 99.9 percent demands immediate investigation — and the breadcrumb context tells you exactly whether the root cause is network failures, storage pressure, or corrupted downloads.

Prevention: CI/CD Guardrails for Dynamic Modules

Prevent dynamic module crashes from reaching production with these CI and CD checks:

  1. Split APK size validation: Verify all on-demand modules are under the download limits — 50 MB for standard on-demand, 150 MB or more with Play Asset Delivery for Android
  2. Module manifest verification: Ensure every dynamic feature module's AndroidManifest.xml declares the dist:module element with correct dist:onDemand or dist:install-time attributes
  3. ODR tag validation: CI should parse the asset catalog and verify all NSBundleResourceRequest tags reference actual asset packs that exist in the build
  4. Installation smoke test: On every CI run, programmatically install all on-demand modules and verify they load without ClassNotFoundException or resource errors

For teams serious about mobile reliability, dynamic module observability should be part of your overall crash monitoring strategy. BugsPulse tracks the full lifecycle of dynamic feature loading — from Play Store delivery through user interaction to resource access — giving you the breadcrumbs needed to reproduce and fix these notoriously elusive crashes.

Ready to take control of your dynamic module crashes? Start monitoring with BugsPulse today and get real-time visibility into every split APK and on-demand resource failure across your Android and iOS apps.