Phân trang Offset và Cursor: Khi nào nên dùng phương pháp nào?

05 tháng 4, 2026·13 phút đọc

Bài viết so sánh chi tiết hai chiến lược phân trang phổ biến nhất trong phát triển phần mềm: Offset và Cursor. Tìm hiểu lý do tại sao phương pháp truyền thống có thể gây lỗi dữ liệu và cách Cursor giải quyết vấn đề này cho các ứng dụng thời gian thực.

Phân trang Offset và Cursor: Khi nào nên dùng phương pháp nào?

Phân trang Offset và Cursor: Khi nào nên dùng phương pháp nào?

Từ những ghi chú khi phỏng vấn đến thực tế triển khai sản phẩm (production), khái niệm phân trang (pagination) luôn là một chủ đề nóng hổi. Mới đây, tôi có cơ hội tiếp cận lại vấn đề này khi cần triển khai tính năng cuộn vô hạn (infinite scroll) kết hợp với ảo hóa danh sách (virtualization).

Đó là lúc tôi nhận ra việc phân trang không chỉ đơn giản là "chia nhỏ dữ liệu". Chúng ta sẽ cùng tìm hiểu sâu hơn về hai phương pháp chính: OffsetCursor, lý do tại sao Offset đôi khi "gây rắc rối", và khi nào nên sử dụng từng cái.

Chúng ta sẽ đề cập đến:

  • Tại sao phân trang dựa trên Offset lại có vấn đề về mặt kỹ thuật.
  • Phân trang Cursor khác biệt như thế nào.
  • Cách chuẩn hóa Cursor qua Relay.
  • Làm sao để chọn đúng phương án cho dự án của bạn.

Phân trang là gì?

Trước khi so sánh, hãy thống nhất về vấn đề cần giải quyết. Khi một API có hàng ngàn bản ghi, việc trả về tất cả cùng một lúc là ý tưởng tồi — quá nhiều dữ liệu, quá chậm và tốn bộ nhớ. Thay vào đó, chúng ta trả về kết quả theo từng phần (chunks). Phân trang là cơ chế kiểm soát xem client nhận được "phần" nào trong tổng thể dữ liệu đó.

Hai phương pháp tiếp cận phổ biến nhất là offset-based (dựa trên độ dời) và cursor-based (dựa trên con trỏ). Âm thanh thì có vẻ tương đồng, nhưng chúng hoạt động hoàn toàn khác nhau.

Phân trang dựa trên Offset (Offset-Based Pagination)

Ý tưởng

Client nói với Server: "Bỏ qua N mục đầu tiên và đưa cho tôi M mục tiếp theo."

Về mặt GraphQL, query sẽ trông như sau:

query GetPosts {
  posts(offset: 40, limit: 20) {
    id
    title
    author
  }
}

Tại phía máy chủ (server), logic này được chuyển hóa thành phép toán mảng thuần túy:

const getPosts = (offset: number, limit: number) => {
  return ALL_POSTS.slice(offset, offset + limit);
};

// offset: 40, limit: 20
// → Trả về ALL_POSTS[40] đến ALL_POSTS[59]

Đơn giản. Dễ dự đoán. Dễ hiểu.

Client sẽ theo dõi offset và tăng nó lên limit trong mỗi lần yêu cầu:

const fetchNextPage = () => {
  const nextOffset = currentItems.length;
  fetchMore({ variables: { offset: nextOffset, limit: 20 } });
};

Để biết khi nào dừng lại, server trả về tổng số lượng (total):

type PostsResult {
  posts: [Post!]!
  total: Int! # Tổng số tất cả các trang — không chỉ trang hiện tại
}
// Client biết khi nào dừng:
const hasNextPage = currentItems.length < total;

Vấn đề: Bỏ sót dữ liệu (Skipped Item Problem)

Đây là nơi mà Offset bắt đầu bộc lộ điểm yếu. Hãy tưởng tượng một danh sách 10 sản phẩm:

Trang 1 → items [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]  (offset: 0, limit: 10)

Trong khi bạn đang xem Trang 1, mục #5 bị xóa. Danh sách dịch chuyển:

Sau khi xóa → [1, 2, 3, 4, 6, 7, 8, 9, 10, 11]

Khi bạn yêu cầu Trang 2 (offset: 10):

Server bỏ qua 10 mục đầu → bắt đầu từ mục #12
Mục #11 chưa bao giờ được hiển thị cho bạn. Nó đã "lọt qua khe hở".

⚠️ Danh sách co lại → bạn bị mất dữ liệu.

Vấn đề: Dữ liệu trùng lặp (Duplicate Item Problem)

Vấn đề này phổ biến hơn và dễ nhận thấy hơn. Ví dụ như một bảng tin (newsfeed) nơi bài viết mới xuất hiện ở đầu danh sách:

Trang 1 → [post10, post9, post8, post7, post6, post5, post4, post3, post2, post1]

Trong khi bạn đang đọc, ai đó đăng bài viết mới. Nó trở thành post11 ở vị trí #1:

Danh sách cập nhật → [post11, post10, post9, post8, post7, post6, post5, post4, post3, post2, ...]

Khi bạn yêu cầu Trang 2 (offset: 10):

Server bỏ qua 10 mục → bắt đầu từ index 10
Index 10 hiện tại là post1 — mà bạn đã thấy ở cuối Trang 1

⚠️ Danh sách mở rộng → bạn thấy dữ liệu bị trùng.

Tại sao điều này xảy ra?

Offset mang tính "vị trí" — nó nói "bỏ qua N vị trí", chứ không phải "bỏ qua N mục cụ thể". Khi danh sách thay đổi hình dạng, các vị trí dịch chuyển nhưng offset không biết và không quan tâm.

// Offset không biết bạn đã xem những gì.
// Nó chỉ biết cần bỏ qua bao nhiêu ô trống.
// Sự khác biệt đó chính là vấn đề.

Khi nào Offset vẫn là lựa chọn đúng

Mặc dù có những nhược điểm này, Offset hoàn toàn ổn khi:

  • Dữ liệu ít thay đổi (mutation) — danh sách đại lý, danh mục sản phẩm, kết quả tìm kiếm. Không ai chèn đại lý mới vào giữa các trang của bạn.
  • Bạn cần "nhảy đến trang N"offset = (page - 1) * limit là phép toán đơn giản. Cursor không làm được điều này.
  • Ưu tiên triển khai đơn giản — không cần mã hóa cursor, không cần chính sách cache đặc biệt, chỉ cần cắt mảng (slice).

Đối với trường hợp sử dụng trong ứng dụng thực tế của chúng tôi? Đây là phương án lựa chọn tối ưu vì dữ liệu không thay đổi trong khi người dùng cuộn trang.

Phân trang dựa trên Cursor (Cursor-Based Pagination)

Ý tưởng

Thay vì nói "bỏ qua 40 mục", client nói "hãy đưa cho tôi các mục xuất hiện sau mục cụ thể này".

Server trả về một cursor (con trỏ) đi kèm với từng kết quả — một con trỏ chỉ đến vị trí của mục đó. Client gửi lại cursor đó trong yêu cầu tiếp theo.

query GetPosts($after: String, $limit: Int) {
  posts(after: $after, limit: $limit) {
    items {
      id
      title
    }
    pageInfo {
      endCursor # cursor trỏ đến mục cuối cùng được trả về
      hasNextPage # xem còn mục nào nữa không
    }
  }
}

Yêu cầu đầu tiên — chưa có cursor:

// Lấy 20 mục đầu tiên
fetchPosts({ variables: { limit: 20 } })

// Server trả về:
{
  items: [...20 posts],
  pageInfo: {
    endCursor: "YTI5Mjg3NDU=",  // cursor cho mục cuối cùng
    hasNextPage: true
  }
}

Yêu cầu tiếp theo — gửi lại cursor:

// Lấy 20 mục tiếp theo bắt đầu sau mục đã xem cuối cùng
fetchPosts({ variables: { after: 'YTI5Mjg3NDU=', limit: 20 } });

Server không bỏ qua N vị trí — nó tìm mục mà cursor trỏ tới và trả về mọi thứ sau nó:

const getPosts = (after: string | null, limit: number) => {
  if (!after) {
    return ALL_POSTS.slice(0, limit);
  }

  const decodedCursor = decodeCursor(after); // ví dụ: timestamp hoặc ID
  const startIndex = ALL_POSTS.findIndex(
    (post) => post.createdAt < decodedCursor,
  );

  return ALL_POSTS.slice(startIndex, startIndex + limit);
};

Tại sao Cursor giải quyết được vấn đề của Offset

Quay lại ví dụ bảng tin của chúng ta:

Bạn đã xem đến: post1 (cursor: "abc123" → trỏ đến timestamp của post1)

Bài viết post11 mới được thêm vào đầu danh sách.

Yêu cầu tiếp theo: "hãy đưa cho tôi các bài viết sau post1"
Server tìm post1 bằng timestamp của nó → trả về từ post2 trở đi

Không quan trọng việc danh sách có mở rộng hay không. Cursor trỏ đến một mục cụ thể, không phải vị trí. Điểm khởi đầu được neo vào dữ liệu, không phải index.

⚠️ Cursor nói "sau mục cụ thể này" — chứ không phải "sau vị trí N". Danh sách có thể to ra hoặc co lại tùy thích. Con trỏ vẫn giữ nguyên.

Tương tự cho việc xóa dữ liệu. Nếu mục mà cursor trỏ tới bị xóa:

// Cursor dựa trên timestamp có khả năng phục hồi:
SELECT * FROM posts WHERE created_at < $cursorTimestamp ORDER BY created_at LIMIT $limit
// "Đưa cho tôi bài viết được tạo trước thời điểm này"
// Kể cả khi bài viết cụ thể đó đã bị xóa, timestamp vẫn hoạt động

Đó là lý do tại sao cursor thường là timestamp hoặc ID đã được mã hóa thay vì ID hàng thô — timestamp sống sót qua việc xóa dữ liệu, trong khi ID hàng có thể không được.

Cursor là gì và tại sao phải mã hóa (Opaque Cursor)?

Chú ý cursor chúng ta trả về ở trên: "YTI5Mjg3NDU=" — đó không phải một chuỗi có ý nghĩa ngay lập tức. Đó là một giá trị đã được mã hóa base64.

// Mã hóa một cursor (phía server)
const encodeCursor = (value: string): string =>
  Buffer.from(value).toString('base64');

// Giải mã một cursor (phía server)
const decodeCursor = (cursor: string): string =>
  Buffer.from(cursor, 'base64').toString('utf-8');

// "2024-01-15T10:30:00Z" → "MjAyNC0wMS0xNVQxMDozMDowMFo="

Tại sao phải ẩn nó đi? Ba lý do:

  1. Trừu tượng hóa (Abstraction) — Client không cần biết hay quan tâm cursor hoạt động bên trong như thế nào. Nó là một hộp đen (black box) để gửi lại.
  2. Bảo mật (Security) — nếu người dùng thấy cursor = 12345, họ có thể thử tự xây dựng các cursor thủ công. Mã hóa ngăn chặn điều này.
  3. Linh hoạt (Flexibility) — server có thể thay đổi cấu trúc bên trong của cursor (chuyển từ ID sang timestamp) mà không làm vỡ bất kỳ client nào coi nó là dạng bất biến (opaque).
type PageInfo = {
  endCursor: string | null; // dạng bất biến — client không bao giờ giải mã nó
  hasNextPage: boolean;
};

Sự đánh đổi bạn phải chấp nhận

Cursor mang lại sự ổn định nhưng đánh đổi một thứ — bạn không thể nhảy đến trang tùy ý.

// Offset — dễ dàng
const jumpToPage = (page: number, limit: number) => ({
  offset: (page - 1) * limit,
});

// Cursor — bất khả thi nếu không fetch tất cả các trang trước đó
// "Trang 5" không có ý nghĩa — bạn cần cursor của trang 1, rồi trang 2, v.v.

Nếu UI của bạn có nút "nhảy đến trang 10", Offset là lựa chọn duy nhất. Phân trang Cursor hoàn toàn tuần tự — chỉ tiến về phía trước.

Relay: Chuẩn đã chính thức hóa Cursor

Khi Facebook open-sourced GraphQL, họ cũng giới thiệu Relay — một framework phía client với spec riêng về phân trang. Hầu hết các hệ sinh thái GraphQL ngày nay đều tham chiếu nó, kể cả khi không sử dụng chính Relay.

Relay đã chính thức hóa phân trang cursor thành một hình thức nhất quán sử dụng ba khái niệm:

  • Connection → toàn bộ danh sách được phân trang
  • Edge → một mục đơn + cursor của nó được gói lại với nhau
  • Node → mục dữ liệu thực tế bên trong Edge

Dưới dạng schema GraphQL:

type PostConnection {
  edges: [PostEdge!]!
  pageInfo: PageInfo!
}

type PostEdge {
  node: Post! # mục dữ liệu thực tế
  cursor: String! # bookmark vị trí của mục đó
}

type PageInfo {
  endCursor: String
  hasNextPage: Boolean!
  startCursor: String
  hasPreviousPage: Boolean!
}

Query sẽ trông như sau:

query {
  posts(first: 10, after: "opaqueCursor") {
    edges {
      cursor # mỗi mục có cursor RIÊNG của nó
      node {
        id
        title
      }
    }
    pageInfo {
      endCursor
      hasNextPage
    }
  }
}

Đổi mới chính của Relay — mỗi mục đều có cursor riêng, không chỉ mục cuối cùng. Điều này cho phép bạn bắt đầu phân trang từ bất kỳ mục nào trong danh sách, không chỉ ở cuối.

Apollo Client có hỗ trợ sẵn tính năng này qua relayStylePagination():

const client = new ApolloClient({
  cache: new InMemoryCache({
    typePolicies: {
      Query: {
        fields: {
          posts: relayStylePagination(), // xử lý gộp (merge) tự động
        },
      },
    },
  }),
});

Đối với phân trang offset, bạn phải xử lý việc gộp cache thủ công — một chủ đề riêng đáng để viết một bài khác.

So sánh trực tiếp

Đặc điểmOffsetCursor
Tham số Queryoffset, limitafter, limit
Logic Serverarray.slice(offset, offset + limit)tìm cursor → trả về N mục tiếp theo
Ổn định khi chèn dữ liệu❌ Bị trùng lặp✅ Ổn định
Ổn định khi xóa dữ liệu❌ Bỏ sót mục✅ Ổn định
Nhảy đến trang N✅ Dễ dàng❌ Bất khả thi
Triển khaiĐơn giảnPhức tạp hơn
Apollo cachemerge thủ côngrelayStylePagination()
Phù hợp nhất choDữ liệu tĩnh / ít thay đổiDữ liệu trực tiếp / thường xuyên thay đổi

Làm sao để chọn?

Chỉ cần một câu hỏi để giải quyết:

Dữ liệu mới có xuất hiện ở đầu danh sách trong khi người dùng đang cuộn qua nó không?

  • → Dùng Cursor. Bảng tin Twitter, thông báo, tin nhắn chat. Mục mới đẩy mọi thứ xuống dưới và offset sẽ hiển thị các mục trùng lặp.
  • Không → Offset là ổn. Tìm đại lý, danh mục sản phẩm, kết quả tìm kiếm. Dữ liệu ổn định giữa các yêu cầu. Sự bất ổn của Offset không bao giờ xuất hiện.

Sai lầm tôi thấy thường xuyên nhất là vội vã chọn sự phức tạp của cursor trong khi offset hoàn toàn phù hợp. Một danh sách công thức nấu ăn không thay đổi trong sáu tháng không cần phân trang cursor. Một luồng hoạt động trực tiếp thì chắc chắn cần.

Tổng kết

Số trang không bị hỏng — chúng chỉ đưa ra một giả định: "danh sách không thay đổi trong khi bạn đang xem nó". Khi giả định đó đúng, chúng hoạt động hoàn hảo. Khi không, bạn sẽ thấy các "bóng ma" (dữ liệu trùng) và dữ liệu bị thiếu.

Cursor loại bỏ hoàn toàn giả định đó. Chúng không quan tâm danh sách trông như thế nào trước yêu cầu của bạn — chúng chỉ quan tâm đến "cái gì comes sau mục cụ thể này".

Việc hiểu được sự khác biệt này không chỉ giúp bạn chọn chiến lược phân trang đúng. Nó thay đổi cách bạn tư duy về việc lấy dữ liệu có trạng thái (stateful data fetching) nói chung — điều hóa ra là một trong những vấn đề thú vị hơn trong kỹ sư frontend.

Bài viết được tổng hợp và biên soạn bằng AI từ các nguồn tin tức công nghệ. Nội dung mang tính tham khảo. Xem bài gốc ↗