Queue di Vercel perlu dirancang berbeda dibanding aplikasi yang memiliki proses worker persisten. Function di Vercel bersifat stateless, berdurasi terbatas, dan tidak cocok dijadikan consumer queue yang hidup terus-menerus. Karena itu, pola yang aman biasanya adalah: API menerima job, menyimpan status ke database, lalu pemrosesan dilakukan secara asynchronous melalui webhook, cron, atau worker/provider eksternal.

Masalah utamanya bukan sekadar bagaimana menjalankan job, tetapi bagaimana mencegah double processing, menangani retry tanpa efek samping ganda, menghindari job hilang, dan memastikan status job tetap akurat di lingkungan serverless. Artikel ini fokus pada implementasi praktis yang bisa diterapkan tanpa bergantung pada worker bawaan Vercel.

Mengapa queue di Vercel tidak bisa diperlakukan seperti worker tradisional

Pada sistem tradisional, queue biasanya memiliki satu atau lebih worker persisten yang terus melakukan polling atau menerima push dari broker seperti Redis, RabbitMQ, atau SQS. Worker menjaga koneksi, memproses pesan, mengatur concurrency, dan menerapkan retry dari proses yang relatif stabil.

Di Vercel, pendekatan itu tidak langsung cocok karena function:

  • Stateless: tidak menyimpan state in-memory antar invokasi.
  • Ephemeral: proses dapat dihentikan kapan saja setelah request selesai.
  • Time-limited: tidak ideal untuk job panjang atau polling terus-menerus.
  • Horizontal scale: invokasi paralel bisa memunculkan race condition bila state tidak dikontrol di database.

Implikasinya, queue di Vercel lebih aman jika diperlakukan sebagai job orchestration berbasis database dan trigger eksternal, bukan sebagai worker loop yang hidup terus.

Arsitektur yang realistis untuk queue di Vercel

Pola yang umum dan aman:

  1. Client memanggil API untuk membuat job.
  2. API menghasilkan idempotency key, menyimpan job ke database dengan status awal.
  3. Sistem mengirim sinyal pemrosesan, misalnya ke webhook internal, scheduler, atau provider queue eksternal.
  4. Processor mengambil job yang siap diproses.
  5. Processor mengunci job secara atomik, menjalankan pekerjaan, lalu memperbarui status.
  6. Jika gagal, job dijadwalkan ulang dengan retry dan backoff.

Diagram alur teks

Client
  |
  | POST /api/jobs  (Idempotency-Key)
  v
Vercel API Route
  |
  |-- simpan job ke DB (status=queued)
  |-- simpan idempotency record
  |-- trigger pemrosesan async
  v
Database / Job Store
  ^
  |
Processor (cron/webhook/provider worker)
  |
  |-- claim job secara atomik
  |-- set status=processing, lock_until=...
  |-- jalankan task
  |-- success  => status=completed
  |-- failed   => attempts++, next_run_at dihitung ulang
  |-- fatal    => status=dead_letter / failed_permanent
  v
Observability (logs, metrics, trace, alert)

Pilihan pemicu pemrosesan async

Tidak ada satu pola yang selalu benar. Pilih berdasarkan kebutuhan latensi, volume, dan kontrol operasional.

  • Cron: sederhana untuk memproses job tertunda secara periodik. Cocok bila toleransi latensi dalam hitungan puluhan detik atau menit masih aman.
  • Webhook internal: setelah job dibuat, API memicu endpoint processor secara asynchronous. Cocok untuk latensi lebih rendah, tetapi tetap perlu retry jika trigger gagal.
  • Provider eksternal: gunakan layanan queue/worker di luar Vercel untuk konsumsi job. Cocok untuk beban tinggi, job berat, atau kebutuhan retry terkelola.

Poin pentingnya: database tetap menjadi sumber kebenaran status job, bukan status yang hanya hidup di memori function.

Skema tabel job yang aman untuk produksi

Skema minimal sebaiknya mendukung state machine, retry, deduplikasi, locking, dan audit operasional. Contoh kolom yang umum dipakai:

jobs
- id                  string/uuid primary key
- job_type            string
- payload_json        json/text
- status              string   -- queued | processing | completed | failed | dead_letter | cancelled
- priority            integer  -- opsional
- attempts            integer
- max_attempts        integer
- idempotency_key     string   -- unik per konteks yang relevan
- dedupe_key          string   -- opsional untuk menahan job identik
- lock_token          string   -- token pemroses saat claim
- lock_until          timestamp
- next_run_at         timestamp
- started_at          timestamp
- finished_at         timestamp
- last_error          text
- result_json         json/text
- created_at          timestamp
- updated_at          timestamp

Status job yang disarankan

  • queued: job sudah tercatat dan menunggu dieksekusi.
  • processing: sedang diproses oleh satu worker.
  • completed: sukses selesai.
  • failed: gagal sementara dan masih bisa di-retry.
  • dead_letter atau failed_permanent: gagal permanen setelah batas retry atau error fatal.
  • cancelled: dibatalkan secara eksplisit sebelum dieksekusi.

Jika sistem Anda sederhana, status bisa diperkecil. Namun untuk debugging dan operasional, pemisahan status sementara dan permanen biasanya sangat membantu.

Tabel idempotency terpisah

Sering kali lebih aman menyimpan idempotency di tabel terpisah, terutama untuk request API yang bisa dipanggil ulang oleh client karena timeout jaringan.

idempotency_records
- idempotency_key     string primary/unique
- request_hash        string
- resource_type       string   -- mis. "job"
- resource_id         string   -- id job yang dibuat
- response_json       json/text -- opsional, untuk replay respons yang sama
- created_at          timestamp

Dengan pendekatan ini, request yang sama tidak membuat job ganda walau endpoint terpanggil berulang.

Idempotensi, deduplikasi, dan mengapa keduanya berbeda

Dua konsep ini sering tercampur, padahal tujuannya berbeda:

  • Idempotensi memastikan request yang sama tidak menghasilkan efek samping ganda.
  • Deduplikasi mencegah job yang identik diproses berkali-kali, meskipun datang dari request berbeda.

Idempotency key

Gunakan header atau field seperti Idempotency-Key saat client membuat job. Saat API menerima request:

  1. Cek apakah key sudah ada.
  2. Jika sudah ada dan request cocok, kembalikan hasil sebelumnya atau referensi ke job yang sama.
  3. Jika belum ada, buat record idempotency dan job dalam satu transaksi bila memungkinkan.

Ini penting untuk kasus seperti:

  • Client mengulang request karena respons timeout.
  • Gateway atau proxy mengirim ulang request.
  • User menekan tombol submit dua kali.

Dedupe key

Dedupe key dipakai ketika dua request berbeda secara teknis, tetapi semestinya merepresentasikan pekerjaan yang sama. Contoh: sinkronisasi invoice untuk invoice_id=123. Jika job dengan dedupe key itu masih queued atau processing, Anda bisa menolak job baru atau mengembalikan referensi ke job yang aktif.

Catatan: idempotency key biasanya berasal dari client atau request boundary. Dedupe key biasanya berasal dari domain bisnis, misalnya kombinasi entity ID dan jenis operasi.

Locking sederhana dan penanganan race condition

Masalah paling umum di queue serverless adalah dua processor mengklaim job yang sama hampir bersamaan. Ini bisa terjadi saat cron overlap, webhook dipanggil ulang, atau function scale out.

Prinsip klaim job yang aman

Jangan pernah mengambil job dengan pola:

1. SELECT job status=queued
2. proses di aplikasi
3. UPDATE status=processing

Pola itu rentan race condition. Dua invokasi bisa membaca baris yang sama sebelum salah satunya meng-update status.

Gunakan claim atomik di level database. Bentuknya tergantung database yang dipakai, tetapi prinsipnya sama: update hanya jika status masih valid dan lock sudah kosong atau kedaluwarsa.

// pseudo-code claim atomik
function claimNextJob(now, workerId) {
  job = db.executeAtomically(`
    pilih satu job yang:
      status in ('queued', 'failed')
      and next_run_at <= now
      and (lock_until is null or lock_until < now)
    urutkan berdasarkan priority desc, created_at asc
    lalu update:
      status = 'processing'
      lock_token = workerId
      lock_until = now + lease_duration
      started_at = coalesce(started_at, now)
    kembalikan job yang berhasil diupdate
  `)

  return job
}

Dengan pola ini, hanya satu pemroses yang berhasil mengubah state job ke processing.

Lease lock, bukan lock permanen

Gunakan lock_until alih-alih asumsi bahwa processor akan selalu selesai dengan baik. Jika function crash atau timeout, lock akan kedaluwarsa dan job bisa diambil ulang. Ini lebih aman daripada lock boolean tanpa expiry karena lock bisa yatim (orphaned lock).

Perpanjangan lock untuk job panjang

Jika sebuah job mungkin berjalan lebih lama dari lease awal, processor perlu memperpanjang lock_until secara periodik. Namun bila durasi kerja terlalu panjang untuk serverless function, itu sinyal bahwa job lebih cocok dipindah ke worker dedicated.

Retry dengan backoff yang aman

Retry sebaiknya tidak dilakukan dengan loop sinkron di dalam satu function sampai sukses. Selain boros waktu eksekusi, ini berisiko timeout dan biaya tidak perlu. Simpan retry sebagai state di database.

Kapan job perlu di-retry

Retry cocok untuk error sementara, misalnya:

  • HTTP 429 atau rate limit.
  • Gangguan jaringan sementara.
  • Database lock contention.
  • Layanan pihak ketiga mengembalikan 5xx.

Retry tidak cocok untuk error fatal seperti payload invalid, referensi data tidak ada, atau pelanggaran aturan bisnis yang tidak akan membaik bila diulang.

Exponential backoff dengan jitter

Backoff mencegah sistem memukul layanan downstream secara serentak setelah terjadi gangguan. Tambahkan jitter agar retry tidak menumpuk di waktu yang sama.

function computeNextRun(attempts) {
  baseSeconds = 30
  maxSeconds = 3600
  exponential = min(maxSeconds, baseSeconds * (2 ** attempts))
  jitter = randomBetween(0, 15)
  return now() + exponential + jitter
}

Gunakan angka yang sesuai kebutuhan sistem Anda; yang penting adalah polanya, bukan angka pastinya.

Menyimpan hasil retry ke status job

function markFailure(job, error) {
  retryable = isRetryable(error)

  if (!retryable) {
    update jobs set
      status = 'dead_letter',
      last_error = serialize(error),
      finished_at = now(),
      lock_token = null,
      lock_until = null
    where id = job.id and lock_token = currentWorker()
    return
  }

  nextAttempts = job.attempts + 1
  if (nextAttempts >= job.max_attempts) {
    update jobs set
      status = 'dead_letter',
      attempts = nextAttempts,
      last_error = serialize(error),
      finished_at = now(),
      lock_token = null,
      lock_until = null
    where id = job.id and lock_token = currentWorker()
  } else {
    update jobs set
      status = 'failed',
      attempts = nextAttempts,
      next_run_at = computeNextRun(nextAttempts),
      last_error = serialize(error),
      lock_token = null,
      lock_until = null
    where id = job.id and lock_token = currentWorker()
  }
}

Penting: saat memperbarui hasil proses, sertakan kondisi lock_token = currentWorker() agar processor yang lock-nya sudah kedaluwarsa tidak menimpa hasil pemroses lain.

Pseudo-code alur end-to-end

1) API menerima job

function createJobHandler(request) {
  idempotencyKey = request.headers['Idempotency-Key']
  payload = validate(request.body)
  requestHash = hash(payload)

  existing = findIdempotencyRecord(idempotencyKey)
  if (existing) {
    if (existing.request_hash != requestHash) {
      return error(409, 'Idempotency key sudah dipakai untuk payload berbeda')
    }
    return success(existing.response_json)
  }

  beginTransaction()
  try {
    // opsional: dedupe berdasarkan konteks bisnis
    activeJob = findActiveJobByDedupeKey(makeDedupeKey(payload))
    if (activeJob) {
      saveIdempotencyRecord(idempotencyKey, requestHash, activeJob.id, { jobId: activeJob.id })
      commit()
      triggerAsyncProcessor()
      return success({ jobId: activeJob.id, deduplicated: true })
    }

    job = insertJob({
      job_type: 'send_email',
      payload_json: payload,
      status: 'queued',
      attempts: 0,
      max_attempts: 8,
      idempotency_key: idempotencyKey,
      dedupe_key: makeDedupeKey(payload),
      next_run_at: now()
    })

    saveIdempotencyRecord(idempotencyKey, requestHash, job.id, { jobId: job.id })
    commit()
  } catch (e) {
    rollback()
    throw e
  }

  triggerAsyncProcessor()
  return success({ jobId: job.id })
}

2) Processor mengambil dan menjalankan job

function processOnce() {
  workerId = newUuid()
  job = claimNextJob(now(), workerId)
  if (!job) return

  try {
    performSideEffectSafely(job)

    update jobs set
      status = 'completed',
      result_json = buildResult(),
      finished_at = now(),
      lock_token = null,
      lock_until = null
    where id = job.id and lock_token = workerId
  } catch (error) {
    markFailure(job, error)
  }
}

3) Operasi side effect juga harus idempoten

Banyak sistem berhenti di idempotensi level API, padahal side effect downstream juga bisa dobel. Misalnya jika job mengirim request ke payment gateway atau service internal, sertakan identifier unik yang sama agar sistem tujuan juga bisa mendeteksi duplikasi.

function performSideEffectSafely(job) {
  // contoh konseptual
  externalApi.call({
    operationId: job.id,
    payload: job.payload_json
  })
}

Jika downstream tidak mendukung idempotensi, Anda perlu menyimpan catatan bahwa side effect tertentu sudah pernah dijalankan sebelum mengulangnya.

Masalah operasional yang paling sering terjadi

1) Double processing

Penyebab umum:

  • Klaim job tidak atomik.
  • Processor timeout setelah side effect berhasil tetapi sebelum status tersimpan.
  • Retry terjadi padahal hasil sebelumnya sebenarnya sukses.

Mitigasi:

  • Gunakan locking berbasis lease dan update bersyarat.
  • Buat side effect downstream idempoten.
  • Simpan jejak request/response eksternal bila perlu audit.

2) Job hilang

Biasanya terjadi ketika sistem hanya mengandalkan trigger tanpa job store yang durable. Misalnya request async sudah dikirim ke webhook processor, tetapi function pemroses gagal dipanggil dan tidak ada catatan bahwa job pernah dibuat.

Mitigasi:

  • Simpan job ke database sebelum trigger pemrosesan.
  • Gunakan cron sebagai safety net untuk memproses job queued/failed yang tertinggal.
  • Buat query rekonsiliasi untuk mendeteksi job lama yang stagnan.

3) Cache status basi

UI sering melakukan polling status job. Jika endpoint status menggunakan cache yang tidak tepat, user bisa melihat job masih processing padahal sudah selesai.

Mitigasi:

  • Untuk endpoint status job, prioritaskan pembacaan langsung ke sumber data yang konsisten.
  • Jika memakai cache, gunakan TTL pendek dan invalidasi jelas.
  • Jangan menyamakan cache-friendly content dengan endpoint status yang berubah cepat.

4) Timeout function

Serverless function bisa habis waktu saat memproses job berat, mengunggah file besar, atau menunggu API eksternal lambat.

Mitigasi:

  • Pecah job besar menjadi langkah lebih kecil.
  • Pindahkan workload CPU-bound atau durasi panjang ke worker dedicated.
  • Jangan melakukan retry loop sinkron di satu invokasi.

5) Poison message

Poison message adalah job yang selalu gagal, misalnya payload korup atau bug deterministik. Tanpa penanganan, job ini akan terus memenuhi antrean retry.

Mitigasi:

  • Batasi max_attempts.
  • Pisahkan error retryable vs fatal.
  • Pindahkan job ke dead_letter setelah batas tertentu.
  • Sediakan dashboard atau prosedur manual untuk inspeksi dan replay.

6) Observability yang lemah

Tanpa logging dan metrik yang baik, queue terlihat “jalan” sampai suatu hari job menumpuk dan tidak jelas di mana bottleneck-nya.

Minimal pantau:

  • Jumlah job per status: queued, processing, failed, dead_letter.
  • Job age: berapa lama job menunggu sebelum diproses.
  • Retry rate dan distribusi attempts.
  • Processing duration.
  • Error rate per job_type.
  • Stuck locks: job processing dengan lock_until sudah lewat.

Tambahkan correlation ID atau gunakan job.id sebagai pengikat log antar komponen.

Webhook, cron, atau worker dedicated: kapan memilih yang mana?

Webhook atau trigger langsung setelah enqueue

Pilih ini jika:

  • Ingin latensi eksekusi cepat.
  • Volume job belum terlalu tinggi.
  • Task relatif singkat dan mostly I/O bound.

Keterbatasan:

  • Masih perlu fallback jika trigger gagal.
  • Bisa memicu ledakan concurrency jika enqueue masif.

Cron sebagai scheduler pemrosesan

Pilih ini jika:

  • Task tidak harus dieksekusi seketika.
  • Ingin desain sederhana dan mudah diaudit.
  • Butuh mekanisme penyelamat untuk job yang tertinggal.

Keterbatasan:

  • Latency bergantung interval cron.
  • Jika throughput tinggi, satu tick cron bisa tidak cukup.

Provider eksternal atau worker dedicated

Pilih ini jika:

  • Job panjang, berat, atau CPU-intensive.
  • Butuh concurrency yang lebih terkontrol.
  • Perlu retry, DLQ, scheduling, dan monitoring yang lebih matang.

Keterbatasan:

  • Operasional lebih kompleks.
  • Biaya dan integrasi bertambah.
  • Menambah komponen yang harus diamankan dan dipantau.

Secara praktis, banyak tim memulai dengan pola database + webhook/cron, lalu memindahkan jenis job tertentu ke worker dedicated ketika volume atau durasinya meningkat.

Kesalahan implementasi yang sering terjadi

  • Menganggap enqueue sama dengan sukses diproses. API sebaiknya mengembalikan jobId, bukan menyatakan efek samping sudah selesai.
  • Tidak membedakan error retryable dan fatal. Akibatnya poison message diulang terus.
  • Mengunci job tanpa expiry. Lock yatim akan membuat job macet permanen.
  • Tidak membuat side effect idempoten. Retry yang benar di sisi queue tetap bisa menghasilkan duplikasi di downstream.
  • Status job hanya disimpan di cache. Cache bukan sumber kebenaran queue.
  • Tidak ada fallback scanner. Trigger yang gagal bisa menyebabkan job tertinggal selamanya.

Checklist implementasi aman di produksi

  1. Job store durable: simpan job di database sebelum trigger pemrosesan.
  2. Idempotency key untuk endpoint create job.
  3. Dedupe key untuk mencegah job bisnis identik berjalan ganda.
  4. Claim atomik saat mengambil job.
  5. Lease lock dengan expiry, bukan lock permanen.
  6. Retry dengan backoff + jitter.
  7. Pemisahan error retryable vs fatal.
  8. Dead-letter state untuk poison message.
  9. Conditional update dengan lock token agar worker usang tidak menimpa status.
  10. Fallback cron/scanner untuk job stagnan atau trigger gagal.
  11. Observability: log terstruktur, metrik antrean, alert backlog, dan korelasi per job.
  12. Endpoint status tanpa cache basi untuk data yang berubah cepat.
  13. Downstream idempotency bila job menghasilkan side effect eksternal.
  14. Runbook operasional untuk replay, cancel, dan investigasi dead-letter.

Penutup

Queue di Vercel tetap bisa dibangun dengan aman meski tidak ada worker persisten bawaan, asalkan desainnya menempatkan database sebagai pusat state dan memperlakukan eksekusi sebagai proses asynchronous yang bisa gagal, diulang, atau berjalan paralel. Kunci utamanya adalah idempotensi, deduplikasi, locking atomik, retry yang terukur, dan observability yang memadai.

Jika job Anda singkat dan dominan I/O, pola API + job store + webhook/cron sering sudah cukup. Namun bila durasi panjang, throughput tinggi, atau efek samping sangat sensitif, pertimbangkan memindahkan eksekusi ke worker dedicated sambil tetap menjaga state job dan idempotensi secara eksplisit.