Skip to main content

Preventing TransactionTooLargeException: Managing Saved State in Modern Android (Android 15, 16, & 17)

Published: · 12 min read
Don Peter
Cofounder and CTO, Appxiom

Modern Android apps juggle a lot of UI state across configuration changes, process death, and navigation. If that state crosses process boundaries as a Bundle that’s too large, you’ll eventually hit the dreaded android.os.TransactionTooLargeException and crash at the worst possible time: when your user backgrounds the app, rotates the device, or navigates deep into your app.

This post shows how to make feature code resilient on Android 15+ (and beyond) by designing a saved-state strategy that’s small, reconstructable, and production-ready - without sacrificing UX.

Prerequisites

  • Android Studio Koala+ (2024.1.2 or newer)
  • Kotlin 2.0+
  • Gradle Android Plugin 8.5+
  • minSdk 23+ (examples assume 23, feel free to use higher)
  • compileSdk 35 (Android 15)
  • Target SDK 35 (works on Android 16–17; Binder limits still apply)
  • Libraries (latest stable in each series):
    • androidx.lifecycle:lifecycle-viewmodel-ktx 2.8+
    • androidx.lifecycle:lifecycle-viewmodel-savedstate 2.8+
    • androidx.navigation:navigation-compose 2.8+
    • androidx.compose BOM 2024.x+
    • androidx.datastore:datastore-preferences 1.1+
    • androidx.room:room-ktx 2.6+

Note: API version numbers above are indicative. Use the latest stable releases available in your project.

The real problem: Binder limits and what actually gets saved

  • Android persists Activity and Fragment state by marshalling a Bundle across Binder to system_server. Binder transactions have a strict upper bound (around 1 MB for the entire process’s in-flight transaction buffer; a single payload > ~1 MB typically fails). This limit has not meaningfully increased in recent Android releases (15–17), so you cannot “scale out” of it.
  • What contributes to that Bundle?
    • Activity onSaveInstanceState (and the FragmentManager’s state for all Fragments)
    • SavedStateRegistry (used by ViewModel SavedStateHandle)
    • Navigation back stack arguments
    • Compose rememberSaveable and Navigation/Compose saveable state registries
    • Any other extras you pass across process boundaries (Intents, PendingIntents, notifications, etc.)

If you stuff large Parcelables, big ByteArrays, image Bitmaps, full lists of domain objects, or many deep-stack entries with heavy arguments, you’re playing chicken with Binder.

The fix isn’t a magical flag; it’s an architectural stance:

  • Save keys, not payloads.
  • Reconstruct heavy state from repositories (Room, network cache, file URIs).
  • Keep per-screen saved state small and bounded.
  • Avoid passing large arguments through navigation or fragment results.

Design principles for saved state that won’t blow up

  1. Keep saved state thin and reconstructable

    • Save only stable identifiers (IDs, URIs, cursors, indices).
    • On restore, query repositories (Room/DataStore/Cache) to rebuild UI state.
  2. Separate three categories of state

    • Derivable state: Don’t save. Recompute from source of truth.
    • Small critical state: Save in SavedStateHandle or rememberSaveable.
    • Large/transient state: Keep in ViewModel memory. If you need it across process death, persist a compact reference and rehydrate.
  3. Treat Navigation arguments as “query params,” not “freight”

    • Pass IDs or small primitives only.
    • Don’t pass lists of entities, Bitmaps, or large serialized graphs.
  4. Use Compose saveables sparingly

    • Prefer remember for ephemeral state that doesn’t need persistence.
    • When you must persist, write a Saver that persists a small projection (e.g., page index, selection keys), not the entire list.
  5. Budget and measure

    • Log approximate saved-state size in debug.
    • Cap your per-destination saved state (e.g., target < 200 KB total for the Activity bundle, much less per screen).

A production pattern: Photo Detail feature (end-to-end)

We’ll build a Photo Detail flow that edits captions and toggles favorites for large images. The trap would be to pass an entire Photo object or ByteArray via navigation and then “save” it across process death. Instead, we pass only photoId and rebuild.

Data model and repository

  • Photos are stored in Room (thumbnails on disk or via content URIs).
  • User edits (caption/favorite) are written to Room immediately.
  • For a short-lived “draft” (caption not yet confirmed), we keep it in memory and mirror only a small string value in SavedStateHandle, keyed by photoId.
// Room entities
@Entity(tableName = "photos")
data class PhotoEntity(
@PrimaryKey val id: String,
val uri: String, // content:// or file://
val caption: String,
val isFavorite: Boolean
)

@Dao
interface PhotoDao {
@Query("SELECT * FROM photos WHERE id = :id")
fun observePhoto(id: String): Flow<PhotoEntity?>

@Query("SELECT * FROM photos WHERE id = :id")
suspend fun getPhoto(id: String): PhotoEntity?

@Update
suspend fun update(photo: PhotoEntity)
}

class PhotoRepository(private val dao: PhotoDao) {
fun observe(id: String): Flow<PhotoEntity?> = dao.observePhoto(id)
suspend fun updateCaption(id: String, newCaption: String) {
val current = dao.getPhoto(id) ?: return
dao.update(current.copy(caption = newCaption))
}
suspend fun toggleFavorite(id: String) {
val current = dao.getPhoto(id) ?: return
dao.update(current.copy(isFavorite = !current.isFavorite))
}
}
@Composable
fun AppNavHost(navController: NavHostController) {
NavHost(navController, startDestination = "feed") {
composable("feed") {
FeedScreen(onOpenPhoto = { photoId ->
navController.navigate("photo/$photoId")
})
}
composable(
route = "photo/{photoId}",
arguments = listOf(navArgument("photoId") { type = NavType.StringType })
) {
val photoId = it.arguments!!.getString("photoId")!!
PhotoDetailRoute(photoId = photoId)
}
}
}

No heavy objects in arguments, just the ID.

ViewModel: small SavedStateHandle + repository rehydration

  • Use SavedStateHandle only for small, critical values (e.g., a draft caption string, selected page index).
  • Rehydrate the rest from Room on restoration.
data class PhotoUiState(
val isLoading: Boolean = true,
val photo: PhotoEntity? = null,
val draftCaption: String = "",
val error: String? = null
)

class PhotoDetailViewModel(
private val repository: PhotoRepository,
private val savedStateHandle: SavedStateHandle,
private val photoId: String,
private val io: CoroutineDispatcher = Dispatchers.IO
) : ViewModel() {

// Keyed by photoId to avoid collisions in back stack
private val draftKey = "draft_caption_$photoId"

private val _draftCaption = MutableStateFlow(savedStateHandle.get<String>(draftKey) ?: "")
val draftCaption = _draftCaption.asStateFlow()

val uiState: StateFlow<PhotoUiState> = combine(
repository.observe(photoId),
draftCaption
) { photo, draft ->
PhotoUiState(
isLoading = (photo == null),
photo = photo,
draftCaption = draft,
error = null
)
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), PhotoUiState())

init {
// Persist small draft to SavedStateHandle (process-death resilient)
viewModelScope.launch {
_draftCaption.collect { draft ->
// Keep this tiny: a short String
savedStateHandle[draftKey] = draft
}
}
}

fun onDraftChanged(newCaption: String) {
// Bound the size to something safe (e.g., 8 KB)
_draftCaption.value = newCaption.take(8 * 1024)
}

fun applyCaption() {
val caption = _draftCaption.value
viewModelScope.launch(io) {
repository.updateCaption(photoId, caption)
}
}

fun toggleFavorite() {
viewModelScope.launch(io) { repository.toggleFavorite(photoId) }
}
}

Key points:

  • We never put the PhotoEntity (or image data) into SavedStateHandle.
  • The only saved key is a small String (bounded).
  • The rest is rehydrated from Room after process death.

Compose screen: save only what you must

Use remember for ephemeral state. When you must persist between process death and restore (e.g., pager index), use rememberSaveable with a small primitive.

@OptIn(ExperimentalFoundationApi::class)
@Composable
fun PhotoDetailRoute(
photoId: String,
viewModel: PhotoDetailViewModel = hiltViewModel() // or provide via factory
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()

// Example of small saveable state: current info panel tab index
val (tabIndex, setTabIndex) = rememberSaveable { mutableStateOf(0) }

// Example pager index, also a small primitive
val pagerState = rememberPagerState(initialPage = 0, pageCount = { 3 })

PhotoDetailScreen(
state = uiState,
tabIndex = tabIndex,
onTabChange = setTabIndex,
pagerState = pagerState,
onDraftChange = viewModel::onDraftChanged,
onApplyCaption = viewModel::applyCaption,
onToggleFavorite = viewModel::toggleFavorite
)
}

@Composable
private fun PhotoDetailScreen(
state: PhotoUiState,
tabIndex: Int,
onTabChange: (Int) -> Unit,
pagerState: PagerState,
onDraftChange: (String) -> Unit,
onApplyCaption: () -> Unit,
onToggleFavorite: () -> Unit
) {
if (state.isLoading) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
return
}

val photo = state.photo ?: return

Column(Modifier.fillMaxSize()) {
// Use Coil/Glide to load from URI; do not pass Bitmaps around
AsyncImage(
model = photo.uri,
contentDescription = null,
modifier = Modifier.fillMaxWidth().height(300.dp),
contentScale = ContentScale.Crop
)

TabRow(selectedTabIndex = tabIndex) {
Tab(selected = tabIndex == 0, onClick = { onTabChange(0) }) { Text("Details") }
Tab(selected = tabIndex == 1, onClick = { onTabChange(1) }) { Text("Edit") }
Tab(selected = tabIndex == 2, onClick = { onTabChange(2) }) { Text("Meta") }
}

when (tabIndex) {
0 -> DetailsTab(photo, onToggleFavorite)
1 -> EditTab(
initialDraft = state.draftCaption,
onDraftChange = onDraftChange,
onApply = onApplyCaption
)
2 -> MetaTab(photo)
}
}
}

@Composable
private fun EditTab(initialDraft: String, onDraftChange: (String) -> Unit, onApply: () -> Unit) {
var text by remember(initialDraft) { mutableStateOf(initialDraft) }
LaunchedEffect(text) { onDraftChange(text) }

OutlinedTextField(
value = text,
onValueChange = { new -> text = new },
label = { Text("Caption") },
modifier = Modifier.fillMaxWidth().padding(16.dp),
maxLines = 3
)
Button(
onClick = onApply,
modifier = Modifier.padding(16.dp)
) { Text("Apply") }
}

Compose best practices here:

  • We only save small primitives (tab index, pager index).
  • The “draft” persists with SavedStateHandle (small string), so process death is fine.
  • We don’t save large lists or image payloads in rememberSaveable.

Guardrails: measure and cap saved-instance-state size (debug builds)

There’s no first-party API to tell you the exact bytes that will cross Binder, but you can approximate a Bundle’s marshalling size. Add a debug hook in your Activity to measure and log.

fun Bundle.approxSizeInBytes(): Int {
val parcel = Parcel.obtain()
return try {
parcel.writeBundle(this)
parcel.dataSize()
} catch (t: Throwable) {
-1
} finally {
parcel.recycle()
}
}

class MainActivity : ComponentActivity() {

override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)

if (BuildConfig.DEBUG) {
// The Activity’s final saved state includes Compose, Fragments, Nav, etc.
val size = outState.approxSizeInBytes()
if (size > 0) {
Log.w("SavedState", "Approx onSaveInstanceState size = ${size / 1024} KB")
if (size > 700 * 1024) { // leave headroom under Binder ~1MB
Log.e("SavedState", "Danger: saved state is very large; investigate keys.")
}
}
}
}
}

Tips to reduce large bundles you discover:

  • Audit SavedStateHandle keys and values. Remove or compress.
  • Replace large rememberSaveable values with remember + repository rehydration.
  • Remove large Nav arguments; pass IDs.
  • Avoid setFragmentResult for large payloads; it still saves into FragmentManager state.
  • Ensure you’re not accidentally saving a full list of items when only an index is needed.

When you really must persist more than a few KB

  • Use Room/DataStore/files as the long-term store and save only a reference in SavedStateHandle.
  • For user drafts longer than a few KB, write to DataStore keyed by the entity ID and timestamp. Restore on ViewModel init, then clean up when committed.
class DraftStore(private val dataStore: DataStore<Preferences>) {

private fun key(photoId: String) = stringPreferencesKey("draft_caption_$photoId")

val Scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)

suspend fun read(photoId: String): String {
val prefs = dataStore.data.first()
return prefs[key(photoId)] ?: ""
}

fun writeAsync(photoId: String, draft: String) {
Scope.launch {
dataStore.edit { it[key(photoId)] = draft.take(64 * 1024) } // hard cap
}
}

fun clearAsync(photoId: String) {
Scope.launch {
dataStore.edit { it.remove(key(photoId)) }
}
}
}

Use this as a supplement when SavedStateHandle is too small for your needs, but still keep data bounded.

  • Deep back stacks with large arguments multiply your risk. Each BackStackEntry can hold its own saved state and arguments.
  • Use a shared ViewModel scoped to the NavGraph to share in-memory state between sibling screens instead of passing large data via arguments:
    • ViewModel is not saved across process death unless you use SavedStateHandle, so still persist only small references.
  • For results between destinations, prefer:
    • A shared ViewModel property
    • Or a small navController.currentBackStackEntry?.savedStateHandle set/get with tiny payloads

Example: passing back a selected ID, not the entire object.

// Send small result
navController.previousBackStackEntry
?.savedStateHandle
?.set("selectedPhotoId", photoId)

// Observe result
val savedStateHandle = navController.currentBackStackEntry!!.savedStateHandle
LaunchedEffect(Unit) {
savedStateHandle.getStateFlow<String?>("selectedPhotoId", null)
.filterNotNull()
.onEach { id -> /* handle */ }
.launchIn(this)
}

Common mistakes that cause TransactionTooLargeException

  • Passing entire entities (especially lists) as navigation args or fragment arguments.
  • Storing Bitmaps, ByteArrays, or large JSON strings in Bundles or SavedStateHandle.
  • Using @Parcelize to make a big object “convenient” to pass around.
  • Compose rememberSaveable of large collections (e.g., thousands of IDs) across multiple destinations.
  • setFragmentResult with a large Bundle.
  • Packing large extras into Intents or PendingIntents (including notifications).

Fix pattern: persist to storage or cache, pass only a reference, reconstruct on arrival.

Troubleshooting and edge cases

  • Crash signature

    • android.os.TransactionTooLargeException
    • Followed by ActivityThread logs during onSaveInstanceState or stop.
    • Often reproducible by rotating the device after heavy interactions or backgrounding the app.
  • BadParcelableException after refactors

    • If you change a Parcelable class while having old instances in saved state, restore can fail.
    • Always keep saved state small and stable; avoid storing custom Parcelables when possible.
  • Compose navigation holding onto too much state

    • Each destination with rememberSaveable contributes to the Activity’s final saved bundle.
    • Audit: temporarily add a large-argument detector around your navigation graph and log counts of saveable entries per destination.
  • Fragments with massive view state

    • Huge nested hierarchies can save large view states. Prefer recomposition-driven UI; for RecyclerView, ensure you’re not stashing large adapters’ internal state in savedInstanceState.
  • Testing process death

    • Developer options: “Don’t keep activities” can stress test, but it’s extreme.
    • Better: put app to background, then kill process:
      • adb shell am kill com.example.app
      • or from Settings > Apps > Force stop
    • Relaunch from Recents and verify your screens restore correctly without crashes and with correct state reconstruction.

A small, safe Saver for Compose

If you need to persist a subset of a collection, write a Saver that compresses to the minimum.

data class SelectionState(val selectedIds: Set<String>)

// Only persist up to 50 IDs
val SelectionSaver: Saver<SelectionState, List<String>> = Saver(
save = { state -> state.selectedIds.take(50).toList() },
restore = { persisted -> SelectionState(persisted.toSet()) }
)

@Composable
fun RememberedSelection(): MutableState<SelectionState> {
return rememberSaveable(saver = SelectionSaver) {
mutableStateOf(SelectionState(emptySet()))
}
}

Checklist: keep your saved state under control

  • Only pass IDs/URIs through Navigation/Fragment arguments.
  • Keep SavedStateHandle values:
    • Small (primitives, short strings)
    • Bounded with explicit caps (e.g., take(8 KB))
    • Namespaced by screen/ID to avoid conflicts
  • Persist large or long-lived data to:
    • Room, DataStore, files
    • Reference it via IDs in saved state
  • Compose:
    • Prefer remember when persistence isn’t required
    • Use rememberSaveable only for small primitives or compact Savers
  • Add a debug-only onSaveInstanceState size logger; keep total well under ~700 KB to allow headroom.
  • Test process death regularly.

Detecting TransactionTooLargeException in Production

Even with a well-designed saved-state strategy, unexpected navigation flows or edge cases can still trigger TransactionTooLargeException in production.

Appxiom automatically detects these crashes, correlates them with the affected user journeys, and helps prioritize fixes based on their business impact. Instead of relying solely on crash logs, developers can quickly identify where oversized saved state is occurring and which users are most affected.

Key takeaways

  • TransactionTooLargeException is a design smell, not a device quirk. The Binder limit is strict and not going away in Android 15–17.
  • Make saved state reconstructable: store references (IDs), reload from repositories, and keep per-screen state tiny.
  • Compose and Navigation make it easy to accidentally accumulate large saved state. Budget and measure.
  • When in doubt, move data to Room/DataStore and save a compact pointer in SavedStateHandle or Nav args.

Next steps

  • Add the saved-state size logger to your base Activity in debug.
  • Audit your Navigation arguments and SavedStateHandle usages; replace large values with IDs.
  • Introduce caps and Savers for Compose saveables.
  • Write process-death tests for critical flows (editor forms, long-running tasks, deep stacks).

With these practices, your app will restore reliably on Android 15, 16, and 17 - without Binder bringing it down.