Saat API di Next.js memicu background worker, masalah yang paling sering muncul bukan sekadar job gagal, tetapi job yang diproses lebih dari sekali. Penyebabnya bisa sederhana: client melakukan retry karena timeout, dua instance aplikasi menerima request yang sama hampir bersamaan, atau worker mengambil ulang job yang statusnya belum sinkron. Akibatnya, cache menjadi tidak konsisten, status bisnis berubah dua kali, dan side effect seperti email, invoice, atau sinkronisasi ke layanan eksternal terjadi berulang.

Untuk mencegahnya, biasanya Anda perlu membedakan dua lapisan proteksi: idempotency key untuk mencegah request yang sama membuat job baru berulang kali, dan Redis lock untuk memastikan hanya satu proses yang benar-benar mengeksekusi pekerjaan kritis pada satu waktu. Keduanya tidak saling menggantikan. Artikel ini fokus pada implementasi praktis di Next.js, termasuk arsitektur sederhana, contoh pseudocode/TypeScript, trade-off TTL lock, dead letter, retry, observability, dan debugging di production.

Masalah nyata: kenapa duplicate job tetap terjadi?

Pola umum pada aplikasi Next.js adalah sebagai berikut: route handler menerima request, menyimpan status awal, lalu memasukkan job ke queue agar diproses worker secara asynchronous. Di atas kertas alurnya terlihat aman, tetapi di production ada banyak failure mode.

Failure mode yang paling sering

  • Retry dari client: client menganggap request gagal karena timeout, padahal server sempat enqueue job. Ketika request dikirim ulang, job kedua ikut masuk.
  • Race condition antar instance: dua instance aplikasi menerima request identik hampir bersamaan, lalu keduanya lolos validasi dan enqueue job yang sama.
  • At-least-once delivery dari queue: banyak sistem queue mengutamakan reliabilitas, sehingga job bisa terkirim ulang jika ack gagal atau worker mati di tengah proses.
  • Status belum ter-update: worker berhasil memanggil layanan eksternal, tetapi gagal menyimpan status final. Saat retry terjadi, sistem menganggap job belum selesai.
  • Lock kedaluwarsa terlalu cepat: proses masih berjalan, tetapi TTL lock habis sehingga worker lain masuk dan mengeksekusi job yang sama.

Jika side effect Anda tidak idempotent, duplicate job bisa menyebabkan kerusakan data yang sulit dilacak. Masalah ini sering tidak terlihat pada local development karena concurrency rendah dan tidak ada retry alami dari jaringan.

Kapan memakai idempotency key, kapan perlu Redis lock?

Gunakan idempotency key untuk deduplikasi request

Idempotency key cocok untuk mencegah request yang secara bisnis sama diproses lebih dari sekali. Misalnya, client mengirim header Idempotency-Key saat membuat laporan, menagih pembayaran, atau memicu sinkronisasi. Server menyimpan key tersebut bersama hasil atau status request pertama. Jika request yang sama datang lagi, server tidak membuat job baru, tetapi mengembalikan referensi ke job yang sudah ada atau hasil yang sama.

Idempotency key efektif untuk lapisan request/API, terutama ketika penyebab duplikasi berasal dari retry client, refresh browser, atau timeout jaringan.

Gunakan Redis lock untuk mutual exclusion saat eksekusi

Redis lock dibutuhkan ketika masalahnya bukan lagi request ganda, tetapi dua proses yang mengeksekusi resource yang sama secara bersamaan. Contohnya:

  • dua worker memproses job untuk orderId yang sama,
  • dua instance route handler melakukan enqueue pada entity yang sama,
  • worker retry berjalan bersamaan dengan worker lama yang sebenarnya belum selesai.

Lock biasanya dipasang pada resource bisnis, misalnya lock:invoice:123 atau lock:user-sync:456. Tujuannya bukan deduplikasi permanen, melainkan memastikan hanya satu proses yang masuk ke critical section.

Kapan perlu keduanya?

Pada banyak sistem production, jawabannya adalah ya, perlu keduanya.

  • Idempotency key mencegah request identik membuat job baru berulang kali.
  • Redis lock mencegah eksekusi paralel terhadap resource yang sama saat job sudah ada di dalam sistem.

Idempotency key menjawab pertanyaan: apakah request ini sudah pernah diterima? Redis lock menjawab pertanyaan: apakah sekarang sudah ada proses lain yang sedang mengerjakan resource ini?

Arsitektur sederhana Next.js + Redis + queue/worker

Arsitektur minimal yang umum dipakai:

  • Next.js API Route atau Route Handler menerima request dari client.
  • Redis menyimpan idempotency record, lock, dan kadang status singkat/cached state.
  • Queue menyimpan job untuk diproses asynchronous.
  • Worker mengambil job dari queue dan menjalankan side effect.
  • Database menyimpan source of truth status job/proses bisnis.

Alur request yang disarankan

  1. Client mengirim request dengan Idempotency-Key.
  2. Route handler memeriksa apakah key sudah pernah dipakai.
  3. Jika belum, server membuat record status awal di database atau storage status yang konsisten.
  4. Server melakukan enqueue job dan menyimpan mapping idempotency key -> jobId.
  5. Worker mengambil job.
  6. Worker mencoba mengambil Redis lock berdasarkan resource bisnis yang sedang diproses.
  7. Jika lock berhasil, worker menjalankan side effect lalu mengubah status final.
  8. Jika lock gagal, worker bisa menunda, skip, atau menandai sebagai duplicate tergantung desain sistem.

Poin pentingnya: jangan mengandalkan queue saja untuk mencegah duplikasi. Banyak queue menjamin pengiriman, bukan exactly-once execution.

Implementasi praktis di Next.js

1. Simpan idempotency key saat request masuk

Di route handler, gunakan key yang stabil untuk satu aksi bisnis. Bila client tidak mengirim key, Anda bisa menolak request untuk endpoint kritis atau membuat key dari kombinasi input, tetapi key dari client biasanya lebih aman untuk retry terkontrol.

// app/api/reports/route.ts atau API route serupa
import { NextRequest, NextResponse } from 'next/server';

export async function POST(req: NextRequest) {
  const body = await req.json();
  const idempotencyKey = req.headers.get('idempotency-key');

  if (!idempotencyKey) {
    return NextResponse.json(
      { error: 'Missing idempotency key' },
      { status: 400 }
    );
  }

  // Pseudocode: cek apakah key sudah ada
  const existing = await idempotencyStore.get(idempotencyKey);
  if (existing) {
    return NextResponse.json(
      {
        jobId: existing.jobId,
        status: existing.status,
        deduplicated: true,
      },
      { status: 202 }
    );
  }

  // Simpan placeholder lebih awal agar race condition antar instance berkurang.
  // Implementasi ideal dilakukan secara atomik.
  const jobId = crypto.randomUUID();
  const accepted = await idempotencyStore.createIfAbsent(idempotencyKey, {
    jobId,
    status: 'queued',
    resourceId: body.resourceId,
    createdAt: Date.now(),
  });

  if (!accepted) {
    const latest = await idempotencyStore.get(idempotencyKey);
    return NextResponse.json(
      {
        jobId: latest?.jobId,
        status: latest?.status,
        deduplicated: true,
      },
      { status: 202 }
    );
  }

  await queue.enqueue({
    jobId,
    resourceId: body.resourceId,
    payload: body,
    idempotencyKey,
  });

  return NextResponse.json({ jobId, status: 'queued' }, { status: 202 });
}

Bagian penting di atas adalah createIfAbsent. Operasi ini sebaiknya atomik, sehingga dua instance tidak sama-sama menganggap key belum ada.

2. Ambil Redis lock di worker, bukan hanya di API

Mengunci di API saja sering tidak cukup, karena eksekusi aktual terjadi di worker. Lock harus dipasang sedekat mungkin dengan critical section.

type Job = {
  jobId: string;
  resourceId: string;
  payload: unknown;
  idempotencyKey: string;
};

async function processJob(job: Job) {
  const lockKey = `lock:resource:${job.resourceId}`;
  const lockValue = `${job.jobId}:${Date.now()}`;
  const lockTtlMs = 60_000;

  const acquired = await redis.set(lockKey, lockValue, {
    nx: true,
    px: lockTtlMs,
  });

  if (!acquired) {
    logger.info('lock_not_acquired', {
      jobId: job.jobId,
      resourceId: job.resourceId,
      lockKey,
    });

    // Pilihan desain:
    // - requeue dengan delay
    // - tandai duplicate/in-progress
    // - drop bila benar-benar redundant
    throw new RetryableError('Resource is locked by another worker');
  }

  try {
    await jobStatusStore.markRunning(job.jobId);

    // Selalu usahakan side effect idempotent jika memungkinkan.
    await doBusinessOperation(job.resourceId, job.payload);

    await jobStatusStore.markCompleted(job.jobId);
    await idempotencyStore.update(job.idempotencyKey, {
      status: 'completed',
      jobId: job.jobId,
    });
  } catch (error) {
    await jobStatusStore.markFailed(job.jobId, error);
    throw error;
  } finally {
    // Hapus lock hanya jika kita masih pemilik lock tersebut.
    await releaseLockSafely(lockKey, lockValue);
  }
}

Mengapa releaseLockSafely penting? Karena lock bisa saja expired lalu diambil proses lain. Jika Anda menghapus lock tanpa memverifikasi pemiliknya, worker lama dapat menghapus lock worker baru. Biasanya ini dilakukan dengan operasi atomik yang membandingkan value lock terlebih dahulu.

3. Perpanjang lock jika proses bisa lebih lama dari TTL

TTL lock adalah perlindungan agar lock tidak menggantung selamanya saat worker crash. Namun TTL juga berbahaya jika terlalu pendek. Bila proses bisa melebihi TTL, gunakan mekanisme lock renewal atau pilih TTL yang cukup aman berdasarkan durasi kerja paling realistis ditambah buffer.

async function withRenewingLock(job: Job) {
  const lockKey = `lock:resource:${job.resourceId}`;
  const lockValue = `${job.jobId}:${Date.now()}`;
  const ttlMs = 60_000;

  const acquired = await redis.set(lockKey, lockValue, {
    nx: true,
    px: ttlMs,
  });

  if (!acquired) throw new RetryableError('Lock busy');

  const heartbeat = setInterval(async () => {
    try {
      await extendLockSafely(lockKey, lockValue, ttlMs);
    } catch (err) {
      logger.error('lock_extend_failed', { jobId: job.jobId, err });
    }
  }, 20_000);

  try {
    await doBusinessOperation(job.resourceId, job.payload);
  } finally {
    clearInterval(heartbeat);
    await releaseLockSafely(lockKey, lockValue);
  }
}

Jangan memperpanjang lock secara buta. Pastikan perpanjangan hanya dilakukan jika value lock masih milik worker yang sama.

Trade-off penting: TTL lock, retry, dan dead letter

TTL lock: terlalu pendek vs terlalu panjang

  • Terlalu pendek: risiko lock habis saat proses masih berjalan, lalu worker lain masuk dan memproses ulang resource yang sama.
  • Terlalu panjang: jika worker mati, resource terkunci terlalu lama dan throughput turun.

Pilih TTL berdasarkan durasi proses yang wajar, bukan kasus tercepat. Jika variasi durasi tinggi, gunakan renewal.

Retry: perlu, tetapi bisa memperbanyak duplikasi

Retry penting untuk kegagalan sementara seperti timeout jaringan atau gangguan layanan eksternal. Namun retry tanpa kontrol bisa menghasilkan job ganda, apalagi bila kegagalan terjadi setelah side effect eksternal sukses tetapi sebelum status lokal tersimpan.

Praktik yang lebih aman:

  • bedakan retryable dan non-retryable error,
  • gunakan exponential backoff,
  • batasi jumlah percobaan,
  • simpan jejak attempt, error terakhir, dan waktu retry berikutnya.

Dead letter queue: jangan retry tanpa batas

Jika job terus gagal setelah beberapa attempt, pindahkan ke dead letter queue atau status terminal yang butuh intervensi operator. Ini penting agar queue utama tidak tersumbat dan tim bisa mengaudit job bermasalah secara terpisah.

Dead letter sangat berguna pada kasus berikut:

  • payload rusak atau tidak valid,
  • dependensi eksternal menolak data secara permanen,
  • konflik status bisnis yang tidak dapat diselesaikan otomatis,
  • bug aplikasi yang membuat job selalu gagal pada titik yang sama.

Pola status yang lebih aman agar tidak inkonsisten

Salah satu sumber masalah duplicate job adalah status yang berubah terlalu optimistis. Hindari hanya mengandalkan cache sementara. Simpan status yang jelas dan eksplisit di database atau storage yang menjadi sumber kebenaran.

Status minimal yang berguna

  • queued
  • running
  • completed
  • failed
  • dead_letter

Jika perlu, tambahkan metadata:

  • attemptCount
  • lastError
  • startedAt
  • completedAt
  • lockedBy
  • resourceId
  • idempotencyKey

Dengan metadata ini, Anda bisa membedakan apakah job benar-benar belum berjalan, sedang tertahan lock, atau sudah mengeksekusi side effect tetapi gagal memperbarui status.

Observability: apa yang harus dicatat agar debugging production lebih cepat?

Masalah duplicate job hampir selalu butuh observability yang baik. Tanpa itu, Anda hanya melihat gejala seperti email terkirim dua kali atau status order meloncat-loncat.

Log terstruktur yang wajib ada

  • requestId untuk setiap request masuk,
  • jobId untuk setiap job,
  • idempotencyKey,
  • resourceId atau entity bisnis utama,
  • attempt number,
  • lockKey dan hasil acquire/release,
  • status transition dari queued ke running, completed, failed,
  • durasi proses dan durasi menunggu lock.

Metric yang berguna

  • jumlah job duplicate yang berhasil didedup,
  • rasio lock contention,
  • jumlah retry per jenis error,
  • jumlah job masuk dead letter,
  • waktu rata-rata antrean dan waktu proses,
  • jumlah lock expired sebelum job selesai.

Trace dan korelasi

Kalau stack observability Anda mendukung tracing, hubungkan span dari request Next.js ke enqueue job hingga worker execution. Tujuannya agar satu insiden bisa ditelusuri dari client sampai side effect eksternal.

Tanpa korelasi antara requestId, jobId, dan idempotencyKey, duplicate job sering terlihat seperti bug acak padahal sebenarnya pola retry dan race condition yang berulang.

Debugging production: skenario yang paling sering menjebak

1. Job jalan dua kali padahal lock ada

Periksa apakah:

  • TTL terlalu pendek sehingga lock expired di tengah proses,
  • release lock tidak memverifikasi value pemilik lock,
  • ada lebih dari satu resource key untuk entity yang sama karena format key tidak konsisten,
  • waktu proses nyata lebih panjang dari asumsi desain.

2. Request sudah dedup, tetapi side effect tetap ganda

Ini biasanya berarti idempotency key hanya melindungi endpoint, tetapi worker atau layanan eksternal masih memproses ulang. Solusinya:

  • pastikan worker juga memakai lock atau mekanisme idempotent,
  • jika memanggil API eksternal, gunakan external idempotency token bila tersedia,
  • simpan penanda bahwa side effect tertentu sudah pernah dilakukan.

3. Job gagal, retry jalan, tetapi status tetap running

Kemungkinan penyebab:

  • worker crash sebelum update status final,
  • operasi status update gagal tetapi exception tertelan,
  • cleanup lock berhasil, namun status tidak pernah dipulihkan.

Mitigasinya adalah reconciliation job berkala yang memeriksa job running terlalu lama, memvalidasi lock, lalu memutuskan apakah job perlu di-retry, ditandai failed, atau ditaruh ke dead letter.

Best practice implementasi yang sering terlupa

Buat operasi bisnis se-idempotent mungkin

Redis lock mengurangi eksekusi paralel, tetapi tidak menjamin exactly-once dalam semua kondisi. Jika memungkinkan, desain side effect agar aman dipanggil ulang. Contohnya:

  • gunakan unique constraint di database untuk mencegah insert ganda,
  • simpan external reference yang unik,
  • cek status entity sebelum transisi state,
  • buat update bersifat conditional, bukan overwrite buta.

Jangan jadikan Redis satu-satunya source of truth status

Redis cocok untuk lock dan state cepat, tetapi untuk status proses bisnis jangka panjang, database biasanya lebih aman sebagai sumber kebenaran. Redis bisa hilang karena eviction, flush, atau perubahan operasional.

Pilih key lock berdasarkan resource bisnis, bukan jobId

Jika lock dibuat berdasarkan jobId, dua job berbeda untuk resource yang sama tetap bisa berjalan paralel. Gunakan key yang mewakili entity yang ingin diproteksi, misalnya orderId, userId, atau kombinasi domain yang benar.

Checklist operasional sebelum dibawa ke production

  • Endpoint kritis menerima dan memvalidasi idempotency key.
  • Penyimpanan idempotency menggunakan operasi create-if-absent yang atomik.
  • Worker memakai Redis lock pada resource bisnis yang tepat.
  • Release dan renewal lock memverifikasi ownership lock.
  • TTL lock ditentukan berdasarkan durasi proses realistis, bukan tebakan optimistis.
  • Retry memakai batas percobaan dan backoff yang jelas.
  • Job yang gagal berulang dipindah ke dead letter.
  • Status job disimpan di storage persisten dengan transisi yang eksplisit.
  • Log memuat requestId, jobId, resourceId, idempotencyKey, attempt, dan hasil lock.
  • Ada dashboard atau alert untuk lock contention tinggi, retry tinggi, dan dead letter.
  • Ada proses rekonsiliasi untuk job running yang terlalu lama.
  • Dependensi eksternal yang mendukung idempotency token dimanfaatkan bila tersedia.

Penutup

Untuk mencegah duplicate job di Next.js saat API Route atau Route Handler memicu background worker, pendekatan yang paling praktis adalah menggabungkan idempotency key di lapisan request dengan Redis lock di lapisan eksekusi worker. Idempotency key mengatasi retry dari client dan request ganda, sedangkan Redis lock mengatasi race condition antar instance atau worker.

Yang paling penting, jangan berhenti di implementasi dasar. Duplicate job biasanya muncul dari kombinasi queue semantics, timeout, status yang tidak sinkron, dan lock yang salah TTL. Karena itu, desain yang baik harus memperhitungkan retry, dead letter, observability, serta kemampuan debugging di production. Jika side effect Anda kritis, anggap duplicate processing sebagai failure mode utama, bukan edge case.