Next.js Worker dengan Redis Lock adalah pola yang cocok saat route handler atau API Anda hanya bertugas menerima request, menyimpan job, lalu memprosesnya di proses worker terpisah. Tujuan utamanya adalah mencegah eksekusi job ganda saat request diulang, worker berjalan paralel, atau terjadi crash di tengah proses.

Masalah yang paling sering muncul bukan sekadar “bagaimana menjalankan background job”, tetapi bagaimana memastikan job aman diproses sekali secara efektif, atau setidaknya tidak merusak data jika diproses ulang. Di sinilah kombinasi Redis lock, idempotency key, retry yang aman, dan visibility timeout sederhana menjadi penting.

Masalah yang Ingin Diselesaikan

Pada aplikasi Next.js, route handler sering dipakai untuk menerima aksi seperti:

  • generate invoice,
  • sinkronisasi data ke sistem lain,
  • kirim email atau webhook,
  • resize gambar,
  • rebuild cache setelah perubahan data.

Jika semua proses dilakukan langsung di request lifecycle, beberapa masalah akan muncul:

  • Response menjadi lambat.
  • Timeout saat pekerjaan memakan waktu lama.
  • Retry dari client bisa membuat job yang sama diproses lebih dari sekali.
  • Horizontal scaling membuat beberapa instance aplikasi menjalankan logika yang sama secara paralel.

Menjalankan worker terpisah memisahkan tanggung jawab:

  • Producer: route handler/API menerima request dan memasukkan job ke antrean.
  • Consumer: worker membaca job dari antrean dan memprosesnya secara asinkron.

Namun memindahkan proses ke worker belum otomatis aman. Jika dua worker mengambil job yang sama, atau worker crash setelah setengah jalan, Anda tetap bisa mendapat duplikasi, data tidak konsisten, atau job macet.

Arsitektur Sederhana: Next.js sebagai Producer, Worker Node.js sebagai Consumer

Komponen utama

  • Next.js route handler/API untuk menerima request dan membuat job.
  • Redis untuk queue sederhana, lock, status job, dan idempotency metadata.
  • Worker terpisah sebagai proses Node.js yang terus mengambil job dari Redis.
  • Database utama untuk menyimpan hasil akhir atau state bisnis.

Alur eksekusi

  1. Client memanggil endpoint Next.js.
  2. Route handler memvalidasi request dan menghitung idempotency key.
  3. Jika request setara sudah pernah dibuat, handler mengembalikan referensi job lama, bukan membuat job baru.
  4. Jika belum ada, handler menyimpan data job dan mendorong ID job ke queue Redis.
  5. Worker mengambil job dari queue.
  6. Worker mencoba mendapatkan Redis lock untuk job tersebut.
  7. Jika lock berhasil, worker menandai job sebagai processing, menjalankan bisnis proses, lalu menandai completed.
  8. Setelah sukses, worker melakukan cache invalidation.
  9. Jika gagal sementara, job dijadwalkan ulang dengan retry count yang terkontrol.

Catatan: Redis lock membantu mencegah dua worker memproses job yang sama pada waktu yang sama, tetapi lock saja tidak cukup. Anda tetap membutuhkan idempotency key dan operasi bisnis yang aman terhadap retry.

Konsep Inti yang Wajib Ada

1. Redis lock untuk mutual exclusion

Lock dipakai agar hanya satu worker yang boleh memproses satu job pada saat tertentu. Pola umumnya:

  • Gunakan key seperti lock:job:{jobId}.
  • Set lock dengan NX dan masa berlaku (TTL).
  • Simpan token acak sebagai nilai lock.
  • Saat selesai, hapus lock hanya jika token cocok.

Mengapa token penting? Karena lock bisa kedaluwarsa lalu diambil worker lain. Jika worker lama kemudian menjalankan DEL tanpa verifikasi token, ia bisa menghapus lock milik worker baru.

2. Idempotency key untuk mencegah job duplikat dari sisi producer

Idempotency key mencegah endpoint membuat dua job identik saat client mengirim ulang request yang sama. Misalnya key dibentuk dari kombinasi:

  • jenis operasi,
  • resource ID,
  • payload yang dinormalisasi,
  • atau header idempotency dari client.

Jika key sudah ada, kembalikan job yang sudah pernah dibuat. Ini penting karena lock hanya bekerja di sisi worker, bukan saat request masuk.

3. Retry aman

Retry sebaiknya hanya dilakukan untuk error yang bersifat sementara, misalnya jaringan putus, rate limit, atau service dependency tidak tersedia. Untuk error permanen seperti validasi gagal atau data sumber tidak ditemukan, job sebaiknya ditandai gagal final.

Retry aman berarti:

  • Operasi boleh dijalankan ulang tanpa efek samping berbahaya.
  • Status retry dicatat.
  • Ada batas maksimum percobaan.
  • Idealnya ada jeda bertahap (backoff).

4. Visibility timeout sederhana

Jika worker mengambil job lalu crash sebelum selesai, job tidak boleh hilang selamanya. Visibility timeout adalah cara sederhana untuk menandai bahwa job sedang diproses sampai waktu tertentu. Jika melewati batas dan worker tidak menyelesaikannya, job boleh dianggap macet dan dimasukkan kembali ke antrean.

Pada implementasi sederhana dengan Redis, Anda bisa menyimpan:

  • status = processing
  • processingStartedAt
  • visibilityDeadline

Proses recovery periodik lalu mencari job dengan status processing yang sudah melewati deadline, kemudian me-queue ulang jika lock sudah tidak valid.

5. Cache invalidation setelah job selesai

Jika job mengubah data yang dibaca halaman Next.js atau API cache, hasil cache lama harus dibersihkan. Tanpa ini, worker sudah sukses tetapi UI tetap menampilkan data stale.

Strateginya tergantung arsitektur Anda:

  • hapus key cache Redis terkait resource,
  • trigger revalidation untuk path atau tag cache di layer aplikasi,
  • atau perbarui versi cache berbasis resource.

Implementasi Minimal dengan Next.js, Node.js, dan Redis

Contoh berikut sengaja dibuat minimal agar fokus pada pola, bukan pada library queue yang lebih lengkap. Struktur ini cocok sebagai dasar sebelum Anda memutuskan perlu pindah ke BullMQ, RabbitMQ, atau sistem lain.

Client Redis dan utilitas dasar

import { createClient } from 'redis';
import crypto from 'node:crypto';

export const redis = createClient({
  url: process.env.REDIS_URL,
});

export async function connectRedis() {
  if (!redis.isOpen) {
    await redis.connect();
  }
}

export function sha256(value) {
  return crypto.createHash('sha256').update(value).digest('hex');
}

Route handler Next.js sebagai producer

Route handler menerima request, membuat idempotency key, menyimpan metadata job, lalu push ke queue.

import { NextResponse } from 'next/server';
import { connectRedis, redis, sha256 } from '@/lib/redis';
import crypto from 'node:crypto';

export async function POST(req) {
  await connectRedis();

  const body = await req.json();
  const userId = body.userId;
  const reportId = body.reportId;

  if (!userId || !reportId) {
    return NextResponse.json({ error: 'userId dan reportId wajib diisi' }, { status: 400 });
  }

  const payloadFingerprint = sha256(JSON.stringify({ userId, reportId }));
  const idempotencyKey = `jobkey:generate-report:${payloadFingerprint}`;

  const existingJobId = await redis.get(idempotencyKey);
  if (existingJobId) {
    return NextResponse.json({ jobId: existingJobId, duplicate: true }, { status: 202 });
  }

  const jobId = crypto.randomUUID();
  const now = Date.now();

  const job = {
    id: jobId,
    type: 'generate-report',
    payload: { userId, reportId },
    status: 'queued',
    attempts: 0,
    maxAttempts: 5,
    createdAt: now,
    updatedAt: now,
    idempotencyKey,
  };

  const multi = redis.multi();
  multi.set(idempotencyKey, jobId, { NX: true, EX: 60 * 60 });
  multi.hSet(`job:${jobId}`, {
    type: job.type,
    payload: JSON.stringify(job.payload),
    status: job.status,
    attempts: String(job.attempts),
    maxAttempts: String(job.maxAttempts),
    createdAt: String(job.createdAt),
    updatedAt: String(job.updatedAt),
    idempotencyKey: job.idempotencyKey,
  });
  multi.rPush('queue:jobs', jobId);

  const result = await multi.exec();

  if (!result) {
    return NextResponse.json({ error: 'Gagal membuat job' }, { status: 500 });
  }

  return NextResponse.json({ jobId, duplicate: false }, { status: 202 });
}

Poin penting dari contoh di atas:

  • Job tidak diproses langsung di route handler.
  • Idempotency key mencegah pembuatan job yang sama berulang.
  • Metadata job disimpan terpisah dari queue agar mudah dimonitor.

Dalam implementasi produksi, Anda perlu memperhatikan atomisitas antara set idempotency key, simpan job, dan push queue. Untuk kasus yang lebih ketat, gunakan transaksi yang dipahami benar atau script Redis/Lua agar kondisi balapan lebih kecil.

Fungsi lock yang aman dengan token

import crypto from 'node:crypto';
import { redis } from './redis';

export async function acquireLock(key, ttlMs) {
  const token = crypto.randomUUID();
  const ok = await redis.set(key, token, { NX: true, PX: ttlMs });
  if (ok !== 'OK') return null;
  return token;
}

export async function releaseLock(key, token) {
  const script = `
    if redis.call('GET', KEYS[1]) == ARGV[1] then
      return redis.call('DEL', KEYS[1])
    else
      return 0
    end
  `;
  await redis.eval(script, {
    keys: [key],
    arguments: [token],
  });
}

Worker sebagai consumer

Worker mengambil job ID dari queue, mengunci job, memproses, lalu menandai hasilnya.

import { connectRedis, redis } from './redis.js';
import { acquireLock, releaseLock } from './lock.js';

const LOCK_TTL_MS = 30_000;
const VISIBILITY_TIMEOUT_MS = 60_000;

async function processGenerateReport(payload) {
  // Ganti dengan logika bisnis nyata.
  // Penting: buat operasi ini idempotent jika mungkin.
  await new Promise((r) => setTimeout(r, 1000));

  // Contoh: simpan hasil ke DB, object storage, atau status report.
  return { ok: true, cacheKeys: [`report:${payload.reportId}`] };
}

async function invalidateCache(cacheKeys) {
  for (const key of cacheKeys) {
    await redis.del(`cache:${key}`);
  }
}

async function markFailed(jobId, errorMessage, retryable) {
  const attempts = Number(await redis.hGet(`job:${jobId}`, 'attempts') || '0');
  const maxAttempts = Number(await redis.hGet(`job:${jobId}`, 'maxAttempts') || '5');
  const nextAttempts = attempts + 1;

  const multi = redis.multi();
  multi.hSet(`job:${jobId}`, {
    attempts: String(nextAttempts),
    updatedAt: String(Date.now()),
    lastError: errorMessage,
  });

  if (retryable && nextAttempts < maxAttempts) {
    multi.hSet(`job:${jobId}`, {
      status: 'queued',
      visibilityDeadline: '0',
    });
    multi.rPush('queue:jobs', jobId);
  } else {
    multi.hSet(`job:${jobId}`, {
      status: 'failed',
      visibilityDeadline: '0',
    });
  }

  await multi.exec();
}

function isRetryableError(error) {
  return error && error.retryable === true;
}

async function handleJob(jobId) {
  const lockKey = `lock:job:${jobId}`;
  const token = await acquireLock(lockKey, LOCK_TTL_MS);
  if (!token) return;

  try {
    const jobKey = `job:${jobId}`;
    const job = await redis.hGetAll(jobKey);
    if (!job || !job.status) return;

    if (job.status === 'completed') return;

    const visibilityDeadline = Date.now() + VISIBILITY_TIMEOUT_MS;
    await redis.hSet(jobKey, {
      status: 'processing',
      updatedAt: String(Date.now()),
      processingStartedAt: String(Date.now()),
      visibilityDeadline: String(visibilityDeadline),
    });

    const payload = JSON.parse(job.payload);
    let result;

    switch (job.type) {
      case 'generate-report':
        result = await processGenerateReport(payload);
        break;
      default:
        throw new Error(`Unknown job type: ${job.type}`);
    }

    await redis.hSet(jobKey, {
      status: 'completed',
      updatedAt: String(Date.now()),
      completedAt: String(Date.now()),
      visibilityDeadline: '0',
    });

    if (result?.cacheKeys?.length) {
      await invalidateCache(result.cacheKeys);
    }
  } catch (error) {
    await markFailed(jobId, error.message || 'unknown error', isRetryableError(error));
  } finally {
    await releaseLock(lockKey, token);
  }
}

async function poll() {
  await connectRedis();

  while (true) {
    const jobId = await redis.lPop('queue:jobs');
    if (!jobId) {
      await new Promise((r) => setTimeout(r, 1000));
      continue;
    }

    await handleJob(jobId);
  }
}

poll().catch((err) => {
  console.error(err);
  process.exit(1);
});

Contoh di atas menunjukkan pola dasar, tetapi ada keterbatasan penting:

  • lPop menghapus job dari queue sebelum sukses diproses, sehingga recovery bergantung pada metadata job dan requeue logic.
  • Untuk skenario lebih andal, biasanya dibutuhkan struktur in-flight terpisah, sorted set untuk retry terjadwal, atau library queue yang sudah matang.

Recovery job macet dengan visibility timeout

Tambahkan proses periodik yang memeriksa job processing yang melewati visibilityDeadline. Jika lock sudah hilang, job dianggap terbengkalai dan bisa dimasukkan kembali ke antrean.

async function recoverStuckJobs(jobIds) {
  const now = Date.now();

  for (const jobId of jobIds) {
    const jobKey = `job:${jobId}`;
    const job = await redis.hGetAll(jobKey);
    if (!job || job.status !== 'processing') continue;

    const deadline = Number(job.visibilityDeadline || '0');
    if (!deadline || deadline > now) continue;

    const lockExists = await redis.exists(`lock:job:${jobId}`);
    if (lockExists) continue;

    await redis.hSet(jobKey, {
      status: 'queued',
      updatedAt: String(now),
      visibilityDeadline: '0',
      lastError: 'Recovered after visibility timeout',
    });
    await redis.rPush('queue:jobs', jobId);
  }
}

Di sistem nyata, Anda tidak akan memindai seluruh Redis tanpa indeks. Minimal, simpan daftar job aktif atau gunakan struktur yang memudahkan penelusuran job berstatus processing.

Mengapa Kombinasi Ini Bekerja

Redis lock menutup race condition antar worker

Tanpa lock, dua worker bisa mengambil job yang sama akibat retry, requeue, atau bug operasional. Lock membuat hanya satu worker yang boleh masuk ke bagian kritis pada satu waktu.

Idempotency key menutup duplikasi dari sisi request

Kalau client menekan tombol dua kali atau request diulang oleh proxy, producer tetap membuat satu job logis yang sama. Ini mencegah beban queue meningkat karena duplikasi dari sumber.

Visibility timeout mengatasi crash di tengah proses

Jika worker mati setelah mengubah status jadi processing tetapi sebelum selesai, job masih bisa dipulihkan. Tanpa mekanisme ini, job akan tampak menggantung selamanya.

Retry aman menurunkan risiko data rusak

Retry tidak boleh hanya “ulang saja”. Operasi yang menyentuh database, pembayaran, email, atau webhook harus dirancang agar pengulangan tidak menghasilkan efek samping ganda.

Masalah Operasional Umum dan Cara Mengatasinya

Race condition saat membuat job

Masalah: dua request identik datang hampir bersamaan, keduanya sama-sama belum melihat idempotency key, lalu membuat job ganda.

Solusi:

  • Gunakan set NX untuk idempotency key sebelum job dianggap valid.
  • Kalau perlu, gunakan script Redis agar cek dan set lebih atomik.
  • Jangan hanya mengandalkan pengecekan di memori aplikasi.

Worker crash setelah efek samping sebagian terjadi

Masalah: worker sudah mengirim email atau menulis sebagian data, lalu crash sebelum menandai job completed.

Solusi:

  • Desain handler job agar idempotent.
  • Simpan penanda bisnis, misalnya reportGeneratedAt atau externalRequestId.
  • Bedakan operasi yang boleh diulang dan yang harus dicegah dengan unique constraint.

Lock kedaluwarsa terlalu cepat

Masalah: job masih berjalan, tetapi TTL lock habis. Worker lain lalu mengambil lock baru dan memproses job yang sama.

Solusi:

  • TTL harus lebih besar dari durasi normal job.
  • Untuk job panjang, pertimbangkan lock renewal atau heartbeat.
  • Pastikan release lock memverifikasi token.

Job macet karena status processing tidak pernah dibersihkan

Masalah: worker mati sebelum menandai gagal atau selesai.

Solusi:

  • Gunakan visibility timeout.
  • Jalankan recovery loop berkala.
  • Monitor jumlah job processing yang melewati deadline.

Data tidak konsisten setelah job selesai

Masalah: database sudah berubah, tetapi cache atau halaman ISR belum ikut diperbarui.

Solusi:

  • Lakukan cache invalidation setelah commit sukses.
  • Pastikan invalidation tidak dilakukan sebelum perubahan utama berhasil.
  • Jika perlu, simpan event audit bahwa invalidation sudah dieksekusi.

Strategi Monitoring dan Debugging

Pola worker tanpa observability akan cepat sulit dioperasikan. Minimal, pantau metrik berikut:

  • Jumlah job queued, processing, completed, dan failed.
  • Lama waktu dari enqueue sampai selesai.
  • Jumlah retry per tipe job.
  • Jumlah recovery akibat visibility timeout.
  • Jumlah lock acquisition yang gagal.

Log yang sebaiknya ada

  • jobId
  • type
  • idempotencyKey
  • attempt
  • workerId
  • lockKey
  • durationMs
  • errorMessage

Checklist debugging cepat

  • Apakah job benar-benar masuk ke queue Redis?
  • Apakah worker tersambung ke Redis yang sama dengan aplikasi Next.js?
  • Apakah lock terlalu pendek sehingga sering kedaluwarsa?
  • Apakah retry mendorong job kembali tanpa menaikkan attempt?
  • Apakah idempotency key terlalu longgar atau terlalu sempit?
  • Apakah cache invalidation berjalan setelah status completed?

Kapan Pola Ini Cocok, dan Kapan Tidak

Cocok jika

  • Anda punya job background dengan durasi lebih lama dari request biasa.
  • Duplikasi job bisa menimbulkan masalah bisnis atau biaya tambahan.
  • Anda butuh kontrol sederhana tanpa langsung mengadopsi sistem queue besar.
  • Tim Anda memahami trade-off operasional Redis dan worker process.

Kurang cocok jika

  • Anda butuh penjadwalan kompleks, prioritas queue, delayed jobs, dead-letter queue, dan throughput tinggi.
  • Anda memerlukan jaminan delivery yang lebih kuat daripada implementasi minimal.
  • Job melibatkan workflow panjang multi-step yang butuh orchestration formal.
  • Anda belum siap mengelola recovery, monitoring, dan tuning lock secara manual.

Untuk kebutuhan yang makin kompleks, library queue yang lebih matang atau message broker khusus biasanya lebih tepat. Namun memahami pola dasar ini tetap penting, karena konsep lock, idempotency, retry, dan recovery akan tetap relevan di sistem yang lebih besar.

Checklist Implementasi Produksi

  • Route handler hanya bertindak sebagai producer, bukan eksekutor job berat.
  • Setiap job punya jobId, status, attempt count, dan metadata waktu.
  • Gunakan idempotency key untuk mencegah job duplikat dari request yang sama.
  • Gunakan Redis lock dengan TTL dan token verifikasi.
  • Pastikan handler job idempotent atau memiliki proteksi terhadap efek samping ganda.
  • Terapkan retry hanya untuk error sementara, dengan batas percobaan yang jelas.
  • Terapkan visibility timeout dan recovery untuk job macet.
  • Lakukan cache invalidation setelah job sukses.
  • Tambahkan logging, metrik, dan alert dasar.
  • Uji skenario crash, restart worker, lock kedaluwarsa, dan request duplikat.

Penutup

Next.js Worker dengan Redis Lock adalah pola praktis untuk memproses background job tanpa eksekusi ganda, selama Anda tidak berhenti di level “masukkan ke queue lalu jalankan worker”. Kunci keandalannya justru ada pada detail operasional: idempotency key di sisi producer, lock di sisi consumer, retry yang aman, visibility timeout untuk recovery, dan cache invalidation setelah selesai.

Jika beban sistem Anda masih moderat, pola ini cukup efisien dan mudah dipahami. Tetapi jika kebutuhan antrean mulai kompleks, anggap implementasi ini sebagai fondasi konsep sebelum beralih ke solusi queue yang lebih matang.