Jika halaman daftar data SSR di Nuxt.js makin lambat seiring pertumbuhan tabel, masalahnya sering bukan di Nuxt itu sendiri, melainkan di query database. Gejala yang umum terlihat adalah TTFB naik, halaman awal masih cepat tetapi halaman 100, 500, atau 1000 mulai berat, dan endpoint daftar yang memakai OFFSET/LIMIT makin mahal saat data bertambah.

Solusi yang biasanya paling efektif ada di dua area: index yang sesuai dengan pola query dan mengganti offset pagination ke keyset pagination untuk daftar yang besar. Dengan dua langkah ini, SSR Nuxt.js tetap bisa cepat karena backend tidak perlu membuang banyak baris hanya untuk mengambil sedikit hasil.

Mengapa SSR Nuxt.js Terasa Lambat Saat Tabel Membesar?

Pada SSR, server Nuxt menunggu data selesai diambil sebelum mengirim HTML ke browser. Artinya, jika query daftar data lambat, waktu render di server ikut tertahan. Akibatnya:

  • TTFB meningkat karena HTML baru bisa dikirim setelah query selesai.
  • Beban server naik saat traffic tinggi karena banyak request menunggu I/O database.
  • Halaman terasa lambat meski komponen frontend sudah ringan.

Pola yang sering menjadi sumber masalah adalah query seperti ini:

SELECT id, title, created_at
FROM posts
WHERE status = 'published'
ORDER BY created_at DESC
LIMIT 20 OFFSET 10000;

Query tersebut tampak sederhana, tetapi OFFSET 10000 berarti database biasanya tetap harus berjalan melewati banyak baris sebelum sampai ke 20 data yang diminta. Semakin besar offset, semakin besar kerja yang harus dilakukan.

Root Cause di Layer SQL: Kenapa OFFSET/LIMIT Makin Berat?

Masalah utama OFFSET

LIMIT 20 OFFSET 10000 tidak berarti database bisa langsung “loncat” murah ke baris ke-10001. Dalam banyak kasus, database tetap perlu:

  • mencari baris yang cocok dengan filter,
  • mengurutkannya sesuai ORDER BY,
  • melewati 10000 baris terlebih dahulu,
  • baru mengambil 20 baris berikutnya.

Jika index tidak sesuai, database bisa melakukan full scan, sort besar, atau kombinasi keduanya. Bahkan jika ada index, offset yang sangat tinggi tetap mahal karena ada banyak entri index yang harus dilewati.

Filter dan sort yang sering muncul

Kasus nyata biasanya memakai filter dan sort seperti:

  • WHERE status = 'published'
  • WHERE created_at >= ?
  • ORDER BY created_at DESC
  • ORDER BY id DESC

Masalah muncul ketika kolom filter dan kolom sort tidak didukung index yang cocok. Misalnya, ada index di status saja, tetapi query mengurutkan berdasarkan created_at. Database mungkin masih harus melakukan sort tambahan setelah filter.

Indexing yang Relevan untuk Daftar Data SSR

Index yang baik harus mengikuti pola query yang benar-benar dipakai aplikasi, bukan sekadar memberi index pada semua kolom. Fokus utama untuk halaman daftar biasanya:

  • kolom filter yang sering dipakai,
  • kolom sort utama,
  • kolom tie-breaker agar urutan stabil, biasanya id.

Single index vs composite index

Misalnya query utama Anda seperti ini:

SELECT id, title, created_at
FROM posts
WHERE status = 'published'
ORDER BY created_at DESC, id DESC
LIMIT 20;

Index yang sering lebih relevan adalah composite index yang mengikuti pola filter lalu sort, misalnya:

CREATE INDEX idx_posts_status_created_id
ON posts (status, created_at, id);

Kenapa lebih baik daripada index terpisah di status, created_at, dan id?

  • Composite index bisa membantu database memfilter dan membaca data dalam urutan yang sudah sesuai.
  • Index terpisah belum tentu bisa digabung secara efisien untuk kebutuhan filter + sort.
  • Sort tambahan dapat dihindari jika urutan index cocok dengan query.

Kapan covering index membantu?

Covering index membantu ketika semua kolom yang dibutuhkan query sudah tersedia di index, sehingga database tidak perlu sering kembali ke tabel utama. Ini berguna untuk endpoint daftar yang hanya menampilkan sebagian kolom.

Contoh jika daftar hanya butuh id, title, dan created_at, Anda dapat mempertimbangkan index yang mencakup pola akses itu. Implementasinya bergantung pada database yang dipakai, tetapi prinsipnya sama: semakin sedikit lookup tambahan ke heap/table, semakin kecil biaya I/O.

Catatan: covering index bukan selalu pilihan terbaik. Index yang terlalu lebar meningkatkan ukuran storage, biaya write, dan maintenance.

Kesalahan umum saat membuat index

  • Terlalu banyak index pada tabel yang sering di-write. Insert/update/delete jadi lebih mahal.
  • Urutan kolom index salah. Index harus mengikuti pola query dominan.
  • Sort tidak sesuai index. Misalnya filter di status tetapi sort di kolom lain yang tidak sejalan dengan index.
  • Tidak ada tie-breaker. ORDER BY created_at DESC saja bisa ambigu jika banyak row memiliki timestamp yang sama.

Offset Pagination vs Keyset Pagination

Offset pagination

Model klasik:

SELECT id, title, created_at
FROM posts
WHERE status = 'published'
ORDER BY created_at DESC, id DESC
LIMIT 20 OFFSET 10000;

Kelebihan:

  • mudah dipahami,
  • mudah membuat nomor halaman,
  • cocok untuk dataset kecil atau halaman admin sederhana.

Kekurangan:

  • makin lambat saat offset membesar,
  • rawan inkonsistensi jika data baru masuk di tengah paginasi,
  • lebih berat untuk SSR karena setiap request harus menunggu query besar.

Keyset pagination

Keyset pagination memakai cursor dari item terakhir halaman sebelumnya, bukan nomor halaman absolut. Contoh:

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

Jika database atau ORM Anda tidak mendukung perbandingan tuple secara langsung, bentuk ekuivalennya biasanya:

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

Kenapa ini lebih cepat?

  • Database tidak perlu melewati ribuan baris seperti pada OFFSET.
  • Dengan composite index yang sesuai, database bisa langsung melanjutkan pembacaan dari posisi cursor.
  • Biaya query cenderung stabil meski dataset membesar.

Kapan memilih keyset pagination?

Pilih keyset jika:

  • daftar data besar dan sering diakses,
  • SSR sensitif terhadap TTFB,
  • sort utama jelas, misalnya created_at DESC, id DESC,
  • UX tidak harus menampilkan loncat bebas ke halaman 237.

Tetap gunakan offset jika:

  • dataset kecil,
  • nomor halaman absolut adalah kebutuhan utama,
  • penggunaan lebih penting daripada optimasi maksimal.

Desain Query dan Cursor yang Stabil

Untuk keyset pagination, urutan harus stabil dan deterministik. Karena itu, jangan hanya mengurutkan dengan created_at jika nilainya bisa sama. Tambahkan id sebagai tie-breaker.

Contoh urutan yang aman:

ORDER BY created_at DESC, id DESC

Cursor yang dikirim ke client dapat berisi:

  • created_at item terakhir,
  • id item terakhir.

Biasanya cursor dikemas sebagai string ter-encode agar tidak terlalu mentah di URL. Namun jangan menganggap cursor aman hanya karena di-encode; jika perlu, tambahkan validasi atau signature di backend.

Contoh Integrasi di Nuxt 3: Server Route dan useAsyncData

Ada dua pola umum:

  1. Nuxt server route bertindak sebagai lapisan API internal yang memanggil database atau backend service.
  2. Halaman SSR memakai useAsyncData untuk memanggil endpoint tersebut.

Contoh server route dengan cursor

Contoh berikut menunjukkan pola umum. Kode database dibuat generik agar tidak bergantung pada driver tertentu.

// server/api/posts.get.ts
export default defineEventHandler(async (event) => {
  const query = getQuery(event)
  const limit = Math.min(Number(query.limit || 20), 100)
  const status = String(query.status || 'published')
  const cursor = query.cursor ? JSON.parse(Buffer.from(String(query.cursor), 'base64').toString('utf8')) : null

  const params: unknown[] = [status]
  let whereCursor = ''

  if (cursor?.createdAt && cursor?.id) {
    whereCursor = `
      AND (
        created_at < ?
        OR (created_at = ? AND id < ?)
      )
    `
    params.push(cursor.createdAt, cursor.createdAt, cursor.id)
  }

  params.push(limit + 1)

  const sql = `
    SELECT id, title, created_at
    FROM posts
    WHERE status = ?
    ${whereCursor}
    ORDER BY created_at DESC, id DESC
    LIMIT ?
  `

  const rows = await db.query(sql, params)
  const items = rows.slice(0, limit)
  const hasMore = rows.length > limit

  const last = items[items.length - 1]
  const nextCursor = hasMore && last
    ? Buffer.from(JSON.stringify({ createdAt: last.created_at, id: last.id })).toString('base64')
    : null

  return {
    items,
    pageInfo: {
      hasMore,
      nextCursor
    }
  }
})

Poin penting dari contoh di atas:

  • limit + 1 dipakai untuk mendeteksi apakah masih ada halaman berikutnya.
  • Cursor dibentuk dari pasangan created_at dan id.
  • Urutan query dan syarat cursor harus konsisten.

Contoh penggunaan di halaman Nuxt dengan SSR

<script setup lang="ts">
const route = useRoute()

const { data, pending, error } = await useAsyncData(
  'posts-list',
  () => $fetch('/api/posts', {
    query: {
      status: route.query.status || 'published',
      cursor: route.query.cursor,
      limit: 20
    }
  }),
  {
    watch: [() => route.query.status, () => route.query.cursor]
  }
)
</script>

<template>
  <div>
    <p v-if="pending">Memuat...</p>
    <p v-else-if="error">Gagal mengambil data.</p>

    <ul v-else>
      <li v-for="item in data.items" :key="item.id">
        {{ item.title }}
      </li>
    </ul>

    <NuxtLink
      v-if="data?.pageInfo?.hasMore"
      :to="{ query: { ...route.query, cursor: data.pageInfo.nextCursor } }"
    >
      Halaman berikutnya
    </NuxtLink>
  </div>
</template>

Pola ini cocok untuk SSR karena data diambil saat render server. Jika endpoint cepat, TTFB ikut membaik.

Strategi Index untuk Query Keyset

Jika query Anda seperti ini:

WHERE status = ?
AND (
  created_at < ?
  OR (created_at = ? AND id < ?)
)
ORDER BY created_at DESC, id DESC

Maka index yang umum relevan adalah:

CREATE INDEX idx_posts_status_created_id
ON posts (status, created_at, id);

Secara konsep, index ini membantu karena:

  • status dipakai untuk filter awal,
  • created_at dan id dipakai untuk urutan sekaligus cursor,
  • database lebih mudah melakukan range scan daripada menghitung offset besar.

Perlu dicatat, detail perilaku eksekusi dapat berbeda antar database. Namun prinsip umumnya tetap: susunan index harus selaras dengan filter, sort, dan pola range.

Cara Membaca EXPLAIN Secara Dasar

Anda tidak harus menjadi DBA untuk mendapat manfaat dari EXPLAIN. Untuk kebutuhan troubleshooting awal, fokus pada beberapa hal ini:

1. Apakah index yang benar dipakai?

Lihat apakah planner memilih index yang Anda harapkan. Jika query daftar status + created_at + id tetapi index yang terpakai hanya status atau bahkan scan penuh, itu tanda ada masalah.

2. Apakah ada sort tambahan?

Jika hasil EXPLAIN menunjukkan operasi sort yang besar, kemungkinan urutan query tidak bisa dilayani langsung oleh index.

3. Apakah ada scan terlalu banyak row?

Jika jumlah row yang dibaca jauh lebih besar dari row yang dikembalikan, query mungkin kurang selektif atau index tidak cocok.

4. Bandingkan offset vs keyset

Jalankan EXPLAIN untuk kedua versi query. Versi keyset yang baik umumnya menunjukkan pembacaan lebih terarah dibanding query dengan offset besar.

Tips: lakukan pengujian dengan data yang cukup besar. Query yang terlihat cepat di tabel kecil sering menipu.

Trade-off UX: Keyset Pagination Tidak Selalu Cocok untuk Semua Halaman

Keyset pagination unggul di performa, tetapi ada konsekuensi UX:

  • Tidak natural untuk “lompat ke halaman 57”.
  • Nomor halaman absolut tidak selalu tersedia.
  • Perlu desain navigasi berbasis “berikutnya/sebelumnya” atau infinite scroll.

Untuk banyak daftar publik, feed, riwayat transaksi, atau log, trade-off ini masuk akal. Namun untuk backoffice yang butuh nomor halaman pasti, offset mungkin masih dipakai pada skala tertentu, atau Anda perlu kompromi desain.

Checklist Debugging untuk Query Lambat SSR di Nuxt.js

  1. Ukur TTFB di browser devtools atau observability Anda. Pastikan bottleneck benar-benar ada di data fetch SSR.
  2. Log durasi query di backend atau server route Nuxt.
  3. Identifikasi query daftar utama yang memakai OFFSET/LIMIT.
  4. Periksa pola filter dan sort: apakah dominan di created_at, id, atau kombinasi dengan status?
  5. Jalankan EXPLAIN untuk memastikan planner memakai index yang tepat.
  6. Buat atau rapikan composite index sesuai pola query dominan.
  7. Ganti offset besar ke keyset pagination jika halaman daftar terus membesar.
  8. Pastikan urutan stabil dengan tie-breaker seperti id.
  9. Ambil kolom secukupnya. Jangan SELECT * untuk daftar sederhana.
  10. Evaluasi beban write sebelum menambah banyak index.

Kesalahan Umum yang Sering Membuat Optimasi Gagal

1. Mengurutkan berdasarkan kolom yang tidak didukung index

Contoh: filter di status, tetapi sort di updated_at tanpa index yang sesuai. Akibatnya, filter mungkin cepat, tetapi sorting tetap mahal.

2. Mengandalkan index tunggal untuk query kompleks

Index di status saja dan created_at saja belum tentu cukup untuk query WHERE status = ? ORDER BY created_at DESC.

3. Cursor tidak stabil

Jika hanya memakai created_at tanpa id, item bisa lompat, ganda, atau hilang saat banyak row memiliki timestamp sama.

4. Tetap memakai SELECT *

Untuk SSR daftar data, membawa semua kolom memperbesar transfer data dan biaya I/O. Ambil hanya yang dibutuhkan UI.

5. Menambahkan terlalu banyak index

Setiap index memperlambat operasi tulis. Fokus pada query yang paling penting dan paling sering dipakai.

Rekomendasi Praktis

Jika Anda mengelola halaman daftar data SSR di Nuxt.js dan performanya memburuk saat tabel membesar, pendekatan yang paling aman dan praktis adalah:

  1. Identifikasi query daftar yang dominan.
  2. Pastikan pola WHERE dan ORDER BY jelas.
  3. Buat composite index yang sesuai dengan pola tersebut.
  4. Jika offset mulai tinggi, pindahkan ke keyset pagination.
  5. Gunakan cursor stabil, misalnya created_at + id.
  6. Verifikasi dengan EXPLAIN, bukan asumsi.

Untuk Nuxt.js, ini berdampak langsung ke SSR karena waktu query adalah bagian dari waktu render. Jadi, mempercepat query biasanya langsung menurunkan TTFB.

Singkatnya: jika daftar SSR Nuxt.js lambat saat data membesar, jangan hanya optimasi frontend. Periksa SQL, pastikan index sesuai, dan pertimbangkan keyset pagination untuk beban baca yang besar.