Pelajaran HyperCard untuk query feed yang makin lambat berangkat dari pola lama yang ternyata masih sangat modern: data disimpan sebagai record sederhana, lalu terus bertambah. Dalam konteks historis HyperCard—seperti yang dibahas di stonetools.ghost.io/hypercard-mac—kita melihat model data yang relatif datar, mudah dipahami, dan efektif selama skala masih kecil. Masalah muncul ketika pola yang sama dipakai untuk feed modern: timeline, activity log, daftar notifikasi, riwayat transaksi, atau katalog konten yang append-heavy.

Jawaban singkatnya: feed sering melambat bukan karena SQL itu sendiri buruk, tetapi karena query yang tadinya murah berubah mahal saat jumlah baris tumbuh dari ribuan ke jutaan. Gejala paling umum adalah OFFSET besar, COUNT(*) real-time yang dipanggil di setiap request, ORDER BY pada kolom yang tidak didukung indeks, dan filter campuran yang membuat optimizer sulit memilih rencana eksekusi yang efisien. Solusinya bukan satu trik tunggal, melainkan audit query nyata, desain indeks yang tepat, pagination yang sesuai pola akses, dan pemisahan beban query daftar dari query agregasi.

Kenapa pola ala HyperCard masih relevan

HyperCard populer karena menyederhanakan interaksi dengan data: record disusun seperti kartu, lalu pengguna menambah isi seiring waktu. Banyak feed modern bekerja dengan cara yang mirip:

  • Data append-heavy: item baru terus masuk.
  • Akses utama berupa daftar terbaru lebih dulu.
  • Filter sering berubah: per user, per status, per kategori, per rentang waktu.
  • UI butuh respons cepat untuk halaman awal, tetapi data lama tetap harus bisa diakses.

Masalahnya, pola append-only memberi ilusi bahwa semuanya akan tetap sederhana. Saat tabel masih 10 ribu baris, query yang kurang efisien mungkin tetap terasa cepat. Saat menjadi 10 juta baris, query yang sama bisa berubah menjadi bottleneck produksi.

Catatan: pola data sederhana bukan masalah. Yang bermasalah adalah mengasumsikan strategi query yang cocok untuk data kecil akan tetap baik saat distribusi data, pola filter, dan beban concurrency berubah.

Gejala di produksi saat feed mulai melambat

Sebelum membahas solusi, kenali dulu tanda-tandanya. Feed yang makin lambat biasanya tidak langsung gagal total, tetapi menunjukkan gejala bertahap:

  • Halaman pertama masih cepat, halaman 100 ke atas jauh lebih lambat.
  • CPU database naik saat ada fitur pencarian atau filter tambahan.
  • Latency API tidak stabil walau jumlah request tidak naik drastis.
  • Query yang sama kadang cepat, kadang sangat lambat, tergantung parameter filter.
  • Lock contention atau antrean koneksi meningkat karena query baca terlalu lama.
  • Grafik p95 atau p99 latency naik lebih cepat daripada rata-rata.

Dalam banyak sistem, masalah ini muncul pada endpoint seperti:

GET /feed?page=250&status=published&author_id=123&sort=created_at_desc

Secara fungsional endpoint ini benar. Secara performa, ia bisa sangat mahal tergantung struktur tabel dan indeks yang tersedia.

Bottleneck utama pada query feed

1. OFFSET besar: database tetap harus melewati baris sebelumnya

Bentuk pagination klasik sering terlihat seperti ini:

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

Masalah utama OFFSET bukan hanya mengambil 20 baris. Database sering tetap harus menelusuri atau membuang banyak baris sebelum sampai ke posisi yang diminta. Semakin besar offset, semakin banyak kerja sia-sia.

Ini biasanya masih terasa baik pada awal pertumbuhan data, lalu mendadak menjadi mahal saat pengguna menelusuri halaman lama atau crawler memukul banyak halaman pagination.

Kenapa ini terjadi? Karena mesin query umumnya tidak bisa “melompat” gratis ke baris ke-100001 kecuali urutan data dan kondisi pencarian sangat terdukung oleh indeks yang tepat. Bahkan dengan indeks, biaya membuang baris tetap ada.

2. COUNT(*) mahal jika dipanggil terus-menerus

Banyak API atau UI menampilkan total item untuk pagination:

SELECT COUNT(*)
FROM posts
WHERE status = 'published' AND author_id = 123;

Pada skala kecil, ini tampak sepele. Pada tabel besar dengan filter aktif, COUNT(*) bisa menjadi query berat, apalagi jika dipanggil pada setiap request daftar. Efeknya lebih buruk bila endpoint menjalankan dua query besar sekaligus: satu untuk mengambil item, satu lagi untuk menghitung total.

Kesalahan umum: menganggap query count selalu murah karena hanya mengembalikan satu angka. Padahal biaya utama ada pada proses membaca dan mengevaluasi baris, bukan ukuran hasil akhirnya.

3. ORDER BY pada kolom tak terindeks

Query feed hampir selalu mengurutkan hasil:

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

Jika kolom sort tidak didukung indeks yang sesuai, database mungkin harus melakukan sort pada himpunan hasil yang besar. Ini bisa berarti penggunaan memori lebih tinggi, file sort sementara, atau pembacaan data yang jauh lebih banyak daripada yang dibutuhkan untuk 20 item pertama.

Sorting menjadi lebih mahal lagi jika digabung dengan filter yang selektivitasnya buruk.

4. Filter campuran yang membuat indeks tidak efektif

Contoh query yang tampak wajar:

SELECT id, user_id, title, created_at
FROM posts
WHERE status = 'published'
  AND category_id = 10
  AND created_at >= '2026-01-01'
ORDER BY created_at DESC
LIMIT 20;

Query seperti ini bisa cepat atau lambat tergantung indeksnya. Jika hanya ada indeks tunggal pada status atau created_at, optimizer mungkin tetap harus membaca banyak baris lalu menyaring sisanya. Masalah makin rumit saat aplikasi menambahkan kombinasi filter yang berbeda-beda: kadang status + user, kadang status + category + date, kadang hanya date + sort.

Tidak semua kombinasi filter harus diindeks. Namun jika Anda tidak memilih indeks berdasarkan pola akses nyata, database mudah jatuh ke full scan atau range scan yang lebar.

Cara audit: mulai dari EXPLAIN, bukan dugaan

Jangan menebak. Ambil query nyata dari log aplikasi atau slow query log, lalu audit dengan EXPLAIN atau fasilitas rencana eksekusi yang setara di database Anda.

Apa yang perlu diperhatikan

  • Apakah query memakai indeks atau melakukan full table scan.
  • Berapa perkiraan jumlah baris yang dibaca.
  • Apakah ada operasi sort tambahan.
  • Apakah urutan indeks selaras dengan WHERE dan ORDER BY.
  • Apakah optimizer memilih indeks yang Anda harapkan.

Contoh audit awal:

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

Jika hasil EXPLAIN menunjukkan pembacaan baris sangat besar atau ada indikasi sort mahal, itu tanda query tidak skala dengan baik. Untuk query dengan filter gabungan, jalankan EXPLAIN pada beberapa kombinasi parameter yang benar-benar sering dipakai, bukan hanya satu contoh ideal.

Pertanyaan audit yang praktis

  1. Query mana yang paling sering dipanggil?
  2. Query mana yang paling lambat di p95 atau p99?
  3. Parameter apa yang paling sering muncul di produksi?
  4. Apakah endpoint menjalankan query daftar dan query count/agregasi sekaligus?
  5. Apakah halaman lama diakses pengguna, crawler, atau job internal?

Audit yang baik fokus pada pola trafik aktual. Mengoptimalkan query langka sering tidak memberi dampak berarti.

Memilih composite index yang sesuai pola feed

Untuk feed, indeks tunggal sering tidak cukup. Yang dibutuhkan biasanya adalah composite index yang mengikuti pola filter dan sort dominan.

Contoh sebelum: indeks terpisah yang tidak cukup membantu

CREATE INDEX idx_posts_status ON posts(status);
CREATE INDEX idx_posts_created_at ON posts(created_at);

Dengan indeks di atas, query berikut belum tentu optimal:

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

Database mungkin harus memilih salah satu indeks lalu tetap melakukan kerja tambahan untuk filter atau sort.

Contoh sesudah: composite index yang lebih selaras

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

Indeks ini masuk akal jika pola dominan Anda adalah:

  • Filter berdasarkan status.
  • Urut berdasarkan created_at menurun atau menaik sesuai kemampuan database memanfaatkan indeks.
  • Menggunakan id sebagai tie-breaker agar urutan stabil.

Untuk query lain, indeks yang berbeda bisa lebih cocok:

SELECT id, user_id, title, created_at
FROM posts
WHERE author_id = 123 AND status = 'published'
ORDER BY created_at DESC
LIMIT 20;
CREATE INDEX idx_posts_author_status_created_at ON posts(author_id, status, created_at, id);

Kenapa ini bekerja? Karena database bisa menyaring baris lebih awal berdasarkan prefix indeks, lalu mengambil hasil dalam urutan yang sudah mendekati kebutuhan query. Ini mengurangi pembacaan acak dan biaya sort tambahan.

Trade-off: indeks mempercepat baca, tetapi menambah biaya tulis

Setiap indeks tambahan ada harganya:

  • INSERT lebih mahal karena indeks harus ikut diperbarui.
  • UPDATE pada kolom terindeks juga lebih mahal.
  • Ukuran penyimpanan bertambah.
  • Optimizer bisa memiliki lebih banyak pilihan, tetapi tidak selalu memilih yang terbaik jika statistik buruk.

Karena itu, jangan menambahkan indeks untuk setiap kombinasi filter yang mungkin. Prioritaskan kombinasi yang paling sering dipakai dan paling mahal biayanya saat ini.

Prinsip praktis: indeks dibuat untuk query nyata, bukan untuk kemungkinan abstrak.

Beralih dari OFFSET ke keyset pagination

Jika masalah utamanya adalah feed yang makin lambat saat halaman makin dalam, keyset pagination biasanya lebih cocok daripada offset pagination.

Sebelum: offset pagination

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

Sesudah: keyset pagination

SELECT id, user_id, title, created_at
FROM posts
WHERE status = 'published'
  AND (created_at, id) < ('2026-07-01 10:15:00', 987654)
ORDER BY created_at DESC, id DESC
LIMIT 20;

Cursor diambil dari item terakhir halaman sebelumnya, misalnya kombinasi created_at dan id. Dengan pendekatan ini, database tidak perlu membuang 100 ribu baris dulu untuk mencapai halaman berikutnya.

Kapan keyset pagination cocok

  • Feed diurutkan berdasarkan kolom yang stabil, biasanya waktu dan ID.
  • Pengguna lebih sering menelusuri “berikutnya” daripada melompat ke nomor halaman spesifik.
  • Anda butuh performa yang konsisten untuk halaman dalam.

Trade-off keyset pagination

  • Tidak cocok jika UI benar-benar butuh “halaman ke-427”.
  • Implementasi cursor lebih kompleks daripada page number biasa.
  • Perlu urutan yang deterministik; gunakan tie-breaker seperti id.

Untuk feed modern, trade-off ini biasanya layak. Pengguna umumnya tidak peduli nomor halaman, mereka peduli kelancaran memuat item berikutnya.

Membatasi COUNT real-time dan memisahkan query daftar dari agregasi

Salah satu sumber latensi paling umum adalah menjalankan query daftar dan query total count dalam satu request sinkron. Secara fungsional nyaman, secara operasional mahal.

Contoh pola yang sering bermasalah

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

Jika kedua query ini dipanggil untuk setiap request feed, total beban database naik signifikan.

Pendekatan yang lebih sehat

  • Tampilkan has next page alih-alih total halaman persis.
  • Gunakan count yang di-cache untuk rentang waktu tertentu jika akurasi detik-ke-detik tidak penting.
  • Simpan agregasi di tabel ringkasan atau materialisasi terpisah.
  • Jalankan penghitungan berat secara asinkron bila UI tidak butuh jawaban instan.

Misalnya, untuk kebutuhan daftar:

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

Ambil 21 baris untuk menentukan apakah masih ada halaman berikutnya, lalu tampilkan 20. Dengan cara ini Anda sering tidak perlu COUNT(*) sama sekali pada jalur request utama.

Memisahkan daftar dari agregasi

Query daftar dan query agregasi memiliki tujuan yang berbeda:

  • Query daftar dioptimalkan untuk latency rendah dan hasil sedikit.
  • Query agregasi dioptimalkan untuk ringkasan, pelaporan, atau statistik.

Jangan memaksa satu query atau satu endpoint memikul keduanya jika kebutuhannya berbeda. Dalam arsitektur yang lebih matang, agregasi sering dipindah ke pipeline terpisah: cache, precompute, atau tabel summary.

Contoh perbaikan query feed sebelum dan sesudah

Kasus 1: feed publik berdasarkan status dan waktu

Sebelum

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

Masalah

  • Offset besar membuat scan makin mahal.
  • Jika indeks tidak cocok, sorting bisa mahal.

Sesudah

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

Kasus 2: feed per penulis dengan total count di UI

Sebelum

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

Sesudah

CREATE INDEX idx_posts_author_status_created_at_id
ON posts(author_id, status, created_at, id);
SELECT id, title, created_at
FROM posts
WHERE author_id = ?
  AND status = 'published'
  AND (created_at, id) < (?, ?)
ORDER BY created_at DESC, id DESC
LIMIT 21;

Perubahan desain

  • UI memakai cursor, bukan nomor halaman.
  • Total count ditampilkan dari cache atau dihilangkan jika tidak benar-benar penting.

Kesalahan umum saat optimasi feed

  • Menambah banyak indeks tanpa data penggunaan nyata.
  • Mengindeks kolom filter tetapi melupakan kolom sort.
  • Mengandalkan offset pagination untuk data append-heavy.
  • Menjalankan COUNT(*) pada setiap request karena alasan UI, bukan kebutuhan bisnis.
  • Tidak memakai urutan deterministik pada pagination, sehingga item bisa lompat atau duplikat.
  • Menguji query hanya pada lingkungan lokal dengan data terlalu sedikit.

Checklist saat tabel tumbuh dari ribuan ke jutaan baris

Gunakan checklist berikut sebelum feed benar-benar menjadi bottleneck:

  1. Inventaris query feed utama. Catat endpoint, parameter filter, dan pola sort yang paling sering dipakai.
  2. Aktifkan observasi query lambat. Gunakan slow query log, APM, atau logging aplikasi untuk melihat query nyata.
  3. Audit dengan EXPLAIN. Periksa scan besar, sort mahal, dan penggunaan indeks.
  4. Rancang composite index berdasarkan pola nyata. Mulai dari query termahal dan tersering.
  5. Pastikan pagination sesuai pola akses. Jika pengguna menelusuri feed berurutan, migrasikan ke keyset pagination.
  6. Hilangkan COUNT real-time dari jalur panas. Pakai cache, summary, atau cukup indikator halaman berikutnya.
  7. Pisahkan query daftar dan agregasi. Jangan gabungkan kebutuhan latency rendah dengan statistik mahal dalam satu jalur sinkron.
  8. Uji pada volume data realistis. Data sintetis yang terlalu kecil sering menyembunyikan masalah sebenarnya.
  9. Perhatikan biaya write. Setiap indeks baru memperberat insert dan update.
  10. Tinjau ulang berkala. Pola akses berubah; indeks yang tepat tahun lalu belum tentu optimal hari ini.

Penutup

Pelajaran dari HyperCard bukan bahwa sistem lama lebih sederhana, melainkan bahwa pola data yang tampak sederhana bisa menimbulkan biaya besar saat skala tumbuh. Feed modern sering berawal dari kumpulan record yang terus bertambah, lalu melambat karena keputusan query yang masuk akal di awal tidak lagi cocok untuk tabel besar.

Jika Anda menghadapi query feed yang makin lambat, fokuslah pada empat sumber masalah yang paling sering terbukti nyata: OFFSET besar, COUNT(*) mahal, ORDER BY tanpa dukungan indeks yang tepat, dan filter campuran yang tidak selaras dengan desain indeks. Audit dengan EXPLAIN, pilih composite index sesuai pola akses, beralih ke keyset pagination jika cocok, dan pisahkan daftar dari agregasi. Langkah-langkah ini tidak glamor, tetapi hampir selalu memberi dampak paling nyata di produksi.