Jika daftar data di aplikasi SvelteKit terasa makin lambat saat user membuka halaman 100, 500, atau lebih, masalahnya sering bukan pada SvelteKit, melainkan pada pola query SQL yang memakai LIMIT/OFFSET. Semakin besar nilai OFFSET, semakin banyak baris yang harus dilewati database sebelum mengembalikan hasil.

Solusi yang umum dipakai untuk daftar besar adalah keyset pagination (sering juga disebut cursor pagination). Alih-alih berkata “lompat ke baris ke-10000”, query akan berkata “ambil data setelah item terakhir yang sudah saya punya”. Pendekatan ini biasanya lebih efisien, lebih stabil untuk data yang sering berubah, dan cocok untuk endpoint server atau load function di SvelteKit.

Kenapa LIMIT/OFFSET makin mahal saat page tinggi?

Query seperti ini terlihat sederhana:

SELECT id, title, created_at
FROM posts
ORDER BY created_at DESC, id DESC
LIMIT 20 OFFSET 10000;

Masalahnya, database tetap perlu menemukan urutan data lalu melewati 10.000 baris pertama sebelum mengambil 20 baris berikutnya. Meskipun ada index, nilai OFFSET yang besar tetap membuat kerja ekstra karena engine harus memindai atau melangkahi banyak entri.

Secara praktis, biaya query meningkat seiring pertambahan page. Ini biasanya terlihat jelas pada:

  • halaman daftar admin dengan data ratusan ribu atau jutaan baris,
  • feed atau log activity yang terus bertambah,
  • tabel transaksi, order, audit trail, atau event.

Gejala di produksi

Beberapa gejala yang sering muncul:

  • Halaman awal cepat, tapi halaman jauh lebih lambat.
  • Lonjakan database CPU saat user melakukan navigasi ke page tinggi.
  • APM atau log menunjukkan waktu query dominan, sementara render SvelteKit relatif kecil.
  • Waktu respons endpoint naik meskipun payload JSON kecil.
  • Query yang sama terasa lambat hanya ketika OFFSET besar.

Jika waktu habis di query SQL, mengganti komponen frontend atau mengoptimalkan rendering SvelteKit tidak akan banyak membantu. Bottleneck ada di database access pattern.

Kapan bottleneck ada di database, bukan di framework?

Indikasinya cukup jelas bila:

  • fungsi load atau endpoint server hanya menjalankan satu query, tapi total waktu request tinggi,
  • profiling menunjukkan sebagian besar waktu di driver database,
  • hasil query sedikit, tetapi durasi tetap besar,
  • query plan dari EXPLAIN menunjukkan scan atau skip dalam jumlah besar.

Dalam kasus ini, SvelteKit hanya menjadi perantara. Optimasi yang tepat adalah mengubah desain pagination dan index, bukan sekadar menambah caching secara membabi buta.

OFFSET pagination vs keyset pagination

OFFSET pagination

Model klasik:

SELECT id, title, created_at
FROM posts
ORDER BY created_at DESC, id DESC
LIMIT 20 OFFSET 40;

Kelebihan:

  • Mudah dipahami.
  • Mudah dipakai untuk UI berbasis nomor halaman.
  • Cocok untuk dataset kecil atau tabel yang jarang bertambah besar.

Kekurangan:

  • Semakin mahal di page tinggi.
  • Kurang stabil saat ada insert/delete di antara perpindahan halaman.
  • Bisa menyebabkan item duplikat atau terlewat ketika data berubah cepat.

Keyset pagination

Model ini memakai nilai kolom terakhir sebagai penanda posisi. Misalnya daftar diurutkan dengan created_at DESC, id DESC. Setelah halaman pertama diambil, client mengirim cursor dari item terakhir:

SELECT id, title, created_at
FROM posts
WHERE (created_at, id) < ($1, $2)
ORDER BY created_at DESC, id DESC
LIMIT 20;

Maknanya: ambil 20 post berikutnya yang posisinya setelah item terakhir yang sudah ditampilkan berdasarkan urutan yang sama.

Kelebihan:

  • Performa lebih konsisten untuk data besar.
  • Tidak perlu “skip” ribuan baris.
  • Lebih stabil untuk data yang terus berubah.

Kekurangan:

  • Lebih sulit dibanding OFFSET.
  • Kurang cocok bila UI mewajibkan lompat langsung ke nomor halaman arbitrer.
  • Butuh sorting yang stabil dan cursor yang tepat.

Syarat penting: sorting harus stabil dan unik

Keyset pagination hanya aman jika urutan data deterministik. Kesalahan umum adalah mengurutkan hanya dengan kolom yang tidak unik, misalnya:

ORDER BY created_at DESC

Jika banyak baris punya created_at yang sama, posisi antarbaris bisa ambigu. Solusi umum adalah menambahkan kolom unik sebagai tie-breaker:

ORDER BY created_at DESC, id DESC

Lalu cursor juga harus membawa kedua nilai tersebut: created_at dan id.

Jika urutan utama adalah kolom unik yang monoton, seperti id DESC, implementasinya lebih sederhana. Namun dalam banyak aplikasi, urutan berdasarkan waktu plus id lebih realistis.

Desain index yang mendukung keyset pagination

Query keyset akan efektif jika ada index yang sesuai dengan pola filter dan ORDER BY. Untuk contoh urutan:

ORDER BY created_at DESC, id DESC

index yang relevan biasanya mengikuti kolom yang sama, misalnya:

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

Beberapa engine database dapat tetap memanfaatkan index meskipun arah ASC/DESC tidak selalu ditulis identik, tetapi prinsip amannya adalah: samakan urutan kolom index dengan urutan sort query.

Jika query juga selalu memfilter subset data tertentu, misalnya hanya post yang published, pertimbangkan desain index yang sesuai dengan pola akses tersebut. Yang penting, jangan membuat index berdasarkan tebakan. Validasi dengan EXPLAIN.

Contoh query halaman pertama dan halaman berikutnya

Halaman pertama:

SELECT id, title, created_at
FROM posts
ORDER BY created_at DESC, id DESC
LIMIT 20;

Halaman berikutnya, setelah client mengirim cursor item terakhir:

SELECT id, title, created_at
FROM posts
WHERE (created_at, id) < ($1, $2)
ORDER BY created_at DESC, id DESC
LIMIT 20;

Pola ini jauh lebih baik dibanding:

SELECT id, title, created_at
FROM posts
ORDER BY created_at DESC, id DESC
LIMIT 20 OFFSET 10000;

Mengubah kontrak API: dari page/offset ke cursor

Saat migrasi dari OFFSET ke keyset pagination, kontrak request biasanya berubah.

Sebelum

GET /api/posts?page=4&limit=20

atau:

GET /api/posts?offset=60&limit=20

Sesudah

GET /api/posts?limit=20&cursor=2024-01-15T10:30:00.000Z_12345

Cursor bisa berupa string terenkripsi, base64, atau format sederhana yang berisi nilai kolom sort. Yang penting:

  • server bisa memvalidasi dan mem-parse dengan aman,
  • cursor tidak mengubah makna urutan,
  • client tidak perlu memahami detail SQL.

Respons API biasanya juga berubah. Bukan lagi fokus ke page total, melainkan ke nextCursor dan apakah masih ada data berikutnya.

{
  "items": [
    {
      "id": 125,
      "title": "Post A",
      "created_at": "2024-01-15T10:35:00.000Z"
    }
  ],
  "nextCursor": "2024-01-15T10:30:00.000Z_12345",
  "hasMore": true
}

Implementasi server-side di SvelteKit

Di SvelteKit, logika ini bisa ditempatkan di endpoint server atau di load function server-side. Untuk daftar data yang dipakai ulang oleh beberapa halaman atau komponen client, endpoint API sering lebih fleksibel. Untuk halaman yang sepenuhnya dirender server, +page.server.js atau +page.server.ts juga cocok.

Contoh endpoint +server.ts

import { json } from '@sveltejs/kit';

function encodeCursor(row) {
  return `${row.created_at}_${row.id}`;
}

function decodeCursor(cursor) {
  const lastUnderscore = cursor.lastIndexOf('_');
  if (lastUnderscore === -1) throw new Error('Invalid cursor');

  return {
    createdAt: cursor.slice(0, lastUnderscore),
    id: Number(cursor.slice(lastUnderscore + 1))
  };
}

export async function GET({ url, locals }) {
  const limit = Math.min(Number(url.searchParams.get('limit') || '20'), 100);
  const cursor = url.searchParams.get('cursor');

  let rows;

  if (cursor) {
    const { createdAt, id } = decodeCursor(cursor);

    rows = await locals.db.query(
      `SELECT id, title, created_at
       FROM posts
       WHERE (created_at, id) < (?, ?)
       ORDER BY created_at DESC, id DESC
       LIMIT ?`,
      [createdAt, id, limit + 1]
    );
  } else {
    rows = await locals.db.query(
      `SELECT id, title, created_at
       FROM posts
       ORDER BY created_at DESC, id DESC
       LIMIT ?`,
      [limit + 1]
    );
  }

  const hasMore = rows.length > limit;
  const items = hasMore ? rows.slice(0, limit) : rows;
  const lastItem = items[items.length - 1];

  return json({
    items,
    hasMore,
    nextCursor: hasMore && lastItem ? encodeCursor(lastItem) : null
  });
}

Catatan penting dari contoh di atas:

  • limit + 1 dipakai untuk mendeteksi apakah masih ada data berikutnya.
  • Cursor dibangun dari pasangan created_at dan id.
  • Query tetap memakai urutan yang sama pada semua halaman.
  • Gunakan parameter binding dari driver database Anda, jangan merangkai SQL dari string mentah.

Contoh +page.server.ts

Jika data langsung dipakai oleh halaman SSR:

export async function load({ url, locals }) {
  const limit = Math.min(Number(url.searchParams.get('limit') || '20'), 100);
  const cursor = url.searchParams.get('cursor');

  let rows;

  if (cursor) {
    const lastUnderscore = cursor.lastIndexOf('_');
    if (lastUnderscore === -1) {
      return { items: [], hasMore: false, nextCursor: null };
    }

    const createdAt = cursor.slice(0, lastUnderscore);
    const id = Number(cursor.slice(lastUnderscore + 1));

    rows = await locals.db.query(
      `SELECT id, title, created_at
       FROM posts
       WHERE (created_at, id) < (?, ?)
       ORDER BY created_at DESC, id DESC
       LIMIT ?`,
      [createdAt, id, limit + 1]
    );
  } else {
    rows = await locals.db.query(
      `SELECT id, title, created_at
       FROM posts
       ORDER BY created_at DESC, id DESC
       LIMIT ?`,
      [limit + 1]
    );
  }

  const hasMore = rows.length > limit;
  const items = hasMore ? rows.slice(0, limit) : rows;
  const lastItem = items[items.length - 1];

  return {
    items,
    hasMore,
    nextCursor: hasMore && lastItem ? `${lastItem.created_at}_${lastItem.id}` : null
  };
}

Pada sisi UI, navigasi biasanya berubah dari nomor halaman menjadi tombol Next, Load more, atau infinite scroll. Ini konsekuensi desain yang perlu diputuskan sejak awal.

Trade-off yang perlu dipahami

1. Tidak ideal untuk lompat ke halaman acak

Keyset pagination unggul untuk navigasi berurutan: halaman pertama, berikutnya, berikutnya lagi. Jika user harus bisa langsung membuka halaman 937, OFFSET lebih natural, walau mahal. Untuk kebutuhan seperti ini, pertimbangkan apakah UI benar-benar butuh nomor halaman eksplisit.

2. Cursor harus berbasis kolom yang stabil

Jangan gunakan kolom yang mudah berubah untuk cursor jika perubahan itu mengubah urutan. Misalnya, jika daftar diurutkan berdasarkan updated_at dan nilai itu sering berubah, posisi item dapat bergeser di antara request. Itu tidak selalu salah, tetapi harus dipahami sebagai karakteristik feed dinamis.

3. Backward navigation lebih rumit

Maju ke halaman berikutnya mudah. Kembali ke halaman sebelumnya lebih sulit karena Anda perlu menyimpan cursor sebelumnya di client, atau mendukung query kebalikan dengan urutan yang dibalik lalu membalik hasil di aplikasi.

Contoh konsep untuk mundur:

  • simpan stack cursor yang pernah dipakai,
  • saat user klik Previous, gunakan cursor sebelumnya,
  • atau jalankan query dengan operator lawan dan urutan terbalik, lalu reverse hasil.

Untuk banyak aplikasi internal, menyimpan stack cursor di client sudah cukup.

4. Total jumlah halaman tidak lagi alami

Pada OFFSET, UI mudah menampilkan “page 12 dari 48”. Pada keyset, konsep tersebut tidak lagi langsung tersedia. Jika bisnis tetap butuh total item, Anda mungkin perlu query terpisah seperti COUNT(*), yang sendiri bisa mahal pada tabel besar tergantung engine dan kondisi query.

Debugging dan validasi dengan EXPLAIN

Jangan berasumsi query keyset otomatis cepat. Validasi dengan EXPLAIN atau fasilitas query plan di database Anda.

Apa yang perlu dicek

  • Apakah index yang benar dipakai?
  • Apakah query plan menunjukkan scan besar yang tidak perlu?
  • Apakah urutan kolom index sesuai dengan filter dan sort?
  • Apakah kondisi tuple comparison atau kondisi gabungan benar-benar memanfaatkan index?

Contoh pemeriksaan:

EXPLAIN
SELECT id, title, created_at
FROM posts
WHERE (created_at, id) < ('2024-01-15 10:30:00', 12345)
ORDER BY created_at DESC, id DESC
LIMIT 20;

Bandingkan dengan query OFFSET:

EXPLAIN
SELECT id, title, created_at
FROM posts
ORDER BY created_at DESC, id DESC
LIMIT 20 OFFSET 10000;

Anda biasanya akan melihat bahwa query OFFSET tetap harus memproses jauh lebih banyak posisi sebelum hasil akhir keluar.

Kesalahan umum saat debugging

  • Sort tidak unik: hasil bisa loncat atau duplikat.
  • Cursor tidak sinkron dengan ORDER BY: misalnya ORDER BY dua kolom, cursor hanya satu kolom.
  • Index tidak cocok: query benar secara logika, tapi tetap lambat.
  • Limit terlalu besar: keyset membantu, tetapi mengambil 5000 item per request tetap berat.
  • Masalah ada di layer lain: misalnya serialisasi JSON, N+1 query tambahan, atau join yang berat.

Pola query alternatif jika tuple comparison tidak dipakai

Pada beberapa tim atau database setup, kondisi tuple seperti (created_at, id) < (?, ?) tidak dipakai. Alternatif logikanya adalah menuliskan kondisi ekuivalen secara eksplisit:

SELECT id, title, created_at
FROM posts
WHERE created_at < ?
   OR (created_at = ? AND id < ?)
ORDER BY created_at DESC, id DESC
LIMIT 20;

Secara logika ini setara untuk urutan created_at DESC, id DESC. Pilihan terbaik tetap perlu divalidasi dengan query plan di database Anda.

Kapan tetap memakai OFFSET?

OFFSET belum tentu salah. Gunakan OFFSET jika:

  • dataset kecil dan tidak tumbuh signifikan,
  • halaman jauh jarang diakses,
  • UI wajib mendukung nomor halaman dan lompat bebas,
  • kompleksitas tambahan keyset tidak sebanding dengan manfaatnya.

Namun jika Anda mengelola daftar besar yang sering diakses berurutan, terutama dari endpoint SvelteKit yang membaca SQL langsung, keyset pagination biasanya pilihan yang lebih sehat untuk jangka panjang.

Checklist migrasi aman dari OFFSET ke keyset

  1. Identifikasi query yang lambat di halaman tinggi, bukan hanya request yang lambat secara umum.
  2. Tentukan urutan yang stabil, misalnya created_at DESC, id DESC.
  3. Pastikan ada kolom unik sebagai tie-breaker.
  4. Buat atau sesuaikan index agar cocok dengan pola sort dan filter.
  5. Ubah kontrak API dari page/offset ke cursor dan limit.
  6. Ambil limit + 1 untuk mendeteksi hasMore.
  7. Encode/decode cursor dengan aman dan validasi input dari client.
  8. Uji konsistensi hasil pada data dengan timestamp kembar atau insert paralel.
  9. Bandingkan EXPLAIN antara query lama dan baru.
  10. Sesuaikan UI navigasi, terutama jika sebelumnya berbasis nomor halaman.
  11. Simpan strategi backward navigation jika tombol Previous tetap dibutuhkan.
  12. Roll out bertahap pada endpoint yang paling bermasalah dulu.

Intinya: saat pagination di SvelteKit melambat seiring pertumbuhan data, sering kali akar masalahnya adalah SQL dengan OFFSET besar. Mengganti ke keyset pagination memperbaiki pola akses database, bukan sekadar mempercantik layer framework. Jika urutan data stabil, index sesuai, dan kontrak API diperbarui dengan benar, performa query biasanya akan jauh lebih konsisten pada dataset besar.