Latency tinggi pada komponen data table Inertia.js saat proses pagination, sorting, atau filtering sering kali bukan disebabkan oleh ketiadaan indeks pada klausa WHERE. Masalah utamanya terletak pada overhead heap fetch (table page lookups). Meskipun indeks B-Tree biasa digunakan untuk menyaring baris, database engine tetap harus melompat ke blok data tabel (heap) untuk mengambil nilai kolom yang didefinisikan pada klausa SELECT.

Root Cause: Mengapa Index Biasa Tetap Lambat pada Data Table

Saat request Inertia masuk ke controller, endpoint biasanya mengembalikan pagination data untuk dirender oleh komponen frontend seperti Vue atau React:

return Inertia::render('Orders/Index', [
    'orders' => Order::query()
        ->where('status', 'paid')
        ->orderByDesc('created_at')
        ->paginate(20)
]);

Jika tabel memiliki indeks biasa (status, created_at), database melakukan Index Scan atau Bitmap Heap Scan. B-Tree leaf node hanya menyimpan kolom indeks dan pointer lokasi baris fisik di disk (Tuple Identifier / TID).

Ketika query mengeksekusi SELECT * (default pada pagination Eloquent tanpa restriksi), database harus mengakses halaman disk tabel (heap) untuk setiap baris yang lolos filter. Jika baris tersebar di banyak blok disk acak, I/O cost membengkak akibat random read, cache buffer pool tertekan, dan latency melonjak drastis seiring bertambahnya ukuran tabel.

Mendeteksi Bottleneck via EXPLAIN (ANALYZE, BUFFERS)

Untuk memvalidasi apakah query Inertia Anda terhambat oleh heap fetch, jalankan profiling query aktual menggunakan PostgreSQL EXPLAIN (ANALYZE, BUFFERS) langsung dari query builder Laravel sebelum mengirimkan response props:

$query = Order::query()
    ->select(['id', 'reference_no', 'grand_total', 'customer_name', 'status', 'created_at'])
    ->where('status', 'paid')
    ->orderByDesc('created_at')
    ->limit(20);

$explain = DB::select("EXPLAIN (ANALYZE, BUFFERS) " . $query->toSql(), $query->getBindings());
logger()->info(json_encode($explain, JSON_PRETTY_PRINT));

Hasil eksekusi dengan indeks B-Tree standar menunjukkan pola berikut:

Index Scan using idx_orders_status_created on orders (cost=0.42..180.20 rows=20 width=64) (actual time=0.085..4.120 rows=20 loops=1)
  Index Cond: (status = 'paid'::text)
  Buffers: shared hit=85 read=42
Planning Time: 0.150 ms
Execution Time: 4.165 ms

Metrik shared read=42 dan scan berjenis Index Scan mengindikasikan bahwa puluhan blok disk dipanggil hanya untuk mengambil kolom pelengkap data table.

Solusi: Implementasi Covering Index (Index-Only Scan)

Covering Index memastikan seluruh data yang diminta oleh klausa WHERE, ORDER BY, dan SELECT termuat langsung di dalam B-Tree index structure. Dengan ini, database engine cukup mengeksekusi Index-Only Scan tanpa menyentuh heap data table sama sekali (Heap Fetches: 0).

Pada PostgreSQL 11+, gunakan klausa INCLUDE. Kolom filter/sorting ditempatkan sebagai index keys, sedangkan kolom tampilan props Inertia ditempatkan sebagai non-key payload columns. Non-key columns tidak mempengaruhi struktur pengurutan B-Tree, menghemat ukuran index tree dibandingkan composite index biasa.

Migration Penambahan Covering Index

use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;

return new class extends Migration {
    public function up(): void
    {
        // Membuat B-tree index dengan INCLUDE clause di PostgreSQL
        DB::statement('
            CREATE INDEX idx_orders_covering_datatable 
            ON orders (status, created_at DESC) 
            INCLUDE (id, reference_no, grand_total, customer_name);
        ');
    }

    public function down(): void
    {
        DB::statement('DROP INDEX IF EXISTS idx_orders_covering_datatable;');
    }
};
Catatan MySQL (InnoDB): MySQL tidak mendukung klausa INCLUDE pada secondary index. Solusinya adalah membuat composite index konvensional yang mencakup seluruh kolom tersebut: INDEX (status, created_at, id, reference_no, grand_total, customer_name).

Menjaga Strict Select Props pada Controller Inertia

Index-Only Scan akan otomatis dibatalkan oleh query planner dan beralih ke Index Scan biasa jika controller meminta setidaknya satu kolom yang tidak terdaftar di dalam key maupun klausa INCLUDE index.

Hindari passing model utuh. Selalu tentukan proyeksi kolom secara eksplisit pada query Eloquent:

// app/Http/Controllers/OrderController.php

public function index(Request $request)
{
    $orders = Order::query()
        // WAJIB: Kolom select HARUS tepat sesuai kolom pada index + INCLUDE
        ->select([
            'id',
            'reference_no',
            'grand_total',
            'customer_name',
            'status',
            'created_at',
        ])
        ->where('status', $request->input('status', 'paid'))
        ->orderByDesc('created_at')
        ->paginate(20)
        ->withQueryString();

    return Inertia::render('Orders/Index', [
        'orders' => $orders,
        'filters' => $request->only(['status']),
    ]);
}

Penyebab Umum Index-Only Scan Pecah

  • Eloquent Accessor / Appends: Properti dinamis yang memicu query relasi lazy loading atau mengakses atribut internal yang tidak masuk dalam select().
  • Global Scopes: Scope bawaan yang menyisipkan filter pada kolom lain (misal deleted_at dari soft deletes atau multi-tenancy ID) yang belum dimasukkan ke index key.
  • Visibility Map Kotor: PostgreSQL membutuhkan Visibility Map untuk memastikan sebuah tuple terlihat oleh snapshot transaksi aktif tanpa mengecek heap. Jika tabel mengalami write rate tinggi dan belum di-VACUUM, planner akan tetap mencatat Heap Fetches: > 0. Pastikan autovacuum berjalan teratur pada tabel bersangkutan.

Perbandingan Performa: Sebelum vs Sesudah Optimasi

Eksekusi ulang query analitik setelah index diaplikasikan:

Index Only Scan using idx_orders_covering_datatable on orders (cost=0.42..12.50 rows=20 width=64) (actual time=0.021..0.082 rows=20 loops=1)
  Index Cond: (status = 'paid'::text)
  Heap Fetches: 0
  Buffers: shared hit=4
Planning Time: 0.125 ms
Execution Time: 0.104 ms

Hasil perbandingan metrik pada tabel transaksi dengan 2.500.000 baris:

  • Scan Type: Berubah dari Index Scan menjadi Index Only Scan.
  • Heap Fetches: Turun dari 20-50 per page menjadi 0.
  • Buffer Access (shared hit/read): Turun dari 127 blok menjadi hanya 4 blok (hanya membaca B-Tree root, branch, dan leaf node).
  • Database Execution Latency: Pangkas dari ~4.16 ms menjadi ~0.10 ms (pengurangan beban hingga ~97%).

Trade-Offs dan Batasan

Covering index bukan solusi serbaguna untuk semua tabel. Pertimbangkan trade-off berikut:

  1. Write Amplification: Setiap operasi INSERT, UPDATE, atau DELETE pada kolom payload (klausa INCLUDE) mengharuskan update pada B-Tree, menambah overhead DML dan write I/O.
  2. Storage Footprint: Index menjadi lebih besar di memori RAM (buffer pool). Masukkan hanya kolom-kolom ringkas yang esensial untuk tampilan grid data table (hindari kolom tipe teks besar seperti JSONB, TEXT panjang, atau BLOB).