The Crash That Only Shows Up in Production
This one caught me off guard on a feature that sailed through testing. User taps a list item, app serializes an object and passes it to a detail screen โ boom, crash. My test data was tiny. Real users had real data: long descriptions, byte arrays for embedded images, nested lists that ballooned to several hundred kilobytes. The IPC transaction buffer caps at 1 MB, shared across all active transactions on the device. I was blowing past it without a single warning during QA.
Here's what the stacktrace looks like:
android.os.TransactionTooLargeException: data parcel size 1,048,576 bytes
at android.os.BinderProxy.transactNative(Native Method)
at android.os.BinderProxy.transact(Binder.java:804)
at android.app.IActivityManager$Stub$Proxy.startActivity(IActivityManager.java:3919)
at android.app.Instrumentation.execStartActivity(Instrumentation.java:1609)
at android.app.Activity.startActivityForResult(Activity.java:4586)
That number after data parcel size is your clue. Anything approaching or exceeding 1 MB through a Bundle or Intent will trigger this exception.
Diagnosing the Problem
Find out what you're actually sending
Drop a quick size check in before you call startActivity. Android doesn't expose Bundle size directly, but you can measure it with a Parcel:
fun Bundle.sizeInBytes(): Int {
val parcel = Parcel.obtain()
parcel.writeBundle(this)
val size = parcel.dataSize()
parcel.recycle()
return size
}
// Before startActivity
val bundle = intent.extras
Log.d("BundleSize", "Intent extras: ${bundle?.sizeInBytes()} bytes")
Test this with real user data, not your dummy fixtures. Seeing anything above ~500 KB? You're already in the danger zone โ that 1 MB limit is shared, not reserved just for your transaction.
Track down the offending field
Serialize each field separately to find the culprit:
val parcel = Parcel.obtain()
parcel.writeParcelable(myLargeObject, 0)
Log.d("ParcelSize", "myLargeObject: ${parcel.dataSize()} bytes")
parcel.recycle()
In most cases it's a bitmap stuffed into a byte array, a list of Parcelable objects, or a deeply nested data class that serializes far larger than it looks on paper. A single 1024ร768 uncompressed bitmap weighs in at roughly 3 MB as a byte array โ that's an instant crash.
Solutions
Option 1: Pass an ID, fetch the data on the other side
This is the cleanest fix. Instead of shipping the whole object across the IPC boundary, pass its identifier and let the destination screen load the data itself.
// Sender
val intent = Intent(this, DetailActivity::class.java)
intent.putExtra("article_id", article.id) // just a Long or String
startActivity(intent)
// Receiver (DetailActivity)
val articleId = intent.getLongExtra("article_id", -1)
viewModel.loadArticle(articleId) // fetch from Room/Repository
Works well whenever your data lives in Room, a Retrofit cache, or any local store. The ViewModel fetches asynchronously while the UI shows a loading state. Simple, testable, and follows the single source of truth principle.
Option 2: Use a shared ViewModel for Fragment-to-Fragment navigation
Skip Bundle arguments entirely for Fragment transitions within the same Activity. An Activity-scoped ViewModel keeps the object in process memory โ no IPC, no size limit.
// Shared ViewModel
class SharedViewModel : ViewModel() {
val selectedItem = MutableLiveData<MyLargeObject>()
}
// Source Fragment
val sharedViewModel: SharedViewModel by activityViewModels()
sharedViewModel.selectedItem.value = largeObject
findNavController().navigate(R.id.action_to_detail)
// Destination Fragment
val sharedViewModel: SharedViewModel by activityViewModels()
sharedViewModel.selectedItem.observe(viewLifecycleOwner) { item ->
// use item
}
This is the recommended pattern for single-Activity apps using Navigation Component. No serialization, no size worries.
Option 3: Temporary in-memory cache
Need to cross Activity boundaries with complex data but can't easily persist it? A lightweight singleton cache bridges the gap:
object DataCache {
private val cache = mutableMapOf<String, Any?>()
fun put(key: String, value: Any?) { cache[key] = value }
fun get(key: String): Any? = cache[key]
fun remove(key: String) { cache.remove(key) }
}
// Sender
val cacheKey = UUID.randomUUID().toString()
DataCache.put(cacheKey, largeObject)
val intent = Intent(this, DetailActivity::class.java)
intent.putExtra("cache_key", cacheKey)
startActivity(intent)
// Receiver
val key = intent.getStringExtra("cache_key") ?: return
val largeObject = DataCache.get(key) as? MyObject
DataCache.remove(key) // clean up immediately
Treat this as a short-lived bridge, not a data store. Remove the cache entry the moment it's consumed to avoid memory leaks.
Option 4: Write to disk, pass the file path
Bitmaps and large binary blobs belong on disk, not in a Bundle. Write the file first, then pass its path:
// Sender โ save bitmap to temp file
fun saveBitmapToCache(context: Context, bitmap: Bitmap): String {
val file = File(context.cacheDir, "temp_image_${System.currentTimeMillis()}.jpg")
file.outputStream().use { out ->
bitmap.compress(Bitmap.CompressFormat.JPEG, 85, out)
}
return file.absolutePath
}
val path = saveBitmapToCache(this, largeBitmap)
val intent = Intent(this, ImageViewerActivity::class.java)
intent.putExtra("image_path", path)
startActivity(intent)
// Receiver
val path = intent.getStringExtra("image_path") ?: return
val bitmap = BitmapFactory.decodeFile(path)
Clean up temp files in onDestroy or run a periodic cache sweep. Leaving them around wastes storage and confuses future debugging sessions.
Option 5: Trim the payload before sending
Often you're passing a full model when the next screen only needs a handful of fields. Create a lightweight projection:
data class ArticleFull(
val id: Long,
val title: String,
val body: String, // could be 50 KB
val author: Author,
val comments: List<Comment>, // could be 500 KB
val rawHtml: String // never put this in a Bundle
)
// Pass only what the detail screen actually needs
data class ArticlePreview(
val id: Long,
val title: String
) : Parcelable
val preview = ArticlePreview(article.id, article.title)
intent.putExtra("article", preview)
A list of 200 comment objects, each holding 10 String fields, can quietly exceed 500 KB. Slimming the payload is often the fastest fix when a full architectural change isn't practical.
Verifying the Fix
Once you've applied a solution, measure again:
val size = intent.extras?.sizeInBytes() ?: 0
Log.d("BundleSize", "After fix: $size bytes")
check(size < 500_000) { "Bundle still too large: $size bytes" }
Also enable StrictMode in debug builds. It surfaces violations before they become production crashes:
if (BuildConfig.DEBUG) {
StrictMode.setVmPolicy(
StrictMode.VmPolicy.Builder()
.detectAll()
.penaltyLog()
.build()
)
}
StrictMode logs warnings early enough to act on them during development. Treat those warnings seriously โ they're telling you exactly where the next production crash is hiding.
Key Takeaways
- Pass IDs, not objects. Let the destination load data from a single source of truth โ Room, ViewModel, Repository. This pattern scales; full-object passing doesn't.
- The 1 MB limit is shared across all transactions. On a busy device, your 800 KB bundle might push another app's transaction over the edge. Or theirs pushes yours. Neither is pretty.
- Android 7.0 changed the behavior. Before Nougat, oversized data was silently dropped. From API 24 onward, it throws. If you're targeting modern APIs and seeing new crashes on old features, this is worth checking.
- Bitmaps are the usual suspect. A 1024ร768 uncompressed image is roughly 3 MB as a byte array. That's three times the total IPC buffer. Never put bitmaps in a Bundle.
- Nested Parcelable lists grow fast. Two hundred list items, each with ten String fields, can quietly exceed 500 KB.

