Cara Handle Pagination di JSON Response API
Bayangkan endpoint GET /products yang punya 500.000 baris data. Kalau semuanya dikembalikan sekaligus dalam satu response JSON, selain lambat, juga boros bandwidth dan bisa bikin server maupun client kehabisan memory. Pagination adalah solusi standar: memecah data besar jadi "halaman" kecil yang dikirim bertahap. Artikel ini membahas pola-pola pagination paling umum dipakai di REST API modern, lengkap struktur JSON-nya, kelebihan dan kekurangan masing-masing.
1. Offset-Based Pagination (LIMIT/OFFSET)
Pola paling sederhana dan paling umum ditemui, biasanya lewat query parameter limit dan offset:
GET /products?limit=20&offset=40
{
"data": [ /* 20 item produk */ ],
"pagination": {
"limit": 20,
"offset": 40,
"total": 5000
}
}
Kelebihan: mudah dipahami dan diimplementasi, cocok untuk data yang jarang berubah, memungkinkan lompat langsung ke halaman manapun.
Kekurangan: performa menurun drastis di offset besar karena database tetap harus scan dan skip semua baris sebelum offset (misalnya OFFSET 100000 tetap harus melewati 100.000 baris dulu). Juga rawan bug "data bergeser" — kalau ada insert/delete data baru di antara request halaman 1 dan halaman 2, item bisa terlewat atau muncul dua kali.
2. Page-Based Pagination
Variasi dari offset-based, tapi pakai konsep "nomor halaman" yang lebih intuitif untuk konsumsi manusia (misalnya UI dengan tombol "halaman 1, 2, 3..."):
GET /products?page=3&perPage=20
{
"data": [ /* 20 item produk */ ],
"pagination": {
"currentPage": 3,
"perPage": 20,
"totalPages": 250,
"totalItems": 5000
}
}
Secara teknis ini cuma offset-based yang dibungkus lebih ramah-user (offset = (page - 1) * perPage). Kelebihan dan kekurangannya sama seperti offset-based di atas.
3. Cursor-Based Pagination
Alih-alih pakai angka offset, cursor-based memakai "penunjuk" (biasanya berupa ID atau token terenkripsi) yang menandai posisi terakhir data yang sudah diambil:
GET /products?limit=20&cursor=eyJpZCI6MTIzfQ==
{
"data": [ /* 20 item produk */ ],
"pagination": {
"nextCursor": "eyJpZCI6MTQzfQ==",
"hasMore": true
}
}
Kelebihan: performa tetap konsisten meskipun dataset besar, karena query database langsung "mulai dari sini" tanpa perlu skip baris (biasanya diimplementasikan dengan WHERE id > :cursor_id ORDER BY id LIMIT 20). Juga tidak rawan bug data bergeser seperti offset-based.
Kekurangan: tidak bisa lompat langsung ke halaman tertentu (misalnya "langsung ke halaman 50"), harus jalan berurutan dari cursor sebelumnya. Cocok untuk infinite scroll, kurang cocok untuk UI dengan nomor halaman.
Ini pola yang dipakai API besar seperti Twitter/X, Facebook Graph API, dan Stripe untuk endpoint list dengan dataset besar.
4. Keyset Pagination
Mirip cursor-based, tapi cursor-nya eksplisit berupa nilai kolom asli (biasanya timestamp atau ID), bukan token yang di-encode:
GET /products?limit=20&after_id=143&after_created_at=2026-07-01T10:00:00Z
Query database di baliknya kurang lebih:
SELECT * FROM products
WHERE (created_at, id) > ('2026-07-01T10:00:00Z', 143)
ORDER BY created_at, id
LIMIT 20
Keyset pagination punya keunggulan performa yang sama dengan cursor-based, tapi lebih transparan karena client bisa melihat nilai kolom aslinya (walau ini juga jadi kekurangan dari sisi keamanan/abstraksi — sebagian tim lebih suka menyembunyikannya di balik cursor terenkripsi).
Perbandingan Ringkas
- Offset/page-based — paling mudah diimplementasi, cocok dataset kecil-menengah yang jarang berubah, mendukung lompat ke halaman manapun, tapi lambat di dataset besar dan rawan data bergeser.
- Cursor-based — performa stabil di dataset besar, aman dari data bergeser, tapi tidak mendukung lompat halaman bebas.
- Keyset pagination — performa setara cursor-based, lebih transparan, tapi expose struktur data internal ke client.
Field Metadata yang Umum Disertakan
Terlepas dari pola mana yang dipilih, beberapa field metadata ini umum ditemukan di response pagination, dan sebaiknya dijadikan standar di seluruh endpoint API kamu supaya client tidak perlu menghafal aturan berbeda-beda untuk tiap resource:
total/totalItems— total jumlah data keseluruhan (opsional di cursor-based karena bisa mahal dihitung di dataset sangat besar)hasMore/hasNextPage— boolean penanda apakah masih ada data selanjutnyanextCursor/nextPageUrl— penunjuk langsung ke halaman berikutnya, memudahkan client tanpa perlu hitung sendiriperPage/limit— jumlah item per halaman yang dipakai, berguna untuk validasi di sisi client
Menyertakan Link Navigasi Langsung (HATEOAS-style)
Beberapa API menyertakan URL lengkap untuk halaman selanjutnya/sebelumnya, sehingga client tidak perlu menyusun query string sendiri:
{
"data": [ /* ... */ ],
"links": {
"self": "/products?page=3&perPage=20",
"next": "/products?page=4&perPage=20",
"prev": "/products?page=2&perPage=20"
}
}
Pendekatan ini sedikit lebih verbose tapi sangat memudahkan client — mereka cukup ikuti link yang diberikan tanpa perlu tahu logic pembentukan URL.
Pagination di GraphQL: Sekilas Konsep Connections
Kalau kamu juga pakai GraphQL selain REST, ada baiknya tahu bahwa GraphQL punya pola standar sendiri bernama Relay Cursor Connections, yang pada dasarnya adalah cursor-based pagination dengan struktur field yang sudah dibakukan (edges, node, pageInfo). Kalau kamu mendesain REST API yang suatu saat mungkin perlu diselaraskan dengan layer GraphQL di atasnya, ada baiknya mempertimbangkan pola cursor-based sejak awal supaya konsepnya lebih mudah dipetakan nanti.
Rekomendasi Praktis
Untuk kebanyakan aplikasi CRUD standar dengan dataset menengah (di bawah beberapa ratus ribu baris) dan UI yang butuh nomor halaman, offset/page-based pagination sudah cukup dan lebih sederhana untuk dikembangkan. Kalau kamu membangun API publik dengan dataset sangat besar, growth tinggi, atau endpoint feed/timeline seperti media sosial, cursor-based adalah pilihan yang jauh lebih aman untuk performa jangka panjang.
Kesalahan Umum Saat Implementasi Pagination
- Default limit tidak dibatasi — kalau client tidak mengirim parameter
limit, pastikan ada default yang wajar (misalnya 20-50), dan batasi limit maksimum yang boleh diminta (misalnya maksimal 100) supaya client nakal tidak bisa requestlimit=1000000dan membebani server. - Tidak konsisten antar endpoint — kalau satu endpoint pakai
page/perPagedan endpoint lain pakaioffset/limit, ini bikin bingung konsumen API. Pilih satu pola dan terapkan di seluruh API. - Menghitung
totaldi dataset sangat besar tanpa index yang tepat — queryCOUNT(*)di tabel jutaan baris bisa jadi lambat kalau tidak dioptimasi, kadang lebih baik di-cache atau dihitung asinkron daripada dihitung ulang di setiap request. - Tidak menyediakan cara mengetahui halaman terakhir di cursor-based — karena sifatnya sequential, beberapa API menyediakan endpoint terpisah untuk estimasi jumlah total kalau memang dibutuhkan UI, tanpa mengorbankan performa endpoint utama.
How to Handle Pagination in JSON API Responses
Imagine a GET /products endpoint with 500,000 rows of data. Returning all of it at once in a single JSON response would be slow, waste bandwidth, and could exhaust memory on both server and client. Pagination is the standard solution: breaking large data into small "pages" delivered incrementally. This article covers the most common pagination patterns used in modern REST APIs, along with their JSON structure and the pros and cons of each.
1. Offset-Based Pagination (LIMIT/OFFSET)
The simplest and most commonly seen pattern, typically via limit and offset query parameters:
GET /products?limit=20&offset=40
{
"data": [ /* 20 product items */ ],
"pagination": {
"limit": 20,
"offset": 40,
"total": 5000
}
}
Pros: easy to understand and implement, works well for data that rarely changes, allows jumping directly to any page.
Cons: performance degrades sharply at large offsets because the database still has to scan and skip every row before the offset (e.g. OFFSET 100000 still has to pass through 100,000 rows first). Also prone to "shifting data" bugs — if rows are inserted or deleted between the requests for page 1 and page 2, items can be skipped or duplicated.
2. Page-Based Pagination
A variant of offset-based pagination, but using the more human-friendly concept of "page numbers" (e.g. a UI with "page 1, 2, 3..." buttons):
GET /products?page=3&perPage=20
{
"data": [ /* 20 product items */ ],
"pagination": {
"currentPage": 3,
"perPage": 20,
"totalPages": 250,
"totalItems": 5000
}
}
Technically this is just offset-based pagination wrapped in a more user-friendly form (offset = (page - 1) * perPage). Its pros and cons are the same as offset-based above.
3. Cursor-Based Pagination
Instead of a numeric offset, cursor-based pagination uses a "pointer" (usually an ID or an encoded token) marking the last position already fetched:
GET /products?limit=20&cursor=eyJpZCI6MTIzfQ==
{
"data": [ /* 20 product items */ ],
"pagination": {
"nextCursor": "eyJpZCI6MTQzfQ==",
"hasMore": true
}
}
Pros: consistent performance even on large datasets, since the database query can "start right here" without skipping rows (typically implemented as WHERE id > :cursor_id ORDER BY id LIMIT 20). Also not prone to shifting-data bugs like offset-based pagination.
Cons: can't jump directly to a specific page (e.g. "go straight to page 50"), must move sequentially from the previous cursor. Good for infinite scroll, less suited to a page-numbered UI.
This is the pattern used by large APIs like Twitter/X, the Facebook Graph API, and Stripe for list endpoints with large datasets.
4. Keyset Pagination
Similar to cursor-based, but the cursor is explicitly the actual column value (usually a timestamp or ID) rather than an encoded token:
GET /products?limit=20&after_id=143&after_created_at=2026-07-01T10:00:00Z
The underlying database query roughly looks like:
SELECT * FROM products
WHERE (created_at, id) > ('2026-07-01T10:00:00Z', 143)
ORDER BY created_at, id
LIMIT 20
Keyset pagination shares the same performance benefits as cursor-based, but is more transparent since the client can see the actual column values (which is also a downside from a security/abstraction standpoint — some teams prefer hiding it behind an encoded cursor instead).
Quick Comparison
- Offset/page-based — easiest to implement, fine for small-to-medium datasets that rarely change, supports jumping to any page, but slow on large datasets and prone to shifting-data issues.
- Cursor-based — stable performance on large datasets, safe from shifting data, but doesn't support jumping freely between pages.
- Keyset pagination — performance on par with cursor-based, more transparent, but exposes internal data structure to the client.
Common Metadata Fields
Regardless of which pattern you pick, these metadata fields commonly show up in pagination responses:
total/totalItems— the overall total record count (optional in cursor-based since it can be expensive to compute on very large datasets)hasMore/hasNextPage— a boolean flag indicating whether more data existsnextCursor/nextPageUrl— a direct pointer to the next page, saving the client from computing it manuallyperPage/limit— the number of items per page used, useful for client-side validation
Including Direct Navigation Links (HATEOAS-style)
Some APIs include full URLs for the next/previous page, so clients don't have to build the query string themselves:
{
"data": [ /* ... */ ],
"links": {
"self": "/products?page=3&perPage=20",
"next": "/products?page=4&perPage=20",
"prev": "/products?page=2&perPage=20"
}
}
This approach is slightly more verbose but makes life much easier for clients — they simply follow the given link without needing to know the URL-building logic.
Practical Recommendation
For most standard CRUD applications with a medium-sized dataset (under a few hundred thousand rows) and a UI that needs page numbers, offset/page-based pagination is good enough and simpler to build. If you're building a public API with a very large, fast-growing dataset, or a feed/timeline endpoint like social media, cursor-based pagination is a much safer choice for long-term performance.
Common Mistakes When Implementing Pagination
- Unbounded default limit — if a client doesn't send a
limitparameter, make sure there's a sensible default (e.g. 20-50), and cap the maximum allowed limit (e.g. 100 max) so a misbehaving client can't requestlimit=1000000and overload the server. - Inconsistency across endpoints — if one endpoint uses
page/perPageand another usesoffset/limit, that confuses API consumers. Pick one pattern and apply it across the whole API. - Computing
totalon a very large dataset without proper indexing — aCOUNT(*)query on a table with millions of rows can be slow if not optimized; sometimes it's better to cache it or compute it asynchronously rather than recalculating it on every request. - No way to estimate the last page in cursor-based pagination — since it's inherently sequential, some APIs provide a separate endpoint for a rough total count when the UI actually needs it, without sacrificing the main endpoint's performance.