Every list you hand to Swift is copied, and the cost is per call
A Kotlin List becomes an NSArray by conversion, not by reference.
TL;DR
- A SwiftUI list bound to
viewModel.visibleItems— a KotlinList<Item>property — dropped to 41 fps on a 2,000-row feed. The Kotlin side was doing no work; the property read was copying 2,000 objects, 60 times a second. - A Kotlin collection crossing to Swift is converted, not referenced.
List<Item>becomes anNSArrayby building a new array and bridging every element. - Three changes took it to a steady 60 fps: expose a count and an indexed accessor, convert once per change rather than per read, and keep the element type small.
- What reading on buys: the per-call cost by collection size, which conversions are O(1) and which are not, and the one measurement that distinguishes this from a Kotlin performance problem.
The case this post is about
A feed view model in shared code, exposing its items the obvious way:
// commonMain/kotlin/feed/FeedViewModel.kt
class FeedViewModel(repo: FeedRepo) {
private val _state = MutableStateFlow(FeedState())
/** Everything the list should show, filtered and sorted. */
val visibleItems: List<Item>
get() = _state.value.items
.filter { it.matches(_state.value.query) }
.sortedWith(_state.value.sort.comparator)
}
// iosApp/Sources/FeedView.swift
struct FeedView: View {
@ObservedObject var model: FeedModel
var body: some View {
List(model.viewModel.visibleItems, id: \.id) { item in
FeedRow(item: item)
}
}
}
That reads correctly in both languages and is the shape every Kotlin consumer uses.
The symptom
Scrolling a 2,000-row feed on an iPhone 12:
# Xcode ▸ Instruments, Time Profiler, 10 s of scrolling
frame rate 41 fps (target 60)
heaviest stack
43.2% SharedFeedViewModel.visibleItems
38.1% kotlin.collections.List → NSArray conversion
4.8% SharedItem allocation
allocations during the scroll 1,214,000 objects
A million allocations in ten seconds, inside a property getter that does a filter and a sort. The first hypothesis was the filter and the sort — the Kotlin code is doing real work, on every read, which is a genuine problem and is not this one.
Caching the computation did not help much:
// cached: filter and sort only when the inputs change. NOT `by lazy` — lazy caches once and
// forever, and its argument is a lock rather than a dependency.
val visibleItems: List<Item> get() = computed.value
private val computed: StateFlow<List<Item>> = _state
.map { s -> s.items.filter { it.matches(s.query) }.sortedWith(s.sort.comparator) }
.stateIn(scope, SharingStarted.Eagerly, emptyList())
frame rate 44 fps
SharedFeedViewModel.visibleItems 39.4%
38.0% List → NSArray conversion ← unchanged
1.1% the cached computation
Three frames per second. The filter and sort were 1% and the conversion was 38%, and the conversion happens whether or not the Kotlin value is cached — because the conversion is not part of the Kotlin computation at all.

Why the obvious fix failed
The first attempt was to return an array type that sounded closer to the destination:
val visibleItems: Array<Item> get() = …
Array<Item> exports as NSArray too, with the same per-element bridge, and it costs a Kotlin
toTypedArray() on top. 44 fps → 43.
The second attempt was kotlinx.collections.immutable:
val visibleItems: ImmutableList<Item> get() = …
Immutability is a Compose stability concern and has no bearing on the Obj-C boundary — an
ImmutableList still exports as NSArray by conversion. Unchanged.
The third attempt was to hold the list on the Swift side and update it on change:
@Published var items: [Item] = []
// … updated from a Kotlin callback
Which does fix the per-read cost and introduced a second copy: the callback hands over a Kotlin
list, that gets converted once, and then SwiftUI's diffing copies the [Item] again on each
assignment. Better — 52 fps — and still paying a full conversion per change, which on a
fast-typing filter was once per keystroke.
The actual mechanism
There is no shared representation between a Kotlin collection and an NSArray, so the boundary
builds one.
List<Item> is a Kotlin object with Kotlin iteration. NSArray is an Obj-C class with its own
storage and its own protocol. Neither can be reinterpreted as the other, so the exporter emits a
conversion: allocate an NSArray, walk the Kotlin list, bridge each element, insert it.
Every element is bridged individually. Item is a Kotlin class, so each one is reached
through a SharedItem — an Obj-C object holding a reference to the Kotlin instance. The runtime
associates a Kotlin object with its wrapper, so the first read of a 2,000-item list allocates
2,000 wrappers and later reads of the same instances look the existing ones up. What is never
reused is the NSArray: it is allocated and filled element by element on every single read, and
the per-element retain, lookup and insert is the cost that repeats.
And the conversion happens per property read, because a property read is a method call across
the boundary. Swift has no way to know the value is the same as last time; visibleItems is
[SharedFeedViewModel visibleItems], and the exporter's implementation converts on each
invocation.
Which is why SwiftUI makes it expensive. body is re-evaluated whenever SwiftUI decides to —
during a scroll, that is every frame. Each evaluation reads model.viewModel.visibleItems once,
which is one full conversion of 2,000 elements at 60 Hz.
So the cost is not Kotlin's and not Swift's: it is elements × reads, and a declarative UI makes
reads large. That also explains why caching the Kotlin computation barely moved it — caching
reduces the work behind the property and not the number of conversions.
What is and is not O(1) across the boundary turned out to be the useful thing to know:
an Int, a String, a Boolean bridged per value, cheap, no allocation for Int
a Kotlin class instance one wrapper allocation, then cached per instance
List<T> / Set<T> / Map<K,V> FULL conversion, every call, O(n)
a count (Int property) O(1)
an indexed get(i) O(1) plus one element bridge
A count and an indexed accessor are both O(1), which is exactly what a list view needs.

The fix
Expose the shape a list view actually consumes — a count and an indexed accessor — and convert at most once per change rather than once per read.
// commonMain/kotlin/feed/FeedViewModel.kt
class FeedViewModel(private val repo: FeedRepo) {
private val _state = MutableStateFlow(FeedState())
/** For Kotlin consumers: the real collection, unchanged. */
val visibleItems: List<Item> get() = computed.value
// Computed once per input change, not per read. The Kotlin side was never the cost, and
// this is here so the indexed accessor below is O(1) rather than O(n) per element.
private val computed: StateFlow<List<Item>> = _state
.map { s -> s.items.filter { it.matches(s.query) }.sortedWith(s.sort.comparator) }
.stateIn(scope, SharingStarted.Eagerly, emptyList())
// ── the exported surface for a list view ──────────────────────────────────────────
//
// A count and an indexed get. Both O(1) across the boundary, where the collection
// property is O(n) PER READ — and a declarative UI reads it once per frame.
val visibleCount: Int get() = computed.value.size
/**
* Returns null rather than throwing on an out-of-range index. SwiftUI can ask for an
* index from a frame it rendered before the list shrank, and a crash there is a race
* nobody can fix from the Swift side.
*/
fun visibleItemAt(index: Int): Item? = computed.value.getOrNull(index)
/** A monotonic token Swift can compare to decide whether to rebuild its own array. */
val visibleRevision: Long get() = revision.value
private val revision = MutableStateFlow(0L)
.also { r -> scope.launch { computed.collect { _ -> r.update { it + 1 } } } }
}
// iosApp/Sources/FeedView.swift
struct FeedView: View {
@ObservedObject var model: FeedModel
var body: some View {
// Driven by a COUNT, so body's evaluation costs one Int read instead of one
// 2,000-element conversion. Each row then bridges exactly the one item it draws.
List(0..<Int(model.viewModel.visibleCount), id: \.self) { i in
if let item = model.viewModel.visibleItemAt(index: Int32(i)) {
FeedRow(item: item)
}
}
}
}
And the element type, which was the third of the three and is worth its own note:
// commonMain/kotlin/feed/Item.kt
//
// The exported row type carries ONLY what a row draws. The full Item had 19 properties
// including a nested author object and a tag list — and a List<Item> conversion bridges
// the nested collection for every element, so the per-element cost was itself O(n).
data class ItemRow(
val id: String,
val title: String,
val subtitle: String,
val timestampLabel: String, // pre-formatted: formatting on the Swift side is a second bridge
val isUnread: Boolean,
)
And the output it produced, same device, same 2,000-row feed, same ten seconds of scrolling:
$ # Instruments, Time Profiler
before cached final
frame rate 41 fps 44 fps 60 fps
visibleItems / visibleCount share 43.2% 39.4% 1.8%
allocations during the scroll 1,214,000 1,190,000 18,400
per-keystroke filter latency 84 ms 31 ms 4 ms
How we proved it
That the conversion and not the Kotlin computation was the cost — the measurement that redirected the whole investigation:
visibleItems share of which conversion
as shipped (filter + sort per read) 43.2% 38.1%
with the computation cached 39.4% 38.0%
Caching removed the filter and the sort and left 38%. The remaining cost is not in any Kotlin function.
That it is per-read and not per-change. A counter on the exported getter, during a scroll with no data changing at all:
data changes in 10 s of scrolling 0
visibleItems getter invocations 614
elements converted 1,228,000
Six hundred reads of an unchanged value. Nothing was recomputed and everything was reconverted.
That a count and an indexed get are O(1) across the boundary, measured rather than assumed:
collection size visibleItems (ms) visibleCount (ms) visibleItemAt (ms)
100 0.9 0.004 0.006
1,000 8.7 0.004 0.006
2,000 17.4 0.004 0.006
10,000 86.1 0.005 0.006
The collection property is linear and the two accessors are flat. That table is the whole fix.
That the element type's own collections multiply the cost. The nested-list discovery, which I had not expected:
element type conversion of 2,000 elements
Item (19 props, nested author, List<Tag>) 17.4 ms
Item without the List<Tag> 9.1 ms
ItemRow (5 flat props) 2.2 ms
A List<Item> where Item contains a List<Tag> bridges the inner list per element, so the
per-element cost is itself O(n). Flattening the row type was worth very nearly 8× before any of
the other changes.
That the nullable accessor is necessary and not defensive padding. A fixture that shrinks the list between a frame's layout and its row rendering:
$ ./gradlew :feature:feed:iosSimulatorArm64Test --tests '*IndexFromAStaleFrame*'
with visibleItemAt(i): Item → IndexOutOfBoundsException, crash
with visibleItemAt(i): Item? → row skipped, next frame correct
SwiftUI asked for index 1,842 of a list that had just become 1,100 items. That is not a bug in either language; it is two render loops with different clocks.
What I cannot tell you is where the crossover is. At 100 elements the collection property costs
0.9 ms and nobody would restructure a view model for that — the indexed accessor is worth it
somewhere between 100 and 1,000, and exactly where depends on how often the UI reads. Our
threshold was "a scrollable list"; a fixed five-item menu still returns a List.
And an honest cost. The exported surface is now three members where it was one, and
visibleRevision is a token Swift has to remember to use. Two of the three Swift call sites that
needed it got it right and one did not — it rebuilt its array every frame anyway, which cost a
conversion we had just removed. A revision token is a protocol, and a protocol can be ignored.

What this generalizes to
A type that exists in both languages is not therefore shared, and the boundary's job is to produce one from the other — which for an aggregate means touching every element. The question to ask of any cross-boundary property is how often it is read and how much it costs per read, because a declarative UI turns a cheap-looking property access into a per-frame operation. Where the answer is O(n) per frame, the fix is usually to export the two O(1) questions a consumer actually asks rather than the aggregate it would prefer to receive.
Related
- A Flow does not cross to Swift. A callback does. — the other shape that needs rethinking rather than exporting, and the handle people omit.
- The shared module cost 400KB on Android and 11MB on iOS — the other cost of a generous exported surface, in bytes rather than frames.
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".