ASP.NET Web API (.NET Framework) · Data

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”.

Intermediate 6 min Lesson 17 of 36Covers ASP.NET Web API 2.2 on .NET Framework 4.8.
Before you start:CRUD API
0/36 in ASP.NET Web API
On this page · 6 min · Intermediate

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#

C# Illustrative
var items = await query
    .OrderBy(p => p.Id)
    .Skip((page - 1) * pageSize)
    .Take(pageSize)
    .ToListAsync();

Code example: Paged products with metadata#

C#ProductsController.cs Illustrative
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);
    }
}
Expected output
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 100

This example is illustrative. Copy or download it and run it in your own environment; the expected output is shown above.

Step-by-step explanation#

  1. PageQuery binds page and pageSize from the query string; null falls back to defaults.
  2. Math.Max/Math.Min clamp the values, so page 0 or pageSize 100000 cannot hurt the server.
  3. CountAsync gets the total once; OrderBy(p => p.Id) gives a stable order for Skip/Take.
  4. Select projects to DTOs, so SQL fetches only the needed columns for one page.
  5. 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#

Skip without OrderBy

EF6 refuses it, and with other tools results jump between pages. Always sort by a unique column (or end with Id as a tiebreaker).

Unlimited page size

pageSize=1000000 turns pagination off and can take the server down. Enforce a maximum.

Counting after paging

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?
Where(o => o.Id > afterId).OrderBy(o => o.Id).Take(limit + 1) — the extra row tells you whether there is a next page.
Show solution
C# Illustrative
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#

Pagination quizPredict the output Question 1 of 3

With page = 3 and pageSize = 10, how many rows does Skip skip?

Score so far: 0/0

Want more? Practise all ASP.NET Web API MCQs · ASP.NET Web API interview questions

My notes#

Saved in this browser.

Progress is saved in this browser. Sign in to sync.