Lonjakan trafik pada Next.js App Router Route Handler sering kali memicu error PrismaClientKnownRequestError: P2024 (Timed out fetching a new connection from the connection pool) yang berujung pada lonjakan respon 504 Gateway Timeout. Masalah ini bersumber dari kebocoran koneksi basis data (database connection leak) dan utilisasi pool koneksi yang tidak terkontrol.

Dua penyebab utamanya adalah instansiasi PrismaClient berulang di dalam siklus request serta penggunaan interactive transaction yang menggantung akibat unhandled promise atau operasi I/O non-database yang lambat di dalam scope transaksi.

1. Analisis Metrik PostgreSQL dan Log Error P2024

Error P2024 terjadi ketika query engine Prisma menunggu tersedianya koneksi kosong dari pool lokal melebihi batas waktu default (biasanya 10 detik). Jika seluruh slot koneksi habis, request baru akan tertahan di queue hingga gateway (Nginx, Cloudflare, atau Vercel) memutus request dengan status 504.

Validasi kondisi koneksi pada database PostgreSQL menggunakan query diagnostik berikut:

-- Periksa jumlah koneksi aktif berdasarkan status
SELECT state, count(*)
FROM pg_stat_activity
GROUP BY state;

-- Identifikasi query yang berjalan lebih dari 5 detik
SELECT pid, now() - query_start AS duration, query, state
FROM pg_stat_activity
WHERE state != 'idle'
  AND now() - query_start > interval '5 seconds'
ORDER BY duration DESC;

Jika metrik menampilkan banyak koneksi berstatus idle in transaction, aplikasi membiarkan transaksi terbuka tanpa mengeksekusi COMMIT atau ROLLBACK. Jika total koneksi mendekati max_connections PostgreSQL, aplikasi membuka terlalu banyak pool terpisah.

2. Implementasi Singleton Pattern PrismaClient

Pada Next.js App Router dengan runtime Node.js, file Route Handler dieksekusi di server. Tanpa singleton pattern, proses module evaluation berulang saat hot-reload (development) atau re-instansiasi modul dapat membuat instance PrismaClient baru, masing-masing dengan pool koneksi bawaan tersendiri.

Kode Bermasalah (Sebelum)

// app/api/orders/route.ts
import { PrismaClient } from '@prisma/client';
import { NextResponse } from 'next/server';

// Anti-pattern: Instansiasi baru di tingkat modul handler
const prisma = new PrismaClient();

export async function GET() {
  const orders = await prisma.order.findMany();
  return NextResponse.json(orders);
}

Solusi: Global Singleton Client (Sesudah)

Simpan instance PrismaClient pada globalThis agar instance tunggal digunakan ulang di seluruh request worker.

// lib/prisma.ts
import { PrismaClient } from '@prisma/client';

const prismaClientSingleton = () => {
  return new PrismaClient({
    log: process.env.NODE_ENV === 'development' ? ['warn', 'error'] : ['error'],
  });
};

declare const globalThis: {
  prismaGlobal: ReturnType<typeof prismaClientSingleton> | undefined;
} & typeof global;

export const prisma = globalThis.prismaGlobal ?? prismaClientSingleton();

if (process.env.NODE_ENV !== 'production') {
  globalThis.prismaGlobal = prisma;
}
// app/api/orders/route.ts
import { prisma } from '@/lib/prisma';
import { NextResponse } from 'next/server';

export async function GET() {
  const orders = await prisma.order.findMany();
  return NextResponse.json(orders);
}

3. Refactoring Interactive Transaction dengan Timeout Eksplisit

Interactive transaction (prisma.$transaction(async (tx) => { ... })) mengambil satu koneksi eksklusif dari pool sampai callback selesai dieksekusi. Memasukkan pemanggilan API eksternal atau enkripsi data berat di dalam callback transaksi dapat menahan koneksi basis data terlalu lama.

Kode Bermasalah (Sebelum)

// app/api/checkout/route.ts
import { prisma } from '@/lib/prisma';
import { NextResponse } from 'next/server';

export async function POST(req: Request) {
  const { cartId, amount } = await req.json();

  // Masalah: Transaksi tanpa konfigurasi timeout & I/O pihak ketiga di dalam transaksi
  const result = await prisma.$transaction(async (tx) => {
    const order = await tx.order.create({ data: { cartId, amount } });
    
    // Menahan koneksi DB saat menunggu network call pihak ketiga
    const payment = await fetch('https://api.paymentgateway.com/charge', {
      method: 'POST',
      body: JSON.stringify({ amount }),
    }).then((res) => res.json());

    await tx.order.update({
      where: { id: order.id },
      data: { paymentId: payment.id },
    });

    return order;
  });

  return NextResponse.json(result);
}

Solusi: Isolasi Operasi & Batasi Timeout Transaksi (Sesudah)

Pindahkan operasi non-database ke luar blok transaksi dan tetapkan parameter timeout serta maxWait secara eksplisit.

// app/api/checkout/route.ts
import { prisma } from '@/lib/prisma';
import { NextResponse } from 'next/server';

export async function POST(req: Request) {
  const { cartId, amount } = await req.json();

  // 1. Eksekusi database query awal via transaksi dengan batasan timeout
  const order = await prisma.$transaction(
    async (tx) => {
      return await tx.order.create({ data: { cartId, amount, status: 'PENDING' } });
    },
    {
      maxWait: 2000, // Waktu tunggu mendapatkan koneksi dari pool (ms)
      timeout: 5000, // Waktu maksimal eksekusi transaksi sebelum di-abort (ms)
    }
  );

  // 2. Operasi I/O non-DB dijalankan di luar transaksi
  let payment;
  try {
    const res = await fetch('https://api.paymentgateway.com/charge', {
      method: 'POST',
      body: JSON.stringify({ amount }),
    });
    if (!res.ok) throw new Error('Payment service unreachable');
    payment = await res.json();
  } catch (error) {
    await prisma.order.update({
      where: { id: order.id },
      data: { status: 'PAYMENT_FAILED' },
    });
    return NextResponse.json({ error: 'Pembayaran gagal diproses' }, { status: 502 });
  }

  // 3. Update status setelah I/O eksternal selesai
  const finalizedOrder = await prisma.order.update({
    where: { id: order.id },
    data: { paymentId: payment.id, status: 'COMPLETED' },
  });

  return NextResponse.json(finalizedOrder);
}

4. Tuning Connection Limit dan Integrasi Connection Pooler

Secara default, Prisma menghitung connection limit per worker dengan formula: num_physical_cpus * 2 + 1. Dalam lingkungan kontainer atau arsitektur serverless (seperti Vercel, ECS, atau Kubernetes), beberapa instance aplikasi berjalan serentak dan melipatgandakan jumlah koneksi langsung ke PostgreSQL.

Konfigurasi Connection Limit

Atur connection_limit dan pool_timeout pada connection string di environment variables untuk mencegah satu instance Next.js menghabiskan kuota koneksi PostgreSQL:

# .env
DATABASE_URL="postgresql://user:password@localhost:5432/mydb?connection_limit=5&pool_timeout=10"
  • connection_limit=5: Membatasi maksimal 5 koneksi simultan per worker instance.
  • pool_timeout=10: Menghentikan antrean request setelah 10 detik jika tidak mendapat koneksi, mencegah request menggantung selamanya.

Integrasi PgBouncer (Transaction Mode)

Jika serverless pod berskala besar, gunakan connection pooler seperti PgBouncer atau AWS RDS Proxy. Tambahkan parameter pgbouncer=true pada DATABASE_URL untuk menonaktifkan prepared statements Prisma yang tidak kompatibel dengan transaction-level pooling.

// prisma/schema.prisma
datasource db {
  provider  = "postgresql"
  url       = env("DATABASE_URL")      // Mengarah ke PgBouncer (port 6543)
  directUrl = env("DIRECT_URL")        // Mengarah langsung ke PostgreSQL (port 5432)
}

Variabel DIRECT_URL digunakan khusus saat menjalankan Prisma CLI (seperti prisma migrate), karena migration memerlukan session-level connection features yang diblokir oleh transaction pooling PgBouncer.