
Nhiều API bắt đầu với LIMIT/OFFSET vì nó dễ hiểu, dễ demo và frontend cũng quen kiểu “page 1, 2, 3”. Vấn đề là khi dữ liệu lớn lên hoặc thay đổi liên tục, offset pagination bắt đầu trả lãi bằng bug vận hành: page sâu chậm dần, item bị lặp hoặc bị mất giữa hai lần bấm next, và database phải đi bộ qua hàng chục nghìn row chỉ để bỏ qua phần không cần trả về.
Cursor pagination giải bài toán đó bằng cách không hỏi “cho tôi page số 201”, mà hỏi “cho tôi 20 item tiếp theo sau item cuối cùng tôi vừa thấy”. Nghe nhỏ, nhưng khác biệt này thay đổi cả cách thiết kế index, contract API, UX của client và cách anh debug consistency khi feed đang có insert mới liên tục.
Bài này đi sâu vào trade-off thực chiến giữa cursor pagination vs offset pagination trong production: khi nào offset vẫn đủ tốt, khi nào phải chuyển sang cursor, cách chọn sort key, cách encode cursor, các lỗi duplicate/skip hay gặp, và checklist migration để không làm gãy client.
Offset pagination thực ra làm gì?
Offset pagination thường có dạng:
SELECT id, created_at, title
FROM posts
WHERE status = 'published'
ORDER BY created_at DESC, id DESC
LIMIT 20 OFFSET 2000;
Về mặt API, client gọi kiểu:
GET /api/posts?limit=20&offset=2000
Đây là mô hình rất hợp khi:
- danh sách nhỏ hoặc tương đối tĩnh;
- user cần nhảy đến page bất kỳ;
- backoffice/reporting quen với page number;
- chi phí page sâu chưa đáng kể.
Nhưng có hai vấn đề production rất thật:
- Page sâu chậm dần: database vẫn phải đi qua nhiều row trước khi bỏ chúng đi.
- Dữ liệu drift: nếu có insert/delete giữa hai request, cùng một item có thể xuất hiện hai lần hoặc bị skip.

Cursor pagination hoạt động ra sao?
Cursor pagination không dùng offset số học. Nó dùng một mốc từ page trước, ví dụ (created_at, id) của item cuối cùng:
SELECT id, created_at, title
FROM posts
WHERE status = 'published'
AND (created_at, id) < ('2026-07-01T17:00:24Z', 159)
ORDER BY created_at DESC, id DESC
LIMIT 20;
API thường có dạng:
GET /api/posts?limit=20&after=eyJjcmVhdGVkX2F0IjoiMjAyNi0wNy0wMVQxNzowMDoyNFoiLCJpZCI6MTU5fQ
Client không cần biết nội dung cursor là gì. Nó chỉ nhận next_cursor từ server rồi gửi lại khi bấm next. Chính vì thế, cursor tốt thường là opaque: encode base64 hoặc ký HMAC để client không phụ thuộc vào format nội bộ.
Vấn đề lớn nhất của offset: dữ liệu đang thay đổi
Giả sử user đang đọc feed theo ORDER BY created_at DESC:
- Request 1 lấy 20 item đầu tiên, item cuối cùng có timestamp T20.
- Giữa lúc user chưa bấm next, có 3 bài mới được insert lên đầu feed.
- Request 2 gọi
OFFSET 20.
Lúc này “20 item đầu tiên” không còn là tập cũ nữa. 3 item mới đã đẩy tất cả xuống dưới, nên request thứ hai dễ:
- lặp lại vài item user đã thấy ở page trước; hoặc
- bỏ sót vài item lẽ ra phải xuất hiện.
Với cursor, request thứ hai nói rõ: “tiếp tục sau item cuối cùng tôi đã thấy”. Nếu sort key ổn định, kết quả nhất quán hơn rất nhiều.
Page sâu với OFFSET vì sao chậm?
Ngay cả khi query dùng index, OFFSET 50000 vẫn thường buộc engine đi qua nhiều entry rồi bỏ chúng đi. Về logic, database không thể “dịch chuyển tức thời” tới row thứ 50001 nếu predicate và sort không cho phép seek trực tiếp.
Điều này đặc biệt đau với:
- feed nhiều triệu row;
- bảng có filter phức tạp theo tenant/status/type;
- join nhiều bảng trước khi paginate;
- mobile client cuộn dài hoặc crawler/backfill đi qua nhiều page.
Đây cũng là lý do khi anh thấy p95 tăng mạnh ở page sâu, đừng chỉ nhìn application. Hãy soi cả query plan, index coverage và xem API đang paginate bằng offset hay seek. Bài PostgreSQL Query Plan Regression trong Production là chỗ nên đọc tiếp nếu anh muốn debug phần database kỹ hơn.
Offset không phải lúc nào cũng xấu
Offset vẫn hợp lý trong một số trường hợp:
- Backoffice nhỏ: vài nghìn row, ít ghi đồng thời, user cần jump to page.
- Report snapshot: dữ liệu được materialize hoặc chụp ảnh theo thời điểm.
- Admin tooling: UX page-number quan trọng hơn độ tối ưu tuyệt đối.
- Search UI kiểu tổng quát: người dùng hay nhảy trang 1, 2, 5 hơn là scroll vô hạn.
Nhưng nếu API là feed, timeline, event log, transaction history, order stream, notification list hoặc bất kỳ danh sách nào đang thay đổi liên tục, offset thường là khoản nợ chờ ngày phải trả.
Chọn sort key cho cursor thế nào?
Sai lầm phổ biến nhất là dùng một key không ổn định hoặc không đủ unique. Ví dụ chỉ sort theo created_at DESC. Nếu nhiều row có cùng timestamp, thứ tự giữa chúng có thể không deterministic, và cursor có thể lặp/skip item khi đi trang tiếp theo.
Mẫu an toàn hơn là dùng composite sort key:
ORDER BY created_at DESC, id DESC
WHERE (created_at, id) < (:created_at, :id)
Ở đây:
-
created_atcho thứ tự nghiệp vụ; -
idlà tie-breaker unique; - index cần khớp với thứ tự sort.
Ví dụ index trong PostgreSQL:
CREATE INDEX idx_posts_published_created_id_desc
ON posts (status, created_at DESC, id DESC);
Nếu filter có tenant_id hoặc category_id, index phải phản ánh luôn các cột filter chính, nếu không cursor chỉ giải quyết đúng logic nhưng query vẫn chậm.

Cursor opaque hay để client tự lắp?
Trong production, tôi nghiêng hẳn về opaque cursor. Server encode state cần thiết, ví dụ:
{
"created_at": "2026-07-01T17:00:24Z",
"id": 159,
"filters_hash": "published|backend"
}
Sau đó base64 hoặc ký HMAC. Vì sao cần vậy?
- Client không bị lock vào implementation nội bộ.
- Anh có thể thêm field vào cursor mà không đổi contract bên ngoài.
- Server dễ validate cursor có khớp filter hiện tại không.
- Giảm rủi ro client chỉnh tay cursor làm query không hợp lệ.
Nếu cần chống tamper thật sự, ký HMAC hoặc encrypt cursor thay vì chỉ base64 plain JSON.
Previous page và jump-to-page là trade-off thật
Đây là điểm nhiều team biết nhưng né không nói rõ. Cursor pagination mạnh ở next-page / infinite scroll, nhưng yếu hơn ở:
- jump to page 57;
- hiển thị “page 4 trên 893”;
- bookmark theo page number tuyệt đối.
Nếu product bắt buộc cần jump-to-page, offset hoặc snapshot/materialized view đôi khi vẫn là lựa chọn hợp lý. Nhưng nếu UX là scroll feed, “load more”, timeline hoặc API cho mobile, cursor thường thắng vì consistency và hiệu năng.
Một cách thực dụng là tách hai use case:
- public/mobile/feed API dùng cursor;
- admin/reporting UI dùng offset trên tập dữ liệu nhỏ hơn hoặc snapshot hóa.
Cursor pagination cho transaction history, audit log và event stream
Các luồng như payment history, order events, audit trail, notification log hoặc Kafka-backed materialized feed gần như luôn hợp với cursor hơn offset. Lý do:
- dữ liệu append liên tục;
- user chủ yếu đi “tiếp xuống dưới” thay vì nhảy trang ngẫu nhiên;
- consistency quan trọng hơn page number đẹp;
- truy vấn thường dựa trên một key tăng dần hoặc gần tăng dần.
Nếu hệ thống còn có replica lag, cursor cũng giúp reasoning về thứ tự dữ liệu rõ hơn, dù nó không tự giải quyết mọi vấn đề consistency. Đọc thêm bài Read-after-write consistency trong production nếu anh đang paginate trên dữ liệu đọc từ replica.
Lỗi thường gặp khi migrate từ offset sang cursor
1. Cursor không chứa context của filter
Nếu page đầu là status=published&category=backend nhưng page sau gửi cursor sang một filter khác, server có thể trả dữ liệu rác. Cursor nên gắn với filter/sort hiện tại, ít nhất bằng hash để detect mismatch.
2. Sort key không unique
Dùng chỉ created_at hoặc chỉ score mà không có tie-breaker là công thức của duplicate/skip.
3. Index không khớp ORDER BY
Cursor logic đúng nhưng DB vẫn scan nặng vì index không hỗ trợ filter + sort. Hãy xem EXPLAIN ANALYZE, không đoán mò.
4. Client phụ thuộc cấu trúc cursor
Nếu client parse được cursor rồi tự sửa, việc thay đổi format sau này sẽ đau. Hãy coi cursor là token opaque.
5. Thiếu test khi dữ liệu thay đổi giữa hai page
Nhiều team test với dataset tĩnh nên không thấy bug. Hãy test insert mới, delete, update sort key và row có cùng timestamp.
Mẫu response API thực dụng
{
"items": [...],
"page_info": {
"next_cursor": "eyJjcmVhdGVkX2F0Ijoi...",
"has_next_page": true
}
}
Nếu cần previous page, anh có thể dùng cặp after/before và đảo chiều query, nhưng nhớ normalize lại thứ tự trả về để client không bị ngược danh sách.
Checklist chọn offset hay cursor
- Nếu danh sách nhỏ, ít thay đổi, cần jump-to-page rõ ràng → offset vẫn ổn.
- Nếu danh sách lớn, update liên tục, user chủ yếu next/load-more → cursor gần như nên là mặc định.
- Nếu page sâu đang chậm → kiểm tra query plan và cân nhắc chuyển sang cursor.
- Nếu đang thấy duplicate/skip item trong feed → offset trên dữ liệu mutable là nghi phạm số một.
- Nếu phải giữ cả hai UX → tách public feed và admin tooling thành hai mode paginate khác nhau.
Kết luận
Cursor pagination vs offset pagination không phải cuộc tranh luận học thuật. Nó là quyết định ảnh hưởng trực tiếp đến latency, consistency và trải nghiệm client khi hệ thống bắt đầu lớn lên. Offset không sai; nó chỉ có biên an toàn hẹp hơn nhiều người nghĩ. Khi dữ liệu mutable, traffic tăng và page bắt đầu sâu, cursor thường là bước trưởng thành tự nhiên của API.
Nếu anh đang vận hành backend production, đừng chỉ hỏi “frontend thích page number hay infinite scroll?”. Hãy hỏi thêm: dữ liệu có thay đổi giữa hai request không, query plan page sâu ra sao, sort key có ổn định không, và client có thật sự cần jump-to-page hay chỉ cần next page đáng tin cậy. Trả lời đúng các câu hỏi đó sẽ dẫn anh đến kiểu pagination đúng hơn bất kỳ trend framework nào.