On desktop your app has windows, and nothing closes them for you
A window holds a composition, a scope and native handles. Nothing releases them for you.
TL;DR
- A desktop build whose memory grew 38 MB per window open-and-close and whose process refused to exit after the last window was closed. Twenty cycles: 112 MB → 871 MB.
- A
Windowis not anActivity. Nothing in the platform owns its composition, its coroutine scope or its native handle, because on desktop yourmainis the owner. - The fix is three changes of ownership —
key(id)so a window's identity is its document rather than its position in a list, an explicitly-owned scope that flushes before it cancels, andexitApplicationdriven by a registry rather than by the composition. - What reading on buys: why
rememberCoroutineScopeis the wrong tool here, and the one measurement that distinguishes a leaked composition from a leaked native handle.
The case this post is about
A document editor with a multi-window menu — File ▸ New Window — written the way the Android
version was:
// desktopMain/kotlin/Main.kt
fun main() = application {
var openDocs by remember { mutableStateOf(listOf(DocId.new())) }
openDocs.forEach { id ->
Window(
onCloseRequest = { openDocs = openDocs - id },
title = "Editor — $id",
) {
// Each window builds its own view model, the way an Activity would
val vm = remember { DocumentViewModel(id, repo) }
DocumentScreen(vm, onNewWindow = { openDocs = openDocs + DocId.new() })
}
}
}
// commonMain/kotlin/DocumentViewModel.kt
class DocumentViewModel(private val id: DocId, private val repo: DocRepo) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
val state = MutableStateFlow(DocState.Loading)
init {
scope.launch { repo.watch(id).collect { state.value = DocState.Ready(it) } }
scope.launch { while (true) { delay(30.seconds); repo.autosave(id, state.value) } }
}
}
That reads correctly, and on Android it would be. onCloseRequest removes the id, the Window
leaves the composition, and the remembered view model goes with it.
The symptom
Two things, and the first one hid the second for a week.
Memory grew per window cycle and never came back. Opening and closing a window twenty times, sampled from the JVM itself after a forced collection:
$ jcmd <pid> GC.run && jcmd <pid> VM.native_memory summary | grep -A2 'Java Heap'
cycle 1 heap 112 MB
cycle 5 heap 263 MB
cycle 10 heap 457 MB
cycle 20 heap 871 MB # after an explicit GC — not garbage, reachable
And the process would not exit. Closing the last window left the app running with no visible
UI — no window, no dock icon state change, and jps still listing it:
$ jps -l
48213 com.example.editor.MainKt
Which on Android has no equivalent symptom at all, because the system kills the process. Here the
process is yours, and application { } exits when its composition ends — which it had not,
because something was still holding on.

Why the obvious fix failed
The first hypothesis was the view model's scope, and the fix was the obvious one:
val vm = remember { DocumentViewModel(id, repo) }
DisposableEffect(Unit) { onDispose { vm.close() } } // vm.close() cancels the scope
Memory per cycle: 38 MB → 31 MB. Better, and still unbounded — so something else was holding the window. The 7 MB it did recover was the autosave coroutine, which is real and was not the bulk.
The second hypothesis was the openDocs list holding stale ids. It was not; the list was correct
after every close:
cycle 20: openDocs.size == 0, heap 871 MB
An empty list and 871 MB of reachable heap is the point where I stopped guessing and took a heap dump. The dominator was not a view model and not a composition:
$ jcmd <pid> GC.heap_dump /tmp/e.hprof
# Eclipse MAT, dominator tree, top entry:
androidx.compose.ui.awt.ComposeWindow 20 instances 612 MiB retained
# (MAT reports MiB and labels it MB; the same bytes appear as 642 MB decimal in the
# histogram further down. 642,139,904 B = 612.4 MiB — one quantity, two conventions.)
└─ javax.swing.JFrame$AccessibleJFrame
└─ java.awt.Frame.framesList ← static, holds every frame ever created
java.awt.Frame keeps a static list of all frames, and nothing had removed ours from it. That is
not a Compose detail; it is AWT, two layers below, and no amount of disposing the composition
reaches it.
The actual mechanism
A desktop window is three resources with three different owners, and only one of them is Compose's:
The composition. Owned by the Window composable's place in the application composition.
Leaving the composition disposes it, and this part does work as expected.
Any scope you created inside it. Owned by nobody. CoroutineScope(...) in a class constructor
has no lifecycle; it is cancelled when something calls cancel(). On Android viewModelScope is
cancelled by onCleared(), which the ViewModelStore calls — and on desktop there is no store
and no onCleared.
The native window. Owned by the toolkit. A ComposeWindow wraps a JFrame, AWT registers
every Frame in a static list, and it is removed on dispose() — a method Compose calls for you
only if the window leaves the composition through the composition, which onCloseRequest
removing an id does do. Ours did not, for a reason that took the heap dump to see: openDocs
was a remember inside application, and forEach over it re-created the Window call sites
positionally. Removing an id from the middle of the list shifted every later window by one
position, so Compose treated them as content changes to existing windows rather than as one
window leaving — exactly the lazy-list identity problem, one layer up.
So the window that "closed" was actually the last one in the list leaving the composition, while the one the user closed kept its frame and had someone else's document rendered into it. Twenty cycles, twenty frames retained, and the visible UI looked right the whole time because the content had shifted along with the identity.
And the second owner compounds it: each retained frame holds the composition that was rendered
into it, which holds the view model, which holds the scope — so one missing identity key retained
all three resource classes at once. That is why the DisposableEffect fix recovered 7 MB and not
612: it disposed the scope of a view model whose window was still alive and still holding it.

The fix
Key the windows by identity, own the scope explicitly, and drive application exit from a registry rather than from the composition.
// desktopMain/kotlin/Main.kt
fun main() = application {
// A LIST keyed positionally is the bug. A map keyed by id makes each Window's identity
// its document, so removing one from the middle removes THAT window rather than shifting
// every later one into a new position.
val openDocs = remember { mutableStateMapOf<DocId, Unit>() }
.also { if (it.isEmpty()) it[DocId.new()] = Unit }
openDocs.keys.toList().forEach { id ->
// key() gives this Window call site a stable identity across recompositions, so a
// removal disposes this subtree instead of renaming its neighbours.
key(id) {
DocumentWindow(
id = id,
onClose = { openDocs.remove(id) },
onNewWindow = { openDocs[DocId.new()] = Unit },
)
}
}
// Exit is OURS to decide on desktop. Driving it off the registry rather than off the
// composition means a zero-window state exits even if a frame is still being torn down.
LaunchedEffect(openDocs.size) {
if (openDocs.isEmpty()) exitApplication()
}
}
@Composable
private fun DocumentWindow(id: DocId, onClose: () -> Unit, onNewWindow: () -> Unit) {
Window(onCloseRequest = onClose, title = "Editor — $id") {
// rememberCoroutineScope() would be WRONG here: it is cancelled when the composition
// leaves, which is right for UI work and wrong for a 30-second autosave that must
// complete its in-flight write. An explicitly-owned scope with a declared teardown
// lets the close path await the final save.
val vm = remember(id) { DocumentViewModel(id, repo) }
DisposableEffect(id) {
onDispose {
// Ordered: flush first, THEN cancel. Cancelling first discards the write,
// which is a data-loss bug that only appears on a fast close.
vm.flushBlocking(timeout = 2.seconds)
vm.close()
}
}
DocumentScreen(vm, onNewWindow = onNewWindow)
}
}
And the view model's scope, with the teardown it now owes:
// commonMain/kotlin/DocumentViewModel.kt
class DocumentViewModel(private val id: DocId, private val repo: DocRepo) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
val state = MutableStateFlow<DocState>(DocState.Loading)
init {
scope.launch { repo.watch(id).collect { state.value = DocState.Ready(it) } }
scope.launch { while (isActive) { delay(AUTOSAVE); save() } }
}
/** Blocking by design: a window close is synchronous and the write must land first. */
fun flushBlocking(timeout: Duration) = runBlocking {
withTimeoutOrNull(timeout) { save() }
?: logger.warn("autosave for $id did not complete in $timeout — window closed anyway")
}
fun close() = scope.cancel()
private suspend fun save() = (state.value as? DocState.Ready)?.let { repo.autosave(id, it) }
companion object { val AUTOSAVE = 30.seconds }
}
And the output it produced, the same twenty-cycle run:
$ jcmd <pid> GC.run && jcmd <pid> VM.native_memory summary | grep 'Java Heap'
cycle 1 heap 109 MB
cycle 5 heap 114 MB
cycle 10 heap 111 MB
cycle 20 heap 113 MB
$ # close the last window
$ jps -l | grep -c editor
0
Flat across twenty cycles, and the process exits.
How we proved it
That the retained frames were the dominator and not the view models — the measurement that redirected the whole investigation:
$ jmap -histo:live <pid> | head -5
num #instances #bytes class name
1: 20 642139904 androidx.compose.ui.awt.ComposeWindow
2: 20 7340032 com.example.DocumentViewModel
Twenty windows for one visible window, and the view models were 1% of the bytes attributed to
the two classes. The first fix had been aimed at the 1%. (The retained figure is the dominator
tree's, above — jmap -histo reports per-class totals, not retained-set sizes, so it is the
instance count and the ratio that this corroborates.)
That the cause was positional identity and not disposal. The test is to close a window from the end of the list rather than the middle, which positional identity handles correctly:
close the LAST window, 20 cycles heap 111 MB → 118 MB (frames: 1)
close the FIRST window, 20 cycles heap 112 MB → 871 MB (frames: 20)
Same code, same count of opens and closes, and the only difference is which one the user closed. That asymmetry is what a disposal bug cannot produce and an identity bug must.
That key(id) is the load-bearing change rather than the map, which is the one I expected to
matter:
list + key(id) 20 cycles, close-first: heap 113 MB frames 1
map, no key(id) 20 cycles, close-first: heap 844 MB frames 19
map + key(id) 20 cycles, close-first: heap 113 MB frames 1
The map is tidier and did nothing on its own. mutableStateMapOf changes how you store the ids;
key() changes what Compose believes an item is, and only the second one was the bug.
That the flush-before-cancel ordering prevents real data loss. A deliberately-broken fixture with the two calls swapped, run before the fix was trusted:
$ ./gradlew :desktop:test --tests '*CloseDuringAutosave*'
FAILED: expected document content "…final edit" but repo held "…previous edit"
(cancel() preceded flushBlocking(); the in-flight write was discarded)
The limit worth naming. flushBlocking blocks the AWT event thread for up to two seconds, and
on a slow disk a user closing a window will see the UI hang. That is a deliberate trade — losing
the last edit is worse — but it is a trade, not a solution, and the honest version would move the
write off the close path entirely by making autosave synchronous on every edit. We did not do
that, because the write amplification on a large document was worse than the hang.
And what I cannot tell you: whether the 38 MB per cycle generalises. It is a function of this
document model's size, and a window rendering a small form would retain correspondingly less. What
transfers is the shape — a retained ComposeWindow holds everything composed into it — and the
measurement that finds it is a histogram of live instances, not a heap total.

What this generalizes to
On Android, three resource lifetimes are managed for you by components whose whole job is managing
them, and the habit that produces is to treat lifecycle as ambient. Desktop removes that: main
is yours, the window is a real object with a real owner, and nothing calls onCleared. The
reliable question when porting a screen is not "what did the Activity do" but "which object owns
this, now that no framework does" — and the answer is usually that you have to name an owner
explicitly, which is also the version that is easier to reason about on every target.
Related
keytells a lazy list what an item is — the same positional-identity defect inside a list, where the symptom is remembered state landing on the wrong row.- A ViewModel's lifecycle is a property of its owner — what supplies
onClearedon each platform, and what you owe where nothing does.
Keep reading
One post a week on shipping mobile software. No digests, no course pitch. Subscribe.
One post a week, no digest
New writing when it lands. No roundups, no course pitch, no "10 tips".