Pagination & Retry Logic
Pagination
Cursor-Based Pagination (Recommended)
Cursor-based pagination is more reliable than offset-based pagination for real-time data.
iOS - Pagination
// App/Features/Feed/Domain/Entities/Page.swift
struct Page<T> {
let items: [T]
let nextCursor: String?
let hasMore: Bool
var isEmpty: Bool {
items.isEmpty
}
}
// App/Features/Feed/Presentation/ViewModels/FeedViewModel.swift
@MainActor
final class FeedViewModel: ObservableObject {
@Published private(set) var posts: [Post] = []
@Published private(set) var isLoading = false
@Published private(set) var hasMore = true
private var nextCursor: String?
private let getFeedUseCase: GetFeedUseCase
init(getFeedUseCase: GetFeedUseCase) {
self.getFeedUseCase = getFeedUseCase
}
func loadInitial() async {
posts = []
nextCursor = nil
hasMore = true
await loadNext()
}
func loadNext() async {
guard !isLoading, hasMore else { return }
isLoading = true
do {
let page = try await getFeedUseCase.execute(cursor: nextCursor)
// De-duplicate by ID
let existingIds = Set(posts.map(\.id))
let newPosts = page.items.filter { !existingIds.contains($0.id) }
posts.append(contentsOf: newPosts)
nextCursor = page.nextCursor
hasMore = page.hasMore
} catch {
// Handle error
}
isLoading = false
}
}
// Usage in SwiftUI
struct FeedView: View {
@StateObject private var viewModel: FeedViewModel
init(viewModel: FeedViewModel) {
_viewModel = StateObject(wrappedValue: viewModel)
}
var body: some View {
List {
ForEach(viewModel.posts) { post in
PostRow(post: post)
.onAppear {
if post == viewModel.posts.last {
Task { await viewModel.loadNext() }
}
}
}
if viewModel.isLoading {
ProgressView()
}
}
.task {
await viewModel.loadInitial()
}
}
}
Android - Pagination with Paging 3
// app/features/feed/data/paging/PostPagingSource.kt
class PostPagingSource @Inject constructor(
private val api: FeedApi
) : PagingSource<String, Post>() {
override suspend fun load(params: LoadParams<String>): LoadResult<String, Post> {
return try {
val cursor = params.key
val response = api.getPosts(cursor, params.loadSize)
LoadResult.Page(
data = response.items.map { it.toDomain() },
prevKey = null, // Only forward pagination
nextKey = response.nextCursor
)
} catch (e: Exception) {
LoadResult.Error(e)
}
}
override fun getRefreshKey(state: PagingState<String, Post>): String? = null
}
// app/features/feed/domain/usecase/GetFeedUseCase.kt
class GetFeedUseCase @Inject constructor(
private val repository: FeedRepository
) {
operator fun invoke(): Flow<PagingData<Post>> {
return repository.getPosts()
}
}
// app/features/feed/presentation/screen/FeedViewModel.kt
@HiltViewModel
class FeedViewModel @Inject constructor(
getFeedUseCase: GetFeedUseCase
) : ViewModel() {
val posts: Flow<PagingData<Post>> = getFeedUseCase()
.cachedIn(viewModelScope)
}
// Usage in Compose
@Composable
fun FeedScreen(viewModel: FeedViewModel = hiltViewModel()) {
val posts = viewModel.posts.collectAsLazyPagingItems()
LazyColumn {
items(posts.itemCount) { index ->
posts[index]?.let { post ->
PostItem(post)
}
}
posts.apply {
when {
loadState.refresh is LoadState.Loading -> {
item { LoadingItem() }
}
loadState.append is LoadState.Loading -> {
item { LoadingItem() }
}
loadState.refresh is LoadState.Error -> {
val e = loadState.refresh as LoadState.Error
item { ErrorItem(e.error) }
}
}
}
}
}
Flutter - Pagination
// lib/features/feed/presentation/feed_bloc.dart
class FeedBloc extends Bloc<FeedEvent, FeedState> {
final GetFeedUseCase _getFeedUseCase;
FeedBloc({required GetFeedUseCase getFeedUseCase})
: _getFeedUseCase = getFeedUseCase,
super(const FeedInitial()) {
on<LoadFeed>(_onLoadFeed);
on<LoadMoreFeed>(_onLoadMoreFeed);
}
Future<void> _onLoadFeed(
LoadFeed event,
Emitter<FeedState> emit,
) async {
emit(const FeedLoading());
try {
final page = await _getFeedUseCase(cursor: null);
emit(FeedLoaded(
posts: page.items,
nextCursor: page.nextCursor,
hasMore: page.hasMore,
));
} catch (e) {
emit(FeedError(e.toString()));
}
}
Future<void> _onLoadMoreFeed(
LoadMoreFeed event,
Emitter<FeedState> emit,
) async {
final currentState = state;
if (currentState is! FeedLoaded) return;
if (currentState.isLoadingMore || !currentState.hasMore) return;
emit(currentState.copyWith(isLoadingMore: true));
try {
final page = await _getFeedUseCase(cursor: currentState.nextCursor);
// De-duplicate
final existingIds = currentState.posts.map((p) => p.id).toSet();
final newPosts = page.items.where((p) => !existingIds.contains(p.id)).toList();
emit(FeedLoaded(
posts: [...currentState.posts, ...newPosts],
nextCursor: page.nextCursor,
hasMore: page.hasMore,
isLoadingMore: false,
));
} catch (e) {
emit(currentState.copyWith(
isLoadingMore: false,
error: e.toString(),
));
}
}
}
// States
sealed class FeedState {
const FeedState();
}
class FeedInitial extends FeedState {
const FeedInitial();
}
class FeedLoading extends FeedState {
const FeedLoading();
}
class FeedLoaded extends FeedState {
final List<Post> posts;
final String? nextCursor;
final bool hasMore;
final bool isLoadingMore;
final String? error;
const FeedLoaded({
required this.posts,
required this.nextCursor,
required this.hasMore,
this.isLoadingMore = false,
this.error,
});
FeedLoaded copyWith({
List<Post>? posts,
String? nextCursor,
bool? hasMore,
bool? isLoadingMore,
String? error,
}) {
return FeedLoaded(
posts: posts ?? this.posts,
nextCursor: nextCursor ?? this.nextCursor,
hasMore: hasMore ?? this.hasMore,
isLoadingMore: isLoadingMore ?? this.isLoadingMore,
error: error ?? this.error,
);
}
}
class FeedError extends FeedState {
final String message;
const FeedError(this.message);
}
// Usage in Widget
class FeedPage extends StatelessWidget {
Widget build(BuildContext context) {
return BlocBuilder<FeedBloc, FeedState>(
builder: (context, state) {
return switch (state) {
FeedLoading() => const Center(child: CircularProgressIndicator()),
FeedLoaded() => ListView.builder(
itemCount: state.posts.length + (state.hasMore ? 1 : 0),
itemBuilder: (context, index) {
if (index == state.posts.length) {
// Load more trigger
if (!state.isLoadingMore) {
context.read<FeedBloc>().add(const LoadMoreFeed());
}
return const Center(child: CircularProgressIndicator());
}
return PostItem(post: state.posts[index]);
},
),
FeedError() => Center(child: Text(state.message)),
_ => const SizedBox.shrink(),
};
},
);
}
}
Retry Logic
Implement retry logic for transient network failures.
iOS - Exponential Backoff
// App/Core/Networking/RetryPolicy.swift
enum RetryPolicy {
case none
case exponential(maxAttempts: Int, baseDelay: TimeInterval)
func shouldRetry(attempt: Int, error: Error) -> Bool {
guard case .exponential(let maxAttempts, _) = self else {
return false
}
guard attempt < maxAttempts else {
return false
}
// Only retry transient errors
if let apiError = error as? APIError {
switch apiError {
case .networkError, .timeout:
return true
case .serverError:
return true
default:
return false
}
}
return false
}
func delay(for attempt: Int) -> TimeInterval {
guard case .exponential(_, let baseDelay) = self else {
return 0
}
let exponentialDelay = baseDelay * pow(2.0, Double(attempt))
let jitter = Double.random(in: 0...0.1) * exponentialDelay
return exponentialDelay + jitter
}
}
func retry<T>(
policy: RetryPolicy = .exponential(maxAttempts: 3, baseDelay: 0.3),
operation: @escaping () async throws -> T
) async throws -> T {
var attempt = 0
while true {
do {
return try await operation()
} catch {
attempt += 1
guard policy.shouldRetry(attempt: attempt, error: error) else {
throw error
}
let delay = policy.delay(for: attempt)
try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
}
}
}
// Usage
let user: User = try await retry {
try await apiClient.request(.user(id: "123"))
}
Android - Retry with Coroutines
// app/core/network/RetryPolicy.kt
suspend fun <T> retry(
times: Int = 3,
initialDelayMs: Long = 300,
maxDelayMs: Long = 3000,
factor: Double = 2.0,
block: suspend () -> T
): T {
var currentDelay = initialDelayMs
repeat(times - 1) { attempt ->
try {
return block()
} catch (e: IOException) {
// Only retry on network errors
delay(currentDelay)
currentDelay = (currentDelay * factor).toLong().coerceAtMost(maxDelayMs)
} catch (e: HttpException) {
// Retry on 5xx errors
if (e.code() in 500..599) {
delay(currentDelay)
currentDelay = (currentDelay * factor).toLong().coerceAtMost(maxDelayMs)
} else {
throw e
}
}
}
return block() // Last attempt
}
// Usage
val user = retry {
apiService.getUser("123")
}
Flutter - Retry with Dio
// lib/core/network/retry_interceptor.dart
// Requires: import 'dart:math';
class RetryInterceptor extends Interceptor {
final Dio _dio;
final int maxAttempts;
final Duration initialDelay;
RetryInterceptor(
this._dio, {
this.maxAttempts = 3,
this.initialDelay = const Duration(milliseconds: 300),
});
Future<void> onError(
DioException err,
ErrorInterceptorHandler handler,
) async {
final attempt = err.requestOptions.extra['retry_count'] as int? ?? 0;
if (attempt >= maxAttempts) {
return handler.next(err);
}
if (!_shouldRetry(err)) {
return handler.next(err);
}
final delay = _calculateDelay(attempt);
await Future.delayed(delay);
try {
final opts = err.requestOptions;
final nextOpts = opts.copyWith(
extra: <String, dynamic>{
...opts.extra,
'retry_count': attempt + 1,
},
);
final response = await _dio.fetch<dynamic>(nextOpts);
return handler.resolve(response);
} on DioException catch (e) {
return handler.next(e);
} catch (_) {
return handler.next(err);
}
}
bool _shouldRetry(DioException err) {
if (err.type == DioExceptionType.connectionTimeout ||
err.type == DioExceptionType.receiveTimeout ||
err.type == DioExceptionType.sendTimeout ||
err.type == DioExceptionType.connectionError) {
return true;
}
final statusCode = err.response?.statusCode;
return statusCode != null && statusCode >= 500;
}
Duration _calculateDelay(int attempt) {
final baseMs = initialDelay.inMilliseconds;
final backoffMs = (baseMs * pow(2, attempt)).toDouble();
final jitterMs = Random().nextDouble() * 0.1 * backoffMs;
return Duration(milliseconds: (backoffMs + jitterMs).toInt());
}
}
Idempotency
For non-idempotent operations (POST, PATCH), use idempotency keys.
// iOS
func createPost(_ post: Post) async throws -> Post {
let idempotencyKey = UUID().uuidString
let endpoint = Endpoint.createPost(post)
.withHeader("Idempotency-Key", idempotencyKey)
return try await retry {
try await apiClient.request(endpoint)
}
}
// Android
suspend fun createPost(post: Post): Post {
val idempotencyKey = UUID.randomUUID().toString()
return retry {
apiService.createPost(
post = post,
idempotencyKey = idempotencyKey
)
}
}
// Flutter
// Assumes your Dio client is configured with RetryInterceptor.
Future<Post> createPost(Post post) async {
final idempotencyKey = const Uuid().v4();
final res = await dio.post<Map<String, dynamic>>(
'/posts',
data: post.toJson(),
options: Options(
headers: <String, dynamic>{
'Idempotency-Key': idempotencyKey,
},
),
);
return Post.fromJson(res.data!);
}
Summary
Pagination & Retry Best Practices:
- Prefer cursor-based pagination over offset-based
- De-duplicate items by stable IDs
- Implement exponential backoff with jitter for retries
- Only retry transient failures (timeouts, 5xx errors)
- Use idempotency keys for non-idempotent operations
- Show loading indicators during pagination/retries
- Cache paginated data when appropriate
| Pattern | iOS | Android | Flutter |
|---|---|---|---|
| Pagination | Manual state management | Paging 3 library | Bloc state |
| Retry | Custom retry function | Coroutines + delay | Dio interceptor |
| De-duplication | Set-based filtering | distinctBy | where + Set |