Design a Reusable Pagination Library
The hard part isn't paging data — it's building an API other developers will happily adopt, extend, and depend on for years.
The hard part isn't paging data — it's building an API other developers will happily adopt, extend, and depend on for years.
This chapter is different from the app case studies: the deliverable is a library, so the API surface is the user experience. Scope and API design (§1–2) set the contract; the architecture and deep dives (§3–7) show how to honor it under real constraints. Anything marked [+] is context added beyond the source chapter — everything else is a rewrite. Diagrams are original.
You're designing for developers, not users#
Pagination — loading a large dataset in chunks instead of all at once — has shown up in earlier app chapters. Here the goal flips: instead of paginating inside one app, build a reusable library that any app can drop in. As an organization grows, that consolidation pays off in consistency, less duplicated code, and one place to concentrate performance work.
Apps serve end users through screens; libraries serve developers through code. So the design goal shifts from a pleasant interface to a pleasant integration: how easily can another engineer adopt it, wire it into their architecture, and solve their pagination problem without reading your internals? Every API decision below is judged by that.
What the interview scoped in#
- Any data type, multiple techniques — offset-based and cursor-based pagination, over both local and remote data sources.
- Business logic only — no UI components. The library manages data; the app renders it.
- Caching — in-memory by default, optional disk cache for persistence across sessions.
- Prefetching — load pages around the current one (e.g. viewing page 5 quietly fetches 6 and 7).
- Open-source — which shapes API design, documentation, and versioning.
Non-functional requirements#
- Performance — efficient, low-overhead, and safe in a multi-threaded environment.
- Reliability — graceful handling of network failures; consistent behavior across devices and OS versions.
- Usability — an intuitive API with real documentation and learning resources.
UI pagination components and fast-scrolling support — both belong to the UI layer, which the library deliberately doesn't own.
Offset pagination asks for "page N" by numeric position — simple, but shifts if the list changes underneath you. Cursor pagination hands back an opaque token pointing at "the item after this one" — stable across inserts, which is why feeds use it. The library must serve both without picking sides.
The public API is the product#
A public API is everything developers touch directly — functions, classes, methods, properties. A good one lets
them use the library without understanding its internals, which takes three things: clear
documentation, stability so behavior doesn't shift under them, and an
intuitive design that follows platform conventions. The cornerstone is a single interface: the
Paginator.
Paginator<T>: one generic interface for everything#
Apps paginate wildly different things — feeds, chat histories, product catalogs. Rather than a class per type,
the library uses generics: Paginator<T> handles any data type from one implementation. That
buys flexibility (no duplicated classes), isolation (each instance owns its own cache and flow), lean memory
(each tracks only its data), and compile-time type safety.
Three responsibilities map to three methods — fetch a page, prefetch pages around one, and clear cached pages:
interface Paginator<T> {
fun fetch(
key: String, pageSize: Int,
listener: PaginatorFetchListener<T>,
priority: FetchPriority = FetchPriority.MEDIUM
): PaginatorExecution
fun fetchAround(
key: String, pageSize: Int,
depthLevel: Int, direction: PageFetchDirection,
listener: PaginatorFetchAroundListener<T>,
keyPriority: FetchPriority = FetchPriority.MEDIUM,
aroundPriority: FetchPriority = FetchPriority.LOW
): PaginatorExecution
fun clear(key: String)
}
// PageFetchDirection: ALL, FORWARD, BACKWARDkey is deliberately a plain String
That's what makes the API technique-agnostic. For offset pagination the key is a page index
("1", "2"); for cursor pagination it's a token ("cursor123"). Same
interface, either strategy — the caller's data source decides.
Calling a method repeatedly with identical inputs yields identical results — no surprise side effects from
a retry. [+] That property is what makes safe retries and prefetching possible
without corrupting state. Defining Paginator as an interface also makes apps easy to test: they
can swap in test doubles that simulate any scenario.
Returning results without forcing a framework#
How should the library hand back results and errors? To stay lightweight and dependency-free, it uses
native callbacks (the listener parameters) rather than mandating an async
framework. Each method returns a PaginatorExecution object so callers can cancel in-flight work.
The bigger principle: every third-party dependency you add becomes a required dependency for every app that
adopts you — so the core stays on native constructs and earns extensibility through optional modules (§6).
Data models — and why not a data class#
The central model is PaginatorPage<T>: the page's content plus the metadata needed to
navigate and cache it.
public class PaginatorPage<T>
content: List<T>
key: String
expirationTime: String? // enables smart cache refresh
prevKey: String? // navigation: previous page
nextKey: String? // navigation: next pagepublic class, not data class / struct
In apps, Kotlin data class and Swift structs are convenient. In a library they're a
trap. Kotlin auto-generates copy, componentN, equals,
hashCode; changing a property changes those signatures and breaks binary compatibility for
consumers. Swift structs are value types whose memory layout is part of their ABI [+] (the binary contract between compiled code), so adding a stored field can break
ABI stability. Plain classes give you control over the public surface across versions.
CallbackPaginator: the configurable implementation#
The interface is the contract; apps need something concrete to instantiate.
CallbackPaginator<T> is the full implementation, configured through dependency
injection so consumers plug in their own specialized pieces:
class CallbackPaginator<T>(
dataSource: PaginatorDataSource<T>, // where pages come from
validator: PageValidator<T>, // is a cached page still valid?
store: PaginatorStore<T>? = null, // optional on-disk storage
config: PaginatorConfig = DefaultPaginatorConfig(),
) : Paginator<T> { ... }- PaginatorDataSource<T> fetches each page — remote API, local DB, or a mix. Isolating retrieval keeps the paginator focused on orchestration.
- PageValidator<T> decides if a cached page is still good (expiration, integrity). A failed check triggers a fresh fetch.
- PaginatorStore<T> (optional) persists pages on disk for offline use.
- PaginatorConfig controls concurrency, eviction policy, and other knobs.
Some libraries use a static Paginator.init(...). Here each instance needs independent, highly
customizable configuration, so config lives per-instance. Built-in eviction policies cover
LRU, FIFO, and LIFO; developers can add custom ones —
TTL, memory-size limits, max page count.
Splitting work into focused parts#
The architecture leans on modularity: the Paginator orchestrates, and specialized components each
own one concern. Two are worth naming up front:
- PaginatorScheduler owns all request operations — queuing, ordering, processing responses — plus debouncing, throttling, retries, and concurrency. Pulling this out keeps the paginator's job small.
- PaginatorEvictor owns cache efficiency for both memory and disk, so eviction strategies can change without touching anything else.
The balance this strikes: developers plug in what data and where it persists; the library owns the tricky machinery of scheduling and cache management.
The dual cache#
Caching is what makes pagination feel instant. The library runs a two-tier system: a fast in-memory
cache always on, and an optional disk store for persistence. Both are tuned by the
eviction policies in PaginatorConfig.
Modeling a page's lifecycle explicitly#
The PaginatorInMemoryCache is a Map<String, PaginatorPageResult<T>>. The
value isn't just the page — it's the page's state, which removes a whole class of "is this loaded yet?"
bugs:
- Key absent — never fetched.
- Loading — a fetch is in flight.
- Success(page) — ready and validated.
- Error(message) — the fetch failed, with diagnostics.
Making these states first-class buys compile-time safety, an unambiguous status at any moment, easier debugging, and room to grow.
What happens on a fetch#
A fetch call (step 0) walks the tiers, only doing expensive work when the cheap options miss:
- In-memory check — a valid page here returns immediately. Lowest latency.
- Disk check (if a store is configured) — a valid page here is promoted into memory, then returned.
- Data-source fetch — both caches missed, so the Scheduler triggers the
PaginatorDataSource. - Validate — the
PageValidatorchecks the fresh page for formatting and expiration. - Cache update — once valid, write it to memory (5.1) and the disk store (5.2) if present.
- Return — hand the fresh page back to the caller.
Key mapping: it assumes cache keys map cleanly to network requests; when they don't, you need custom key-generation logic. Thread safety: the engine is multi-threaded, so cache access and updates must be atomic (concurrent maps, synchronization) or you get race conditions and corrupted pages. Wiring in analytics and logger providers surfaces hit/miss/eviction rates so developers can tune it.
Store5 (Kotlin Multiplatform) and Hyperoslo Cache (iOS) both write to
memory and disk — fast reads with a durable backup. Google's Jetpack Paging pushes a
network-plus-database hybrid: the local DB is the single source of truth, and a RemoteMediator
fetches new pages into it only when local data runs out.
Making urgent requests win#
Not all fetches are equal. A user scrolling for the next page must beat a background prefetch; in a
fetchAround, the page the user asked for must load before its neighbors. So the API exposes
priority controls, and the scheduler honors them.
That's why fetch takes a priority, and fetchAround splits into two:
keyPriority for the requested page (default MEDIUM) and aroundPriority
for the surrounding prefetch (default LOW). FetchPriority is simply HIGH,
MEDIUM, LOW.
How to schedule by priority#
| Approach | Upside | Downside |
|---|---|---|
| Queue per level | Simple; clear separation; direct control of order. | Degrades as levels grow; awkward load balancing; more memory. |
| Priority queue ✓ | Efficient ordering via a heap; one queue; low memory. | Heap-reorder overhead; no fairness — low-priority tasks can starve. |
| Thread pools per level | True hardware parallelism; good resource use. | Complex pool management; higher resource demand. |
| Weighted round-robin | Every priority gets processed; predictable; fair. | Fiddly weight tuning; high-priority tasks can still be delayed. |
A priority queue keeps tasks ordered by importance in one heap-backed structure [+] (a tree that always surfaces the highest-priority item in roughly log-n time), so urgent requests run first with a simple, low-footprint design — a good fit for mobile constraints. Its known weakness, starving low-priority work, is manageable for this workload.
Google's Volley networking library lets developers tag each request with a
Priority, so an "immediate" request jumps ahead in the queue — the same idea applied to network
scheduling.
Staying dependency-free and testable#
Two decisions make or break adoption of a mobile library: how it handles async work, and how easy it is to test.
Don't marry one async framework#
It's tempting to build the whole API around Kotlin Coroutines or Swift's Combine. But mandating one forces it on every consumer — imagine an RxJava app dragged into Coroutines just to paginate. The fix is a modular core-plus-extensions design: a dependency-free core, and optional adapters for each framework.
coroutines, combine,
rx) and a testing artifact bolt on as needed.paginator-core— essential functionality, zero external dependencies.paginator-coroutines,paginator-combine,paginator-rx— optimized adapters for each async stack.
The payoff: developers pull only the extensions their stack needs, the core stays portable, and each adapter
feels native to its framework. Distribution supports this — Gradle publishes multiple artifacts from one
codebase; on iOS you reach the same modularity via conditional compilation, extension files, or Swift Package
Manager targets. With a coroutines adapter, fetch becomes a clean
suspend fun fetch(...): PaginatorPage<T>; with Swift async/await,
func fetch(...) async throws -> PaginatorPage<T>.
Ship the test tooling too#
Because the API is built on interfaces, it's inherently testable — but the library goes further with a
dedicated testing-only artifact: pre-built test doubles for PaginatorDataSource,
PaginatorStore, and PageValidator; helpers to generate sample data or fake network
latency; and error-simulation tools. Consumers get thorough coverage without hand-rolling mocks, the doubles
stay in sync with releases automatically, and none of it bloats production builds.
Jetpack Paging 3 exposes one PagingData stream that can surface as LiveData,
an RxJava Flowable, or a Kotlin Flow — framework-agnostic by design. iOS's Moya was built
test-first: a sampleData / stubbed-response per endpoint lets unit tests run without real
network calls.
Failing gracefully, versioning honestly#
A pagination library sits where data sources, networks, and device limits collide — so failure isn't an edge case, it's the job. The principle throughout: degrade gracefully, report through callbacks, and never crash the host app.
| Failure point | Mitigation |
|---|---|
| Network | Retry with exponential backoff; surface detailed errors via callbacks so apps can fall back to cache or show a friendly message. |
| Database / local source | Robust error handling in PaginatorDataSource; configurable fallbacks like serving stale
cache. |
| Cache inconsistency | PageValidator checks expiry/checksums; invalid pages trigger invalidation and a fresh
fetch; eviction keeps caches lean. |
| Concurrency | Thread-safe structures (concurrent maps) and synchronization guard the cache and store against races. |
| Invalid page keys | Validate keys before processing; reject expired/malformed ones with a clear error so apps can reset to a known point. |
Versioning an open-source library#
Predictable versioning is what earns long-term trust. The library uses Semantic Versioning —
MAJOR.MINOR.PATCH:
- MAJOR — breaking changes needing code updates (e.g. changing the
Paginatorinterface or default caching behavior). - MINOR — new, backward-compatible features (e.g. extending
PaginatorConfig). - PATCH — bug fixes with no behavior change.
This isn't cosmetic: both Gradle/Maven and Swift Package Manager use SemVer as the foundation for dependency
resolution, so following it is what lets those tools resolve conflicts automatically. [+] SemVer also treats a pre-1.0.0 version as "anything may change," which is
why libraries signal stability by reaching 1.0.0.
Jetpack Compose pairs SemVer with Calendar Versioning. Individual Compose
libraries use SemVer, but a Bill of Materials uses CalVer (YYYY.MM.PATCH, e.g.
2025.02.00) to map one dated release to a set of mutually compatible library versions — solving
"which versions work together?" for a fast-moving toolkit.
Recap#
Library design is API design under a compatibility constraint. Almost every choice here — generics over
duplication, plain classes over data class, native callbacks over a framework, a lean core with
opt-in modules, strict SemVer — trades short-term convenience for a public surface you can evolve
without breaking the people who depend on you.
Further reading#
Every link below was fetched and confirmed live on 24 August 2026. One source reference — a JavaNexus blog post on optimizing Volley — now returns a 404, so it's omitted. One live page has been materially rewritten since the chapter; it's flagged.
API & binary compatibility
- Test doubleThe vocabulary behind the library's testing artifact: stubs, mocks, fakes, spies.
- Public API challenges in
KotlinExactly why
data classbreaks library compatibility, with thecopy/componentNtraps spelled out. - Swift ABI Stability ManifestoWhat ABI stability means and why struct layout is part of a library's binary contract.
- Swift Library Evolution (SE-0260)The mechanism (resilient types,
@frozen) that lets Swift libraries change without breaking ABI.
Caching & paging libraries
- Store5 (Kotlin Multiplatform)A production take on the memory-plus-disk pattern; actively maintained (5.1.0 line).
- Hyperoslo Cache (iOS)Swift caching with combined memory and disk storage and expiry — the iOS analogue.
- Jetpack
Paging 3 — network + databaseGoogle's
RemoteMediatorpattern: local DB as source of truth, network only when it runs dry.
Scheduling & networking
- Priority Queue patternThe pattern behind §5. Note: rewritten (updated Aug 2026) around single- vs multiple-queue implementations. changed
- Moya (iOS)A test-first networking
library;
sampleDatastubs make endpoints unit-testable without the network. - Handling network calls in Swift with MoyaA hands-on walkthrough of Moya's
TargetTypeand its stubbing for tests.
Versioning & distribution
- Semantic VersioningThe MAJOR.MINOR.PATCH contract the library commits to.
- Swift Package Manager — PackageDescriptionHow SPM consumes SemVer for dependency resolution, and how to define separate products/targets.
- Jetpack ComposeThe SemVer-plus-CalVer BOM example from §7, for coordinating many co-versioned libraries.