AL.
🇺🇸 EN
Volver al blog
.NET y C# · 7 min de lectura

Paginacion en APIs .NET: Offset vs Keyset vs Cursor

Aprende las tres estrategias principales de paginacion en APIs .NET — Offset, Keyset y Cursor — con casos de uso reales y Record DTO.


La paginación es fundamental para cualquier API que retorne colecciones de datos. Sin ella, retornar miles o millones de registros en una sola respuesta puede sobrecargar tanto el servidor como el cliente. Pero no todas las estrategias de paginación son iguales.

En este artículo, cubriremos las tres estrategias principales de paginación en APIs .NET:

  1. Offset-based pagination (SKIP/TAKE)
  2. Keyset pagination (también llamada seek-based)
  3. Cursor-based pagination

Veremos cómo implementar cada una en .NET, cuándo usar cuál, y cómo diseñar un DTO de respuesta paginada genérico.

1. Offset-Based Pagination (SKIP/TAKE)

Este es el enfoque más común. El cliente especifica cuántos registros saltar (offset o skip) y cuántos tomar (limit o page size).

Ejemplo de Request

GET /api/users?page=2&pageSize=10

Esto retornaría los registros 11-20.

Implementación en .NET

[ApiController]
[Route("api/[controller]")]
public class UsersController : ControllerBase
{
    private readonly AppDbContext _context;

    public UsersController(AppDbContext context)
    {
        _context = context;
    }

    [HttpGet]
    public async Task<ActionResult<PaginatedResponse<UserDto>>> GetUsers(
        [FromQuery] int page = 1,
        [FromQuery] int pageSize = 10)
    {
        if (page < 1) page = 1;
        if (pageSize < 1 || pageSize > 100) pageSize = 10;

        var totalCount = await _context.Users.CountAsync();
        var users = await _context.Users
            .OrderBy(u => u.Id)
            .Skip((page - 1) * pageSize)
            .Take(pageSize)
            .Select(u => new UserDto
            {
                Id = u.Id,
                Name = u.Name,
                Email = u.Email
            })
            .ToListAsync();

        return Ok(new PaginatedResponse<UserDto>
        {
            Data = users,
            Page = page,
            PageSize = pageSize,
            TotalCount = totalCount,
            TotalPages = (int)Math.Ceiling(totalCount / (double)pageSize)
        });
    }
}

Ventajas

  • Simple de implementar y entender.
  • Permite saltar a cualquier página directamente.
  • Funciona bien con paginación basada en UI (botones de página).

Desventajas

  • Performance: SKIP puede ser lento en offsets grandes. En una tabla con 1 millón de registros, SKIP(990000) tiene que escanear y descartar 990,000 filas.
  • Resultados inconsistentes: Si los datos cambian entre requests (inserts/deletes), los usuarios pueden ver duplicados o perderse registros.

Cuándo Usarlo

Usa offset-based pagination cuando:

  • El dataset es pequeño a mediano (menos de 100k registros).
  • Los usuarios necesitan acceso a páginas arbitrarias (ej. “ir a página 5”).
  • Los datos son mayormente estáticos.

2. Keyset Pagination (Seek-Based)

En lugar de contar filas, usas el último valor visto de una clave ordenada (como Id o CreatedAt) para recuperar la siguiente página.

Ejemplo de Request

GET /api/users?lastId=100&pageSize=10

Esto retorna los siguientes 10 usuarios después de Id = 100.

Implementación en .NET

[HttpGet("keyset")]
public async Task<ActionResult<PaginatedResponse<UserDto>>> GetUsersKeyset(
    [FromQuery] int? lastId = null,
    [FromQuery] int pageSize = 10)
{
    if (pageSize < 1 || pageSize > 100) pageSize = 10;

    var query = _context.Users.OrderBy(u => u.Id).AsQueryable();

    if (lastId.HasValue)
    {
        query = query.Where(u => u.Id > lastId.Value);
    }

    var users = await query
        .Take(pageSize)
        .Select(u => new UserDto
        {
            Id = u.Id,
            Name = u.Name,
            Email = u.Email
        })
        .ToListAsync();

    return Ok(new PaginatedResponse<UserDto>
    {
        Data = users,
        PageSize = pageSize,
        NextId = users.LastOrDefault()?.Id
    });
}

Ventajas

  • Rendimiento consistente: Usa un índice en la clave, sin importar el offset.
  • Resultados estables: No sufre de duplicados/omisiones si los datos cambian.
  • Perfecto para feeds infinitos (scroll infinito).

Desventajas

  • No puedes saltar a una página arbitraria.
  • Requiere una clave ordenada y única (usualmente Id o CreatedAt).

Cuándo Usarlo

Usa keyset pagination cuando:

  • Tienes datasets grandes (>100k registros).
  • Los usuarios hacen scroll secuencialmente (feeds, timelines).
  • La performance es crítica.

3. Cursor-Based Pagination

Similar a keyset, pero el cursor es opaco — usualmente un string codificado que encapsula la posición.

Ejemplo de Request

GET /api/users?cursor=eyJpZCI6MTAwfQ==&pageSize=10

El cursor es un valor base64-encoded que representa el último registro visto.

Implementación en .NET

public record Cursor(int Id);

[HttpGet("cursor")]
public async Task<ActionResult<PaginatedResponse<UserDto>>> GetUsersCursor(
    [FromQuery] string? cursor = null,
    [FromQuery] int pageSize = 10)
{
    if (pageSize < 1 || pageSize > 100) pageSize = 10;

    int? lastId = null;
    if (!string.IsNullOrEmpty(cursor))
    {
        try
        {
            var decoded = Convert.FromBase64String(cursor);
            var json = Encoding.UTF8.GetString(decoded);
            var cursorObj = JsonSerializer.Deserialize<Cursor>(json);
            lastId = cursorObj?.Id;
        }
        catch
        {
            return BadRequest("Invalid cursor");
        }
    }

    var query = _context.Users.OrderBy(u => u.Id).AsQueryable();

    if (lastId.HasValue)
    {
        query = query.Where(u => u.Id > lastId.Value);
    }

    var users = await query
        .Take(pageSize + 1) // Toma uno extra para detectar si hay más
        .Select(u => new UserDto
        {
            Id = u.Id,
            Name = u.Name,
            Email = u.Email
        })
        .ToListAsync();

    var hasMore = users.Count > pageSize;
    if (hasMore) users.RemoveAt(users.Count - 1);

    string? nextCursor = null;
    if (hasMore && users.Any())
    {
        var lastUser = users.Last();
        var cursorObj = new Cursor(lastUser.Id);
        var json = JsonSerializer.Serialize(cursorObj);
        nextCursor = Convert.ToBase64String(Encoding.UTF8.GetBytes(json));
    }

    return Ok(new PaginatedResponse<UserDto>
    {
        Data = users,
        PageSize = pageSize,
        NextCursor = nextCursor,
        HasMore = hasMore
    });
}

Ventajas

  • Opaco: Los clientes no necesitan saber sobre la estructura de datos interna.
  • Flexible: Puedes cambiar la lógica de paginación sin romper clientes.
  • Rendimiento: Igual que keyset.

Desventajas

  • Más complejidad (codificación/decodificación de cursors).
  • Debugging más difícil (los cursors son opacos).

Cuándo Usarlo

Usa cursor-based pagination cuando:

  • Quieres ocultar detalles de implementación.
  • La lógica de paginación puede cambiar con el tiempo.
  • Construyes APIs GraphQL (estilo Relay).

Diseñando un DTO de Respuesta Paginada

Para mantener consistencia a través de tu API, define un DTO genérico de respuesta paginada:

public class PaginatedResponse<T>
{
    public List<T> Data { get; set; } = new();
    public int? Page { get; set; }
    public int PageSize { get; set; }
    public int? TotalCount { get; set; }
    public int? TotalPages { get; set; }
    public int? NextId { get; set; } // Para keyset
    public string? NextCursor { get; set; } // Para cursor-based
    public bool HasMore { get; set; }
}

Esto funciona para los tres enfoques:

  • Offset: Incluye Page, PageSize, TotalCount, TotalPages.
  • Keyset: Incluye NextId, PageSize.
  • Cursor: Incluye NextCursor, HasMore, PageSize.

Implementación con Entity Framework Core

Para todos estos enfoques, asegúrate de tener índices apropiados:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<User>()
        .HasIndex(u => u.Id); // Para keyset/cursor

    modelBuilder.Entity<User>()
        .HasIndex(u => u.CreatedAt); // Si paginas por timestamp
}

Sin índices, incluso keyset/cursor pagination será lento.

Comparación: Offset vs Keyset vs Cursor

CaracterísticaOffsetKeysetCursor
Performance en datasets grandesLentoRápidoRápido
Saltar a página arbitrariaNoNo
Resultados estables bajo cambiosNo
Complejidad de implementaciónBajaMediaMedia-Alta
Mejor paraUI con páginasFeeds infinitosAPIs GraphQL

Conclusión

La paginación es más que solo agregar SKIP y TAKE. Elegir la estrategia correcta depende de tu caso de uso:

  • Offset-based: Simple, funciona bien para datasets pequeños y UIs basadas en páginas.
  • Keyset-based: Rápido y estable, ideal para feeds y datasets grandes.
  • Cursor-based: Opaco y flexible, genial para APIs públicas y GraphQL.

En .NET, implementar cualquiera de estas es sencillo con Entity Framework Core. Asegúrate de agregar índices apropiados y validación, y estarás listo.

Para la mayoría de aplicaciones modernas con datasets grandes, recomiendo empezar con keyset o cursor pagination. Son más escalables y proveen mejor experiencia de usuario para patrones de scroll infinito.