Ana Sayfa / Blog / Pagination: cursor vs offset, hangi zaman hangi?

Pagination: cursor vs offset, hangi zaman hangi?

API'de listeleri sayfalamanın iki yolu: offset-based (LIMIT/OFFSET) ve cursor-based. Performansı ve kullanıcı deneyimini nasıl etkiliyor?

Pagination kolay bir konu gibi görünüyor. Yüzlerce item varsa kullanıcıya sayfa sayfa gönder. Ama pagination mekanizmasını yanlış seçmek büyük scale’de performans felaketi oluyor.

İki ana yaklaşım var: offset-based ve cursor-based. Her birinin avantajı/dezavantajı var. Bu yazıda hangi durumda hangisini seçeceğinizi anlatacağım.

Offset-based pagination (geleneksel)

En yaygın yaklaşım. “Sayfa 3, her sayfa 20 item” demek 41-60 arası item’ları istiyorsun.

API:

GET /api/posts?page=3&per_page=20
veya
GET /api/posts?offset=40&limit=20

SQL:

SELECT * FROM posts
ORDER BY created_at DESC
LIMIT 20 OFFSET 40;

Avantajlar:

  • Kullanıcı 5. sayfaya direkt atlayabiliyor
  • “Toplam 1000 sonuçtan 40-60 arası” gibi metadata vermek kolay
  • UI’da “Page 1, 2, 3 … 50” navigation yapmak basit
  • Bookmark’lanabilir URL’ler (“?page=3” direct link)

Dezavantajlar:

  • Büyük offset’lerde yavaş. SQL OFFSET 10000 database’e 10000 row scan ettiriyor sonra atıyor. Dataset büyüdükçe yavaşlıyor.
  • Tutarsız sonuçlar. Sayfa 1’i gösterirken yeni bir item insert edildi. Sayfa 2’ye geçtiğinde aynı item bir daha görünüyor (duplicate).
  • Deletion durumunda item atlama. Birisi sayfa 1’deki bir item’ı sildi. Sayfa 2’de aslında sayfa 1’in son item’ı var, sen onu atlıyorsun.

Offset 100-1000 arası genelde OK. 10000+ performans düşüyor.

Cursor-based pagination (modern tercih)

Cursor bir “nerede kaldım” işareti. Son gördüğün item’ın ID’si veya timestamp’i ile bir sonraki item’dan başlatıyorsun.

API:

GET /api/posts?after=cur_abc123&limit=20

SQL:

SELECT * FROM posts
WHERE created_at < (cursor'dan decode edilen timestamp)
ORDER BY created_at DESC
LIMIT 20;

Cursor genelde base64-encoded: {"created_at":"2024-11-15","id":12345}.

Response:

{
  "items": [...],
  "next_cursor": "cur_xyz789",
  "has_more": true
}

Client bir sonraki isteği next_cursor ile yapıyor.

Avantajlar:

  • Büyük dataset’te bile hızlı. Her query O(log n) index lookup, LIMIT 20 return. Offset’in sorunu yok.
  • Tutarlı sonuçlar. Yeni item insert edilse bile cursor şu an kadar olanları döndürüyor. Duplicate veya skip yok.
  • Real-time feed’lerde ideal. Twitter/Facebook’un infinite scroll’u cursor-based.

Dezavantajlar:

  • Kullanıcı 5. sayfaya direkt atlayamıyor. Sadece sequential (sonraki, önceki).
  • “Toplam 1000 sonuçtan 40-60 arası” demek zor (total count için ayrı query).
  • UI’da “Page 1, 2, 3” navigation yapmak imkansız. Sadece “Load More” veya “Next” butonu.

Karar kriterleri

Offset-based seç:

  1. Dataset küçük ya da orta (<10000 total)
  2. Kullanıcı sayfalar arasında atlamak isteyecek
  3. “Total count” görünür olmalı
  4. Traditional web UI (search results gibi)
  5. Admin panel, CRM, reporting

Cursor-based seç:

  1. Dataset büyük (10K+)
  2. Sürekli yeni item ekleniyor (feed, notification, message)
  3. Infinite scroll UX
  4. Mobile app (cursor daha natural)
  5. Real-time veya near-real-time data

Hybrid approach

Bazı API’larda iki yöntemi birden sunmak mantıklı:

# Cursor-based (preferred, real-time feeds için)
GET /api/posts?after=cur_abc

# Offset-based (backward compat, search için)
GET /api/posts?page=3&per_page=20

Stripe bu yaklaşımı kullanıyor. Cursor-based birincil, offset bazı endpoint’lerde var.

Performance karşılaştırması

Basit bir benchmark (100K row’luk posts tablosu, PostgreSQL):

| Query Type | Total | Time |
|————|——-|——|
| Offset page 1 (LIMIT 20 OFFSET 0) | 100K | 2ms |
| Offset page 100 (LIMIT 20 OFFSET 2000) | 100K | 5ms |
| Offset page 1000 (LIMIT 20 OFFSET 20000) | 100K | 50ms |
| Offset page 5000 (LIMIT 20 OFFSET 100000) | 100K | 450ms |
| Cursor-based (her query) | 100K | 2ms |

Cursor-based her query constant time. Offset page number arttıkça linear yavaşlıyor.

Cursor içinde ne olmalı?

Cursor’ı tasarlarken:

1. Ordering field’ı içermeli. Sort edilen alan cursor’da olsun.

ORDER BY created_at DESC
cursor: {created_at: "2024-11-15 10:30"}

2. Tiebreaker gerekiyor. Aynı created_at’te birden fazla item varsa, ID de eklenmeli:

cursor: {created_at: "2024-11-15 10:30", id: 12345}
SQL: WHERE (created_at, id) < ('2024-11-15 10:30', 12345)

3. Opaque olarak encode et. Base64 yap. Client cursor içini parse edemesin. Sen format’ı değiştirme özgürlüğü kazanırsın.

cursor = base64(JSON.stringify({ca: ts, id: 123}))
# "eyJjYSI6IjIwMjQtMTEtMTUiLCJpZCI6MTIzfQ=="

4. Security: cursor’ı signature’la. Tamper edilemesin. HMAC-SHA256 ekle.

cursor = base64(payload + ":" + hmac(payload, secret))

SQL indexing for cursor-based

Cursor-based sadece hızlı query ile değer kazanıyor. İlgili index lazım:

CREATE INDEX idx_posts_created_at_id ON posts (created_at DESC, id DESC);

Composite index, cursor query’lerini O(log n)’de yapıyor. Bu index olmadan cursor-based da yavaş.

Bi-directional cursor

Kullanıcı geri dönmek istiyor. “Previous page” butonu.

API:

GET /api/posts?after=cur_xyz   # İleri
GET /api/posts?before=cur_abc  # Geri

Backend:

# before için ORDER farklı, sonra reverse
SELECT * FROM posts
WHERE created_at > :cursor_ts
ORDER BY created_at ASC  # Ters
LIMIT 20;
# Reverse before return

Response’ta her iki cursor da ver:

{
  "items": [...],
  "next_cursor": "cur_next",
  "prev_cursor": "cur_prev"
}

Metadata challenges

Cursor-based’de “toplam sonuç” göstermek zor. Separate count query gerekiyor:

SELECT COUNT(*) FROM posts WHERE ...;

Bu büyük dataset’te yavaş. Birkaç optimization:

1. Approximate count. “100K+” göster. PostgreSQL’de pg_class.reltuples yaklaşık değer verir.

2. Cache count. Toplam count’u her 5 dakikada bir hesapla, cache’e koy.

3. Don’t show total. Bazı UI’larda total count önemli değil. Twitter feed’de “123,456 post” görmüyorsun.

Common mistakes

1. Cursor’ı client tarafında üretmek. Client son gördüğü item’ın ID’sini cursor olarak gönderiyor. Güvenlik problemi. Cursor’ı her zaman server üretsin.

2. Ordering field’ının unique olmaması. ORDER BY created_at ama created_at duplicated. Cursor fails. Her zaman tiebreaker ekle.

3. Limit validation yok. Client limit=10000 gönderiyor, backend döndürüyor. Max limit server-side zorla (100 tipik).

4. Offset pagination’da total count’u her request’te hesaplamak. Cache’le.

Sonuç

Small dataset’te offset OK. Dataset büyüdükçe cursor-based zorunlu. Hybrid approach (iki tanesi de sun) flexibility veriyor.

Cursor içinde ordering field + tiebreaker, opaque encoding, signed cursors. Composite index SQL’de. Bu kurallarla performant pagination kuruluyor.

Büyük API’ların (Stripe, GitHub, Twitter) hepsi cursor-based. Kendi API’nı tasarlarken onları örnek al.

Bu konuda bir projeniz mi var?

Kısa bir özet bırakın, 24 saat içinde size dönüş yapayım.

İletişime Geç