Race condition Prisma di Next.js biasanya muncul saat dua request membaca data yang sama, lalu menulis hasil perubahan tanpa mengetahui bahwa request lain sudah lebih dulu memodifikasi baris tersebut. Gejalanya sering terlihat sebagai stok minus, pesanan ganda, atau jumlah data yang tidak konsisten meskipun log aplikasi tampak normal.

Di artikel ini, kita akan membuat reproduksi bug sederhana: dua request paralel mengurangi stok produk yang sama. Setelah itu, kita uji dengan request serempak, amati hasilnya sebelum perbaikan, lalu verifikasi solusi dengan row lock menggunakan SELECT ... FOR UPDATE. Fokusnya adalah langkah yang bisa langsung dipakai di lingkungan lokal atau staging.

Catatan penting: Pendekatan SELECT ... FOR UPDATE relevan untuk database relasional yang mendukung row-level locking, seperti PostgreSQL dan MySQL/InnoDB. Jika Anda memakai SQLite, hasil dan perilaku konkurensinya berbeda sehingga reproduksi race condition tidak akan representatif.

Mengapa race condition terjadi pada Prisma + Next.js

Masalah utamanya bukan di Next.js atau Prisma semata, melainkan di pola akses data:

  1. Request A membaca stok saat ini, misalnya 1.

  2. Request B membaca stok yang sama, juga melihat 1.

  3. Request A mengurangi stok menjadi 0 dan menyimpan hasilnya.

  4. Request B, yang masih memegang nilai lama 1, juga mengurangi stok menjadi 0 atau bahkan memproses pembelian kedua yang seharusnya gagal.

Secara logika bisnis, hanya satu request yang seharusnya berhasil jika stok awal hanya 1. Namun tanpa sinkronisasi di level database, dua request bisa sama-sama lolos validasi karena membaca keadaan lama yang sama.

Prisma memudahkan akses data, tetapi ia tidak otomatis menyelesaikan masalah konkurensi logis. Jika operasi Anda adalah pola read-check-write, Anda tetap perlu memilih strategi kontrol konkurensi yang tepat.

Menyiapkan reproduksi bug sederhana

Skema data minimal

Kita gunakan model produk dengan stok integer. Contoh skema Prisma:

model Product {
  id        Int      @id @default(autoincrement())
  name      String
  stock     Int
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
}

Lalu isi satu data awal, misalnya produk dengan stok 1. Tujuannya agar hasil race condition mudah diamati.

Endpoint Next.js yang rentan race condition

Contoh berikut memakai Route Handler Next.js. Logikanya sengaja dibuat rentan: baca stok, cek, beri jeda kecil agar tabrakan lebih mudah terjadi, lalu update.

import { NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'

function sleep(ms: number) {
  return new Promise((resolve) => setTimeout(resolve, ms))
}

export async function POST(req: Request) {
  const body = await req.json()
  const productId = Number(body.productId)
  const qty = Number(body.qty ?? 1)

  const product = await prisma.product.findUnique({
    where: { id: productId },
  })

  if (!product) {
    return NextResponse.json({ error: 'Product not found' }, { status: 404 })
  }

  if (product.stock < qty) {
    return NextResponse.json({ error: 'Insufficient stock' }, { status: 409 })
  }

  await sleep(200)

  const updated = await prisma.product.update({
    where: { id: productId },
    data: { stock: product.stock - qty },
  })

  return NextResponse.json({
    ok: true,
    productId: updated.id,
    stock: updated.stock,
  })
}

Kenapa ada sleep(200)? Karena saat menguji lokal, dua request kadang terlalu cepat selesai sehingga bentrokan tidak konsisten. Menambahkan jeda setelah pembacaan stok memperbesar peluang dua request membaca nilai lama sebelum salah satunya menulis perubahan.

Jangan biarkan jeda artifisial ini di production. Ini hanya alat reproduksi bug agar pengujian mudah diulang.

Seed data awal

import { PrismaClient } from '@prisma/client'

const prisma = new PrismaClient()

async function main() {
  await prisma.product.deleteMany()
  await prisma.product.create({
    data: {
      name: 'Produk Uji',
      stock: 1,
    },
  })
}

main()
  .catch(console.error)
  .finally(async () => {
    await prisma.$disconnect()
  })

Membuktikan bug dengan concurrent request

Script uji beban ringan dari Node.js

Kita tidak perlu alat kompleks untuk membuktikan race condition. Script Node.js sederhana sudah cukup untuk menembakkan dua request bersamaan ke endpoint yang sama.

const url = 'http://localhost:3000/api/purchase'

async function run() {
  const payload = {
    productId: 1,
    qty: 1,
  }

  const requests = Array.from({ length: 2 }, () =>
    fetch(url, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(payload),
    }).then(async (res) => ({
      status: res.status,
      body: await res.json().catch(() => ({})),
    }))
  )

  const results = await Promise.all(requests)
  console.log(JSON.stringify(results, null, 2))
}

run().catch(console.error)

Jika race condition terjadi, hasil yang mungkin Anda lihat adalah:

  • Kedua request mengembalikan status sukses.

  • Padahal stok awal hanya 1.

  • Secara bisnis, satu request seharusnya gagal dengan 409.

Dalam beberapa kasus, stok akhir terlihat 0 sehingga bug tidak langsung tampak dari nilai terakhir. Ini sering membingungkan. Masalahnya bukan selalu stok menjadi minus, tetapi dua pembelian berhasil dari stok yang hanya cukup untuk satu pembelian. Itu sudah cukup untuk membuktikan race condition.

Integration test untuk request serempak

Jika Anda ingin menjadikannya pengujian berulang di CI atau staging, tulis integration test yang:

  1. Reset database ke kondisi awal.

  2. Kirim dua request paralel.

  3. Periksa bahwa hanya satu yang berhasil.

  4. Periksa stok akhir sesuai ekspektasi.

Contoh bentuk test generik:

import { prisma } from '@/lib/prisma'

describe('purchase concurrency', () => {
  beforeEach(async () => {
    await prisma.product.deleteMany()
    await prisma.product.create({
      data: { id: 1, name: 'Produk Uji', stock: 1 },
    })
  })

  it('membuktikan race condition pada implementasi rentan', async () => {
    const payload = { productId: 1, qty: 1 }

    const [r1, r2] = await Promise.all([
      fetch('http://localhost:3000/api/purchase', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(payload),
      }),
      fetch('http://localhost:3000/api/purchase', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(payload),
      }),
    ])

    const statuses = [r1.status, r2.status]
    console.log(statuses)

    const product = await prisma.product.findUnique({ where: { id: 1 } })
    console.log(product)
  })
})

Untuk reproduksi bug, test tidak harus langsung melakukan assert kaku di percobaan pertama. Di fase debugging, justru lebih berguna mencetak hasil beberapa kali agar pola gagal/sukses terlihat. Setelah bug terbukti, barulah ubah menjadi test verifikasi yang tegas.

Menambahkan logging yang membantu

Tambahkan log dengan penanda request agar urutan kejadian mudah dibaca:

const requestId = crypto.randomUUID()
console.log(`[${requestId}] start`)
console.log(`[${requestId}] read stock=${product.stock}`)
console.log(`[${requestId}] updating stock to ${product.stock - qty}`)
console.log(`[${requestId}] done`)

Log seperti ini memudahkan Anda melihat dua request membaca stok yang sama sebelum salah satunya menulis hasil update.

Memperbaiki dengan transaksi dan SELECT ... FOR UPDATE

Mengapa row lock menyelesaikan masalah

SELECT ... FOR UPDATE mengunci baris yang dibaca sampai transaksi selesai. Akibatnya, jika Request A sedang memproses produk dengan id=1, Request B yang mencoba mengambil lock pada baris yang sama harus menunggu.

Urutannya menjadi:

  1. Request A memulai transaksi.

  2. Request A menjalankan SELECT ... FOR UPDATE pada produk.

  3. Request B memulai transaksi, tapi tertahan saat mencoba lock baris yang sama.

  4. Request A mengecek stok, mengurangi stok, lalu commit.

  5. Baru setelah lock dilepas, Request B melanjutkan, membaca stok terbaru, lalu gagal jika stok sudah habis.

Dengan kata lain, validasi dan update terjadi terhadap keadaan yang konsisten di dalam transaksi yang sama.

Implementasi route yang memakai row lock

Pada Prisma, pendekatan yang umum adalah memakai $transaction lalu menjalankan query mentah untuk lock, karena kebutuhan utamanya adalah perilaku SQL database. Contoh berikut tetap menjaga alur bisnis di satu transaksi.

import { NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'

export async function POST(req: Request) {
  const body = await req.json()
  const productId = Number(body.productId)
  const qty = Number(body.qty ?? 1)

  try {
    const result = await prisma.$transaction(async (tx) => {
      const rows = await tx.$queryRaw`
        SELECT id, stock
        FROM "Product"
        WHERE id = ${productId}
        FOR UPDATE
      `

      const product = rows[0]

      if (!product) {
        return { status: 404, body: { error: 'Product not found' } }
      }

      if (product.stock < qty) {
        return { status: 409, body: { error: 'Insufficient stock' } }
      }

      const updated = await tx.product.update({
        where: { id: productId },
        data: { stock: product.stock - qty },
      })

      return {
        status: 200,
        body: {
          ok: true,
          productId: updated.id,
          stock: updated.stock,
        },
      }
    })

    return NextResponse.json(result.body, { status: result.status })
  } catch (error) {
    console.error('purchase transaction failed', error)
    return NextResponse.json({ error: 'Transaction failed' }, { status: 500 })
  }
}

Beberapa catatan penting:

  • Nama tabel dan penulisan identifier SQL tergantung database dan mapping schema Anda. Pastikan query mentah mengikuti nama tabel nyata di database.

  • Jangan memisahkan SELECT ... FOR UPDATE dan UPDATE ke koneksi atau transaksi berbeda.

  • Semua validasi yang bergantung pada stok harus terjadi setelah lock diperoleh.

Alternatif yang kadang lebih sederhana

Untuk kasus pengurangan stok sederhana, Anda juga bisa memakai conditional update atomik, misalnya update hanya jika stock >= qty. Ini sering lebih efisien dan lebih aman daripada pola baca-lalu-update biasa. Namun karena fokus artikel ini adalah membuktikan dan memverifikasi perbaikan dengan SELECT ... FOR UPDATE, kita tetap memakai row lock agar alur debugging lebih jelas.

Pilih conditional update jika:

  • Operasinya sangat sederhana.

  • Anda ingin meminimalkan waktu lock.

  • Semua logika bisnis bisa diekspresikan dalam satu statement update.

Pilih SELECT ... FOR UPDATE jika:

  • Anda perlu membaca beberapa nilai dulu sebelum memutuskan write.

  • Ada beberapa langkah validasi di dalam satu transaksi.

  • Anda butuh urutan logika bisnis yang eksplisit dan mudah di-debug.

Memverifikasi perbaikan dengan test yang sama

Ekspektasi setelah row lock diterapkan

Jalankan script concurrent request yang sama seperti sebelumnya. Sekarang perilaku yang diharapkan adalah:

  • Satu request sukses dengan status 200.

  • Satu request gagal, umumnya 409, karena stok sudah habis saat ia memperoleh lock dan membaca nilai terbaru.

  • Stok akhir tetap konsisten di 0.

Itulah pembuktian bahwa race condition sudah tertangani untuk skenario ini.

Contoh test verifikasi yang lebih tegas

import { prisma } from '@/lib/prisma'

describe('purchase concurrency with row lock', () => {
  beforeEach(async () => {
    await prisma.product.deleteMany()
    await prisma.product.create({
      data: { id: 1, name: 'Produk Uji', stock: 1 },
    })
  })

  it('hanya mengizinkan satu pembelian sukses', async () => {
    const payload = { productId: 1, qty: 1 }

    const [r1, r2] = await Promise.all([
      fetch('http://localhost:3000/api/purchase', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(payload),
      }),
      fetch('http://localhost:3000/api/purchase', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(payload),
      }),
    ])

    const statuses = [r1.status, r2.status].sort()

    expect(statuses).toEqual([200, 409])

    const product = await prisma.product.findUnique({ where: { id: 1 } })
    expect(product?.stock).toBe(0)
  })
})

Kalau Anda menjalankan test ini berkali-kali dan hasilnya stabil, itu tanda bahwa solusi locking bekerja sesuai tujuan.

Membaca log transaksi, timeout, dan deadlock

Gejala saat request menunggu lock

Setelah row lock diterapkan, Anda mungkin melihat satu request terasa lebih lambat. Itu normal jika request lain sedang memegang lock pada baris yang sama. Gejalanya:

  • Log masuk request kedua muncul, tetapi log setelah query lock tertunda.

  • Durasi response meningkat hanya pada endpoint yang menyentuh row yang sama.

  • Database menunjukkan sesi yang sedang menunggu lock.

Ini bukan bug baru, melainkan konsekuensi sinkronisasi yang memang diperlukan untuk menjaga konsistensi data.

Timeout transaksi

Jika transaksi terlalu lama, request lain bisa menunggu terlalu lama lalu gagal karena timeout. Penyebab umumnya:

  • Ada operasi non-database di dalam transaksi, seperti memanggil API eksternal.

  • Ada sleep debugging yang lupa dihapus.

  • Query di dalam transaksi lambat karena indeks buruk atau full scan.

Prinsip pentingnya: buat transaksi sesingkat mungkin. Ambil lock, validasi, update, lalu commit. Jangan meletakkan pekerjaan yang tidak perlu di dalam blok transaksi.

Deadlock dan cara mengenalinya

Deadlock terjadi ketika dua transaksi saling menunggu lock yang dipegang satu sama lain. Contoh sederhana:

  1. Transaksi A mengunci baris produk 1 lalu ingin mengunci produk 2.

  2. Transaksi B mengunci baris produk 2 lalu ingin mengunci produk 1.

  3. Keduanya saling menunggu.

Database biasanya akan mendeteksi kondisi ini dan membatalkan salah satu transaksi. Gejalanya:

  • Terjadi error transaksi gagal secara acak di bawah beban paralel.

  • Error hanya muncul saat beberapa row diakses dalam urutan berbeda.

  • Retry kadang berhasil tanpa perubahan kode lain.

Cara mengurangi risiko deadlock:

  • Kunci row dalam urutan yang konsisten.

  • Jaga transaksi tetap pendek.

  • Hindari pola lock pada banyak tabel/baris tanpa desain urutan yang jelas.

  • Tambahkan mekanisme retry terbatas untuk error deadlock/transient failure jika memang sesuai kebutuhan bisnis.

Log yang sebaiknya dicatat

Untuk debugging di lokal atau staging, log berikut sangat membantu:

  • requestId unik per request.

  • productId dan qty.

  • Waktu mulai transaksi dan waktu selesai.

  • Status sukses/gagal dan alasan gagal.

  • Lama tunggu sebelum lock didapat jika Anda ingin mengukurnya.

Contoh pola log yang mudah dibaca:

[req-1] tx begin productId=1 qty=1
[req-1] lock acquired productId=1 stock=1
[req-1] stock updated to 0
[req-1] tx commit duration=120ms

[req-2] tx begin productId=1 qty=1
[req-2] waiting for lock productId=1
[req-2] lock acquired productId=1 stock=0
[req-2] insufficient stock
[req-2] tx commit duration=240ms

Dengan pola ini, Anda bisa membedakan mana error logika bisnis, mana gejala lock contention yang normal, dan mana masalah performa yang perlu ditangani.

Kesalahan umum saat menguji race condition

  • Menggunakan SQLite untuk simulasi concurrency produksi. Perilakunya berbeda dari PostgreSQL/MySQL dalam hal locking dan concurrency.

  • Tidak benar-benar membuat request paralel. Jika test Anda mengirim request berurutan, race condition tidak akan muncul.

  • Meletakkan validasi stok di luar transaksi. Lock jadi tidak berguna karena keputusan bisnis sudah dibuat berdasarkan data lama.

  • Mencampur operasi di dalam dan luar transaction client. Semua query yang terkait harus memakai client transaksi yang sama.

  • Menyimpulkan bug hilang hanya karena sulit direproduksi. Race condition bisa bersifat intermiten. Buat reproduksi yang deterministik dengan stok kecil dan jeda artifisial saat debugging.

Checklist praktis untuk lokal atau staging

  1. Gunakan database relasional yang sama atau mirip dengan production.

  2. Buat data awal sederhana, misalnya satu produk dengan stok 1.

  3. Tulis endpoint rentan race condition dengan pola read-check-write.

  4. Tambahkan jeda singkat untuk memperbesar peluang bentrokan saat reproduksi.

  5. Jalankan dua atau lebih request paralel via script atau integration test.

  6. Catat hasil sebelum perbaikan: dua request bisa sama-sama sukses.

  7. Terapkan transaksi + SELECT ... FOR UPDATE.

  8. Jalankan test yang sama dan verifikasi: hanya satu request yang sukses.

  9. Hapus jeda debugging, pendekkan transaksi, lalu uji ulang.

  10. Pantau timeout, lock wait, dan gejala deadlock jika skenario Anda melibatkan lebih dari satu row.

Penutup

Menguji race condition Prisma di Next.js dengan concurrent request tidak perlu menunggu bug muncul di production. Dengan reproduksi sederhana berupa dua request paralel yang mengurangi stok yang sama, Anda bisa membuktikan masalahnya secara lokal. Setelah itu, perbaikan dengan transaksi dan SELECT ... FOR UPDATE dapat diverifikasi dengan test yang sama: satu request berhasil, satu lagi gagal secara benar.

Yang terpenting, jangan berhenti di implementasi lock. Pastikan Anda juga membaca gejala di log, memahami efek timeout, dan mengantisipasi deadlock jika transaksi menjadi lebih kompleks. Dengan begitu, perbaikannya bukan hanya benar secara teori, tetapi juga aman dijalankan di staging dan production.