Jika halaman SSR di SvelteKit terasa makin lambat ketika jumlah data tumbuh, penyebabnya sering bukan SvelteKit itu sendiri, melainkan pola akses database di server-side rendering: N+1 query, join yang terlalu berat, atau index yang tidak sesuai pola filter dan sorting. Gejalanya biasanya muncul pada halaman list dan detail yang tadinya cepat, lalu melambat seiring pertumbuhan tabel.
Artikel ini fokus pada cara audit yang praktis: bagaimana mengenali gejala, membaca query log, memeriksa EXPLAIN, memahami beda full scan dan index scan, lalu memperbaiki bottleneck di load function dan endpoint server SvelteKit. Tujuannya bukan sekadar “menambah index”, tetapi memastikan SSR hanya menjalankan query yang benar-benar perlu dan memakai rencana eksekusi yang masuk akal.
Gejala khas bottleneck SSR di SvelteKit
Pada aplikasi SvelteKit, masalah performa SSR sering terlihat dari waktu respons halaman pertama. Karena HTML dirender di server, setiap query yang lambat langsung menambah waktu tunggu pengguna.
Pola gejala yang sering muncul
- Halaman list makin lambat saat jumlah row bertambah, terutama bila ada filter, pencarian, atau sorting.
- Halaman detail terasa lambat karena memuat banyak relasi kecil satu per satu.
- TTFB meningkat walau ukuran HTML kecil.
- CPU database naik saat trafik SSR naik.
- Lonjakan jumlah query per request, misalnya dari 3 query menjadi puluhan atau ratusan.
Jika satu request SSR menghasilkan terlalu banyak query atau satu query melakukan scan besar, performa akan turun lebih cepat daripada pertumbuhan trafik normal.
Contoh pola masalah di load function dan endpoint
Masalah paling umum adalah query berulang di server. Dalam SvelteKit, ini biasanya terjadi di +page.server.js, +layout.server.js, atau endpoint seperti +server.js.
Contoh N+1 query pada halaman list
Misalkan halaman SSR menampilkan daftar order beserta nama customer dan jumlah item. Anti-pattern yang sering terjadi:
// +page.server.js
export async function load({ locals, url }) {
const orders = await locals.db.query(
`SELECT id, customer_id, created_at
FROM orders
ORDER BY created_at DESC
LIMIT 20`
);
const result = [];
for (const order of orders.rows) {
const customer = await locals.db.query(
`SELECT id, name FROM customers WHERE id = $1`,
[order.customer_id]
);
const itemCount = await locals.db.query(
`SELECT COUNT(*) AS count FROM order_items WHERE order_id = $1`,
[order.id]
);
result.push({
...order,
customer: customer.rows[0],
itemCount: Number(itemCount.rows[0].count)
});
}
return { orders: result };
}Untuk 20 order, kode ini bisa memicu 1 query awal + 20 query customer + 20 query item count = 41 query dalam satu SSR. Saat data dan trafik naik, pola ini cepat menjadi bottleneck.
Versi yang lebih efisien
Gabungkan kebutuhan data menjadi lebih sedikit query, atau lakukan batching.
export async function load({ locals }) {
const orders = await locals.db.query(
`SELECT
o.id,
o.created_at,
c.name AS customer_name,
COUNT(oi.id) AS item_count
FROM orders o
JOIN customers c ON c.id = o.customer_id
LEFT JOIN order_items oi ON oi.order_id = o.id
GROUP BY o.id, c.name
ORDER BY o.created_at DESC
LIMIT 20`
);
return { orders: orders.rows };
}Ini bukan berarti semua data harus dijadikan satu query besar. Jika join menjadi terlalu berat, batching dua atau tiga query terkontrol sering lebih baik daripada puluhan query kecil.
Alur diagnosis: dari request lambat ke query bermasalah
Audit performa yang efektif dimulai dari satu request SSR yang lambat, lalu ditelusuri sampai query yang menjadi bottleneck.
1. Catat durasi per request SSR
Tambahkan logging sederhana di area server SvelteKit untuk mengukur waktu eksekusi.
export async function load({ locals }) {
const started = Date.now();
const data = await locals.db.query(`SELECT id, title FROM posts ORDER BY created_at DESC LIMIT 20`);
console.log('SSR /posts load took', Date.now() - started, 'ms');
return { posts: data.rows };
}Jika perlu, ukur juga tiap query, bukan hanya total fungsi. Tujuannya agar terlihat apakah lambat karena satu query besar atau banyak query kecil.
2. Aktifkan query log di layer database/aplikasi
Apa pun driver atau ORM yang dipakai, pastikan Anda bisa melihat:
- SQL yang dijalankan
- parameter penting
- durasi eksekusi
- jumlah query per request
Yang dicari bukan hanya query paling lambat, tetapi juga pola repetitif. Query 5 ms yang dipanggil 100 kali tetap mahal untuk SSR.
Catatan: Saat logging aktif di production, hindari mencetak data sensitif dan pertimbangkan sampling agar log tidak berlebihan.
3. Kelompokkan query berdasarkan halaman
Masalah sering tersembunyi karena satu endpoint terlihat “normal” jika dilihat terpisah. Padahal dalam satu request SSR, beberapa komponen atau helper bisa memicu query berbeda. Kelompokkan log per request atau per route untuk melihat total beban sebenarnya.
4. Jalankan EXPLAIN pada query yang mencurigakan
Begitu Anda menemukan query yang sering dipanggil atau berdurasi tinggi, periksa rencana eksekusinya dengan EXPLAIN atau EXPLAIN ANALYZE sesuai kemampuan dan kebijakan lingkungan Anda.
EXPLAIN
SELECT id, customer_id, created_at
FROM orders
WHERE status = 'paid'
ORDER BY created_at DESC
LIMIT 20;Fokus utama saat membaca hasilnya:
- apakah database melakukan full table scan / sequential scan
- apakah ada index scan
- berapa banyak row yang diperkirakan dan yang benar-benar dibaca
- apakah sorting dilakukan setelah membaca row terlalu banyak
- apakah join menyebabkan pembacaan data meledak
Memahami full scan vs index scan
Full scan
Full scan berarti database membaca sebagian besar atau seluruh tabel untuk menemukan row yang cocok. Ini tidak selalu salah; untuk tabel kecil, full scan bisa wajar. Masalah muncul ketika tabel sudah besar tetapi query tetap memaksa pembacaan masif.
Contoh pola yang sering memicu full scan:
- kolom
WHEREtidak memiliki index yang relevan - index ada, tetapi urutan kolom tidak cocok
- fungsi pada kolom filter membuat index sulit dipakai
- query memfilter kolom berselektivitas rendah tanpa strategi lain
Index scan
Index scan berarti database memakai struktur index untuk menemukan row lebih cepat. Ini berguna saat query memfilter, join, atau sorting pada kolom yang sesuai. Namun index juga punya biaya: memperbesar storage dan memperlambat operasi INSERT/UPDATE/DELETE.
Kesimpulannya: tujuan audit bukan membuat semua query memakai index, tetapi memastikan query yang kritikal untuk SSR memakai akses path yang efisien.
Strategi index untuk halaman list SSR
Halaman list biasanya memadukan filter, sorting, dan pagination. Di sinilah composite index sering lebih penting daripada index tunggal.
Contoh query list yang umum
SELECT id, customer_id, total_amount, created_at
FROM orders
WHERE status = 'paid'
ORDER BY created_at DESC
LIMIT 20;Jika hanya ada index pada status atau hanya pada created_at, database belum tentu bisa menjalankan query ini secara optimal. Sering kali lebih cocok memakai composite index yang mengikuti pola filter lalu sorting.
CREATE INDEX idx_orders_status_created_at
ON orders (status, created_at DESC);Kenapa pendekatan ini sering bekerja:
statusdipakai untuk mempersempit kandidat rowcreated_atmembantu mengambil urutan yang dibutuhkan tanpa sort besar di akhirLIMIT 20menjadi lebih efektif karena database bisa berhenti lebih cepat
Kesalahan umum saat membuat index
- Membuat index per kolom secara terpisah, padahal query nyata selalu memakai kombinasi kolom.
- Menebak-nebak index tanpa melihat query log dan
EXPLAIN. - Menambahkan terlalu banyak index sampai write performance turun.
- Tidak menyamakan index dengan pola sorting, misalnya query selalu
ORDER BY created_attetapi index hanya pada kolom filter.
Kapan composite index relevan
Pertimbangkan composite index jika query SSR Anda konsisten seperti ini:
WHERE tenant_id = ? AND status = ? ORDER BY created_at DESC LIMIT ?WHERE post_id = ? ORDER BY published_at DESC LIMIT ?WHERE category_id = ? AND is_active = ?
Prinsipnya: cocokkan index dengan urutan akses data yang paling sering dipakai aplikasi, bukan berdasarkan intuisi umum.
Mengurangi query berulang di load function dan endpoint server
1. Hindari loop yang memanggil database
Jika ada for, map, atau iterasi lain yang di dalamnya ada query, curigai N+1. Solusinya bisa berupa:
- join terkontrol
- batch fetch dengan
IN (...) - agregasi di query utama
2. Pakai batching saat join tunggal terlalu berat
Join besar tidak selalu terbaik. Kadang lebih efisien mengambil data utama lebih dulu, lalu ambil relasi dalam satu query batch.
export async function load({ locals }) {
const ordersRes = await locals.db.query(
`SELECT id, customer_id, created_at
FROM orders
WHERE status = $1
ORDER BY created_at DESC
LIMIT 20`,
['paid']
);
const orders = ordersRes.rows;
const customerIds = [...new Set(orders.map((o) => o.customer_id))];
const orderIds = orders.map((o) => o.id);
const customersRes = await locals.db.query(
`SELECT id, name FROM customers WHERE id = ANY($1)`,
[customerIds]
);
const countsRes = await locals.db.query(
`SELECT order_id, COUNT(*) AS item_count
FROM order_items
WHERE order_id = ANY($1)
GROUP BY order_id`,
[orderIds]
);
const customerMap = new Map(customersRes.rows.map((c) => [c.id, c]));
const countMap = new Map(countsRes.rows.map((r) => [r.order_id, Number(r.item_count)]));
return {
orders: orders.map((o) => ({
...o,
customer: customerMap.get(o.customer_id) ?? null,
itemCount: countMap.get(o.id) ?? 0
}))
};
}Pola ini menukar 41 query menjadi 3 query, tanpa memaksa satu join besar yang mungkin sulit dioptimalkan.
3. Waspadai fetch internal yang memicu akses database berulang
Di SvelteKit, halaman SSR kadang memanggil endpoint internal yang pada akhirnya mengakses database lagi. Secara arsitektur ini rapi, tetapi bisa menyebabkan query dobel jika data yang sama sudah tersedia di load server.
Jika data hanya dipakai untuk SSR halaman tersebut, lebih efisien akses database langsung dari server-side load daripada membuat rantai request internal yang berulang.
4. Pisahkan data kritikal dan data sekunder
Jangan paksakan semua data masuk ke SSR awal jika sebagian sebenarnya tidak menentukan render awal. Untuk data sekunder, pertimbangkan:
- mengambil setelah render awal jika memang tidak kritikal
- menciutkan payload SSR menjadi data ringkas
- menghindari agregasi mahal pada request pertama
Ini bukan solusi database murni, tetapi sering menurunkan waktu SSR secara signifikan.
Join berat: kapan dipertahankan, kapan dipecah
Join tidak buruk secara default. Masalah muncul saat query mencoba menyelesaikan terlalu banyak pekerjaan sekaligus: beberapa tabel besar, agregasi, sort, dan filter yang tidak didukung index memadai.
Pertahankan join jika:
- jumlah tabel masih masuk akal
- kolom join terindeks dengan benar
- hasil
EXPLAINmenunjukkan pembacaan row tetap terkendali - query menggantikan banyak query kecil yang mahal secara total
Pecah menjadi batching jika:
- join membuat cardinality membengkak
- hasil agregasi sulit dioptimalkan
- Anda hanya butuh relasi untuk subset kecil row hasil utama
- query gabungan sulit dipelihara dan rentan regresi
Tidak ada aturan tunggal. Pilih berdasarkan query nyata, rencana eksekusi, dan jumlah row yang benar-benar diproses.
Kapan perlu denormalisasi ringan
Jika SSR sangat sering membaca agregasi atau atribut turunan yang sama, denormalisasi ringan bisa lebih efektif daripada menghitung ulang setiap request.
Contoh kasus
- Menyimpan
comment_countdi tabelpostsdaripada menghitung dari tabel komentar setiap kali halaman list dirender. - Menyimpan
last_order_atdi tabel customer untuk dashboard list. - Menyimpan status ringkas hasil proses bisnis yang mahal dihitung lewat join berlapis.
Trade-off denormalisasi
- Kelebihan: SSR lebih cepat dan query lebih sederhana.
- Kekurangan: ada risiko inkonsistensi jika update tidak dikelola dengan baik.
Pilih denormalisasi ringan jika read jauh lebih sering daripada write, dan data turunan itu memang sering dibutuhkan pada SSR.
Contoh audit nyata: dari list lambat ke perbaikan terukur
Situasi awal
Route SSR /orders menampilkan 20 order terakhir dengan nama customer dan jumlah item. Awalnya cepat, lalu melambat setelah tabel orders dan order_items membesar.
Temuan dari query log
- 1 query untuk mengambil daftar order
- 20 query untuk customer
- 20 query untuk item count
- Total 41 query per request
Selain itu, query daftar order memakai filter status dan sorting created_at, tetapi belum ada composite index yang sesuai.
Langkah perbaikan
- Ubah pola N+1 menjadi 3 query batch.
- Tambahkan composite index pada tabel order untuk pola list yang paling sering dipakai.
- Pastikan tabel
order_itemsmemiliki index padaorder_iduntuk agregasi per order. - Jalankan
EXPLAINulang untuk memeriksa apakah scan dan sort berkurang.
Contoh SQL index sederhana
CREATE INDEX idx_orders_status_created_at
ON orders (status, created_at DESC);
CREATE INDEX idx_order_items_order_id
ON order_items (order_id);Hasil yang diharapkan
- jumlah query SSR turun drastis
- query list tidak lagi membaca row berlebihan
- sort lebih murah karena didukung index
- TTFB lebih stabil saat data bertambah
Anda tidak perlu angka benchmark buatan untuk tahu perbaikannya berhasil. Cukup bandingkan durasi request, jumlah query, dan hasil EXPLAIN sebelum-sesudah.
Checklist verifikasi sebelum dan sesudah optimasi
Sebelum
- Apakah satu request SSR menjalankan banyak query kecil berulang?
- Apakah ada loop di server yang memanggil database?
- Apakah query list utama punya filter + sort + limit yang jelas?
- Apakah query yang lambat sudah diperiksa dengan
EXPLAIN? - Apakah index yang ada sesuai dengan pola query nyata?
- Apakah endpoint internal dipanggil dari SSR padahal data bisa diambil langsung?
Sesudah
- Apakah jumlah query per request menurun?
- Apakah durasi query dominan berkurang?
- Apakah rencana eksekusi beralih dari full scan yang mahal ke index scan yang lebih tepat, jika memang relevan?
- Apakah sort besar berkurang karena index mendukung
ORDER BY? - Apakah write performance masih aman setelah penambahan index?
- Apakah hasil data tetap benar dan tidak ada duplikasi akibat join/agregasi?
Kesalahan yang sering terjadi saat audit performa
- Langsung menambah cache tanpa memahami query dasar yang bermasalah.
- Mengandalkan ORM secara buta tanpa memeriksa SQL yang dihasilkan.
- Menganggap semua join buruk, padahal yang salah bisa jadi index-nya.
- Menganggap semua full scan salah, padahal pada tabel kecil itu bisa normal.
- Menambah banyak index tanpa memikirkan dampaknya ke operasi tulis.
- Mengukur hanya di lokal dengan data kecil sehingga bottleneck sebenarnya tidak terlihat.
Penutup
Audit N+1 query dan index untuk SSR yang melambat di SvelteKit sebaiknya dimulai dari pola request nyata: berapa query yang dijalankan, query mana yang berulang, dan bagaimana database mengeksekusinya. Dua sumber masalah paling umum adalah akses database berulang di load function atau endpoint, serta index yang tidak mencerminkan pola filter dan sorting halaman SSR.
Pendekatan yang biasanya paling efektif adalah kombinasi dari: mengurangi query berulang, menyusun batching atau join yang lebih sehat, dan menambahkan composite index berdasarkan query nyata. Jika beban baca tetap tinggi untuk agregasi yang sama, barulah pertimbangkan denormalisasi ringan. Fokuslah pada bukti: query log, EXPLAIN, dan perbandingan sebelum-sesudah.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!