Pagination: Cursor-Based & Offset-Based APIs
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_pageor 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_cursorisnullin 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 = falsewhenpage >= last_pageor when the returned list is empty. - Cursor: set
_hasMore = falsewhennext_cursorisnull. - Always guard the fetch with
if (_isLoading || !_hasMore) return;to prevent concurrent requests.
_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.
_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.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.