API /api/orders?page=312&pageSize=20 mất 4.7 giây. Database có 2 triệu đơn hàng, và mỗi lần user lật sang trang 312, PostgreSQL phải scan qua 6,240 row rồi vứt đi, chỉ để trả 20 row tiếp theo. EXPLAIN ANALYZE: Seq Scan trên toàn bộ bảng vì OFFSET 6240 không dùng index được. Trang 1 load trong 15ms, trang 312 mất gần 5 giây — cùng query, cùng pageSize, nhưng hiệu năng khác nhau hàng trăm lần.

Offset pagination không scale. Cursor-based ổn định hơn khi data thay đổi nhưng không cho nhảy trang. Keyset pagination hiệu năng tốt nhất với index nhưng cũng có ràng buộc riêng. Bài này đi qua ba chiến lược pagination, kèm SQL thực tế, API design, và khi nào chọn cái nào.


Tại sao phải pagination

Một API trả về toàn bộ 2 triệu đơn hàng trong một response là thảm hoạ ở mọi tầng. Database phải đọc toàn bộ bảng. Server phải serialize 2 triệu object thành JSON — tốn CPU và memory. Network phải truyền hàng trăm MB data. Client phải parse và render — browser đứng hình hoặc crash trên mobile.

Pagination chia dataset lớn thành các “trang” nhỏ, mỗi trang chứa N item. Client request từng trang, server trả đúng N item cùng metadata để client biết cách lấy trang tiếp theo. Đơn giản về mặt khái niệm, nhưng cách implement pagination ảnh hưởng trực tiếp đến hiệu năng database, tính nhất quán dữ liệu khi data thay đổi, và trải nghiệm người dùng.


Offset pagination — đơn giản nhưng có giá

Cách hoạt động

Offset pagination là cách mà hầu hết developer học đầu tiên, và cũng là cách mà hầu hết tutorial dạy. Client gửi hai tham số: page (trang thứ mấy) và pageSize (bao nhiêu item mỗi trang). Server tính OFFSET = (page - 1) * pageSize rồi query:

SELECT id, customer_name, total, created_at
FROM orders
ORDER BY created_at DESC
LIMIT 20 OFFSET 6240;

API thường trông thế này:

GET /api/orders?page=313&pageSize=20

Response trả về data kèm metadata:

{
  "data": [...],
  "pagination": {
    "page": 313,
    "pageSize": 20,
    "totalItems": 2034567,
    "totalPages": 101729
  }
}

Ưu điểm rõ ràng: dễ hiểu, dễ implement, client có thể nhảy đến bất kỳ trang nào (page=500), và hiển thị được “trang 313 / 101,729” trên UI. Admin panel, back-office tool, bảng dữ liệu có phân trang kiểu truyền thống — offset pagination hoàn toàn hợp lý khi dataset không quá lớn.

Vấn đề 1 — OFFSET scan và vứt bỏ row

Đây là vấn đề nghiêm trọng nhất của offset pagination. Khi database thực thi OFFSET 6240, nó không “nhảy” đến row thứ 6240 — nó phải đọc và vứt bỏ 6240 row đầu tiên, rồi mới trả 20 row tiếp theo. Càng xa trang đầu, càng nhiều row bị scan rồi bỏ.

-- Trang 1: scan 20 row, trả 20 row
EXPLAIN ANALYZE
SELECT * FROM orders ORDER BY created_at DESC LIMIT 20 OFFSET 0;
-- Execution Time: 0.8 ms

-- Trang 10,000: scan 200,020 row, trả 20 row
EXPLAIN ANALYZE
SELECT * FROM orders ORDER BY created_at DESC LIMIT 20 OFFSET 200000;
-- Execution Time: 342 ms

-- Trang 50,000: scan 1,000,020 row, trả 20 row
EXPLAIN ANALYZE
SELECT * FROM orders ORDER BY created_at DESC LIMIT 20 OFFSET 1000000;
-- Execution Time: 2847 ms

Hiệu năng suy giảm tuyến tính theo OFFSET — page càng lớn, query càng chậm. Với bảng vài triệu row, trang cuối có thể mất hàng chục giây. Đây không phải vấn đề lý thuyết — mình đã thấy nó nhiều lần trên production khi user dùng filter rồi lật đến trang cuối.

Một số người nghĩ index sẽ cứu, nhưng OFFSET bắt buộc database phải đếm row — index giúp sắp xếp nhanh hơn nhưng không giúp “nhảy” qua N row. PostgreSQL vẫn phải traverse index node đến row thứ N rồi mới bắt đầu trả kết quả.

Vấn đề 2 — data shift khi insert/delete

Vấn đề này tinh vi hơn và dễ bị bỏ qua. Giả sử user đang xem trang 5 (row 81-100). Trong khi user đọc, có đơn hàng mới được tạo — nó nằm đầu danh sách vì ORDER BY created_at DESC. Khi user lật sang trang 6, toàn bộ data đã dịch xuống 1 vị trí: row thứ 100 (cuối trang 5) giờ thành row thứ 101 (đầu trang 6). User thấy item đó hai lần — một lần ở cuối trang 5, một lần ở đầu trang 6.

Ngược lại, nếu đơn hàng bị xoá giữa hai lần request, user sẽ skip mất một item — không bao giờ thấy nó. Với dataset thay đổi liên tục (feed, timeline, real-time data), offset pagination cho trải nghiệm không nhất quán.

Khi nào offset vẫn OK

Offset pagination vẫn là lựa chọn hợp lý trong nhiều trường hợp. Admin panel nội bộ với vài nghìn record — user hiếm khi lật quá trang 10, hiệu năng không phải vấn đề. Bảng dữ liệu cần “nhảy đến trang X” — offset là cách duy nhất hỗ trợ random page access dễ dàng. Dataset nhỏ và ít thay đổi — report lịch sử, danh sách tĩnh. Trong những trường hợp này, sự đơn giản của offset vượt qua nhược điểm của nó.


Cursor-based pagination — ổn định khi data thay đổi

Ý tưởng cốt lõi

Thay vì nói “cho tôi trang 5”, cursor pagination nói “cho tôi 20 item sau item có cursor là abc123”. Cursor là một token không rõ nghĩa (opaque) đại diện cho vị trí trong dataset — thường là ID hoặc timestamp của item cuối cùng đã trả, được encode (base64 hoặc encrypt) để client không phụ thuộc vào cấu trúc bên trong.

API request trông thế này:

GET /api/orders?limit=20&after=eyJpZCI6MTIzNDV9

Response:

{
  "data": [...],
  "pagination": {
    "hasNextPage": true,
    "hasPreviousPage": true,
    "startCursor": "eyJpZCI6MTIzMjZ9",
    "endCursor": "eyJpZCI6MTIzNDV9"
  }
}

Client muốn trang tiếp theo thì gửi after=endCursor. Muốn trang trước thì gửi before=startCursor. Không có khái niệm “page number” — client chỉ biết “tiếp theo” và “quay lại”.

Tại sao ổn định hơn offset

Khi có item mới được insert hoặc item cũ bị xoá, cursor không bị ảnh hưởng. Cursor trỏ đến một item cụ thể (“sau item #12345”), không phải vị trí tuyệt đối (“row thứ 100”). Dù data thay đổi thế nào, “20 item sau item #12345” luôn trả kết quả nhất quán — không skip, không duplicate.

Đây là lý do mà mọi feed và timeline lớn đều dùng cursor: Facebook feed, Twitter timeline, GitHub API, Slack message history. Data thay đổi liên tục, user scroll liên tục — cursor đảm bảo mỗi lần scroll xuống đều thấy item mới chứ không lặp lại.

Hạn chế

Không thể nhảy đến trang bất kỳ. “Go to page 50” không tồn tại trong cursor pagination — client chỉ có thể đi tiến hoặc lùi từ vị trí hiện tại. Với infinite scroll thì không sao (user cuộn xuống liên tục), nhưng với giao diện bảng dữ liệu truyền thống cần page number thì cursor pagination không phù hợp.

Cursor cũng phức tạp hơn để implement và debug. Client phải lưu cursor giữa các request, server phải encode/decode cursor, và nếu cursor chứa thông tin nội bộ (ID, timestamp) mà không encode đúng cách thì client có thể tamper.

Encode cursor đúng cách

Cursor nên là opaque token — client không nên parse hay hiểu nội dung bên trong. Cách đơn giản nhất:

import base64, json

def encode_cursor(last_id, last_created_at):
    payload = json.dumps({"id": last_id, "ts": last_created_at.isoformat()})
    return base64.urlsafe_b64encode(payload.encode()).decode()

def decode_cursor(cursor):
    payload = base64.urlsafe_b64decode(cursor.encode()).decode()
    return json.loads(payload)

Tuyệt đối không để cursor chứa thông tin nhạy cảm (email, internal path) vì base64 không phải encryption — ai cũng decode được. Nếu cần bảo vệ cursor khỏi tamper, ký bằng HMAC hoặc encrypt bằng AES. Mình thường dùng base64 cho internal API và HMAC-signed cursor cho public API.


Keyset pagination — hiệu năng tốt nhất

Seek method

Keyset pagination (hay seek method) là kỹ thuật đằng sau cursor pagination, nhưng ở tầng SQL. Thay vì OFFSET, bạn dùng WHERE clause để “seek” đến vị trí tiếp theo dựa trên giá trị cột đã index:

-- Trang đầu tiên
SELECT id, customer_name, total, created_at
FROM orders
ORDER BY created_at DESC, id DESC
LIMIT 20;

-- Trang tiếp theo: dùng giá trị cuối trang trước
SELECT id, customer_name, total, created_at
FROM orders
WHERE (created_at, id) < ('2026-05-28T10:30:00', 12345)
ORDER BY created_at DESC, id DESC
LIMIT 20;

Database dùng index trên (created_at, id) để nhảy thẳng đến vị trí cần đọc — không scan, không vứt bỏ row. Hiệu năng không đổi bất kể bạn đang ở “trang” bao nhiêu:

-- Keyset: trang 1
EXPLAIN ANALYZE
SELECT * FROM orders
ORDER BY created_at DESC, id DESC
LIMIT 20;
-- Index Scan using idx_orders_created_id on orders
-- Execution Time: 0.9 ms

-- Keyset: "trang 50,000" (WHERE seek đến vị trí tương đương)
EXPLAIN ANALYZE
SELECT * FROM orders
WHERE (created_at, id) < ('2025-01-15T08:00:00', 50123)
ORDER BY created_at DESC, id DESC
LIMIT 20;
-- Index Scan using idx_orders_created_id on orders
-- Execution Time: 1.1 ms

So sánh với offset: trang 50,000 bằng offset mất 2.8 giây, bằng keyset mất 1.1ms. Chênh lệch hơn 2,500 lần.

Composite key — khi sort column không unique

Nếu bạn sort theo created_at mà nhiều row có cùng created_at (chính xác đến giây), WHERE created_at < $1 sẽ bỏ sót row. Giải pháp là dùng composite key — thêm cột unique (thường là id) làm tiebreaker:

-- Index cho composite keyset
CREATE INDEX idx_orders_created_id ON orders (created_at DESC, id DESC);

-- Query với row value comparison
SELECT * FROM orders
WHERE (created_at, id) < ($1, $2)
ORDER BY created_at DESC, id DESC
LIMIT 20;

Row value comparison (created_at, id) < ($1, $2) trong PostgreSQL hoạt động đúng với composite ordering — nó so sánh created_at trước, nếu bằng thì so sánh id. MySQL cũng hỗ trợ cú pháp tương tự.

Nếu database không hỗ trợ row value comparison tốt (hoặc optimizer không dùng index), bạn có thể viết tương đương:

SELECT * FROM orders
WHERE created_at < $1
   OR (created_at = $1 AND id < $2)
ORDER BY created_at DESC, id DESC
LIMIT 20;

Cả hai cho cùng kết quả, nhưng row value comparison thường được optimize tốt hơn trên PostgreSQL. Kiểm tra EXPLAIN để xác nhận index được dùng.

Keyset với nhiều cột sort

Khi API hỗ trợ sort theo nhiều cột — ví dụ ?sort=status,-created_at — keyset pagination phức tạp hơn. Mỗi tổ hợp sort cần index riêng và WHERE clause riêng:

-- Sort theo status ASC, created_at DESC
CREATE INDEX idx_orders_status_created ON orders (status ASC, created_at DESC, id DESC);

SELECT * FROM orders
WHERE (status, created_at, id) > ($1, $2, $3)
   OR (status = $1 AND (created_at, id) < ($2, $3))
ORDER BY status ASC, created_at DESC, id DESC
LIMIT 20;

Phức tạp tăng nhanh với mỗi cột sort thêm vào. Mình thường giới hạn số tổ hợp sort mà API hỗ trợ — 2-3 predefined sort options thay vì arbitrary sort. Mỗi option có index tương ứng đã được tạo sẵn.


Total count — đắt hơn bạn tưởng

Hầu hết pagination response trả về totalItems hoặc totalPages. Nghe vô hại, nhưng để tính total count, database phải:

SELECT COUNT(*) FROM orders WHERE status = 'completed';

Trên PostgreSQL, COUNT(*) phải scan toàn bộ row thoả điều kiện (PostgreSQL không cache count vì MVCC — mỗi transaction có thể thấy số row khác nhau). Với bảng vài triệu row, query này có thể mất vài trăm ms đến vài giây. Và nó chạy mỗi lần request pagination — cộng vào latency của query chính.

EXPLAIN ANALYZE SELECT COUNT(*) FROM orders WHERE status = 'completed';
-- Parallel Seq Scan on orders
-- Rows: 1847293
-- Execution Time: 1243 ms

Hơn 1 giây chỉ để đếm. Mỗi request pagination tốn thêm 1 giây vì total count.

Giải pháp cho total count

Bỏ total count hoàn toàn. Với infinite scroll, user không cần biết “có 2 triệu item”. Chỉ cần hasNextPage: true/false. Đây là cách tốt nhất cho feed và timeline — đơn giản, nhanh, không query thừa.

Estimate count. PostgreSQL có pg_class.reltuples cho estimate số row cực nhanh — không chính xác 100% nhưng sai số vài phần trăm, đủ tốt cho UI hiển thị “khoảng 2 triệu kết quả”:

SELECT reltuples::bigint AS estimate
FROM pg_class
WHERE relname = 'orders';
-- estimate: 2034892 (trả về trong < 1ms)

Với filtered count, dùng EXPLAIN output để lấy estimated rows:

# PostgreSQL EXPLAIN trả về estimated rows cho filtered query
cursor.execute("EXPLAIN (FORMAT JSON) SELECT * FROM orders WHERE status = 'completed'")
plan = cursor.fetchone()[0][0]['Plan']
estimated_rows = plan['Plan Rows']

Lazy count. Trang đầu tiên trả response không có total count, UI hiện “Loading…”. Một request riêng (hoặc background job) tính total count và cache kết quả. Các request sau dùng cached count. Invalidate cache khi data thay đổi đáng kể (INSERT/DELETE vượt ngưỡng).

Cache count với TTL. Tính total count một lần, cache trong Redis 5 phút. Trong 5 phút đó, mọi request dùng cached value. Count có thể sai vài đơn vị — chấp nhận được cho hầu hết UI. Đây là cách mình dùng nhiều nhất cho admin panel cần hiển thị total.

import redis

def get_total_orders(status, r: redis.Redis, db):
    cache_key = f"orders:count:{status}"
    cached = r.get(cache_key)
    if cached:
        return int(cached)
    
    count = db.execute(
        "SELECT COUNT(*) FROM orders WHERE status = %s", (status,)
    ).fetchone()[0]
    r.setex(cache_key, 300, count)  # cache 5 phút
    return count

API response design

Dù dùng offset, cursor, hay keyset, API response cần cấu trúc nhất quán để client dễ consume. Mình thấy hai pattern phổ biến.

Pattern envelope đơn giản

{
  "data": [
    {"id": 12345, "customer": "Nguyễn Văn A", "total": 500000},
    {"id": 12344, "customer": "Trần Thị B", "total": 250000}
  ],
  "pagination": {
    "hasNextPage": true,
    "hasPreviousPage": true,
    "endCursor": "eyJpZCI6MTIzNDQsInRzIjoiMjAyNi0wNS0yOCJ9",
    "startCursor": "eyJpZCI6MTIzNDUsInRzIjoiMjAyNi0wNS0yOCJ9"
  }
}

Client dùng endCursor cho ?after= parameter ở request tiếp theo. hasNextPage giúp UI biết có cần hiện nút “Load more” hay không.

{
  "data": [...],
  "links": {
    "self": "/api/orders?limit=20&after=abc123",
    "next": "/api/orders?limit=20&after=xyz789",
    "prev": "/api/orders?limit=20&before=abc123"
  }
}

Client không cần tự build URL — follow link next là xong. Đây là pattern RESTful hơn và giảm coupling giữa client và server — server có thể đổi cấu trúc URL mà client không cần sửa code.

Mình thường dùng pattern envelope cho internal API (team mình control cả client lẫn server) và HATEOAS links cho public API (client bên ngoài không nên hardcode URL pattern).


Infinite scroll và UX

Cursor pagination sinh ra để phục vụ infinite scroll — user cuộn xuống, app tự load thêm data. Nhưng triển khai infinite scroll có vài gotcha mà backend engineer cần biết.

Trigger load thêm trước khi user đến cuối. Nếu đợi user cuộn đến item cuối cùng rồi mới fetch, user thấy loading spinner — UX tệ. Tốt hơn: bắt đầu fetch khi user còn cách cuối 3-5 item (intersection observer trên item thứ N-5). Đến lúc user cuộn đến cuối, data đã sẵn sàng.

Giữ cursor state ở client. Client lưu endCursor từ response trước, gửi kèm request tiếp theo. Nếu user refresh trang, cursor mất — user quay về đầu danh sách. Đây là hành vi mong đợi cho feed (luôn thấy mới nhất khi refresh), nhưng không tốt cho admin panel (user mất vị trí đang làm việc). Với admin panel, cân nhắc lưu cursor vào URL query param hoặc session storage.

Rate limit phía client. Infinite scroll dễ tạo “scroll storm” — user cuộn nhanh, mỗi intersection trigger một request, 10 request liên tiếp trong 2 giây. Debounce hoặc throttle fetch call, và cancel request cũ khi request mới được trigger.


GraphQL Connections spec

GraphQL có spec chuẩn cho cursor pagination gọi là Relay Connection spec. Nhiều GraphQL API ngoài hệ sinh thái Relay cũng adopt spec này vì nó thiết kế tốt:

query {
  orders(first: 20, after: "cursor123") {
    edges {
      cursor
      node {
        id
        customerName
        total
      }
    }
    pageInfo {
      hasNextPage
      hasPreviousPage
      startCursor
      endCursor
    }
    totalCount
  }
}

edges là mảng object chứa cursor (vị trí của item đó) và node (data thực). pageInfo chứa metadata pagination. Mỗi edge có cursor riêng — client có thể paginate từ bất kỳ item nào trong danh sách, không chỉ item cuối.

Spec này đáng tham khảo ngay cả khi bạn không dùng GraphQL — cấu trúc response rõ ràng, tách biệt metadata khỏi data, và hỗ trợ cả forward lẫn backward pagination.


Chọn chiến lược nào cho use case nào

Admin panel, back-office

User cần nhảy đến trang bất kỳ, xem “trang 5 / 200”, và dataset thường dưới vài trăm nghìn row. Offset pagination là lựa chọn tự nhiên. Nếu dataset lớn hơn và user phàn nàn trang cuối chậm, giới hạn max page (ví dụ không cho vượt quá trang 500) và encourage user dùng filter để thu hẹp kết quả.

-- Giới hạn max offset để tránh query chậm
SELECT * FROM orders
ORDER BY created_at DESC
LIMIT 20 OFFSET LEAST($offset, 10000);

Feed, timeline, activity log

Data thay đổi liên tục, user cuộn xuống liên tục, không cần nhảy đến trang cụ thể. Cursor-based pagination là lựa chọn duy nhất hợp lý. Implement bằng keyset query bên dưới để có hiệu năng tốt nhất.

API public cho dataset lớn

Third-party developer consume API, dataset có thể rất lớn (hàng triệu row), và bạn không muốn cho phép query chậm ảnh hưởng database. Keyset pagination bắt buộc — hiệu năng nhất quán bất kể dataset size. Không cung cấp offset parameter vì nó là vector cho slow query.

GitHub API, Stripe API, Shopify API đều dùng cursor pagination cho public endpoint — không phải ngẫu nhiên.

Search results

Kết quả tìm kiếm thường được sort theo relevance score, và user hiếm khi xem quá trang 10 (Google cũng chỉ cho xem 10-20 trang đầu). Offset pagination chấp nhận được ở đây — OFFSET vài trăm row không phải vấn đề, và user cần page number để navigate. Elasticsearch mặc định giới hạn from + size ≤ 10,000 cũng vì lý do tương tự.


Implement keyset pagination trên REST API

Ví dụ đầy đủ cho endpoint danh sách đơn hàng:

from fastapi import FastAPI, Query
from datetime import datetime
import base64, json, hmac, hashlib

app = FastAPI()
SECRET = b"pagination-secret-key"

def sign_cursor(data: dict) -> str:
    payload = json.dumps(data, default=str).encode()
    sig = hmac.new(SECRET, payload, hashlib.sha256).hexdigest()[:16]
    token = base64.urlsafe_b64encode(payload).decode()
    return f"{token}.{sig}"

def verify_cursor(cursor: str) -> dict:
    token, sig = cursor.rsplit(".", 1)
    payload = base64.urlsafe_b64decode(token.encode())
    expected = hmac.new(SECRET, payload, hashlib.sha256).hexdigest()[:16]
    if not hmac.compare_digest(sig, expected):
        raise ValueError("Invalid cursor")
    return json.loads(payload)

@app.get("/api/orders")
async def list_orders(
    limit: int = Query(20, ge=1, le=100),
    after: str = Query(None),
    db=Depends(get_db),
):
    if after:
        cursor_data = verify_cursor(after)
        rows = db.execute(
            """
            SELECT id, customer_name, total, created_at
            FROM orders
            WHERE (created_at, id) < (%s, %s)
            ORDER BY created_at DESC, id DESC
            LIMIT %s
            """,
            (cursor_data["ts"], cursor_data["id"], limit + 1),
        ).fetchall()
    else:
        rows = db.execute(
            """
            SELECT id, customer_name, total, created_at
            FROM orders
            ORDER BY created_at DESC, id DESC
            LIMIT %s
            """,
            (limit + 1,),
        ).fetchall()

    has_next = len(rows) > limit
    items = rows[:limit]

    end_cursor = None
    if items:
        last = items[-1]
        end_cursor = sign_cursor({"id": last.id, "ts": last.created_at})

    return {
        "data": [dict(r) for r in items],
        "pagination": {
            "hasNextPage": has_next,
            "endCursor": end_cursor,
        },
    }

Trick LIMIT N+1: fetch thêm 1 row so với limit. Nếu nhận được N+1 row, nghĩa là còn trang tiếp theo (hasNextPage: true). Trả về chỉ N row cho client. Đơn giản hơn nhiều so với query COUNT riêng.

Cursor được ký bằng HMAC — client không thể giả mạo cursor để inject giá trị tuỳ ý vào WHERE clause. Đây là bảo mật quan trọng cho public API vì cursor decode ra thành tham số SQL.


Anti-pattern hay gặp

Luôn dùng offset cho mọi API. Mình từng review codebase có 30 endpoint pagination, tất cả dùng offset. Endpoint feed activity có 50 triệu row — user không bao giờ lật quá trang 3 vì trang 4 timeout. Chuyển sang keyset, mọi trang load trong 5ms.

COUNT(*) mỗi request. Query chính mất 2ms, COUNT(*) mất 800ms. Tổng latency API 802ms, trong đó 99% do đếm total. Nếu UI chỉ cần “Load more” button, bỏ total count hoàn toàn. Nếu cần hiển thị total, cache nó.

Cursor chứa thông tin nội bộ không encode. ?after=12345 — client thấy đó là ID, bắt đầu manipulate: ?after=1 để xem data từ đầu, ?after=99999999 để probe. Encode cursor thành opaque token, ký bằng HMAC nếu cần tamper protection.

Không validate limit. Client gửi ?limit=1000000 — server trả 1 triệu row, serialize thành JSON hàng trăm MB, response mất 30 giây. Luôn set max limit (100 là con số hợp lý cho hầu hết API) và validate input.

Dùng created_at làm cursor key mà không có tiebreaker. Nhiều row có cùng created_at (insert batch, clock resolution thấp). WHERE created_at < $1 bỏ sót row cùng timestamp. Luôn thêm unique column (ID) làm tiebreaker trong composite key.

Expose internal ID trực tiếp trong cursor. Nếu cursor là {"user_id": 42} không encode, attacker đổi thành {"user_id": 1} có thể xem data user khác. Cursor nên opaque và signed — hoặc đảm bảo authorization check không phụ thuộc vào cursor value.


Tóm tắt

Offset pagination dùng LIMIT/OFFSET, đơn giản, hỗ trợ random page access, nhưng chậm tuyến tính theo page number và không ổn định khi data thay đổi. Hợp cho admin panel, dataset nhỏ, và khi user cần page number trên UI.

Cursor-based pagination dùng opaque token trỏ đến vị trí trong dataset, ổn định khi insert/delete, nhưng không hỗ trợ nhảy đến trang bất kỳ. Hợp cho feed, timeline, infinite scroll, và public API.

Keyset pagination (seek method) là engine đằng sau cursor — dùng WHERE clause thay OFFSET, hiệu năng không đổi bất kể vị trí trong dataset nhờ index. Luôn dùng composite key với unique tiebreaker để tránh bỏ sót row cùng giá trị sort.

Total count là query đắt nhất trong pagination — bỏ nếu không cần, estimate nếu cho phép, cache nếu bắt buộc hiển thị. Trick LIMIT N+1 cho hasNextPage mà không cần COUNT. Cursor phải opaque và signed cho public API — không để client manipulate giá trị bên trong. Chọn chiến lược pagination theo use case cụ thể, không phải theo thói quen.