This is the build material behind the pagination library interview case. It covers:
- The public surface and key styles
- The state model and engine rules
- The mediator (the hook that writes fetched pages into a database) and versioning
- The Swift side and platform limits
None of it belongs in the 45 minutes. I draw it only if asked.
The sketches are simplified. They are not the real Paging 3 signatures. I compiled them against Paging 3.4.2. Paging 3.5.3 is current as of 2026-10-14. Room (Android's database library) appears only in prose here. The official Room guide now shows Room 3 (androidx.room3). The design is the same with either version.
1. Public API surface and what stays internal
The rule: show the fewest types that let a user of the library do everything. Every extra public type must earn its place.
Public:
PageSource<K, V>: one suspend function to load a page, plus a refresh key function (section 2).PagingConfig: page size, prefetch distance (how far ahead to load), initial load size, max cached items, and a placeholders flag.LoadStateandCombinedLoadStates: the state model (section 3).PagingData<V>: the opaque snapshot that you give to the UI adapter. Opaque means the user cannot look inside it.Mediator: the optional hook for paging that uses a database (section 6).PagingEvents: the interface for watching what the library does (section 7).
Internal:
- The fetcher, the coordinator and the page store
- The prefetch policy and retry timing
- The diffing glue (code that finds the changes between two lists)
Users of the library must not be able to reach any of these. Reflection (code that finds classes at run time) and guessing class names must not work either.
Tools that enforce this:
- Kotlin
internalplus explicit API mode, which forces a choice of visibility for every declaration. - A binary-compatibility checker in CI (the automatic checks that run on every change). It compares the public API dump on every pull request. Kotlin's guidelines for library authors describe the validator.
- Swift
packageorinternalaccess, 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. It does not "throw sometimes". - Stay closed by default. You can open a class later without breaking anyone. You cannot close one.
This is the layer drawing from the interview page, for reference:
App code (ViewModel / presenter)
│ Pager(config, source).flow
┌──────────────────────────── public API ─────────────────────────────┐
│ PageSource<K, V> PagingConfig LoadState PagingData<V> Mediator │
└────────────────────────────────────────────────────────────────────┘
┌──────────────────────────── internal ──────────────────────────────┐
│ PageFetcher (one active generation) │
│ LoadCoordinator (dedupe, priority, cancel) PageStore (bounded cache) │
│ PrefetchPolicy (distance, jump) Metrics hooks │
└────────────────────────────────────────────────────────────────────┘
│ calls │ optional
App-supplied PageSource App-supplied Mediator ──► Local DB
2. 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 |
In the docs, steer users toward cursors. Do not forbid the others, because you will not control every backend.
refreshKey is the part that people miss. PagingState is the snapshot of what is on screen. The sketch borrows the type from Paging 3.
When data changes, the library asks the source where to restart. The user keeps the scroll position and does not jump to the top. Paging 3 has the same idea. The load-and-display page says the key is the one passed to load when data is refreshed or invalidated after the first load.
The loaded size is passed in, not fixed in code. The initial load is usually larger than later pages. The source should treat loadSize as a hint that it may ignore. The library must work with a short or long page.
Known gaps in the sketch above. The compile check and the doc read on 2026-10-14 found these. I left the code as compiled and list the changes here.
PageSourcehas two members, not one:loadandrefreshKey.- Paging 3 documents an invalid result. A source that is no longer valid, for example after a change in the underlying data, returns it so the library replaces the source.
LoadResulthere has onlyPageandError. I would add anInvalidcase. LoadResult.Pagehas no counts of items before and after the page. Placeholders (null items for rows not yet loaded) need those counts, so the sketch cannot express them. Paging also needsnextKeyandprevKeyfor placeholders and jumping.- Paging 3.4 added
PagingState.closestItemAroundPosition. It helps to build a refresh key from an item when the best anchor is near the target position, not at it.
3. State model
Users of the library build spinners, retry rows and empty views from this model. So it is the most used part of the API. Paging 3 models it as three states for each load type:
NotLoadingwith anendOfPaginationReachedflag.Loading.Errorwith the cause.
The load types are refresh (the first load or a restart), prepend and append. A combined object also holds state for the source and for the mediator, kept apart. So the 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?,
)
LoadStates, the type for one source, is not defined in the sketch. It is a refresh, prepend and append triple.
Design points I would make here:
- 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 show either.
- End of list is a state, not an error. Model it as
NotLoading(endReached = true)so the UI can show a footer. - Retry and refresh are two verbs.
retry()repeats only the failed load.refresh()discards the generation and starts over. This split is my design. The load-state page showsretry()in its click handlers. It does not compare it with refresh. - Loading footers and placeholders conflict. The load-state page says its footer example works only with placeholders off. With placeholders on, the footer appears after the placeholders.
- Emit only values that changed. Merge repeated identical states, so the UI does not redraw for no reason.
- Make states cheap to compare. Use plain value types, with no lambdas inside state. The sketch uses data classes to be short. Kotlin's library guidelines list "avoid using data classes in your API" as a compatibility rule. So a shipped library would write them by hand.
4. Cache, duplicate requests, cancellation and prefetch
All of this lives in the internal engine. Users of the library see it only through the config.
In-memory cache. Keep loaded pages in a bounded store. When maxSize is set, drop pages from the end that is farthest from the visible rows.
- Check the config when it is built. If
maxSizeis smaller thanpageSize + 2 * prefetchDistance, fail at once with a clear message. The other choice is to thrash (load and drop the same pages again and again) at run time. - Paging 3 enforces exactly this rule in
PagingConfigand throwsIllegalArgumentException. ItsmaxSizedefault is unbounded. - Paging 3 documents in-memory caching as built in. The 3.5.3 release notes add that at least two pages stay loaded when the cap is passed.
Request deduplication (joining duplicate requests into one). Allow at most one running load for each direction. A second append request while one is running joins the first or is dropped. Without this, a fast scroll sends ten requests for the same key. Paging 3 lists built-in request deduplication among its features.
Generations. Give each refresh a generation number. Discard results from a cancelled generation, even if the network call finished. This closes the race where a slow old response replaces a fresh refresh.
Priority and cancellation.
- Refresh outranks append. Starting a refresh cancels any running append or prepend.
- A load for visible rows outranks a guess-ahead prefetch.
- Cancellation is cooperative, which means the code must check for it. Nothing stops the code by force. On Android, the load runs in a coroutine. Cancelling the scope cancels the call if the source uses suspend functions that can be cancelled. Document that sources must not swallow
CancellationException. - When the stream has no collectors, stop loading and release pages. The exception is when the app chose to cache beyond the life of the collector.
Prefetch distance. The config field is prefetchDistance. It sets 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 a cellular network.
- A sensible default is one page size. Paging 3 also uses one page size (
prefetchDistance = pageSizeinPagingConfig). Its initial load defaults to three pages.
Let users of the library set the distance. Let the library clamp it (cut it to a safe limit), so a huge value cannot defeat maxSize.
Config defaults and rules, as read from the PagingConfig source on 2026-10-13:
| Field | Default or rule | So the design changes by |
|---|---|---|
prefetchDistance | Equals pageSize | Our default matches, so a host app that moves between libraries sees no change |
initialLoadSize | Three pages | The source must accept a size that differs from later pages |
maxSize | Unbounded; if set, at least pageSize + 2 * prefetchDistance, else IllegalArgumentException | Validate in the constructor, as above |
jumpThreshold | Undefined; if set, must be positive | Jumping stays a parked feature until a restart policy exists |
enablePlaceholders | True | The sketch must carry counts before and after a page (section 2) |
5. Threading and concurrency contract
Write this down as part of the API. Users of the library break it first.
PageSource.loadis called from the library's dispatcher (the thread pool that runs its work). It runs one call at a time for each direction, and it may suspend.loadmust be main-safe. The library never calls it on the main thread, and the source does not need to switch threads for ordinary work. Say this clearly. If you do not, every user adds an extrawithContext.- State and data are emitted on a documented context. Choose one, for example the context of the collector. Never change it in a minor release.
- Internal state stays in one serialized context. This is a single-threaded dispatcher or an actor guarded by a mutex (a lock that lets one caller in at a time). Do not scatter locks through the code.
- Public classes are immutable (they never change) or are 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.
6. Composing with a local database
The library should not require a database, but it should make the database path easy. Paging 3 is the model to follow. The database is the source of truth.
A RemoteMediator fetches from the network when the cached range runs out. It writes the results into the database. A source made by the database feeds the UI. When data is inserted, the source becomes invalid, and a new generation reads the updated rows.
// Sketch: mediator contract, shape adapted from the Paging 3 network-and-database
// sample (Apache License 2.0), https://developer.android.com/topic/libraries/architecture/paging/v3-network-db
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
}
Invalidation is the part people forget. A row may be edited, deleted or inserted under the list. Then the database-backed source invalidates itself. The library starts a new generation, and refreshKey picks the restart position. So the user stays near the same item.
With Room, that trigger comes for free. With a source that you write by hand, the app must call invalidate itself. This is why the contract should say so.
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. - Show mediator state apart from source state.
- Cancel a mediator load when the generation ends.
App:
- Store remote keys.
- Run the refresh as one transaction (all changes are saved together or not at all).
- Clear old rows only after the network call succeeds.
- Keep local changes that are not yet sent when merging server data.
In offline-first terms, the mediator pulls from the server on demand, as the user scrolls. The news feed build guide section 2 has a full mediator. The offline-first guide covers the wider choices for writing and syncing.
A mediator result is Success(endReached) or Error(cause). The mediator reports "end reached" by return value, not by an empty page, so the library does not guess. The sketch also uses three types that section 1 does not list: LoadType, InitializeAction and MediatorResult. In Paging 3, the last two are nested in RemoteMediator.
7. Testability, versioning and observability
Testability. Ship test tools as a separate artifact, so production code does not carry them. Paging 3 does this with a testing module:
TestPagerloads a source page by page.asSnapshotcollects a stream while you simulate scrolling.asPagingSourceFactorybuilds fake sources from lists. 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 (pass in a clock and a dispatcher), so tests do not need to sleep.
The use example in the one page of docs matters as much as the internals. This is a sketch against my API. I did not compile it:
class FeedViewModel(source: PageSource<String, Post>) : ViewModel() {
val posts = Pager(PagingConfig(pageSize = 20), source).flow.cachedIn(viewModelScope)
}
@Composable
fun FeedScreen(vm: FeedViewModel) {
val items = vm.posts.collectAsLazyPagingItems()
LazyColumn { items(items.itemCount) { i -> items[i]?.let { PostRow(it) } } }
}
Versioning and compatibility.
- Use semantic versioning (the version number tells the size of the change). Removals and signature changes are major. Additions are minor. Fixes are patch.
- Behavior is API. A change to a default page size, an error type, a callback thread or the retry policy breaks something for someone, even if the compiler says nothing.
- Deprecate before you remove. Mark the symbol deprecated with a replacement. Keep it for a stated period, for example two minor releases and at least six months. Warn at build time, not at run time.
- Binary compatibility: on Android, do not change public method signatures. Do not add 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
@frozenshould be used rarely. - Make config a builder or a data class with defaults. Adding a field with a default is compatible. Changing the order of constructor parameters is not. Prefer named parameters.
- Ship a sample app and run it against each release as a contract test.
Observability. Give 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 send to analytics. Default to a no-op (a listener that does nothing). The aim is that each host app can answer "how slow are our pages, and how often do they fail" without changing the library.
One gap: onLoadStarted as sketched passes the raw page key, and a cursor can encode user data. I would pass a key kind (offset, page number, cursor) instead of the key.
8. Doing it on iOS
The design carries over unchanged: a generic page source, a state model, an engine with one running load for each direction, and an optional database mediator. I know of no official Apple paging library. So the iOS version is the part you build and own.
The Paging release notes list iOS among the native targets of the common modules (3.3 for iOS compatibility, 3.4 for more targets). A core compiled from Kotlin is therefore a real option. Section 1 of the interview page says what I would test before choosing. Swift concurrency gives the building blocks:
asyncfunctions for loads- Structured
Taskcancellation - An actor (a type that guards its own state) for engine state
AsyncSequence(most oftenAsyncStream) for results
| Concern | Android | iOS |
|---|---|---|
| Page source | suspend fun load | async throws function |
| Result stream | Flow<PagingData<V>> | AsyncStream of snapshots, one stream per subscriber |
| Engine isolation | Single dispatcher or mutex | actor |
| Cancellation | Coroutine cancellation | Task cancellation (cooperative) |
| Prefetch trigger | Item access position | Row onAppear in SwiftUI, UICollectionViewDataSourcePrefetching in UIKit |
| Database | Room PagingSource + RemoteMediator | SwiftData or Core Data + your mediator |
| API compatibility tools | Binary compatibility validator | Library evolution; SwiftPM has an API-breakage diagnosis command (see the notes) |
| Test tools | TestPager, asSnapshot | Fake source plus a manual executor; no official equivalent |
// Sketch: iOS public surface (signatures only)
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)
// One stream per call, built with .bufferingNewest(1); snapshot must be Sendable
public func updates() -> AsyncStream<PagingSnapshot<Item>>
public func loadMoreIfNeeded(visibleIndex: Int) async
public func refresh() async
public func retry() async
}
What changes on iOS:
- One stream per subscriber. An
AsyncStreamis meant for a single consumer. Apple's page does not say this directly. But the proposal that added it, SE-0314, calls concurrent iteration a programmer error. If two views, or a view and a test, read the same stream, they compete for items. - So
updates()is meant to create a stream on each call. The sketch shows signatures only. The real code keeps the continuations inside the actor and uses.bufferingNewest(1), because only the latest snapshot matters. An@Observablesnapshot is the other option. - Swift cancellation is cooperative. Cancelling the task that reads an
AsyncSequencedoes not force the source to stop. Document that sources must check for cancellation. Clean up in the continuation'sonTerminationcallback, which Apple documents as called when iteration is cancelled. Keep that callback non-blocking. Apple says it has the same deadlock risks aswithTaskCancellationHandler. Sendableappears in the API. Key and item types must beSendable(safe to pass between threads) to cross the actor boundary. State this requirement in the public signature. Do not let a user find it in their own build.- No built-in list integration. You ship thin adapters in a separate module, so the core stays free of any UI toolkit. One is a SwiftUI helper that calls
loadMoreIfNeededfromonAppear. The other is a UIKit prefetching delegate. - Prefetch is a hint. Apple's prefetching page says the callback is not necessarily called for every cell. The cell provider must handle data that is ready, running or never requested. The page for the lazy vertical stack says nothing about appear callbacks. So the SwiftUI trigger is design intent.
- Binary distribution has stricter rules. If you ship an XCFramework, library evolution limits what you can change. If you ship source through Swift Package Manager, source compatibility is the main concern. Decide which one you offer.
Platform limits that change the design.
| Limit | Android | iOS | So the design changes by |
|---|---|---|---|
| Prefetch signal | Index distance from the loaded edge, default one page | UIKit prefetch is not called for every cell; SwiftUI appear callbacks undocumented | The engine triggers by index, and row appearance can call the same load; never rely on the callback alone |
| Result stream | A PagingData is single use unless cached with cachedIn | An AsyncStream suits one consumer (SE-0314) | One owner scope on Android; one stream per subscriber on iOS |
| Cancellation | Cooperative; sources must not swallow CancellationException | Cooperative; onTermination fires on cancellation, with a deadlock caution | Document that sources check for cancellation, and drop late results by generation anyway |
| Shared code | Common Paging modules compile for iOS targets | None that I know of | A shared Kotlin core is an option to test before building a second engine |
9. Failure modes and edge cases
| Scenario | Handling |
|---|---|
| Fast scroll fires many append requests | One running load per direction; extra requests join or drop |
| Refresh while an append is running | Cancel the append; raise the generation number; discard late results |
Source returns a short or empty page with a nextKey | Treat as valid; guard against endless empty-page loops with a limit |
Source returns the same nextKey as the current key | Detect it 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 remove duplicates by ID; the app must (document it) or use a database keyed by ID |
| Server answers 429 or 5xx | Source throws a typed error. Temporary means IOException, 408, 429 and 5xx. These can be retried with retry(), honouring Retry-After when present. Other 4xx are permanent and show as an error |
| 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 |
| User calls from the wrong thread | Document it and, where cheap, assert in debug builds |
10. More practice prompts
These are practice prompts, not a claim about what any interviewer asks. I chose them, and I reconstructed them like the follow-up questions on the interview page.
- How do you remove an API that thirty apps use? (Deprecation period, replacement, telemetry on usage, a codemod or migration guide, then a major release.)
- How do you test the library itself? (Fake sources, a controlled dispatcher, property tests on generation races, a sample app run against each release.)
- How would you add placeholders? (A nullable item list plus a known total. Explain the cost when the total is unknown, and the footer conflict in section 3.)
- How do you make list scrolling and image loading work together? (Cancel prefetched image work for rows scrolled past; see image loading.)
- Someone proposes a thin wrapper around Paging 3 instead of a library. What do you check? (What the wrapper would hide, what the 3.5 item-snapshot flow already exposes, and who owns the wrapper's versioning.)
Further reading (official docs)
- Pagination library interview case: the 45-minute version
- Paging library overview (Android): components, built-in caching and request deduplication
- Load and display paged data (Android): keys, invalid results, refresh key, placeholders
- Transform paged data (Android):
cachedInand single collection - 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)
- PagingConfig source (AndroidX): defaults and the
maxSizecheck - Paging release notes (Android): multiplatform targets, 3.5 additions
- AsyncSequence (Apple)
- AsyncStream (Apple): continuation, buffering,
onTermination - SE-0314, AsyncStream (Swift Evolution): concurrent iteration is a programmer error
- Backward compatibility guidelines for library authors (Kotlin): the binary compatibility validator, explicit API mode, data classes in APIs
- UICollectionViewDataSourcePrefetching (Apple): prefetch is not called for every cell
- Staged rollouts (Play Console Help)
Further reading (other, not official)
Prep material I used as references for the questions, not for wording:
- The ByteByteGo mobile system design book (library chapter)
- DesignGurus (the library-prompt article and pagination lesson, public parts only)
- The weeBox mobile design repository (its library notes on caller ergonomics, stability and resource handling)
- The Educative mobile course
Verification notes (last reviewed 13 Oct 2026)
this is the build guide split off the pagination library case on 2026-10-14. The code blocks are unchanged from the live page of 2026-10-13 (byte-identical, diffed on the split); the "known gaps" list, the config defaults table, the platform limits table, the Paging 3.5.3 note and the practice prompts 1 to 5 are new prose. Android design checked against the official guidance on 2026-10-13. The Paging overview assigns Pager to the ViewModel layer; the load-and-display and transform pages (fetched 2026-10-14) show cachedIn(viewModelScope) in the ViewModel and say each PagingData instance can be used once unless cached, so cachedIn is documented now. Verified by fetching: Paging 3 overview (components, built-in caching and deduplication), load state page, test page, network-plus-database page, and the PagingConfig source on 2026-10-13 (prefetchDistance defaults to pageSize, initialLoadSize to three pages, maxSize unbounded, and maxSize must be at least pageSize + 2 * prefetchDistance, otherwise IllegalArgumentException; the jumpThreshold default is undefined and must be positive; enablePlaceholders defaults to true). Verified on the Paging release notes on 2026-10-14: 3.3 moved the common modules to multiplatform including iOS; 3.4 added more native targets and closestItemAroundPosition; 3.5.0 added asItemSnapshotListFlow and Pager.append, Pager.prepend and Pager.refresh; I did not quote a retry signature because the page names it inconsistently; 3.5.2 made an early append a no-op; 3.5.3 keeps at least two pages over max size. Verified on the load-state page 2026-10-14: footer items work only with placeholders off, and retry() appears only in click handlers. Verified on Apple's prefetching protocol page 2026-10-14: the callback is not necessarily called for every cell. Verified via the Apple docs JSON on 2026-10-13: AsyncStream continuation is Sendable, buffer is unbounded by default, and onTermination is called on task cancellation with a deadlock note pointing at withTaskCancellationHandler. Fact-check on 2026-10-14, fetched again: the release notes (3.3, 3.4, 3.5.0, 3.5.2 and 3.5.3 as listed above; 3.4 describes the closest-item helper as taking a predicate), the overview (source and mediator in the repository layer, Pager and PagingData in the ViewModel layer, LazyPagingItems in the UI; in-memory caching and request deduplication listed as features), the load-and-display page, the transform page, the load-state page (three states, the source and mediator split, no refresh contrast), the network-and-database page (initialize, append and prepend wait for the refresh, Success and Error results, one transaction), the test page (TestPager, asSnapshot, asPagingSourceFactory; each emission of a flow is a new generation; mediator tests cover valid data, empty response and an exception on an in-memory database), the Play help page, the developer.android.com license page (code samples are Apache 2.0), Apple's AsyncStream onTermination page (called on task cancellation, deadlock caution), SE-0314 (concurrent iteration is a programmer error, which now backs the single-consumer claim), the Kotlin backward compatibility guidelines (binary compatibility validator, explicit API mode section, "avoid using data classes in your API"), and the SwiftPM source (the diagnose-api-breaking-changes command exists). Changed in that pass: the single-consumer claim cites SE-0314; the "no Apple paging library" claim is changed to a claim that I know of none; the prefetch trigger row says item access, since the Android docs use LazyPagingItems and not an adapter; the data-class advice carries the Kotlin guideline; the news feed link points at the news feed build guide, where the mediator now lives. Not verified: the bufferingNewest policy page, Sendable requirements for the Pager actor, library evolution details, UIKit prefetching adapter details, the 408 retry classification, and that Apple ships no paging library; ask an iOS engineer to review section 8. No source found that describes what interviewers expect for library-design questions; the follow-ups are my own reading, and the checklist in sections 1, 4 and 7 (explicit API mode, binary compatibility validator, deprecation windows, threading contract) is my synthesis. Deprecation window lengths and the example config values are examples, 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 (newer releases exist, Paging 3.5.3 as of 2026-10-14), with stubs only for app-defined placeholders; the consumer snippet in section 7 and the Swift updates() change were added afterwards and not compiled. 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, before the updates() change. The Swift public surface is declaration-only. The 429/5xx row follows the retry rule used across the other case studies. The mediator sketch's shape is adapted from the Paging 3 network-and-database sample (Apache License 2.0), as its code comment says. The platform limits table restates facts verified elsewhere on this page, and its last column is design intent. A plain-English pass was done on 2026-10-14: common words, defined terms and no idioms. No code block, number, link or evidence marker was changed.
Questions or corrections?
Add them to the discussion thread for this case on GitHub. To post, you need a free GitHub account. What you write there is public and follows GitHub's terms. Good answers are added to this page.
Keep reading
Get the next case study when it is published.
You get an email when a new case study is published, and nothing else.
You can unsubscribe at any time. Privacy.