How to read this. This is a library question, not an app-feature question. Your users are app developers, and your product is the public API. Sections 1 to 9 use Kotlin, with Jetpack Paging 3 as the reference design. Section 10 shows what an iOS equivalent looks like.
TL;DR. Split the library into a small stable public core (a page source contract, a config, a state model, a stream of results) and an internal engine that owns caching, deduplication, cancellation and prefetch. Make the page key a generic type so offset, cursor and page number all fit. Model load state explicitly per direction (refresh, prepend, append). Let the app own the data source and the UI, and let the library own the scheduling. Treat every default, error type and threading rule as part of the API you must keep compatible.
1. Clarify the requirements (about 5 min)
The consumers are other engineers, so ask them. Good opening questions:
- Who are the integrating teams, and how many? (Assume five app teams, each with different backends.)
- Which key styles must we support? (Offset, page number and opaque cursor, all three.)
- Network only, or network plus a local database? (Both: the database is optional but must compose cleanly.)
- Which UI toolkits? (Compose and RecyclerView on Android; SwiftUI and UIKit on iOS. The core must not depend on any UI toolkit.)
- Is the list one-directional (feed) or bidirectional (chat history)? (Support both, default to append only.)
- Which minimum OS and language versions do consumers target? (This sets the API ceiling.)
- What can the library assume about the server? (Nothing beyond "returns a page and maybe a next key".)
Functional: load an initial page, load more near the end, refresh, retry after an error, expose load state, expose items as a stream, optional placeholders, optional database-backed mode.
Non-functional (the part that makes it a library question):
| Concern | Target |
|---|---|
| API size | A new team integrates in under a day with one page of docs |
| Stability | No source or binary breaks inside a major version |
| Efficiency | No duplicate in-flight requests, bounded memory, cancelled work stops quickly |
| Predictability | Documented threading contract; callbacks never on a surprise thread |
| Testability | Consumers can test their list logic without a network or a UI |
| Footprint | Few dependencies, small binary, no hidden global state |
| Observability | Hooks for load timing, errors and cache hits, off by default |
Out of scope for now: image loading, list rendering, diffing algorithms, the network stack itself. Say this out loud. A pagination library that also loads images becomes impossible to version.
2. High-level architecture (about 10 min)
Official architecture mapping:
PageSourceandMediatorsit in the app's data layer, wrapped by a repository, with Room as source of truth.PagerandPagingDataare built in the ViewModel and cached withcachedIn(viewModelScope). The UI layer collects them (collectAsLazyPagingItems). See Paging overview and data layer.
The key decision: the app supplies "how to fetch one page", and the library decides "when, how many, and what to do on failure". Everything on the first side is a small interface. Everything on the second side is internal and can be rewritten without a major version.
This mirrors Paging 3, which splits responsibilities into PagingSource (one data source), RemoteMediator (layered network plus database), Pager with PagingConfig (stream construction) and PagingData (a snapshot container). The UI layer only adapts that stream to a list. The ViewModel never touches the DAO or network service itself; a repository hands it the PagingSource or Mediator.
3. Public API surface and what stays internal
The rule: expose the fewest types that let a consumer do everything, and make every extra public type earn its place.
Public:
PageSource<K, V>: one suspend function to load a page.PagingConfig: page size, prefetch distance, initial load size, max cached items, placeholders flag.LoadStateandCombinedLoadStates: the state model (section 5).PagingData<V>: the opaque snapshot you hand to the UI adapter.Mediator: the optional hook for database-backed paging (section 8).PagingEvents: the observability interface (section 9).
Internal: the fetcher, coordinator, page store, prefetch policy, retry scheduling and the diffing glue. Consumers must not be able to depend on them, even by reflection-friendly naming.
Enforcement tools: Kotlin internal plus explicit API mode, which forces a visibility decision on every declaration; a binary-compatibility checker in CI that diffs the public API dump on every pull request; Swift package or internal access plus library evolution settings when you ship a binary framework.
Two design habits matter here:
- Sealed results over exceptions for expected outcomes. A page load returns
PageorError, not "throws sometimes". - Stay closed by default. You can open a class later without breaking anyone. You cannot close one.
4. Generics and the data source abstraction
The library should know nothing about how a key is encoded. A generic key type covers all three styles.
// Sketch: the core contract
interface PageSource<K : Any, V : Any> {
suspend fun load(params: LoadParams<K>): LoadResult<K, V>
fun refreshKey(state: PagingState<K, V>): K? // where to restart after invalidation
}
sealed interface LoadParams<K> {
val key: K? // null on first load
val loadSize: Int
class Refresh<K>(override val key: K?, override val loadSize: Int) : LoadParams<K>
class Append<K>(override val key: K, override val loadSize: Int) : LoadParams<K>
class Prepend<K>(override val key: K, override val loadSize: Int) : LoadParams<K>
}
sealed interface LoadResult<K, V> {
data class Page<K, V>(val data: List<V>, val prevKey: K?, val nextKey: K?) : LoadResult<K, V>
data class Error<K, V>(val cause: Throwable) : LoadResult<K, V>
}
How each key style fits:
| Style | K | nextKey | Pitfall |
|---|---|---|---|
| Page number | Int | key + 1 | Skips or repeats rows when data is inserted mid-scroll |
| Offset | Int | key + loadSize | Same drift problem, plus slow deep offsets on the server |
| Cursor | String (opaque) | Server value | Cursors can expire; need a restart path |
Steer consumers toward cursors in the docs, but do not forbid the others; you will not control every backend.
refreshKey is the easy-to-miss part. When data is invalidated, the library asks the source where to restart so the user keeps their scroll position instead of jumping to the top. Paging 3 uses the same idea.
The loaded size is passed in, not hard-coded, because the initial load is usually larger than later pages. The source should treat loadSize as a hint it may ignore, and the library must cope with a short or long page.
5. State model
Consumers build spinners, retry rows and empty views from this, so it is the most-used part of the API. Paging 3 models it as three states per load type:
NotLoadingwith anendOfPaginationReachedflag.Loading.Errorcarrying the cause.
Load types are refresh (initial or restart), prepend and append. A combined object also carries state for the source and for the mediator separately, so UI can tell "reading the local database" from "syncing with the network".
sealed interface LoadState {
data class NotLoading(val endReached: Boolean) : LoadState
data object Loading : LoadState
data class Error(val cause: Throwable) : LoadState
}
data class CombinedLoadStates(
val refresh: LoadState, val prepend: LoadState, val append: LoadState,
val source: LoadStates, val mediator: LoadStates?,
)
Design points to say out loud:
- Separate refresh from append. A failed append should leave the list on screen with a retry row. A failed refresh with no data should show a full-screen error. One combined "loading" flag cannot express either.
- End of list is a state, not an error. Model it as
NotLoading(endReached = true)so UI can show a footer. - Retry and refresh are two verbs.
retry()repeats only the failed load.refresh()discards the generation and starts over. - Emit distinct values only. Collapse repeated identical states so UI does not recompose for nothing.
- Make states cheap to compare. Data classes and objects, no lambdas inside state.
6. Cache, deduplication, cancellation and prefetch
All of this lives in the internal engine, and consumers see it only through config.
In-memory cache. Keep loaded pages in a bounded store. With maxSize set, drop pages from the end farthest from the viewport. Validate config at construction: if the max size cannot hold the initial load plus the prefetch window, fail fast with a clear message instead of thrashing at runtime. Paging 3 documents in-memory caching as built in.
Request deduplication. Track at most one in-flight load per direction. A second append request while one is running joins the first or is dropped. Without this, a fast scroll fires ten requests for the same key. Paging 3 lists built-in request deduplication among its features.
Generations. Tag each refresh with a generation number. Results from a cancelled generation are discarded, even if the network call finished. This closes the race where a slow old response overwrites a fresh refresh.
Prioritization and cancellation.
- Refresh outranks append. Starting a refresh cancels any running append or prepend.
- A visible-range load outranks a speculative prefetch.
- Cancellation is cooperative. On Android, the load runs in a coroutine, and cancelling the scope cancels the call if the source uses cancellable suspend functions. Document that sources must not swallow
CancellationException. - When the stream has no collectors, stop loading and release pages, unless the app opted into caching beyond the collector's lifetime.
Prefetch distance. Config field prefetchDistance: how many items from the loaded edge the user may be before the next page is requested. Defaults and trade-offs:
- Too small: users see the end and wait.
- Too large: wasted requests and memory, and extra data on cellular.
- A sensible default is one page size (Paging 3 uses the same default; see the Draft notes on verification).
Let consumers set it, and let the library clamp it so a huge value cannot defeat maxSize.
7. Threading and concurrency contract
Write this down as part of the API, because it is the thing integrators break first.
PageSource.loadis called from the library's dispatcher, one call at a time per direction, and may suspend.loadmust be main-safe: the library never calls it on the main thread, and the source need not switch threads for ordinary work. State this explicitly, or every consumer will add a redundantwithContext.- State and data are emitted on a documented context. Choose one (for example, the collector's context) and never change it in a minor release.
- Internal state is confined to one serialized context (a single-threaded dispatcher or a mutex-guarded actor), not scattered locks.
- Public classes are immutable or documented as thread-safe.
PagingDatais a snapshot and is safe to pass between threads. - No hidden global singletons. A
Pagerowns its state, so two lists never share a cache by accident.
8. Composing with a local database
The library should not require a database, but it should make the database path easy. Paging 3's design is the template: the database is the source of truth, a RemoteMediator fetches from the network when the cached range runs out, writes results into the database, and a database-generated PagingSource feeds the UI. Inserting data invalidates the source, and a new generation reads the updated rows.
// Sketch: mediator contract, simplified from the Paging 3 shape
interface Mediator<K : Any, V : Any> {
suspend fun initialize(): InitializeAction // skip or launch the first refresh
suspend fun load(type: LoadType, state: PagingState<K, V>): MediatorResult
}
What the library guarantees, and what the app owns:
- Library: call
initialize()first; if it asks for a refresh, hold appends until the refresh succeeds; surface mediator state separately from source state; cancel a mediator load when the generation ends. - App: store remote keys, run the refresh as one transaction, clear old rows only after the network succeeds, keep local pending changes when merging server data.
In offline-first terms this is pull-based sync: the mediator fetches on demand as the user scrolls. A mediator result is Success(endReached) or Error(cause). A detail worth saying: the mediator reports "end reached" by return value, not by an empty page, so the library does not guess.
9. Testability, versioning and observability
Testability. Ship test tooling as a separate artifact so production code does not carry it. Paging 3 does this with a testing module: TestPager loads a source page by page, asSnapshot collects a stream while you simulate scrolling, and asPagingSourceFactory builds fake sources from lists, where a flow of lists simulates invalidation. Mediator tests check the returned result (valid data, empty response, thrown exception) and use an in-memory database. Make time injectable (a clock and a dispatcher parameter) so tests do not sleep.
Versioning and compatibility.
- Use semantic versioning: removals and signature changes are major; additions are minor; fixes are patch.
- Behavior is API. Changing a default page size, an error type, a callback thread or the retry policy is a breaking change for someone, even if the compiler is silent.
- Deprecate before removing: mark the symbol deprecated with a replacement, keep it for a stated window (for example, two minor releases and at least six months), and log a build-time warning, not a runtime one.
- Binary compatibility: on Android, avoid changing public method signatures or adding abstract members to public interfaces (use default methods or new interfaces). Use a binary-compatibility validator in CI. On iOS, a binary XCFramework needs library evolution mode and
@frozenused sparingly. - Config as a builder or a data class with defaults. Adding a field with a default is compatible; reordering constructor parameters is not. Prefer named parameters.
- Ship a sample app and run it against each release as a contract test.
Observability. Provide an opt-in listener, not a logging dependency.
interface PagingEvents {
fun onLoadStarted(type: LoadType, key: Any?) {}
fun onLoadFinished(type: LoadType, durationMs: Long, itemCount: Int, error: Throwable?) {}
fun onCacheHit(pageCount: Int) {}
fun onGenerationCancelled(reason: String) {}
}
Keep payloads free of item contents so the hook is safe to forward to analytics. Default to a no-op. The aim is for each host app to answer "how slow are our pages, and how often do they fail" without patching the library.
10. How to build a pagination library on iOS (Swift)
The design carries over unchanged: a generic page source, a state model, an engine with one in-flight load per direction, and an optional database mediator. There is no official Apple paging library, so the iOS version is the part you build and own. Swift concurrency supplies the primitives: async functions for loads, structured Task cancellation, an actor for engine state, and AsyncSequence (most often AsyncStream) for results.
| Concern | Android | iOS |
|---|---|---|
| Page source | suspend fun load | async throws function |
| Result stream | Flow<PagingData<V>> | AsyncStream of snapshots |
| Engine isolation | Single dispatcher or mutex | actor |
| Cancellation | Coroutine cancellation | Task cancellation (cooperative) |
| Prefetch trigger | Adapter access position | Row onAppear in SwiftUI, UICollectionViewDataSourcePrefetching in UIKit |
| Database | Room PagingSource + RemoteMediator | SwiftData or Core Data + your mediator |
| API compatibility tooling | Binary compatibility validator | Library evolution, swift package diagnose-api-breaking-changes |
| Test tooling | TestPager, asSnapshot | Fake source plus a manual executor; no official equivalent |
// Sketch: iOS public surface
public protocol PageSource<Key, Item>: Sendable {
associatedtype Key: Sendable
associatedtype Item: Sendable
func load(_ params: LoadParams<Key>) async throws -> Page<Key, Item>
}
public actor Pager<Key: Sendable, Item: Sendable> {
public init(source: some PageSource<Key, Item>, config: PagingConfig)
public nonisolated let updates: AsyncStream<PagingSnapshot<Item>> // snapshot must be Sendable
public func loadMoreIfNeeded(visibleIndex: Int) async
public func refresh() async
public func retry() async
}
What is genuinely different:
- Cancellation is cooperative, and so is the loop. Cancelling the task that consumes an
AsyncSequencedoes not by itself make every source stop. Document that sources must check for cancellation, and clean up in the continuation'sonTerminationcallback, which Apple documents as called when iteration is cancelled. - Swift 6
Sendablerules show up in the API. Key and item types must beSendableto cross the actor boundary. State that requirement in the public signature instead of discovering it in a consumer's build. - No built-in list integration. You ship thin adapters (a SwiftUI helper that calls
loadMoreIfNeededfromonAppear, a UIKit prefetching delegate), kept in a separate module so the core stays toolkit-free. - Binary distribution has stricter rules. If you ship an XCFramework, library evolution constrains what you can change; if you ship source via Swift Package Manager, source compatibility is the main concern. Decide which you offer.
11. Failure modes and edge cases
| Scenario | Handling |
|---|---|
| Fast scroll fires many append requests | One in-flight load per direction; extra requests join or drop |
| Refresh while an append is running | Cancel the append; bump the generation; discard late results |
Source returns a short or empty page with a nextKey | Treat as valid; guard against infinite empty-page loops with a limit |
Source returns the same nextKey as the current key | Detect and end paging with an error, not a loop |
| Cursor expires | Source returns a typed error; library calls refresh with a restart key |
| Duplicate items across pages | The library does not dedupe items by ID; the app must (document it) or use a database keyed by ID |
| Page error mid-list | Append state becomes Error; list stays; retry() repeats only that load |
| Memory pressure | Bounded maxSize; drop far pages; reload on scroll back |
| Collector leaves (rotation, navigation) | Cache the stream in the owner's scope (cachedIn(viewModelScope)) so state survives; stop loading with no owner |
| Exception types leak from internals | Wrap in a documented error type; never expose third-party exceptions |
| Consumer calls from the wrong thread | Document and, where cheap, assert in debug builds |
12. What changes by level
- Mid-level: a working generic
PageSource, aPagerthat loads the next page near the end, a state model with loading and error, and retry. - Senior: a deliberate public/internal split, key-type genericity, per-direction state, deduplication and generation-based cancellation, a documented threading contract, and a semantic versioning and deprecation policy.
- Staff: binary compatibility tooling and a migration plan for breaking changes, version-adoption telemetry, the database mediator contract, a multi-team rollout (sample app, docs, support model), measuring the library's own cost (memory, jank, request counts), and the build-versus-adopt decision against Paging 3 on Android.
Staff answer: the decision and the rollout
Decision: for Android, adopt Paging 3 behind your own thin module unless you can name what it cannot do. Its overview documents PagingSource, RemoteMediator, Pager, built-in in-memory caching, request deduplication, and refresh and retry handling. Build your own only for a contract shared with iOS. Revisit trigger: two or more teams working around the same Paging limitation, or the wrapper becoming the thing teams fight. See the native vs cross-platform decision guide for the same shape of decision.
Deprecation window: announce the replacement API, mark the old one deprecated with a pointer, and remove it only after adoption telemetry shows the old version below a threshold you set in advance. Window length is a policy choice.
What to measure: which library version each app build ships, the share of call sites on deprecated APIs, and request counts and memory per list. Guardrail: removal is blocked while any team above the agreed share is still on the old API, and a new release must not raise request counts on the sample screens.
Rollback on mobile: a library cannot be recalled from installed apps. Rollback means a patch release that restores the old behavior, a flag where behavior is switchable at runtime, and keeping the deprecated path alive until telemetry says it is safe. A Play staged rollout can be halted but does not uninstall the update. See the staff mobile loop guide.
Opinion vs verified: the Paging 3 component list was read from developer.android.com; the telemetry design, thresholds and policy are my opinion.
13. Follow-up questions to expect
- Why not just use Paging 3? (On Android, often you should; a wrapper is justified only for house conventions, a shared cross-platform contract, or missing features. Say this honestly.)
- How do you support bidirectional paging, such as jumping to a search result in a chat? (Prepend and append from a restart key;
jumpThreshold-style reinitialization.) - How do you remove an API that thirty apps use? (Deprecation window, replacement, telemetry on usage, codemod or migration guide, then a major release.)
- How do you test the library itself? (Fake sources, controlled dispatcher, property tests on generation races, a sample app run against each release.)
- How do you measure it? (Load latency per type, error rate, cache hit rate, duplicate-request count, peak items in memory.)
- How would you add placeholders? (A nullable item list plus a known total; explain the cost when the total is unknown.)
Further reading (official docs)
- Paging library overview (Android): components, built-in caching and request deduplication
- Data layer (Android): repositories, data sources, work lifetimes
- Offline-first apps (Android): pull-based sync and
RemoteMediator - Paging from network and database (Android):
RemoteMediator,LoadType,initialize() - Load state (Android):
LoadState,CombinedLoadStates, retry and refresh - Test your Paging implementation (Android):
TestPager,asSnapshot,asPagingSourceFactory - PagingConfig reference (Android)
- AsyncSequence (Apple)
- AsyncStream (Apple): continuation, buffering,
onTermination - A framework for mobile system design interviews (ByteByteGo): general interview structure, not library-specific
- Grokking Modern Mobile System Design Interview: libraries and SDKs assessment (Design Gurus): lists public API behavior, compatibility and migration policies as topics
Verification notes (last verified 13 Oct 2026)
Android architecture audited against the official guidance on 2026-10-13. The Paging overview assigns Pager to the ViewModel layer; the network-and-database page does not name a layer and neither page documents cachedIn, so that advice rests on general Paging 3 knowledge. Verified by fetching: Paging 3 overview (components, built-in caching and deduplication), load state page, test page, and network-plus-database page. Not verified from a primary source: the PagingConfig reference page loaded as a navigation index only, so the claims about prefetchDistance defaulting to page size, maxSize validation and jumpThreshold come from general knowledge and must be checked against the API reference. Verified via the Apple docs JSON: AsyncStream continuation is Sendable, buffer is unbounded by default, and onTermination is called on task cancellation (cleanup there). The protocol sketch was fixed to declare primary associated types correctly. Unverified: Sendable requirements for the Pager actor, library evolution, the swift package diagnose-api-breaking-changes command name, UIKit prefetching adapter details; The iOS sections have not yet been reviewed by an iOS engineer. No source found that describes what interviewers expect for library-design questions specifically; the Design Gurus and ByteByteGo pages only support the general framing, and the checklist in sections 3, 7 and 9 (explicit API mode, binary compatibility validator, deprecation windows, threading contract) is the author's synthesis. The API sketches are simplified and are not the real Paging 3 signatures. Deprecation window lengths and the example config values are illustrative, not rules. Kotlin snippets compile-checked offline on 2026-10-13 against Kotlin 2.3.21 (AGP 9.1.0, compileSdk 36), Paging 3.4.2, with stubs only for app-defined placeholders. The mediator sketch was checked with stubbed InitializeAction and MediatorResult, because the real ones are nested in RemoteMediator. Swift snippets type-checked on 2026-10-13 against the iOS 27 simulator SDK (Swift 6.4, language modes 5 and 6) with stubs only for app-defined placeholders. The Swift public surface is declaration-only; it was checked with stub bodies. Added a staff-answer section (section 12) on 2026-10-13; verified the Paging 3 component list on developer.android.com and the Play staged-rollout help page; the telemetry and deprecation policy are opinion.
Keep reading
Follow new case studies
Subscribe with any RSS reader. Found a mistake? Open an issue on GitHub.
New case studies appear in the feed first. Any RSS reader works.