Read time: 6 minutes
Today I’ll challenge what you know about pagination in ASP.NET Core.
You probably know how important it is to paginate your data before sending it from your API backend to your frontend.
However, most tutorials you’ll find on the Web follow an approach that is not efficient for large datasets, resulting in significant load for your database.
If you have to deal with large amounts of data, you need a different approach to pagination, and that’s what I’ll show you today.
Let’s dive in.
Pagination for small datasets
Most tutorials you’ll find on the Web will show you how to do what is known as offset pagination, which is an approach where you divide the total number of rows by the number of rows to display per page, and then you let the user choose the page number they want to see.
In the backend, the C# code use to retrieve the rows if also using Entity Framework Core and a relational database, would look something like this:
var pageNumber = 3;
var pageSize = 5;
var games = dbContext.Games
.OrderBy(game => game.Id)
.Skip(pageNumber * pageSize)
.Take(pageSize);
The problem with this approach is that it’s not efficient. The database must still process the first 15 entries, even if they aren’t returned to the application.
This can result in significant load for your database that increases with the number of rows being skipped. So, it’s not a good approach for large datasets.
Let’s see what’s the recommended approach for large datasets.
Pagination for large datasets
Keyset pagination, also known as seek-based pagination, is a more efficient way to paginate through a large dataset. It’s based on the idea of using a unique key to fetch the next set of rows.
This means that you don’t need to skip any rows. You just need to remember the last key you fetched and use it to fetch the next set of rows.
Your C# LINQ query would now look something like this:
var lastId = 5;
var pageSize = 5;
var games = dbContext.Games
.OrderBy(game => game.Id)
.Where(game => game.Id > lastId)
.Take(pageSize);
As long as the Id column is indexed, this query will be very efficient, even for large datasets.
Let’s see how to implement keyset pagination in a full-stack ASP.NET Core application.
The DTOs
We will use 2 DTOs to represent the request and response for the pagination.
Here’s the DTO to represent the request:
public record class GetGamesDto(
int? Cursor, // The last ID fetched
bool? IsNextPage, // A flag to indicate if we want the next page or the previous page
int PageSize = 5); // The number of rows to fetch
And this is the DTO to represent the response:
public record class GamesPageDto(
IEnumerable<GameSummaryDto> Data, // The list of resources
int? NextId, // The next ID to fetch
int? PreviousId, // The previous ID to fetch
bool IsFirstPage); // A flag to indicate if this is the first page
For completeness, here’s the DTO to represent the game summary, although it’s not really relevant to the pagination:
public record class GameSummaryDto(
int Id,
string Name,
decimal Price);
Now, let’s see how to implement the pagination logic in the backend.
Backend API implementation
I’ll show you an implementation that lets you fetch both the next and previous pages, which can be a bit tricky.
If you only need the next page (common in infinite scrolling scenarios) the logic would be simpler.
The first step is to order your rows by the Id column, to ensure that the rows are returned in the same order every time, and to take advantage of the index:
app.MapGet("/games", async (CatalogContext dbContext, [AsParameters] GetGamesDto request) =>
{
// Order by Id for keyset pagination
IQueryable<Game> games = dbContext.Games.OrderBy(game => game.Id);
});
Then, we need to select the rows based on the Cursor and IsNextPage parameters:
// Take 1 extra record to check if there's a next page.
int takeAmount = request.PageSize + 1;
if (request.Cursor is not null)
{
if (request.IsNextPage == true)
{
// Fetch the next page
games = games.Where(game => game.Id > request.Cursor);
}
else
{
// Fetch the previous page
games = games.Where(game => game.Id < request.Cursor)
.OrderByDescending(game => game.Id);
// No extra record needed in this case
takeAmount = request.PageSize;
}
}
games = games.Take(takeAmount);
// Reverse the list if it's a previous page request
if (request.IsNextPage == false && request.Cursor is not null)
{
games = games.Reverse();
}
Notice how we are not skipping any rows. We are just filtering the rows based on the Id column and wether we want the next or previous page.
Now that the games variable contains the rows we want to return, let’s make sure we include the game genres, convert the rows to the DTOs, and turn everything into a list:
var gamesOnPage = await games
.Include(game => game.Genre)
.Select(game => game.ToGameSummaryDto())
.AsNoTracking()
.ToListAsync();
Next, we need to do a few calculations to determine the NextId and PreviousId we’ll include in the response:
bool isFirstPage = !request.Cursor.HasValue
|| (request.Cursor.HasValue && gamesOnPage.First().Id
== dbContext.Games.OrderBy(g => g.Id).First().Id);
// There's a next page if:
// 1. We got an extra record
// 2. We're navigating to the previous page
bool hasNextPage = gamesOnPage.Count > request.PageSize ||
(request.Cursor is not null && request.IsNextPage == false);
// Remove the extra record used for next page detection
if (gamesOnPage.Count > request.PageSize)
{
gamesOnPage.RemoveAt(gamesOnPage.Count - 1);
}
int? nextId = hasNextPage
? gamesOnPage.Last().Id
: null;
int? previousId = gamesOnPage.Count > 0 && !isFirstPage
? gamesOnPage.First().Id
: null;
Finally, we return the response:
return new GamesPageDto(gamesOnPage, nextId, previousId, isFirstPage);
Like I said, it’s a bit tricky, and took me a while to get it right, but it’s a very efficient way to paginate through large datasets.
Now let’s see how to implement the frontend.
Blazor frontend implementation
In our Blazor Static SSR application, we’ll start by defining a typed client that can make use of the HttpClient to make requests with the expected parameters to the backend:
public class GamesClient(HttpClient httpClient)
{
public async Task<GamesPage> GetGamesAsync(int? cursor, bool isNextPage, int pageSize)
{
var query = QueryString.Create("pageSize", pageSize.ToString())
.Add("isNextPage", isNextPage.ToString());
if (cursor is not null)
{
query = query.Add("cursor", cursor.Value.ToString());
}
return await httpClient.GetFromJsonAsync<GamesPage>($"games{query}")
?? new GamesPage([], null, null, true);
}
}
To understand how that GamesClient instance is registered, checkout my HTTP Client Tutorial.
For completeness, here’s the GamesPage record, which is mostly a copy of the GamesPageDto class used in the backend:
public record class GamesPage(
IEnumerable<GameSummary> Data,
int? NextId,
int? PreviousId,
bool IsFirstPage);
We will also need to implement a record that we’ll call PaginationInfo, which will be handy in our upcoming Pagination component:
public record class PaginationInfo(int? NextId, int? PreviousId, bool IsFirstPage)
{
public bool HasPrevious => !IsFirstPage && PreviousId is not null;
public bool HasNext => NextId is not null;
}
Let’s now create the actual Pagination.razor component, which will be in charge of rendering our Previous and Next links, based on the information we get from the PaginationInfo record:
@inject NavigationManager Navigation
@if (PaginationInfo is not null)
{
<nav>
<ul class="pagination justify-content-center">
<li class="page-item @(!PaginationInfo.HasPrevious ? "disabled" : null)">
<a class="page-link" href="@PaginationUri(PaginationInfo.PreviousId, false)">
Previous
</a>
</li>
<li class="page-item @(!PaginationInfo.HasNext ? "disabled" : null)">
<a class="page-link" href="@PaginationUri(PaginationInfo.NextId, true)">
Next
</a>
</li>
</ul>
</nav>
}
@code {
[Parameter]
public PaginationInfo? PaginationInfo { get; set; }
private string PaginationUri(int? cursor, bool isNextPage)
=> Navigation.GetUriWithQueryParameters(new Dictionary<string, object?>()
{
{ "cursor", cursor },
{ "isNextPage", isNextPage }
});
}
Finally, we can use both our GamesClient and Pagination components in our Home.razor component:
@page "/"
@inject GamesClient Client
@attribute [StreamRendering]
<PageTitle>Game Store</PageTitle>
@if (gamesPage is null || paginationInfo is null)
{
<p class="mt-3"><em>Loading...</em></p>
}
else
{
<div class="row row-cols-1 row-cols-md-5 mt-3">
@foreach (var game in gamesPage.Data)
{
<div class="col">
<a href="game/@game.Id" style="text-decoration: none;">
<div class="card h-100">
<div class="card-img-container">
<img class="card-img-top" src="@game.ImageUri">
</div>
<div class="card-body">
<h5 class="card-title">@game.Name</h5>
<p class="card-text">@game.Price.ToString("C2")</p>
</div>
</div>
</a>
</div>
}
</div>
<div class="row mt-2">
<div class="col">
<Pagination PaginationInfo="paginationInfo" />
</div>
</div>
}
@code {
private GamesPage? gamesPage;
PaginationInfo? paginationInfo;
const int PageSize = 5;
[SupplyParameterFromQuery]
public int? Cursor { get; set; }
[SupplyParameterFromQuery]
public bool? IsNextPage { get; set; }
protected override async Task OnInitializedAsync()
{
gamesPage = await Client.GetGamesAsync(Cursor, IsNextPage ?? false, PageSize);
paginationInfo = new PaginationInfo(
gamesPage.NextId,
gamesPage.PreviousId,
gamesPage.IsFirstPage);
}
}
The end result
Here’s a screenshot of the full-stack ASP.NET core application, with keyset pagination enabled:
Notice the urls produced by the Pagination component, which include the cursor and isNextPage parameters.
Mission accomplished!
Whenever you’re ready, there are 4 ways I can help you:
-
.NET Cloud Developer Bootcamp: Everything you need to go from zero to building real-world .NET cloud applications, step by step.
-
Building .NET REST APIs: A carefully crafted package to kickstart your career as an ASP.NET Core Full Stack Developer, step by step.
-
Patreon Community: Join for exclusive discounts on all my in-depth courses and access my Discord server for community support and discussions.
-
Promote yourself to 17,000+ subscribers by sponsoring this newsletter.