Render thousands of synced rows without dropping frames — on Android and iOS
buoyient keeps your local database hydrated with offline-first sync. But once you have hundreds or thousands of rows, the question becomes: how do you render them without making the UI sluggish?
A naive SELECT * works for 50 rows. At 5,000 rows your list jank-scrolls; at 50,000 the app freezes for a second when the view appears.
OFFSET pagination breaks under writesOFFSET-based pagination silently skips or duplicates rows when items are inserted or deleted between page loads — exactly what happens when sync-down lands while a user is scrolling.
Your data is stored as a JSON blob inside SQLite. Filtering “all rows where status = active” means a full table scan unless you've thought carefully about indexes.
Two layers: a fast, correct keyset cursor primitive that ships in every buoyient artifact, and a platform adapter that plugs into the idiomatic UI library on each platform.
BuoyientPagingSourceBuoyientPagedList with StateFlowThe basics most consumers care about — plus the knobs you reach for when your dataset gets large.
Append new pages as the user scrolls down. Or start mid-list (e.g., resume at a saved cursor) and prepend earlier rows when they scroll up. Items stay in the configured sort order, always.
Filter on any JSON field with Filter.eq, Filter.gt, Filter.like, plus and, or, not. Filters compose cleanly with pagination, sort order, and sync-status — one call, one page.
Page by created_at, alphabetical name, priority, or anything else. Direction defaults to the service's configured sortOrder but can be flipped per call — useful for "newest first" vs "oldest first" toggles without reconstructing the service.
Show only synced rows. Or only pending writes. Or only the row in conflict. Composes with everything else — "filter to active, only show synced, page by created_at descending" is one call.
Opt in to autoRefreshOnLocalStoreChange and your list re-renders when sync-down lands, when the user creates a row offline, or when a conflict resolves — no manual wiring.
Declare indexedJsonPaths on your service and buoyient creates a SQLite expression index per path. Filter queries that would scan the table now seek directly.
Wire a BuoyientPagingSource into a Pager and bind it to a LazyColumn. About 20 lines of Compose plus what your row composable looks like.
Construct a BuoyientPagedList, bind its items StateFlow to a SwiftUI List, and trigger loadMore() when the bottom row appears.
dependencies {
implementation("com.elvdev.buoyient:syncable-objects:<version>")
implementation("com.elvdev.buoyient:syncable-objects-paging:<version>")
}
Default is by clientId. Override pagingConfig in the service constructor to page by something meaningful — usually the timestamp or domain-specific field you sort by in your UI.
class TodoService(/* ... */) : SyncableObjectService<Todo, TodoRequestTag>(
serializer = Todo.serializer(),
serverProcessingConfig = todoServerConfig,
serviceName = "todos",
pagingConfig = PagingConfig(
keyExtractor = { it.createdAt },
sortOrder = PagingConfig.SortOrder.DESC,
),
indexedJsonPaths = listOf("$.status"), // optional: index hot filter paths
)
LazyColumn@Composable
fun TodoListScreen(todoService: TodoService) {
val pager = remember {
Pager(PagingConfig(pageSize = 20)) {
BuoyientPagingSource(
service = todoService,
filter = Filter.eq("$.status", "active"),
autoRefreshOnLocalStoreChange = true,
)
}
}
val items = pager.flow.collectAsLazyPagingItems()
LazyColumn {
items(count = items.itemCount, key = items.itemKey { it.clientId }) { index ->
items[index]?.let { TodoRow(it) }
}
}
}
That's it. Paging 3 handles the rest — prefetch, append on scroll, prepend if you set an initialKey, diffing, recomposition. Background sync-down arrives? With autoRefreshOnLocalStoreChange = true the list updates on its own.
BuoyientPagedList ships in the existing framework
No extra SPM target. The class lives in Buoyient.xcframework alongside the rest of the sync engine. Just import Buoyient and use it.
Defined in your Kotlin shared module — same as Android. Default is by clientId; override for a real sort key.
class TodoService(/* ... */) : SyncableObjectService<Todo, TodoRequestTag>(
serializer = Todo.serializer(),
serverProcessingConfig = todoServerConfig,
serviceName = "todos",
pagingConfig = PagingConfig(
keyExtractor = { it.createdAt },
sortOrder = PagingConfig.SortOrder.DESC,
),
indexedJsonPaths = listOf("$.status"),
)
Listimport SwiftUI
import Buoyient
struct TodoListView: View {
let service: TodoService
@State private var items: [Todo] = []
@State private var list: BuoyientPagedList<Todo, TodoRequestTag>?
var body: some View {
List(items, id: \.clientId) { item in
TodoRow(item: item)
.task {
if item.clientId == items.last?.clientId {
try? await list?.loadMore()
}
}
}
.refreshable { try? await list?.refresh() }
.task {
let pagedList = BuoyientPagedList(
service: service,
pageSize: 20,
syncStatus: nil,
filter: Filter.eq(path: "$.status", value: "active"),
autoRefreshOnLocalStoreChange: true,
initialKey: nil
)
list = pagedList
try? await pagedList.refresh()
for await snapshot in pagedList.items {
items = snapshot
}
}
.onDisappear { list?.close() }
}
}
The for await loop bridges Kotlin's StateFlow (mapped by SKIE to a Swift AsyncSequence) into SwiftUI state. The closure is already main-actor-isolated, so the assignment is safe.
The Filter sealed type is the same on both platforms. Combine predicates with and / or / not and pass the result straight to loadPage(), BuoyientPagingSource, or BuoyientPagedList.
// Equality, comparison, set membership
Filter.eq("$.status", "active")
Filter.gt("$.priority", 3)
Filter.isIn("$.assignee", listOf("me", "alice"))
// Pattern matching (SQL LIKE)
Filter.like("$.title", "buy%")
// Null checks
Filter.isNull("$.dueDate")
// All clauses must match
Filter.and(
Filter.eq("$.status", "active"),
Filter.gte("$.priority", 5),
)
// Any clause matches
Filter.or(
Filter.eq("$.assignee", "me"),
Filter.isNull("$.assignee"),
)
// Inverts
Filter.not(Filter.eq("$.status", "archived"))
Filters compile to a single SQL WHERE clause that joins the cursor predicate, the sort order, and your optional sync-status constraint. No N+1 queries, no in-memory filtering after the fact.
Most apps have a natural sort — newest first, alphabetical, by priority. Some need a user-flippable toggle. The sortOrder parameter on each layer lets you do this without touching the service.
StateFlow to the pagerReconstruct the BuoyientPagingSource when the user flips the toggle. flatMapLatest cancels the old pager's flow so the UI only sees items from the current direction. Cursors don't carry across directions; reconstruction starts fresh from the head, which is what the user expects.
val sortOrderFlow = MutableStateFlow(PagingConfig.SortOrder.DESC)
val pagedItems: Flow<PagingData<Todo>> = sortOrderFlow
.flatMapLatest { order ->
Pager(PagingConfig(pageSize = 20)) {
BuoyientPagingSource(todoService, sortOrder = order)
}.flow
}
.cachedIn(viewModelScope)
fun toggleSort() {
sortOrderFlow.value = when (sortOrderFlow.value) {
PagingConfig.SortOrder.DESC -> PagingConfig.SortOrder.ASC
PagingConfig.SortOrder.ASC -> PagingConfig.SortOrder.DESC
}
}
@State to .task(id:).task(id: sortOrder) cancels the old list's for await loop and runs a fresh one whenever sortOrder changes. The new BuoyientPagedList starts from the head with the new direction. Same pattern you already use for dynamic filters.
@State private var sortOrder: PagingConfigSortOrder = .desc
@State private var items: [Todo] = []
@State private var list: BuoyientPagedList<Todo, TodoRequestTag>?
var body: some View {
VStack {
Picker("Sort", selection: $sortOrder) {
Text("Newest first").tag(PagingConfigSortOrder.desc)
Text("Oldest first").tag(PagingConfigSortOrder.asc)
}
.pickerStyle(.segmented)
List(items, id: \.clientId) { TodoRow(item: $0) }
}
.task(id: sortOrder) { // re-runs on toggle
let pagedList = BuoyientPagedList(
service: service,
pageSize: 20,
sortOrder: sortOrder
)
list = pagedList
try? await pagedList.refresh()
for await snapshot in pagedList.items {
items = snapshot
}
}
}
Cursor note. A PageCursor returned in one direction isn't meaningful when reused with the opposite direction — the cursor predicate flips. The reconstruction pattern above handles this for you because the new source starts at FromHead. If you're calling loadPage() directly, just don't mix cursors across sortOrder values.
A few details that matter once your dataset grows past what fits comfortably on a screen.
Pages are addressed by an opaque PageCursor — a tuple of (paging_key, client_id). SQLite seeks directly to the right spot via the B-tree index. Adding rows between page loads doesn't cause skips or duplicates the way OFFSET would. Each page fetch is O(loadSize), not O(offset).
Declare indexedJsonPaths = listOf("$.status", "$.priority") on your service and buoyient runs CREATE INDEX IF NOT EXISTS for each path at startup. Filters on those paths skip the full-table scan and seek directly. Other paths still work — they just scan.
When you page without a filter, the call routes through one of eight prepared SQLDelight queries covering (first vs next) × (ASC vs DESC) × (sync-status filter or not). No dynamic SQL building, no statement-cache misses, predictable plan.
The cursor includes a client_id tiebreaker so two rows that share a paging key (identical timestamps, for instance) don't trip up next-page queries. Background sync-down can land mid-scroll without the user seeing a skipped or duplicated row.
The platform adapters are convenient, but sometimes you just need one page. service.loadPage() is the underlying primitive — available on every platform, no extra dependency.
// First page from the head, no filter
val firstPage = service.loadPage(loadSize = 20)
firstPage.items // List<Todo>
firstPage.nextCursor // PageCursor? - null = end of forward
firstPage.prevCursor // PageCursor? - null at the head
// Next page forward
val nextPage = service.loadPage(
direction = PageDirection.Forward(firstPage.nextCursor!!),
loadSize = 20,
)
// Backward from a cursor (e.g., user scrolled up from mid-list)
val earlierPage = service.loadPage(
direction = PageDirection.Backward(someCursor),
loadSize = 20,
)
// Filtered + sync-status constrained + paged
val filtered = service.loadPage(
direction = PageDirection.FromHead,
loadSize = 20,
syncStatus = SyncableObject.SyncStatus.SYNCED,
filter = Filter.eq("$.status", "active"),
)
Same call, same return type, every platform. Use it for one-shot queries, custom paging UIs, or just to peek at the first N rows.
Full step-by-step guides on both platforms, including refresh keys, filter recipes, and the design rationale behind the cursor model.
The design plan covers cursor semantics, the eight-query routing, and why filter queries drop to dynamic SQL.