DEV Community

ZèD
ZèD

Posted on

Cursor vs Offset Pagination in EF Core: What Actually Works in Production

Cursor vs Offset Pagination in EF Core: What Actually Works in Production

Pagination is easy to implement, but hard to get right when data grows.

Most applications start with Skip/Take. It works fine… until it doesn’t.

This article explains where each approach fits, what breaks, and how to choose properly.


Why This Matters

When pagination is wrong, you will see:

  • slow queries on large pages
  • duplicate or missing rows
  • unnecessary database load

The goal is not just pagination — it is consistent and scalable data access.


1. Offset Pagination (Skip/Take)

The most common approach:

var items = await context.Posts
    .AsNoTracking()
    .OrderByDescending(x => x.CreatedAt)
    .ThenByDescending(x => x.Id)
    .Skip((pageNumber - 1) * pageSize)
    .Take(pageSize)
    .ToListAsync();
Enter fullscreen mode Exit fullscreen mode

Why it works well

  • simple and predictable
  • supports page numbers (page 1, 2, 3...)
  • easy to expose in APIs

Where it breaks

Offset pagination becomes inefficient as the offset grows.

Even if you request 20 rows, the database still processes all skipped rows before returning results.

Also, if data changes between requests:

  • some rows may appear twice
  • some rows may be skipped

Because pagination is based on position, not actual data.


2. Cursor Pagination (Keyset)

Cursor pagination moves through data using a reference point.

Instead of page number, you pass the last item from the previous result.

var items = await context.Posts
    .AsNoTracking()
    .OrderByDescending(x => x.CreatedAt)
    .ThenByDescending(x => x.Id)
    .Where(x => x.CreatedAt < lastCreatedAt
        || (x.CreatedAt == lastCreatedAt && x.Id < lastId))
    .Take(pageSize)
    .ToListAsync();
Enter fullscreen mode Exit fullscreen mode

Why it works better for large data

  • no row skipping
  • uses index efficiently
  • stable even if data changes

The database jumps directly to the correct position instead of scanning.


3. Ordering Must Be Stable

This is critical for both approaches.

Bad:

.OrderBy(x => x.CreatedAt)
Enter fullscreen mode Exit fullscreen mode

Good:

.OrderBy(x => x.CreatedAt)
.ThenBy(x => x.Id)
Enter fullscreen mode Exit fullscreen mode

Without a unique order, pagination will eventually return inconsistent results.


4. Performance Comparison

Scenario Offset Cursor
small dataset good good
large page number slow stable
data frequently changing inconsistent stable
sequential navigation ok best

Cursor pagination avoids unnecessary work, especially at scale.


5. Limitations of Cursor Pagination

Cursor pagination is not a full replacement.

It does not support:

  • jumping to arbitrary page
  • clean page numbers
  • accurate “page X of Y” navigation

Because it works with positions, not pages.


6. When to Use What

Use offset pagination when:

  • UI requires page numbers
  • users jump between pages
  • dataset size is manageable

Example: admin dashboard


Use cursor pagination when:

  • data is large
  • users navigate sequentially
  • consistency matters

Example: feeds, logs, activity streams


7. Practical Recommendation

In many systems, the best approach is:

  • offset pagination → for page-based navigation
  • cursor pagination → for next/previous navigation

Each solves a different problem. Using both is normal.


8. Indexing Matters

For cursor pagination to perform well:

modelBuilder.Entity<Post>()
    .HasIndex(x => new { x.CreatedAt, x.Id });
Enter fullscreen mode Exit fullscreen mode

Without proper indexing, both approaches degrade.


Quick Recap

  • Offset is simple but slows down with large offsets
  • Cursor is efficient and stable but limited in navigation
  • Always use a unique ordering
  • Choose based on how users interact with data

Final Thought

Offset solves UI navigation.
Cursor solves data traversal.

Pick based on the problem, not the implementation.

Top comments (0)