Flutter Infinite Scroll Pagination: The v5 Package and a Hand-Rolled Version
Add infinite scroll to a Flutter list with infinite_scroll_pagination v5 or your own widget. Tested on Flutter 3.47, with error states, cursors and tests.

Infinite scroll in Flutter comes down to three things: a lazily built list (ListView.builder or one of its cousins), a rule for when to request the next page, and clear loading, error and end-of-list states. You can get all three from the infinite_scroll_pagination package, or write them yourself. In our example the package version of the screen is 66 lines, including pull-to-refresh and an empty state, and the hand-rolled widget, which has neither, is 93.
This guide shows both, plus a cursor-based version for APIs that hand you a next token instead of page numbers. One warning first: version 5.0.0 of infinite_scroll_pagination (February 2025) removed addPageRequestListener, appendPage and appendLastPage. Many tutorials that still come up for this search were written before 2025 and use those methods, so their code won't compile against the current release. Everything below targets the v5 API.
infinite_scroll_pagination 5.1.1 and http 1.6.0. Every Dart file shown on this page passed dart analyze, and the list widgets passed seven widget and unit tests in flutter test. The HTTP client was not run against the live API from our test machine; we checked the API's response shape in a browser instead.
How infinite scroll works in Flutter
Flutter's own docs explain the first half: ListView.builder() "creates items as they're scrolled onto the screen" instead of building every row up front (Work with long lists). Pagination adds the second half. You keep a growing list of items, and when the user gets close to the end you fetch the next slice and append it.
Most pagination bugs come from the fetch rule. A good one:
- asks for page n + 1 only once, even if the user flings past the trigger point five times;
- knows when there are no more pages, so it stops asking;
- keeps what's already loaded when a request fails, and offers a retry;
- can start over for pull-to-refresh.
Your API decides how you name "the next slice". Offset pagination asks for "20 items, skip 40" or "page 3". It's simple and lets you jump to any page, but items can repeat or go missing if rows are inserted or deleted between requests. Cursor pagination hands you an opaque token with each page ("give me what comes after abc123"). It stays stable while data changes, but you can only walk forward. Both work with the code below.
Option A: infinite_scroll_pagination 5.1.1
The package is MIT-licensed, published by the verified publisher edsonbueno.com, and its 5.1.1 release dates from August 2025 (pub.dev, changelog). It gives you a PagingController that owns the state and layout widgets (PagedListView, PagedGridView, PagedSliverList, PagedPageView, masonry grids and .separated variants) that call it.
flutter pub add infinite_scroll_pagination http
The API client
Our example uses DummyJSON's public products endpoint, which pages with limit and skip and returns products, total, skip and limit (DummyJSON docs). Put the model and client in lib/product.dart and lib/product_api.dart:
class Product {
const Product({required this.id, required this.title});
final int id;
final String title;
factory Product.fromJson(Map<String, dynamic> json) =>
Product(id: json['id'] as int, title: json['title'] as String);
}
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'product.dart';
/// Offset pagination against DummyJSON's public products endpoint:
/// GET https://dummyjson.com/products?limit=20&skip=40
class ProductApi {
ProductApi({http.Client? client}) : _client = client ?? http.Client();
static const pageSize = 20;
final http.Client _client;
/// [page] starts at 1.
Future<List<Product>> fetchPage(int page) async {
final uri = Uri.https('dummyjson.com', '/products', {
'limit': '$pageSize',
'skip': '${(page - 1) * pageSize}',
'select': 'id,title',
});
final response = await _client.get(uri);
if (response.statusCode != 200) {
// An Exception (not an Error), so the paging controller stores it
// in state.error and shows the retry UI instead of rethrowing.
throw ApiException('GET $uri failed: ${response.statusCode}');
}
final body = jsonDecode(response.body) as Map<String, dynamic>;
final items = body['products'] as List<dynamic>;
return items
.map((e) => Product.fromJson(e as Map<String, dynamic>))
.toList();
}
}
class ApiException implements Exception {
ApiException(this.message);
final String message;
@override
String toString() => message;
}
The custom ApiException implements Exception on purpose, and so does the ClientException that package:http throws for network failures. The next section explains why that matters. (We avoided the name HttpException because dart:io already has one.)
The list screen
lib/product_list_page.dart creates the controller, wires it to a PagedListView through PagingListener, and adds pull-to-refresh:
import 'package:flutter/material.dart';
import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart';
import 'product.dart';
typedef PageFetcher = Future<List<Product>> Function(int page);
class ProductListPage extends StatefulWidget {
const ProductListPage({
super.key,
required this.fetchPage,
this.pageSize = 20,
});
final PageFetcher fetchPage;
final int pageSize;
@override
State<ProductListPage> createState() => _ProductListPageState();
}
class _ProductListPageState extends State<ProductListPage> {
late final _pagingController = PagingController<int, Product>(
getNextPageKey: (state) {
// Stop as soon as a page comes back short, so we don't spend an
// extra request just to receive an empty list.
final lastPage = state.pages?.lastOrNull;
if (lastPage != null && lastPage.length < widget.pageSize) return null;
return state.nextIntPageKey;
},
fetchPage: widget.fetchPage,
);
@override
void dispose() {
_pagingController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Products')),
body: RefreshIndicator(
onRefresh: () async => _pagingController.refresh(),
child: PagingListener(
controller: _pagingController,
builder: (context, state, fetchNextPage) =>
PagedListView<int, Product>.separated(
state: state,
fetchNextPage: fetchNextPage,
separatorBuilder: (context, index) => const Divider(height: 1),
builderDelegate: PagedChildBuilderDelegate<Product>(
itemBuilder: (context, product, index) => ListTile(
key: ValueKey(product.id),
title: Text(product.title),
),
noItemsFoundIndicatorBuilder: (context) =>
const Center(child: Text('No products yet')),
),
),
),
),
);
}
}
And lib/main.dart:
import 'package:flutter/material.dart';
import 'product_api.dart';
import 'product_list_page.dart';
void main() {
final api = ProductApi();
runApp(MaterialApp(
home: ProductListPage(
fetchPage: api.fetchPage,
pageSize: ProductApi.pageSize,
),
));
}
Run it and you get the package's default first-page spinner, then the list. When a row near the end is built (by default the fourth-to-last, because invisibleItemsThreshold is 3), the layout calls fetchNextPage. A spinner row appears at the bottom while the page loads.
Why getNextPageKey checks for a short page
The README's example ends pagination with state.lastPageIsEmpty ? null : state.nextIntPageKey. That works, but it only stops after the server has returned an empty page, which means one request that can never contain data.
We measured it in a unit test. With 45 fake items and a page size of 20, the README rule made four requests (pages 1, 2, 3 and an empty 4). The short-page rule in our controller made three. On DummyJSON, which reported "total":194 when we checked on 7 October 2026, page 10 (skip=180) returns 14 items, so the short-page rule stops there instead of asking for an empty page 11.
Use the short-page rule when the API always fills a page unless it's the last one. If it can return short pages mid-list (some search APIs filter after paging), use total, a hasMore flag or the cursor approach instead.
Errors: throw an Exception, not an Error
PagingController.fetchNextPage catches whatever your fetchPage throws and stores it in state.error, which drives the "Try Again" and "Tap to try again" indicators. The 5.1.1 source then rethrows anything that isn't an Exception:
if (error is! Exception) {
// Errors which are not exceptions indicate that something
// went unexpectedly wrong. These errors are rethrown
// so they can be logged and investigated.
rethrow;
}
So a StateError, a TypeError from a bad JSON cast, or throw 'offline' (a String) still shows the error UI, but it also escapes as an uncaught error. Our test confirmed both halves: the controller's error was set, and the zone's error handler received the same StateError. That's the behaviour you want for genuine bugs. For expected failures like a 500 or no connection, throw a class that implements Exception, as ProductApi does.
refresh() returns void, so onRefresh: () async => _pagingController.refresh() completes immediately and the refresh spinner disappears before page 1 arrives. The list's own first-page spinner covers the gap. If you want the pull spinner to stay until data loads, return a Future that completes when the controller's isLoading goes back to false.
Migrating from v4
If you're updating older code, these are the changes from the 5.0.0 changelog that break most v4 tutorials:
| v4 | v5 |
|---|---|
PagingController(firstPageKey: 1) + addPageRequestListener |
PagingController(getNextPageKey: ..., fetchPage: ...) |
appendPage(items, nextKey) / appendLastPage(items) |
Return the items from fetchPage; return null from getNextPageKey to stop |
controller.itemList, controller.error |
state.items (flattened from state.pages), state.error |
PagedListView(pagingController: ...) |
PagingListener + PagedListView(state: ..., fetchNextPage: ...) |
retryLastFailedRequest() |
Call fetchNextPage() again (the default error indicators do this) |
Two smaller changes: PagingState now stores pages (a list of lists) and keys instead of one itemList, and its error is typed Object?. controller.mapItems updates loaded items in place, for example after a user likes a post, without refetching. state.filterItems returns a filtered view for display; the package source advises against assigning it back to the controller.
Option B: hand-rolled infinite scroll
You don't need a package. This widget listens for scroll notifications and loads more when the user is within 400 pixels of the bottom:
import 'package:flutter/material.dart';
import 'product.dart';
/// Infinite scroll without a package: load more when the user is
/// within [loadMoreExtent] pixels of the bottom.
class HandRolledProductList extends StatefulWidget {
const HandRolledProductList({
super.key,
required this.fetchPage,
this.pageSize = 20,
this.loadMoreExtent = 400,
});
final Future<List<Product>> Function(int page) fetchPage;
final int pageSize;
final double loadMoreExtent;
@override
State<HandRolledProductList> createState() => _HandRolledProductListState();
}
class _HandRolledProductListState extends State<HandRolledProductList> {
final _items = <Product>[];
int _nextPage = 1;
bool _loading = false;
bool _hasMore = true;
Object? _error;
@override
void initState() {
super.initState();
_loadMore();
}
Future<void> _loadMore() async {
if (_loading || !_hasMore) return;
setState(() {
_loading = true;
_error = null;
});
try {
final page = await widget.fetchPage(_nextPage);
if (!mounted) return;
setState(() {
_items.addAll(page);
_nextPage++;
_hasMore = page.length == widget.pageSize;
});
} catch (e) {
if (!mounted) return;
setState(() => _error = e);
} finally {
if (mounted) setState(() => _loading = false);
}
}
bool _onScroll(ScrollNotification n) {
// After an error, wait for the Retry tap instead of retrying on scroll.
if (_error != null) return false;
if (n.metrics.extentAfter < widget.loadMoreExtent) _loadMore();
return false;
}
@override
Widget build(BuildContext context) {
final showFooter = _loading || _error != null;
return NotificationListener<ScrollNotification>(
onNotification: _onScroll,
child: ListView.builder(
itemCount: _items.length + (showFooter ? 1 : 0),
itemBuilder: (context, index) {
if (index < _items.length) {
return ListTile(title: Text(_items[index].title));
}
if (_error != null) {
return ListTile(
title: const Text('Could not load more'),
trailing: TextButton(
onPressed: _loadMore,
child: const Text('Retry'),
),
);
}
return const Padding(
padding: EdgeInsets.all(16),
child: Center(child: CircularProgressIndicator()),
);
},
),
);
}
}
The parts that matter:
_loadingand_hasMoreguards. Scroll notifications fire many times a second. Without the early return, one fling would start several requests for the same page.extentAfter. It's the scrollable distance left below the viewport, so the trigger works the same on any screen height.mountedchecks. The user can leave the screen while a request is in flight. In debug builds, callingsetStateafterdisposethrows.- No retry on scroll after an error. Without the
_errorcheck in_onScroll, every scroll near the bottom would quietly fire the failed request again. The package behaves the same way: after a new-page error it waits for a tap. - What's missing. If the first page doesn't fill the screen, the user can't scroll, so no notification fires and page 2 never loads. The package handles this because its trigger is "an item near the end was built", not "the user scrolled". If your page size is small, call
_loadMore()again after a load whenextentAfteris already below the threshold, or fetch bigger pages.
In our tests the hand-rolled list loaded all 45 items in three requests, the same as the package version.
Cursor-based APIs
fetchPage returns only a list, so there's nowhere in the package's state to put the cursor the server sends back. Keep it in the widget's state and read it in getNextPageKey:
import 'package:flutter/material.dart';
import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart';
class Post {
const Post(this.id, this.text);
final String id;
final String text;
}
class PostPage {
const PostPage(this.items, this.nextCursor);
final List<Post> items;
final String? nextCursor; // null when there are no more pages
}
typedef PostFetcher = Future<PostPage> Function({String? after});
class CursorFeed extends StatefulWidget {
const CursorFeed({super.key, required this.fetchPosts});
final PostFetcher fetchPosts;
@override
State<CursorFeed> createState() => _CursorFeedState();
}
class _CursorFeedState extends State<CursorFeed> {
String? _nextCursor;
late final _controller = PagingController<String, Post>(
getNextPageKey: (state) {
if (state.keys == null) return ''; // first page: '' means "no cursor"
return _nextCursor; // null ends pagination
},
fetchPage: (cursor) async {
final page = await widget.fetchPosts(after: cursor.isEmpty ? null : cursor);
_nextCursor = page.nextCursor;
return page.items;
},
);
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) => PagingListener(
controller: _controller,
builder: (context, state, fetchNextPage) => PagedListView<String, Post>(
state: state,
fetchNextPage: fetchNextPage,
builderDelegate: PagedChildBuilderDelegate(
itemBuilder: (context, post, index) => ListTile(title: Text(post.text)),
),
),
);
}
The empty string stands for "first page, no cursor", because returning null from getNextPageKey means "stop". Our test fed it three pages of 15 posts and checked the requests were after: null, '15' and '30', then nothing.
One caveat if you add pull-to-refresh here: _nextCursor lives outside the controller, and refresh() doesn't stop a fetch that's already in flight from writing it. Reset _nextCursor when you refresh, and ignore the result of a fetch that started before the refresh, or the next request can use a stale cursor.
Test your pagination
Pagination bugs hide until someone scrolls fast on a slow network, so test the fetch rule directly. Here's the core of our widget test for the package version, using a fake fetcher instead of the network:
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:paging_demo/hand_rolled_list.dart';
import 'package:paging_demo/product.dart';
import 'package:paging_demo/product_list_page.dart';
void main() {
// 45 fake products: two full pages of 20 and one short page of 5.
final all = List.generate(45, (i) => Product(id: i, title: 'Product $i'));
late List<int> requested;
Future<List<Product>> fakeFetch(int page) async {
requested.add(page);
await Future<void>.delayed(const Duration(milliseconds: 50));
final start = (page - 1) * 20;
return all.sublist(start.clamp(0, 45), (start + 20).clamp(0, 45));
}
setUp(() => requested = []);
Future<void> scrollToEnd(WidgetTester tester) async {
for (var i = 0; i < 30; i++) {
await tester.drag(find.byType(Scrollable).first, const Offset(0, -600));
await tester.pumpAndSettle();
}
}
testWidgets('package v5: loads pages on scroll and stops on a short page',
(tester) async {
await tester.pumpWidget(MaterialApp(
home: ProductListPage(fetchPage: fakeFetch, pageSize: 20),
));
await tester.pumpAndSettle();
expect(find.text('Product 0'), findsOneWidget);
expect(requested, [1]);
await scrollToEnd(tester);
expect(find.text('Product 44'), findsOneWidget);
expect(requested, [1, 2, 3]); // no 4th request for an empty page
});
testWidgets('package v5: a thrown Exception shows the error UI',
(tester) async {
await tester.pumpWidget(MaterialApp(
home: ProductListPage(
fetchPage: (page) async => throw Exception('offline'),
),
));
await tester.pumpAndSettle();
expect(find.text('Try Again'), findsOneWidget);
});
testWidgets('hand-rolled: loads pages on scroll and stops on a short page',
(tester) async {
await tester.pumpWidget(MaterialApp(
home: Scaffold(body: HandRolledProductList(fetchPage: fakeFetch)),
));
await tester.pumpAndSettle();
await scrollToEnd(tester);
expect(find.text('Product 44'), findsOneWidget);
expect(requested, [1, 2, 3]);
});
}
Run it with flutter test. A failing expect(requested, [1, 2, 3]) is the quickest way to catch duplicate or endless requests.
Package or hand-rolled?
infinite_scroll_pagination 5.1.1 |
Hand-rolled | |
|---|---|---|
| Code you own (our example) | 66-line screen with refresh and empty state, plus the package | 93-line widget, no dependency |
| Grids, slivers, page views, masonry | Built in | You write each one |
| First, new-page, error, empty and end indicators | Built in, all replaceable | You write each one |
| Short first page | Handled (build-based trigger) | Needs an extra check |
| Pull-to-refresh | controller.refresh() |
Reset your fields |
| Behaviour you can read in one file | Spread across controller and layout | Yes |
For a feed, catalogue or search results with grids or slivers, use the package. For one simple list, or a codebase that avoids small dependencies, the hand-rolled widget is easy to read and to test.
Common problems
"The method addPageRequestListener isn't defined." You're following a v4 tutorial. See the migration table above.
It keeps requesting pages forever. getNextPageKey never returns null. Check your stop rule against what the API actually returns on the last page.
Every page loads twice. In hand-rolled code, the loading guard is missing or set after the await. In the package, fetchNextPage already ignores calls while a fetch is running; our test fired it three times in a row and saw one request.
The error UI shows, but the console also shows an uncaught error. You threw something that isn't an Exception. See "Errors" above.
Items repeat after new rows are added on the server. That's offset pagination shifting under you. De-duplicate by ID when appending, or move to cursors.
Rows jump when images load. Give rows a fixed or predictable height. For ListView.builder you can also pass prototypeItem or itemExtent so Flutter doesn't have to measure each row.
Next steps
Once pages load reliably, the next thing users notice is the blank screen on a cold start. Caching the first page locally fixes that; our guide to the Flutter local database options in 2026 compares what to store it in. If the controller is going to live outside one screen, the Flutter state management guide covers where to put it.
If you'd rather describe the list screen than wire it by hand, FlutterGo, an AI Flutter app builder, generates the Flutter project from a prompt and lets you download the code or push it to GitHub. Either way, run the request-count test above against the generated list.


