Pagination
Never return 50,000 rows in one response. Pagination returns a page at a time — small, fast responses — plus enough information for the client to show “Page 2 of 40”.
On this page · 6 min · Intermediate
On this page
- Learning objectives
- Explanation
- Syntax
- Code example
- Step by step
- Real-world use
- Common mistakes
- Best practices
- Remember this
- Practice exercise
- Mini quiz
- Related topics
- My notes
- Reading time
- 6 min
- Difficulty
- Intermediate
- Updated
- Oct 28, 2026
Related tutorials
Learning objectives#
- Accept page and pageSize from the query string
- Clamp values to safe limits
- Query a page with OrderBy, Skip and Take
- Return total count and paging metadata
Explanation#
Offset pagination uses page and pageSize: page 3 of size 20 skips 40 rows and takes 20. In LINQ: OrderBy(...).Skip((page - 1) * size).Take(size).
Always OrderBy before Skip/Take. Without a stable order, rows can appear on two pages or none. EF6 even throws: “The method Skip is only supported for sorted input”.
Never trust the client’s pageSize: cap it (for example at 100) so nobody can request a million rows.
Return metadata — page, pageSize, totalCount, totalPages — in the body or in headers such as X-Total-Count. With headers, add Access-Control-Expose-Headers so browsers can read them across origins.
A [FromUri] class with no query-string values binds as null in Web API 2, so fall back to defaults.
For very large or fast-changing tables, keyset (cursor) pagination — “give me 20 after id 4000” — is faster than large Skip values.
Syntax#
var items = await query
.OrderBy(p => p.Id)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
Code example: Paged products with metadata#
using System;
using System.Collections.Generic;
using System.Data.Entity;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Threading.Tasks;
using System.Web.Http;
using ShopApi.Data;
using ShopApi.Models;
public class PageQuery
{
public int Page { get; set; } = 1;
public int PageSize { get; set; } = 20;
}
public class PagedResponse<T>
{
public List<T> Items { get; set; }
public int Page { get; set; }
public int PageSize { get; set; }
public int TotalCount { get; set; }
public int TotalPages => (int)Math.Ceiling(TotalCount / (double)PageSize);
public bool HasNext => Page < TotalPages;
}
public class ProductsController : ApiController
{
private const int MaxPageSize = 100;
private readonly ShopDbContext _db;
public ProductsController(ShopDbContext db) { _db = db; }
// GET api/products?page=2&pageSize=20
[HttpGet, Route("api/products")]
public async Task<IHttpActionResult> Get([FromUri] PageQuery query)
{
query = query ?? new PageQuery(); // no query string → null
int page = Math.Max(1, query.Page);
int pageSize = Math.Min(Math.Max(1, query.PageSize), MaxPageSize);
var source = _db.Products.AsNoTracking();
int total = await source.CountAsync();
var items = await source
.OrderBy(p => p.Id) // stable order first
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(p => new ProductDto { Id = p.Id, Name = p.Name, Price = p.Price, Category = p.Category })
.ToListAsync();
var body = new PagedResponse<ProductDto>
{
Items = items, Page = page, PageSize = pageSize, TotalCount = total,
};
var response = Request.CreateResponse(HttpStatusCode.OK, body);
response.Headers.Add("X-Total-Count", total.ToString());
return ResponseMessage(response);
}
}
GET /api/products?page=2&pageSize=2 (5 products in the table)
→ 200 X-Total-Count: 5
{
"Items": [{"Id":3,"Name":"Notebook",...},{"Id":4,"Name":"Bag",...}],
"Page": 2, "PageSize": 2, "TotalCount": 5, "TotalPages": 3, "HasNext": true
}
GET /api/products?pageSize=100000 → pageSize is capped at 100This example is illustrative. Copy or download it and run it in your own environment; the expected output is shown above.
Step-by-step explanation#
- PageQuery binds page and pageSize from the query string; null falls back to defaults.
- Math.Max/Math.Min clamp the values, so page 0 or pageSize 100000 cannot hurt the server.
- CountAsync gets the total once; OrderBy(p => p.Id) gives a stable order for Skip/Take.
- Select projects to DTOs, so SQL fetches only the needed columns for one page.
- The body carries metadata (TotalPages, HasNext) and the X-Total-Count header carries the total too.
Real-world use case#
An order-history screen shows 25 orders per page. The API answers in 20 ms regardless of whether a customer has 30 orders or 30,000.
Common mistakes#
EF6 refuses it, and with other tools results jump between pages. Always sort by a unique column (or end with Id as a tiebreaker).
pageSize=1000000 turns pagination off and can take the server down. Enforce a maximum.
Calling Count() on the paged query returns at most pageSize. Count the filtered query before Skip/Take.
Best practices#
- Default and maximum page sizes
- Stable ordering with a unique tiebreaker
- Return totals and HasNext
- Consider keyset pagination for deep pages
Remember this#
OrderBy → Skip((page-1)*size) → Take(size).
Cap pageSize. Always.
Count before you page.
Practice exercise#
Your task: Implement keyset pagination: GET api/orders?afterId=4000&limit=20 returns up to 20 orders with Id > afterId ordered by Id, plus nextAfterId (the last Id, or null when there are no more).
Need a hint?
Show solution
using System;
using System.Collections.Generic;
using System.Linq;
using System.Web.Http;
public class Order { public int Id { get; set; } public decimal Total { get; set; } }
public class OrdersController : ApiController
{
private static readonly List<Order> Orders =
Enumerable.Range(1, 5000).Select(i => new Order { Id = i, Total = i * 10m }).ToList();
[HttpGet, Route("api/orders")]
public IHttpActionResult Get(int afterId = 0, int limit = 20)
{
limit = Math.Min(Math.Max(limit, 1), 100);
var rows = Orders.Where(o => o.Id > afterId).OrderBy(o => o.Id).Take(limit + 1).ToList();
bool hasMore = rows.Count > limit;
var page = rows.Take(limit).ToList();
return Ok(new { items = page, nextAfterId = hasMore ? page.Last().Id : (int?)null });
}
}
Mini quiz#
With page = 3 and pageSize = 10, how many rows does Skip skip?
Want more? Practise all ASP.NET Web API MCQs · ASP.NET Web API interview questions
My notes#
Saved in this browser.