All Posts
ArchitectureKotlinPaginationAndroid

The Pagination Pattern I Keep Coming Back To

A clean, cursor-based pagination implementation that works across REST and local databases without leaking infrastructure concerns into your UI layer.

February 20, 20262 min read

Pagination is one of those problems that looks solved until you try to extend it. Offset-based pagination breaks under concurrent inserts. Paging 3 is powerful but has significant API surface area. Most hand-rolled solutions leak infrastructure details into the ViewModel.

Here's the pattern I keep returning to.

The Interface

data class Page<T>(
  val items: List<T>,
  val nextCursor: String?,
  val hasMore: Boolean
)

interface PaginatedSource<T> {
  suspend fun load(cursor: String?, pageSize: Int): Result<Page<T>>
}

The ViewModel knows about Page<T> and PaginatedSource<T>. It knows nothing about HTTP, SQL, or offsets. Those are implementation details of whatever class implements the interface.

Cursor-based pagination survives concurrent writes. If new items are inserted between pages, the cursor still points to the correct position. Offsets silently skip or duplicate items.

The Coordinator

class PaginationCoordinator<T>(
  private val source: PaginatedSource<T>,
  private val pageSize: Int = 20,
  private val scope: CoroutineScope
) {
  private val _state = MutableStateFlow(PaginationState<T>())
  val state = _state.asStateFlow()

  fun loadNext() {
    val current = _state.value
    if (current.isLoading || !current.hasMore) return

    scope.launch {
      _state.update { it.copy(isLoading = true) }
      source.load(current.nextCursor, pageSize)
        .onSuccess { page ->
          _state.update { s ->
            s.copy(
              items = s.items + page.items,
              nextCursor = page.nextCursor,
              hasMore = page.hasMore,
              isLoading = false
            )
          }
        }
        .onFailure { error ->
          _state.update { it.copy(isLoading = false, error = error) }
        }
    }
  }

  fun refresh() {
    _state.value = PaginationState()
    loadNext()
  }
}

Why This Works

The coordinator is not a ViewModel. It's a plain class that accepts a coroutine scope. The ViewModel creates it, passes its own viewModelScope, and exposes coordinator.state directly.

When you need to switch from a REST source to a local database source for offline support, you swap the PaginatedSource implementation. The ViewModel, the coordinator, and the UI are unchanged.

This is the part most pagination implementations get wrong: they hardcode the data source strategy into the coordinator, which means changing backends requires rewriting the pagination logic too.