API Pagination Strategien
API Pagination Strategien
Pagination ist essentiell für performante APIs mit großen Datenmengen. Lernen Sie verschiedene Pagination-Strategien und deren Vor- und Nachteile.
Warum Pagination?
┌─────────────────────────────────────────────────────────────┐ │ OHNE PAGINATION │ ├─────────────────────────────────────────────────────────────┤ │ │ │ GET /api/users │ │ │ │ ┌─────────────────────────────────────────────────────┐ │ │ │ │ │ │ │ Response: 100.000 User-Objekte │ │ │ │ │ │ │ │ • 50 MB Response │ │ │ │ • 10+ Sekunden Ladezeit │ │ │ │ • Datenbank unter Last │ │ │ │ • Client-Speicher erschöpft │ │ │ │ │ │ │ └─────────────────────────────────────────────────────┘ │ │ │ │ MIT PAGINATION │ ├─────────────────────────────────────────────────────────────┤ │ │ │ GET /api/users?page=1&limit=20 │ │ │ │ ┌─────────────────────────────────────────────────────┐ │ │ │ │ │ │ │ Response: 20 User-Objekte │ │ │ │ │ │ │ │ • 10 KB Response │ │ │ │ • < 100ms Ladezeit │ │ │ │ • Datenbank effizient │ │ │ │ • Gute UX mit Paging UI │ │ │ │ │ │ │ └─────────────────────────────────────────────────────┘ │ │ │ └─────────────────────────────────────────────────────────────┘
Pagination-Strategien
| Strategie | Gut für | Probleme |
|---|---|---|
| Offset/Limit | Einfache Fälle, UI mit Seitenzahlen | Performance bei großen Offsets |
| Cursor/Keyset | Große Datenmengen, Real-time Feeds | Keine direkten Seitensprünge |
| Seek/Keyset | Sortierte Daten, konsistente Performance | Komplexere Implementierung |
Offset-Based Pagination
// Request
GET /api/users?page=3&limit=20
// oder
GET /api/users?offset=40&limit=20
// Response
{
"data": [
{ "id": 41, "name": "User 41", ... },
{ "id": 42, "name": "User 42", ... },
...
],
"pagination": {
"page": 3,
"limit": 20,
"total": 1000,
"totalPages": 50
},
"links": {
"first": "/api/users?page=1&limit=20",
"prev": "/api/users?page=2&limit=20",
"next": "/api/users?page=4&limit=20",
"last": "/api/users?page=50&limit=20"
}
}
// SQL Implementation
SELECT * FROM users
ORDER BY created_at DESC
LIMIT 20 OFFSET 40; -- Seite 3 mit 20 pro Seite
// PHP Implementation
class UserController {
public function index(Request $request): JsonResponse {
$page = $request->input('page', 1);
$limit = min($request->input('limit', 20), 100); // Max 100
$offset = ($page - 1) * $limit;
$users = DB::table('users')
->orderBy('created_at', 'desc')
->offset($offset)
->limit($limit)
->get();
$total = DB::table('users')->count();
return response()->json([
'data' => $users,
'pagination' => [
'page' => $page,
'limit' => $limit,
'total' => $total,
'totalPages' => ceil($total / $limit)
]
]);
}
}
VORTEILE: ✅ Einfach zu implementieren ✅ Intuitive UI (Seite 1, 2, 3...) ✅ Direkte Sprünge möglich ✅ Total Count möglich NACHTEILE: ❌ OFFSET scannt alle vorherigen Rows ❌ Performance degradiert bei hohen Offsets ❌ Inkonsistente Ergebnisse bei Änderungen ❌ OFFSET 100000 = DB muss 100000 Rows überspringen
Cursor-Based Pagination
// Request
GET /api/users?cursor=eyJpZCI6MTAwfQ&limit=20
// cursor ist encoded: {"id": 100}
// Response
{
"data": [
{ "id": 101, "name": "User 101", ... },
{ "id": 102, "name": "User 102", ... },
...
],
"pagination": {
"nextCursor": "eyJpZCI6MTIwfQ", // Base64 encoded
"prevCursor": "eyJpZCI6MTAxfQ",
"hasMore": true
}
}
// SQL Implementation
-- Statt OFFSET: WHERE Clause mit letztem Wert
SELECT * FROM users
WHERE id > 100 -- Cursor-Wert
ORDER BY id ASC
LIMIT 20;
// PHP Implementation
class UserController {
public function index(Request $request): JsonResponse {
$limit = min($request->input('limit', 20), 100);
$cursor = $this->decodeCursor($request->input('cursor'));
$query = DB::table('users')
->orderBy('id', 'asc')
->limit($limit + 1); // +1 für hasMore Check
if ($cursor) {
$query->where('id', '>', $cursor['id']);
}
$users = $query->get();
$hasMore = $users->count() > $limit;
if ($hasMore) {
$users = $users->slice(0, $limit);
}
$lastUser = $users->last();
return response()->json([
'data' => $users,
'pagination' => [
'nextCursor' => $hasMore
? $this->encodeCursor(['id' => $lastUser->id])
: null,
'hasMore' => $hasMore
]
]);
}
private function encodeCursor(array $data): string {
return base64_encode(json_encode($data));
}
private function decodeCursor(?string $cursor): ?array {
if (!$cursor) return null;
return json_decode(base64_decode($cursor), true);
}
}
VORTEILE: ✅ Konstante Performance (unabhängig von Position) ✅ Konsistente Ergebnisse bei Änderungen ✅ Kein OFFSET Overhead ✅ Gut für Infinite Scroll / Load More NACHTEILE: ❌ Keine direkten Seitensprünge ❌ Kein Total Count ohne zusätzliche Query ❌ Komplexer bei mehreren Sort-Feldern ❌ Opaque Cursor für Client
Keyset Pagination (Multi-Column)
// Bei Sortierung nach mehreren Feldern
// Request
GET /api/posts?cursor=eyJjcmVhdGVkX2F0IjoiMjAyNC0wMS0xNSIsImlkIjo1MDB9
// SQL - Mehrere Spalten für eindeutige Sortierung
SELECT * FROM posts
WHERE (created_at, id) < ('2024-01-15', 500)
ORDER BY created_at DESC, id DESC
LIMIT 20;
// Oder mit OR für DB-Kompatibilität
SELECT * FROM posts
WHERE created_at < '2024-01-15'
OR (created_at = '2024-01-15' AND id < 500)
ORDER BY created_at DESC, id DESC
LIMIT 20;
// JavaScript/TypeScript Implementation
interface Cursor {
createdAt: string;
id: number;
}
async function getPosts(cursor?: Cursor, limit = 20) {
let query = db('posts')
.orderBy('created_at', 'desc')
.orderBy('id', 'desc')
.limit(limit + 1);
if (cursor) {
query = query.where(function() {
this.where('created_at', '<', cursor.createdAt)
.orWhere(function() {
this.where('created_at', '=', cursor.createdAt)
.andWhere('id', '<', cursor.id);
});
});
}
const posts = await query;
const hasMore = posts.length > limit;
if (hasMore) posts.pop();
const lastPost = posts[posts.length - 1];
return {
data: posts,
nextCursor: hasMore ? {
createdAt: lastPost.created_at,
id: lastPost.id
} : null,
hasMore
};
}
Performance Vergleich
OFFSET bei 1 Million Rows: Page 1 (OFFSET 0): ~5ms ✅ Page 100 (OFFSET 1980): ~15ms ✅ Page 1000 (OFFSET 19980): ~120ms ⚠️ Page 10000 (OFFSET 199980): ~800ms ❌ Page 50000 (OFFSET 999980): ~4000ms ❌❌ CURSOR bei 1 Million Rows: Page 1: ~5ms ✅ Page 100: ~5ms ✅ Page 1000: ~5ms ✅ Page 10000: ~5ms ✅ Page 50000: ~5ms ✅ → Cursor-Pagination hat KONSTANTE Performance!
API Response Format
// JSON:API Style
{
"data": [...],
"links": {
"self": "/api/users?page[cursor]=abc",
"first": "/api/users",
"next": "/api/users?page[cursor]=xyz",
"prev": "/api/users?page[cursor]=def"
},
"meta": {
"hasMore": true
}
}
// HAL Style
{
"_embedded": {
"users": [...]
},
"_links": {
"self": { "href": "/api/users?cursor=abc" },
"next": { "href": "/api/users?cursor=xyz" }
}
}
// Simple Style
{
"data": [...],
"pagination": {
"cursor": "abc123",
"nextCursor": "xyz789",
"hasMore": true,
"limit": 20
}
}
Wann welche Strategie?
OFFSET-BASED WÄHLEN WENN: • Kleine Datenmengen (< 100.000) • UI mit Seitenzahlen benötigt • Direkte Seitensprünge nötig • Total Count wichtig • Einfachheit Priorität CURSOR-BASED WÄHLEN WENN: • Große Datenmengen (> 100.000) • Infinite Scroll UI • Real-time Feeds (Twitter, News) • Performance kritisch • Daten ändern sich häufig KEYSET WÄHLEN WENN: • Sortierung nach mehreren Feldern • Maximale Performance benötigt • Konsistente Ergebnisse bei Updates wichtig
Hybrid Approach
// Biete beide Optionen an
// Offset für kleine Requests
GET /api/users?page=1&limit=20
// Cursor für große Requests / Iteration
GET /api/users?cursor=abc&limit=20
// Implementation
class UserController {
public function index(Request $request): JsonResponse {
$limit = min($request->input('limit', 20), 100);
// Wenn Cursor vorhanden: Cursor-Pagination
if ($request->has('cursor')) {
return $this->cursorPagination($request, $limit);
}
// Sonst: Offset-Pagination
return $this->offsetPagination($request, $limit);
}
}
💡 Best Practices:
1. Limit immer mit Maximum begrenzen (z.B. max 100)
2. Cursor-Pagination für große Datenmengen
3. Navigation Links in Response mitgeben
4. Cursor opaque halten (Base64 encoded)
5. Index auf Sort-Spalten für Performance
2. Cursor-Pagination für große Datenmengen
3. Navigation Links in Response mitgeben
4. Cursor opaque halten (Base64 encoded)
5. Index auf Sort-Spalten für Performance