| name | pagination-and-lazy-loading |
| description | List virtualization and pagination patterns per platform — RecyclerView/Paging 3, Compose LazyColumn, SwiftUI List, Flutter ListView.builder, RN FlashList. |
Pagination and Lazy Loading
Instructions
Long lists break three things at once: memory, scroll performance, and network. Pagination fixes all three when implemented consistently with the platform's virtualization.
1. Principles
- Virtualize. Never build all rows up front. Every platform has a windowed list widget — use it.
- Page from a cursor, not an offset. Offsets break when the list mutates. Use keyset / cursor pagination (
nextCursor=abc123).
- Show stale data first. Render cached page instantly; refetch in background (see
request-batching-and-caching).
- Prefetch the next page when the user is 80% through the current viewport.
- Reserve layout space with a placeholder while rows load, so scroll position never jumps.
2. Android — Paging 3
class FeedSource(private val api: Api) : PagingSource<String, Item>() {
override suspend fun load(params: LoadParams<String>): LoadResult<String, Item> =
try {
val page = api.feed(cursor = params.key, limit = params.loadSize)
LoadResult.Page(
data = page.items,
prevKey = null,
nextKey = page.nextCursor,
)
} catch (e: IOException) { LoadResult.Error(e) }
override fun getRefreshKey(state: PagingState<String, Item>) = null
}
class FeedViewModel(api: Api) : ViewModel() {
val pager = Pager(
config = PagingConfig(pageSize = 20, prefetchDistance = 5, enablePlaceholders = false),
pagingSourceFactory = { FeedSource(api) },
).flow.cachedIn(viewModelScope)
}
Compose usage:
val items = viewModel.pager.collectAsLazyPagingItems()
LazyColumn {
items(items.itemCount, key = items.itemKey { it.id }) { index ->
val item = items[index] ?: return@items
Row(item)
}
when (items.loadState.append) {
is LoadState.Loading -> item { LoadingRow() }
is LoadState.Error -> item { ErrorRow(onRetry = items::retry) }
else -> Unit
}
}
3. iOS — SwiftUI List + Async Pagination
@MainActor
final class FeedStore: ObservableObject {
@Published private(set) var items: [Item] = []
private var cursor: String?
private var loading = false
func loadMoreIfNeeded(currentItem: Item) async {
guard let idx = items.firstIndex(where: { $0.id == currentItem.id }),
idx >= items.count - 5, !loading else { return }
loading = true; defer { loading = false }
let page = try? await api.feed(cursor: cursor, limit: 20)
if let page {
items.append(contentsOf: page.items)
cursor = page.nextCursor
}
}
}
struct FeedView: View {
@StateObject var store = FeedStore()
var body: some View {
List(store.items) { item in
Row(item: item).task { await store.loadMoreIfNeeded(currentItem: item) }
}
.listStyle(.plain)
}
}
Use LazyVStack inside a ScrollView if you need custom layouts; List is optimized for tables.
4. Flutter — ListView.builder + pagination helpers
Plain pattern:
class FeedNotifier extends StateNotifier<AsyncValue<List<Item>>> {
FeedNotifier(this._api) : super(const AsyncValue.loading()) { _load(); }
final Api _api;
String? _cursor;
bool _loading = false;
Future<void> loadMore() async {
if (_loading) return;
_loading = true;
final page = await _api.feed(cursor: _cursor, limit: 20);
final current = state.valueOrNull ?? const [];
state = AsyncValue.data([...current, ...page.items]);
_cursor = page.nextCursor;
_loading = false;
}
Future<void> _load() async { await loadMore(); }
}
UI:
ListView.builder(
itemCount: items.length + 1,
itemExtent: 96, // uniform rows => O(1) scrolling
itemBuilder: (ctx, i) {
if (i == items.length) {
ref.read(feedProvider.notifier).loadMore();
return const LoadingRow();
}
return Row(item: items[i]);
},
)
For offline-first, use packages like infinite_scroll_pagination or a paging layer on top of Drift/Isar.
5. React Native — FlashList + onEndReached
<FlashList
data={items}
keyExtractor={it => it.id}
estimatedItemSize={96}
onEndReachedThreshold={0.6}
onEndReached={loadMore}
ListFooterComponent={isLoading ? <LoadingRow /> : null}
renderItem={({ item }) => <Row item={item} />}
/>
Guard loadMore against re-entrancy; onEndReached can fire multiple times in close succession.
const loadingRef = useRef(false);
const loadMore = async () => {
if (loadingRef.current) return;
loadingRef.current = true;
try { await fetchNextPage(); } finally { loadingRef.current = false; }
};
6. Cursors vs Offsets
Cursors survive insertions, allow server-side optimization, and are idempotent.
Offsets:
GET /feed?offset=100&limit=20 — breaks if item 0 is deleted.
Cursors:
GET /feed?cursor=abc123&limit=20 — server encodes position in cursor.
If the server only supports offsets, hide the instability by always refreshing page 0 on pull-to-refresh and refusing to backfill.
7. Image and Row-Media Strategy
- Rows should not load their heavy image until on screen. All virtualization libs handle this if you use their first-party image widgets (Coil
AsyncImage, SDWebImage, cached_network_image, expo-image).
- Use low-res thumbs in the list; swap to full-res in the detail screen.
- See
image-delivery and bitmap-and-image-optimization.
8. Empty, Error, and Retry States
Don't jump layout when errors appear. Reserve the same height; show inline retry.
@Composable
fun ErrorRow(onRetry: () -> Unit) = Row(Modifier.height(96.dp).fillMaxWidth()) {
Text("Failed to load"); Spacer(Modifier.weight(1f))
TextButton(onClick = onRetry) { Text("Retry") }
}
9. Testing
- Paging 3 has
TestPagingSource for unit tests.
- Flutter: golden-test a list at page boundaries.
- RN: test
onEndReached debouncing with @testing-library/react-native.
- iOS:
XCUIApplication().tables.cells assertions with scroll scripts.
Checklist