Networking & REST API Integration

Pagination: Cursor-Based & Offset-Based APIs

16 min Lesson 9 of 13

Pagination: Cursor-Based & Offset-Based APIs

When working with large datasets from a REST API, loading all records in a single request is impractical. Pagination breaks the data into pages and lets the client request one page at a time. Flutter apps commonly implement an infinite scroll pattern: the user scrolls a ListView and new items load automatically when they approach the bottom. This lesson covers the two dominant pagination strategies — offset/page-number and cursor-based — and how to wire them into a Flutter widget.

Offset / Page-Number Pagination

With offset pagination the API accepts a page (or offset) parameter alongside a limit/per_page value. The server returns a fixed slice of results. You detect the end of data when the returned list is shorter than the requested page size, or when the API explicitly signals "has_more": false.

  • Request: GET /posts?page=2&per_page=20
  • Response shape: { "data": [...], "total": 200, "page": 2, "last_page": 10 }
  • End of data: page >= last_page or returned list length < per_page

Offset Pagination — Dart Model & Service

// Model
class Post {
  final int id;
  final String title;
  Post({required this.id, required this.title});

  factory Post.fromJson(Map<String, dynamic> json) =>
      Post(id: json['id'] as int, title: json['title'] as String);
}

// Service
class PostService {
  static const _baseUrl = 'https://api.example.com';
  static const _perPage = 20;
  final http.Client _client;

  PostService(this._client);

  /// Returns (posts, hasMore).
  Future<(List<Post>, bool)> fetchPage(int page) async {
    final uri = Uri.parse('$_baseUrl/posts')
        .replace(queryParameters: {
          'page': page.toString(),
          'per_page': _perPage.toString(),
        });

    final response = await _client.get(uri);
    if (response.statusCode != 200) {
      throw Exception('Failed to load posts: ${response.statusCode}');
    }

    final body = jsonDecode(response.body) as Map<String, dynamic>;
    final data = (body['data'] as List)
        .map((e) => Post.fromJson(e as Map<String, dynamic>))
        .toList();

    final lastPage = body['last_page'] as int;
    return (data, page < lastPage);
  }
}

Cursor-Based Pagination

Cursor pagination uses an opaque token (often the ID or timestamp of the last returned item) to mark your position in the dataset. The API returns a next_cursor field; when it is null there are no more pages. This approach is stable under insertions and deletions, making it the preferred strategy for real-time feeds.

  • First request: GET /posts?limit=20
  • Subsequent requests: GET /posts?limit=20&cursor=eyJpZCI6NDB9
  • End of data: next_cursor is null in the response

Cursor Pagination — Service & StatefulWidget

// Service method for cursor pagination
Future<(List<Post>, String?)> fetchCursor(String? cursor) async {
  final params = <String, String>{'limit': '20'};
  if (cursor != null) params['cursor'] = cursor;

  final uri = Uri.parse('$_baseUrl/posts').replace(queryParameters: params);
  final response = await _client.get(uri);
  if (response.statusCode != 200) {
    throw Exception('Failed to load: ${response.statusCode}');
  }

  final body = jsonDecode(response.body) as Map<String, dynamic>;
  final posts = (body['data'] as List)
      .map((e) => Post.fromJson(e as Map<String, dynamic>))
      .toList();

  final nextCursor = body['next_cursor'] as String?; // null = end of data
  return (posts, nextCursor);
}

// --- StatefulWidget wiring ---
class PostListPage extends StatefulWidget {
  const PostListPage({super.key});

  @override
  State<PostListPage> createState() => _PostListPageState();
}

class _PostListPageState extends State<PostListPage> {
  final _service = PostService(http.Client());
  final _posts = <Post>[];
  final _scrollController = ScrollController();

  String? _nextCursor; // null means "first fetch" or "no more data"
  bool _hasMore = true;
  bool _isLoading = false;

  @override
  void initState() {
    super.initState();
    _fetchNext();
    _scrollController.addListener(_onScroll);
  }

  void _onScroll() {
    final pos = _scrollController.position;
    // Trigger when within 200px of the bottom
    if (pos.pixels >= pos.maxScrollExtent - 200 && !_isLoading && _hasMore) {
      _fetchNext();
    }
  }

  Future<void> _fetchNext() async {
    setState(() => _isLoading = true);
    try {
      final (newPosts, cursor) = await _service.fetchCursor(_nextCursor);
      setState(() {
        _posts.addAll(newPosts);
        _nextCursor = cursor;
        _hasMore = cursor != null;
      });
    } catch (e) {
      // Handle error
    } finally {
      setState(() => _isLoading = false);
    }
  }

  @override
  void dispose() {
    _scrollController.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Posts')),
      body: ListView.builder(
        controller: _scrollController,
        itemCount: _posts.length + (_hasMore ? 1 : 0),
        itemBuilder: (context, index) {
          if (index == _posts.length) {
            return const Center(
              child: Padding(
                padding: EdgeInsets.all(16),
                child: CircularProgressIndicator(),
              ),
            );
          }
          return ListTile(
            leading: Text('${_posts[index].id}'),
            title: Text(_posts[index].title),
          );
        },
      ),
    );
  }
}

Detecting the End of Data

Correctly stopping further requests prevents pointless network calls and duplicate spinner rendering:

  • Offset: set _hasMore = false when page >= last_page or when the returned list is empty.
  • Cursor: set _hasMore = false when next_cursor is null.
  • Always guard the fetch with if (_isLoading || !_hasMore) return; to prevent concurrent requests.
Note: For offset pagination the _page counter must only increment after a successful response, not before the request. Incrementing prematurely causes gaps when a request fails and is retried.

Scroll Detection with ScrollController

Attach a ScrollController to your ListView and listen to its position. The standard trigger point is when pixels >= maxScrollExtent - threshold. A threshold of 200–400 pixels gives a comfortable preload buffer without fetching too early.

Tip: Always call _scrollController.dispose() in the widget’s dispose() method to prevent memory leaks. Forgetting this is a very common source of subtle bugs in production apps.
Warning: Do not call setState inside the scroll listener synchronously on every pixel change. Guard the trigger behind the !_isLoading && _hasMore flags so you fire at most one concurrent request at a time.

Choosing Between the Two Strategies

  • Use offset pagination for admin tables, search results, or any dataset that does not change during browsing (simpler math, easy “jump to page N”).
  • Use cursor pagination for social feeds, activity logs, or live data where rows are constantly inserted or deleted (no duplicate/missing items caused by shifting offsets).

Summary

Pagination is essential for any Flutter app that consumes list APIs. Both offset and cursor strategies follow the same Flutter pattern: maintain a ScrollController, detect proximity to the bottom, set an _isLoading guard, append results to the existing list, and render a loading indicator as the last item while more data is available. The key difference is how you request the next page — a page number versus an opaque cursor token — and how you detect the end of the dataset.