Paging that scales with your data

Render thousands of synced rows without dropping frames — on Android and iOS

Platform:

The problem

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?

Loading the whole table

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 writes

OFFSET-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.

Filters scan the whole blob

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.

What buoyient gives you

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.

Android — LazyColumn
Jetpack Paging 3 via BuoyientPagingSource
iOS — SwiftUI List
BuoyientPagedList with StateFlow
SyncableObjectService.loadPage()
Keyset cursor pagination — one page in, one page out. Composes filters, sort order, and sync-status.
SQLite
B-tree seek + sequential read. Optional expression indexes on hot filter paths.

What you can do with it

The basics most consumers care about — plus the knobs you reach for when your dataset gets large.

Bidirectional scrolling

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.

Composable filters

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.

Configurable sort key — with per-call direction

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.

Sync-status filtering

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.

Live updates

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.

Indexes where they matter

Declare indexedJsonPaths on your service and buoyient creates a SQLite expression index per path. Filter queries that would scan the table now seek directly.

Quick start

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.

Add the dependency

build.gradle.kts
dependencies {
    implementation("com.elvdev.buoyient:syncable-objects:<version>")
    implementation("com.elvdev.buoyient:syncable-objects-paging:<version>")
}

Configure the paging key on your service

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.

TodoService.kt
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
)

Bind it to LazyColumn

TodoListScreen.kt
@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.

Configure the paging key on your service

Defined in your Kotlin shared module — same as Android. Default is by clientId; override for a real sort key.

TodoService.kt (shared module)
class TodoService(/* ... */) : SyncableObjectService<Todo, TodoRequestTag>(
    serializer = Todo.serializer(),
    serverProcessingConfig = todoServerConfig,
    serviceName = "todos",
    pagingConfig = PagingConfig(
        keyExtractor = { it.createdAt },
        sortOrder = PagingConfig.SortOrder.DESC,
    ),
    indexedJsonPaths = listOf("$.status"),
)

Drive a SwiftUI List

TodoListView.swift
import 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.

Filtering, composed

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.

Common predicates

Filter.kt
// 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")

Boolean composition

Filter.kt
// 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.

Letting users flip direction

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.

Bind a sort-direction StateFlow to the pager

Reconstruct 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.

TodoListViewModel.kt
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
    }
}

Bind a SwiftUI @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.

TodoListView.swift
@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.

Built for big lists

A few details that matter once your dataset grows past what fits comfortably on a screen.

Keyset cursor, not OFFSET

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).

Expression indexes for hot filter paths

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.

Eight prepared statements for the no-filter hot path

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.

Stable cursors under concurrent writes

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.

When you want the primitive

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.

loadPage example
// 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.

Ready to wire it up?

Full step-by-step guides on both platforms, including refresh keys, filter recipes, and the design rationale behind the cursor model.

Android guide → iOS guide → Setup Guide Design plan

The design plan covers cursor semantics, the eight-query routing, and why filter queries drop to dynamic SQL.