Skip to content

Pagination & Infinite Scroll

Essential techniques for efficiently loading and displaying large datasets in mobile applications.

Pagination Architecture Patterns

Traditional Pagination

  • Offset-Based Pagination:
    • Page-based navigation with ?page=2&limit=20
    • Simple implementation but performance issues with large datasets
    • Database query optimization challenges with skip-limit pattern
  • Cursor-Based Pagination:
    • Stateful navigation with ?after=cursor_id&limit=20
    • Consistent results even with data changes
    • Better performance for large datasets

Mobile-Optimized Pagination Strategies

  • Progressive Loading: Small initial page, larger subsequent pages
  • Adaptive Page Sizes: Dynamic page sizing based on network conditions
  • Predictive Loading: Intelligent prefetching based on user behavior patterns

Platform-Specific Pagination Implementations

Android Pagination Solutions

  • Paging 3 Library Architecture:
    • Data loading abstraction with PagingSource
    • Network + database coordination with RemoteMediator
    • RecyclerView integration with PagingDataAdapter
    • Loading/error state handling with LoadState
  • Advanced Features:
    • Grouped content with Separators
    • Additional content with Headers/Footers
    • Error recovery with Retry mechanisms
    • Smooth scrolling with Placeholder support
kotlin
// Android Paging 3 Implementation
class ItemPagingSource(
    private val apiService: ApiService,
    private val query: String
) : PagingSource<Int, Item>() {
    
    override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Item> {
        return try {
            val page = params.key ?: 1
            val response = apiService.getItems(
                query = query,
                page = page,
                size = params.loadSize
            )
            
            LoadResult.Page(
                data = response.items,
                prevKey = if (page == 1) null else page - 1,
                nextKey = if (response.items.isEmpty()) null else page + 1
            )
        } catch (exception: Exception) {
            LoadResult.Error(exception)
        }
    }
    
    override fun getRefreshKey(state: PagingState<Int, Item>): Int? {
        return state.anchorPosition?.let { anchorPosition ->
            state.closestPageToPosition(anchorPosition)?.prevKey?.plus(1)
                ?: state.closestPageToPosition(anchorPosition)?.nextKey?.minus(1)
        }
    }
}

// ViewModel Integration
class ItemViewModel(private val repository: ItemRepository) : ViewModel() {
    val items = Pager(
        config = PagingConfig(
            pageSize = 20,
            enablePlaceholders = false,
            prefetchDistance = 5
        ),
        pagingSourceFactory = { ItemPagingSource(repository.apiService, searchQuery) }
    ).flow.cachedIn(viewModelScope)
}

// Fragment Usage
class ItemFragment : Fragment() {
    private val adapter = ItemPagingAdapter()
    
    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        
        recyclerView.adapter = adapter
        
        viewModel.items.observe(this) { pagingData ->
            adapter.submitData(lifecycle, pagingData)
        }
        
        // Load state handling
        adapter.addLoadStateListener { loadState ->
            when (loadState.refresh) {
                is LoadState.Loading -> showProgressBar()
                is LoadState.Error -> showError(loadState.refresh.error)
                is LoadState.NotLoading -> hideProgressBar()
            }
        }
    }
}

iOS Pagination Solutions

  • UITableView/UICollectionView Prefetching:
    • Prefetch data source delegate for predictive loading
    • Cell prefetching for smooth scrolling
    • Background loading coordination
  • Combine Framework Integration:
    • Publisher-based pagination flows
    • Automatic retry and error handling
    • State management with CurrentValueSubject
swift
// iOS Pagination Manager
class PaginationManager<T: Codable> {
    private var currentPage = 1
    private var isLoading = false
    private var hasMorePages = true
    
    @Published var items: [T] = []
    @Published var loadingState: LoadingState = .idle
    
    private let pageSize: Int
    private let apiService: APIService
    private var cancellables = Set<AnyCancellable>()
    
    init(pageSize: Int = 20, apiService: APIService) {
        self.pageSize = pageSize
        self.apiService = apiService
    }
    
    func loadNextPage() {
        guard !isLoading && hasMorePages else { return }
        
        isLoading = true
        loadingState = .loading
        
        apiService.fetchItems(page: currentPage, limit: pageSize)
            .receive(on: DispatchQueue.main)
            .sink(
                receiveCompletion: { [weak self] completion in
                    self?.isLoading = false
                    switch completion {
                    case .failure(let error):
                        self?.loadingState = .error(error)
                    case .finished:
                        self?.loadingState = .idle
                    }
                },
                receiveValue: { [weak self] response in
                    guard let self = self else { return }
                    
                    self.items.append(contentsOf: response.items)
                    self.currentPage += 1
                    self.hasMorePages = response.items.count == self.pageSize
                }
            )
            .store(in: &cancellables)
    }
    
    func refresh() {
        currentPage = 1
        hasMorePages = true
        items.removeAll()
        loadNextPage()
    }
}

// SwiftUI Integration
struct InfiniteScrollView<T: Codable & Identifiable>: View {
    @StateObject private var paginationManager: PaginationManager<T>
    
    var body: some View {
        List {
            ForEach(paginationManager.items) { item in
                ItemRow(item: item)
                    .onAppear {
                        if item.id == paginationManager.items.last?.id {
                            paginationManager.loadNextPage()
                        }
                    }
            }
            
            if paginationManager.loadingState == .loading {
                ProgressView()
                    .frame(maxWidth: .infinity, alignment: .center)
            }
        }
        .refreshable {
            paginationManager.refresh()
        }
        .onAppear {
            if paginationManager.items.isEmpty {
                paginationManager.loadNextPage()
            }
        }
    }
}

Flutter Pagination Solutions

  • Built-in Scroll Controllers:
    • ScrollController for scroll position detection
    • NotificationListener for scroll events
    • ListView.builder for efficient item rendering
  • External Packages:
    • infinite_scroll_pagination for advanced features
    • pull_to_refresh for refresh functionality
    • cached_network_image for image pagination
dart
// Flutter Pagination Controller
class PaginationController<T> extends ChangeNotifier {
  List<T> _items = [];
  bool _isLoading = false;
  bool _hasReachedMax = false;
  String? _error;
  
  List<T> get items => _items;
  bool get isLoading => _isLoading;
  bool get hasReachedMax => _hasReachedMax;
  String? get error => _error;
  
  final Future<PaginatedResponse<T>> Function(int page) _fetchPage;
  final int pageSize;
  int _currentPage = 1;
  
  PaginationController({
    required Future<PaginatedResponse<T>> Function(int page) fetchPage,
    this.pageSize = 20,
  }) : _fetchPage = fetchPage;
  
  Future<void> loadNextPage() async {
    if (_isLoading || _hasReachedMax) return;
    
    _isLoading = true;
    _error = null;
    notifyListeners();
    
    try {
      final response = await _fetchPage(_currentPage);
      
      if (response.items.isEmpty) {
        _hasReachedMax = true;
      } else {
        _items.addAll(response.items);
        _currentPage++;
        _hasReachedMax = response.items.length < pageSize;
      }
    } catch (e) {
      _error = e.toString();
    } finally {
      _isLoading = false;
      notifyListeners();
    }
  }
  
  Future<void> refresh() async {
    _currentPage = 1;
    _hasReachedMax = false;
    _items.clear();
    await loadNextPage();
  }
}

// Flutter Infinite Scroll Widget
class InfiniteScrollListView<T> extends StatefulWidget {
  final PaginationController<T> controller;
  final Widget Function(BuildContext context, T item) itemBuilder;
  final Widget? loadingWidget;
  final Widget? errorWidget;
  
  const InfiniteScrollListView({
    Key? key,
    required this.controller,
    required this.itemBuilder,
    this.loadingWidget,
    this.errorWidget,
  }) : super(key: key);
  
  @override
  _InfiniteScrollListViewState<T> createState() => _InfiniteScrollListViewState<T>();
}

class _InfiniteScrollListViewState<T> extends State<InfiniteScrollListView<T>> {
  final ScrollController _scrollController = ScrollController();
  
  @override
  void initState() {
    super.initState();
    _scrollController.addListener(_onScroll);
    widget.controller.addListener(_onControllerUpdate);
    
    // Load initial data
    WidgetsBinding.instance.addPostFrameCallback((_) {
      if (widget.controller.items.isEmpty) {
        widget.controller.loadNextPage();
      }
    });
  }
  
  void _onScroll() {
    if (_isBottom) {
      widget.controller.loadNextPage();
    }
  }
  
  bool get _isBottom {
    if (!_scrollController.hasClients) return false;
    final maxScroll = _scrollController.position.maxScrollExtent;
    final currentScroll = _scrollController.offset;
    return currentScroll >= (maxScroll * 0.9);
  }
  
  void _onControllerUpdate() => setState(() {});
  
  @override
  Widget build(BuildContext context) {
    return RefreshIndicator(
      onRefresh: widget.controller.refresh,
      child: ListView.builder(
        controller: _scrollController,
        itemCount: widget.controller.hasReachedMax
            ? widget.controller.items.length
            : widget.controller.items.length + 1,
        itemBuilder: (context, index) {
          if (index >= widget.controller.items.length) {
            return widget.loadingWidget ?? 
                   const Center(child: CircularProgressIndicator());
          }
          
          return widget.itemBuilder(context, widget.controller.items[index]);
        },
      ),
    );
  }
  
  @override
  void dispose() {
    _scrollController.removeListener(_onScroll);
    _scrollController.dispose();
    widget.controller.removeListener(_onControllerUpdate);
    super.dispose();
  }
}

JavaScript/React Native Pagination

  • FlatList Optimization:
    • onEndReached for infinite scroll
    • getItemLayout for performance
    • removeClippedSubviews for memory management
  • State Management Integration:
    • Redux/Zustand for pagination state
    • React Query for server state management
    • SWR for data fetching and caching
javascript
// React Native Pagination Hook
import { useState, useCallback, useRef } from 'react';

export const usePagination = (fetchFunction, pageSize = 20) => {
  const [data, setData] = useState([]);
  const [loading, setLoading] = useState(false);
  const [refreshing, setRefreshing] = useState(false);
  const [hasMore, setHasMore] = useState(true);
  const [error, setError] = useState(null);
  
  const currentPage = useRef(1);
  const isLoadingMore = useRef(false);
  
  const loadMore = useCallback(async () => {
    if (loading || isLoadingMore.current || !hasMore) return;
    
    isLoadingMore.current = true;
    setLoading(true);
    setError(null);
    
    try {
      const response = await fetchFunction({
        page: currentPage.current,
        limit: pageSize,
      });
      
      const newItems = response.data || [];
      
      setData(prevData => 
        currentPage.current === 1 ? newItems : [...prevData, ...newItems]
      );
      
      setHasMore(newItems.length === pageSize);
      currentPage.current += 1;
    } catch (err) {
      setError(err.message);
    } finally {
      setLoading(false);
      isLoadingMore.current = false;
    }
  }, [fetchFunction, pageSize, loading, hasMore]);
  
  const refresh = useCallback(async () => {
    setRefreshing(true);
    currentPage.current = 1;
    setHasMore(true);
    setData([]);
    
    await loadMore();
    setRefreshing(false);
  }, [loadMore]);
  
  const reset = useCallback(() => {
    setData([]);
    setLoading(false);
    setRefreshing(false);
    setHasMore(true);
    setError(null);
    currentPage.current = 1;
  }, []);
  
  return {
    data,
    loading,
    refreshing,
    hasMore,
    error,
    loadMore,
    refresh,
    reset,
  };
};

// FlatList Implementation
import React, { useEffect } from 'react';
import { FlatList, ActivityIndicator, Text } from 'react-native';

const PaginatedList = ({ fetchItems, renderItem, keyExtractor }) => {
  const {
    data,
    loading,
    refreshing,
    hasMore,
    error,
    loadMore,
    refresh,
  } = usePagination(fetchItems);
  
  useEffect(() => {
    loadMore(); // Initial load
  }, []);
  
  const renderFooter = () => {
    if (!hasMore) return null;
    
    return (
      <ActivityIndicator 
        style={{ margin: 15 }} 
        size="small" 
        color="#0066cc" 
      />
    );
  };
  
  const renderError = () => {
    if (!error) return null;
    
    return (
      <Text style={{ textAlign: 'center', color: 'red', margin: 10 }}>
        {error}
      </Text>
    );
  };
  
  return (
    <FlatList
      data={data}
      renderItem={renderItem}
      keyExtractor={keyExtractor}
      onEndReached={loadMore}
      onEndReachedThreshold={0.1}
      onRefresh={refresh}
      refreshing={refreshing}
      ListFooterComponent={renderFooter}
      ListHeaderComponent={renderError}
      removeClippedSubviews={true}
      maxToRenderPerBatch={10}
      updateCellsBatchingPeriod={50}
      initialNumToRender={20}
      windowSize={10}
    />
  );
};

Advanced Pagination Techniques

Cursor-Based Implementation

  • Benefits: Consistent pagination even with data changes
  • Implementation: Use stable identifiers (timestamps, UUIDs)
  • Database Support: Efficient queries with indexed cursor fields
javascript
// Cursor-based API endpoint
app.get('/api/posts', async (req, res) => {
  const { after, limit = 20 } = req.query;
  
  const query = {
    ...(after && { createdAt: { $lt: new Date(after) } })
  };
  
  const posts = await Post.find(query)
    .sort({ createdAt: -1 })
    .limit(parseInt(limit) + 1); // +1 to check if there are more
  
  const hasNextPage = posts.length > limit;
  const edges = posts.slice(0, limit);
  
  const pageInfo = {
    hasNextPage,
    endCursor: edges.length > 0 ? edges[edges.length - 1].createdAt : null,
  };
  
  res.json({
    edges: edges.map(post => ({ node: post })),
    pageInfo,
  });
});

Virtual Scrolling for Large Lists

  • Use Cases: Lists with thousands of items
  • Memory Optimization: Render only visible items
  • Platform Libraries: react-window, @tanstack/react-virtual
javascript
// React Virtual Scrolling
import { FixedSizeList as List } from 'react-window';
import InfiniteLoader from 'react-window-infinite-loader';

const VirtualizedInfiniteList = ({ 
  items, 
  hasNextPage, 
  isNextPageLoading, 
  loadNextPage 
}) => {
  const itemCount = hasNextPage ? items.length + 1 : items.length;
  const isItemLoaded = index => !!items[index];
  
  const Item = ({ index, style }) => {
    const item = items[index];
    
    if (!item) {
      return (
        <div style={style}>
          <div>Loading...</div>
        </div>
      );
    }
    
    return (
      <div style={style}>
        <ItemComponent item={item} />
      </div>
    );
  };
  
  return (
    <InfiniteLoader
      isItemLoaded={isItemLoaded}
      itemCount={itemCount}
      loadMoreItems={loadNextPage}
    >
      {({ onItemsRendered, ref }) => (
        <List
          ref={ref}
          height={400}
          itemCount={itemCount}
          itemSize={50}
          onItemsRendered={onItemsRendered}
        >
          {Item}
        </List>
      )}
    </InfiniteLoader>
  );
};

Performance Optimization Strategies

Memory Management

  • Item Recycling: Reuse view components for better performance
  • Image Loading: Lazy loading with placeholder images
  • Data Cleanup: Remove off-screen items from memory

Smooth Scrolling Techniques

  • Prefetch Distance: Load data before reaching the end
  • Throttled Loading: Prevent excessive API calls
  • Background Processing: Load data on background threads

Network Optimization

  • Request Consolidation: Batch multiple page requests
  • Compression: Use gzip compression for API responses
  • Caching Strategy: Cache pages for offline browsing
javascript
// Advanced Pagination with Caching
class PaginationCache {
  constructor(maxSize = 50) {
    this.cache = new Map();
    this.maxSize = maxSize;
  }
  
  set(key, value) {
    if (this.cache.size >= this.maxSize) {
      const firstKey = this.cache.keys().next().value;
      this.cache.delete(firstKey);
    }
    this.cache.set(key, value);
  }
  
  get(key) {
    const value = this.cache.get(key);
    if (value) {
      // Move to end (LRU)
      this.cache.delete(key);
      this.cache.set(key, value);
    }
    return value;
  }
  
  has(key) {
    return this.cache.has(key);
  }
}

const paginationCache = new PaginationCache();

const fetchPageWithCache = async (page) => {
  const cacheKey = `page_${page}`;
  
  if (paginationCache.has(cacheKey)) {
    return paginationCache.get(cacheKey);
  }
  
  const response = await api.fetchPage(page);
  paginationCache.set(cacheKey, response);
  
  return response;
};

Best Practices

User Experience Guidelines

  • Loading States: Show clear loading indicators
  • Error Handling: Provide retry mechanisms for failed loads
  • Empty States: Design meaningful empty state screens
  • Pull-to-Refresh: Implement intuitive refresh gestures

Performance Considerations

  • Page Size Optimization: Balance between load time and user experience
  • Debounce Scrolling: Prevent excessive scroll event handling
  • Memory Monitoring: Track and optimize memory usage

Accessibility Support

  • Screen Reader: Announce loading states and page updates
  • Focus Management: Maintain proper focus when new items load
  • Keyboard Navigation: Support keyboard-only navigation patterns

This comprehensive pagination system provides efficient data loading while maintaining excellent user experience across all mobile platforms.

Created by Eren Demir.