API Route Timeout akibat koneksi DB bocor di Next.js biasanya tidak muncul sebagai satu error yang jelas. Gejalanya sering dimulai dari latency yang naik perlahan, request sporadis yang menggantung, lalu di production berubah menjadi timeout massal karena koneksi database habis dipakai. Masalah ini sering terjadi ketika koneksi atau pool dibuat berulang pada setiap request, tidak dipakai ulang dengan benar, atau tidak cocok dengan model eksekusi serverless.

Dalam studi kasus ini, kita bahas pola kerusakan yang umum pada API Route maupun Route Handler Next.js, bagaimana cara mengonfirmasi bahwa bottleneck memang berasal dari kebocoran koneksi database, dan bagaimana memperbaikinya tanpa sekadar menambah limit koneksi di database. Fokus utamanya adalah akar masalah teknis, bukan gejala permukaan.

Gejala nyata di production

Pada awalnya, endpoint yang bermasalah biasanya masih terlihat "normal" saat traffic rendah. Masalah baru terlihat ketika concurrency naik, misalnya setelah jam kerja dimulai, job background aktif, atau ada lonjakan request dari frontend.

Pola gejala yang sering muncul

  • Latency meningkat bertahap: endpoint yang biasanya cepat menjadi lambat, lalu semakin tidak stabil.
  • Timeout: request ke API Route atau Route Handler berhenti di tengah dan berakhir timeout dari platform, reverse proxy, atau client.
  • Koneksi database penuh: database mulai menolak koneksi baru atau query menunggu terlalu lama untuk mendapat slot koneksi.
  • Dampak merembet ke endpoint lain: endpoint yang tidak banyak query pun ikut melambat karena berbagi pool, instance, atau resource database yang sama.
  • Error acak: sebagian request sukses, sebagian gagal, sehingga masalah tampak seperti intermittent network issue padahal akar masalahnya lifecycle koneksi.

Ini yang membuat debugging sulit: endpoint A kelihatan rusak, tetapi akar masalahnya bisa berasal dari endpoint B yang membanjiri database dengan koneksi baru pada setiap request.

Studi kasus: endpoint makin lambat lalu timeout

Bayangkan sebuah aplikasi Next.js memiliki endpoint untuk mengambil daftar order. Pada traffic rendah, endpoint ini berjalan baik. Namun setelah deploy beberapa hari dan trafik meningkat, tim melihat hal berikut:

  • P95 latency naik terus.
  • Beberapa request timeout saat menunggu respons dari database.
  • Monitoring database menunjukkan jumlah koneksi aktif terus bertambah.
  • Endpoint lain seperti /api/profile dan /api/dashboard ikut terkena dampak.

Setelah investigasi, penyebabnya bukan query yang berat, melainkan pola inisialisasi client database yang salah: pool dibuat berulang di dalam handler. Akibatnya setiap request bisa membentuk pool baru, dan total koneksi ke database tumbuh tak terkendali.

Root cause teknis: kebocoran koneksi dan salah kelola pool

1. Membuat client atau pool di dalam handler

Ini adalah pola yang paling sering menjadi sumber masalah:

import { Pool } from 'pg';

export default async function handler(req, res) {
  const pool = new Pool({
    connectionString: process.env.DATABASE_URL,
  });

  const result = await pool.query('SELECT * FROM orders ORDER BY created_at DESC LIMIT 20');
  res.status(200).json(result.rows);
}

Masalahnya bukan hanya bahwa objek Pool dibuat setiap request. Banyak driver akan mempertahankan koneksi internal untuk dipakai ulang di dalam pool tersebut. Jika pool baru terus dibuat, aplikasi menghasilkan banyak grup koneksi yang tidak pernah benar-benar dipakai ulang secara global. Dalam runtime yang long-lived, ini menyebabkan koneksi terus menumpuk. Dalam environment serverless, cold start dan concurrency dapat memperburuk ledakan jumlah koneksi.

2. Mengambil koneksi manual tetapi tidak pernah dilepas

Pola lain yang berbahaya adalah memakai pool.connect() lalu lupa memanggil release() di semua jalur eksekusi, terutama saat terjadi exception.

const client = await pool.connect();
const result = await client.query('SELECT * FROM orders');
return result.rows;
// client.release() lupa dipanggil

Jika ini terjadi cukup sering, pool terlihat seperti "penuh" walaupun query sudah selesai. Koneksi tertahan, request berikutnya menunggu, lalu latency naik dan timeout mulai muncul.

3. Global singleton tidak stabil saat hot reload atau bundling

Di lingkungan development, hot reload dapat mengeksekusi ulang modul. Di production Node runtime, instance proses bisa bertahan lama. Di serverless, instance bisa hidup cukup lama untuk melayani banyak request, tetapi tidak bisa diasumsikan selalu satu. Karena itu, pembuatan singleton harus dilakukan hati-hati di level modul, dan bila perlu disimpan pada globalThis agar tidak menduplikasi client saat reload development.

4. Salah asumsi pada serverless

Pada serverless, banyak developer berasumsi bahwa koneksi akan otomatis aman karena fungsi bersifat singkat. Ini tidak selalu benar. Setiap instance fungsi bisa memiliki client atau pool sendiri. Saat concurrency naik, total koneksi lintas instance bisa meledak. Jadi, walaupun kode tampak benar pada satu instance, perilakunya bisa buruk di production ketika fungsi diskalakan horizontal.

Cara reproduksi masalah

Reproduksi penting agar tim tidak menebak-nebak. Buat skenario sederhana:

  1. Siapkan endpoint yang membuat pool baru per request.
  2. Jalankan aplikasi dengan koneksi ke database yang sama seperti environment staging.
  3. Gunakan load test ringan untuk menaikkan concurrency.
  4. Amati latency aplikasi dan jumlah koneksi aktif di database.

Contoh endpoint yang sengaja salah:

import { Pool } from 'pg';

export async function GET() {
  const pool = new Pool({
    connectionString: process.env.DATABASE_URL,
  });

  const result = await pool.query('SELECT NOW()');
  return Response.json({ now: result.rows[0] });
}

Lalu jalankan load test sederhana dari mesin lokal atau environment uji:

npx autocannon -c 30 -d 20 http://localhost:3000/api/orders

Yang perlu diperhatikan bukan hanya throughput, tetapi apakah:

  • latency naik seiring waktu, bukan stabil,
  • error timeout mulai muncul saat test masih berjalan,
  • jumlah koneksi di database tidak kembali turun dengan cepat.

Jika database menyediakan view statistik koneksi, gunakan itu untuk melihat apakah koneksi aktif terus bertambah selama pengujian.

Langkah investigasi yang efektif

1. Mulai dari timeline gejala

Jangan langsung mengubah konfigurasi database. Susun kronologi:

  • Kapan latency mulai naik?
  • Endpoint mana yang pertama bermasalah?
  • Apakah kenaikan koneksi DB terjadi sebelum timeout API?
  • Apakah deploy terakhir mengubah cara inisialisasi database client?

Kronologi ini penting untuk membedakan apakah sumber masalah berasal dari query lambat, deadlock, atau koneksi bocor.

2. Tambahkan log pada lifecycle koneksi

Jika belum ada observability memadai, tambahkan log secukupnya pada titik berikut:

  • saat pool/client dibuat,
  • saat request masuk ke endpoint,
  • durasi query,
  • jumlah request yang sedang berjalan,
  • error saat acquire connection atau query timeout.

Contoh sederhana:

let poolCreateCount = 0;

function createPool() {
  poolCreateCount += 1;
  console.log('[db] creating pool, total created:', poolCreateCount);
  return new Pool({ connectionString: process.env.DATABASE_URL });
}

Jika log menunjukkan pool terus dibuat selama traffic normal, itu red flag yang sangat kuat.

3. Cek metrik database, bukan hanya aplikasi

Di sisi database, amati indikator berikut:

  • jumlah koneksi aktif,
  • jumlah sesi idle yang tidak wajar,
  • waktu tunggu acquire connection jika tersedia,
  • query yang menunggu terlalu lama,
  • rasio timeout atau error connection refused.

Jika query sebenarnya cepat tetapi aplikasi tetap timeout, kemungkinan besar masalah ada pada antrean untuk mendapatkan koneksi, bukan pada eksekusi SQL itu sendiri.

4. Audit lokasi inisialisasi database client

Periksa semua file API Route, Route Handler, dan util database. Pertanyaan utamanya:

  • Apakah ada new Pool(), new PrismaClient(), atau inisialisasi serupa di dalam handler?
  • Apakah helper DB di-import dari modul tunggal, atau dibuat ulang di banyak tempat?
  • Apakah koneksi manual selalu dilepas di blok finally?
  • Apakah ada transaksi yang tidak diakhiri saat error?

Sering kali bug bukan di endpoint yang timeout, tetapi di util helper yang dipakai bersama oleh banyak endpoint.

Pola inisialisasi yang aman

Gunakan singleton di level modul

Pola dasar yang aman untuk Node runtime adalah membuat client atau pool sekali per proses, lalu dipakai ulang oleh semua request dalam proses tersebut.

import { Pool } from 'pg';

declare global {
  var __dbPool: Pool | undefined;
}

export const pool = global.__dbPool ?? new Pool({
  connectionString: process.env.DATABASE_URL,
});

if (process.env.NODE_ENV !== 'production') {
  global.__dbPool = pool;
}

Lalu di handler:

import { pool } from '@/lib/db';

export async function GET() {
  const result = await pool.query(
    'SELECT id, status, total FROM orders ORDER BY created_at DESC LIMIT 20'
  );

  return Response.json({ data: result.rows });
}

Kenapa ini bekerja? Karena pool tidak lagi dibuat per request. Selama proses masih hidup, request cukup memakai pool yang sama, sehingga jumlah koneksi lebih terkendali dan reuse koneksi menjadi efektif.

Jika memakai koneksi manual, selalu release di finally

import { pool } from '@/lib/db';

export async function POST(req) {
  const client = await pool.connect();

  try {
    await client.query('BEGIN');

    const body = await req.json();
    const insert = await client.query(
      'INSERT INTO orders(user_id, total) VALUES($1, $2) RETURNING id',
      [body.userId, body.total]
    );

    await client.query('COMMIT');
    return Response.json({ id: insert.rows[0].id }, { status: 201 });
  } catch (err) {
    await client.query('ROLLBACK');
    throw err;
  } finally {
    client.release();
  }
}

Blok finally penting karena memastikan koneksi dikembalikan ke pool baik request sukses maupun gagal. Tanpa ini, kebocoran akan muncul justru ketika traffic tinggi dan error mulai sering terjadi.

Pahami trade-off Node runtime vs serverless

Di Node runtime yang prosesnya relatif long-lived, singleton per proses biasanya efektif. Di serverless, singleton tetap membantu reuse di dalam instance yang sama, tetapi tidak menyelesaikan ledakan koneksi lintas banyak instance saat skala naik. Untuk kasus serverless dengan database relasional tradisional, Anda mungkin perlu mempertimbangkan:

  • pool dengan ukuran kecil dan konservatif,
  • connection proxy atau pooler di sisi database,
  • membatasi concurrency di layer aplikasi atau job worker,
  • memisahkan workload baca dan tulis jika arsitektur memungkinkan.

Kesalahan umum adalah menaikkan max connections database tanpa memperbaiki pola inisialisasi client. Ini hanya menunda masalah dan bisa memperbesar blast radius saat load naik lagi.

Contoh perbaikan dari kode bermasalah

Sebelum

import { Pool } from 'pg';

export default async function handler(req, res) {
  const pool = new Pool({
    connectionString: process.env.DATABASE_URL,
  });

  const client = await pool.connect();
  const result = await client.query('SELECT * FROM users WHERE active = true');
  res.status(200).json(result.rows);
}

Masalah pada kode di atas

  • Pool dibuat pada setiap request.
  • Koneksi diambil manual tetapi tidak dikembalikan dengan release().
  • Tidak ada penanganan error yang menjaga lifecycle koneksi.

Sesudah

import { pool } from '@/lib/db';

export default async function handler(req, res) {
  try {
    const result = await pool.query(
      'SELECT id, email FROM users WHERE active = true ORDER BY id DESC LIMIT 100'
    );

    res.status(200).json(result.rows);
  } catch (err) {
    console.error('[users-api] query failed', err);
    res.status(500).json({ error: 'internal_error' });
  }
}

Jika tidak butuh transaksi atau operasi koneksi manual, lebih aman memakai pool.query() langsung. Anda mengurangi risiko lupa memanggil release().

Validasi bahwa perbaikan benar-benar berhasil

Perbaikan tidak cukup hanya dengan deploy lalu berharap timeout hilang. Anda perlu memvalidasi perilakunya.

1. Uji beban ulang dengan skenario yang sama

Jalankan load test yang sama seperti sebelum perbaikan. Bandingkan:

  • stabilitas latency sepanjang durasi test,
  • jumlah error timeout,
  • jumlah koneksi aktif di database,
  • kemampuan endpoint lain tetap sehat saat endpoint utama dibebani.

Hasil yang sehat biasanya menunjukkan jumlah koneksi lebih stabil dan tidak meningkat liar seiring bertambahnya request.

2. Pantau setelah deploy

Di production, pantau beberapa jam hingga beberapa hari pertama:

  • apakah p95/p99 latency membaik,
  • apakah koneksi DB turun ke pola yang normal,
  • apakah error pada endpoint lain ikut hilang,
  • apakah ada peningkatan memory atau CPU yang tidak diharapkan setelah perubahan pola pooling.

3. Tambahkan guardrail observability

Minimal, simpan metrik atau log untuk:

  • durasi query per endpoint,
  • jumlah error database,
  • timeout rate aplikasi,
  • jumlah inisialisasi client/pool per proses atau per instance.

Tanpa guardrail ini, bug serupa mudah kembali muncul saat ada refactor.

Kesalahan umum saat memperbaiki masalah ini

  • Fokus pada query saja: query memang perlu diperiksa, tetapi query cepat pun tetap bisa timeout jika antrean koneksi macet.
  • Menambah limit koneksi DB terlalu cepat: ini bisa menyamarkan bug dan menambah beban database.
  • Mengabaikan endpoint lain: satu endpoint bocor bisa membuat seluruh aplikasi tampak lambat.
  • Tidak membedakan development dan production: hot reload di development bisa menghasilkan gejala yang berbeda, tetapi pola singleton tetap harus dirancang dengan benar.
  • Mengandalkan close/disconnect per request: menutup koneksi secara agresif per request dapat menambah overhead connect/disconnect dan belum tentu menyelesaikan masalah pooling.

Checklist pencegahan

  • Simpan inisialisasi DB client atau pool di satu modul terpusat.
  • Jangan membuat pool di dalam API Route atau Route Handler.
  • Gunakan pool.query() bila transaksi tidak diperlukan.
  • Jika memakai connect(), selalu release() di finally.
  • Audit semua jalur error dan rollback transaksi.
  • Pastikan ukuran pool masuk akal terhadap pola deployment dan kapasitas database.
  • Tambahkan log atau metrik untuk mendeteksi pool/client yang dibuat terlalu sering.
  • Uji concurrency sebelum deploy perubahan besar pada layer database.
  • Pahami model runtime: Node process, container, atau serverless instance punya implikasi berbeda pada lifecycle koneksi.

Pelajaran operasional untuk developer

Kasus API Route Timeout akibat koneksi DB bocor di Next.js hampir selalu menunjukkan masalah lifecycle resource, bukan sekadar performa query. Pelajaran utamanya:

  • Resource server-side harus diperlakukan sebagai stateful, walaupun handler terlihat sederhana.
  • Model deployment memengaruhi desain koneksi. Kode yang tampak aman di lokal belum tentu aman di production dengan concurrency nyata.
  • Observability wajib ada sebelum insiden. Log inisialisasi client, metrik latency, dan statistik koneksi database sangat membantu membedakan query lambat dari kebocoran koneksi.
  • Perbaikan harus divalidasi dengan beban, bukan hanya pengujian fungsional biasa.

Jika Anda menemukan endpoint Next.js yang makin lambat, mulai timeout, lalu menyebabkan endpoint lain ikut terganggu, jangan berhenti pada optimasi SQL. Audit terlebih dahulu bagaimana client database dibuat, dipakai, dan dikembalikan. Pada banyak kasus, itulah akar masalah yang sebenarnya.