
Background Job dan Queue untuk Task Berat: Konsep dan Implementasi dengan BullMQ
Pengalaman implementasi background job queue BullMQ dan Redis untuk task berat: konsep worker, retry, idempotency, plus kesalahan yang saya kena di produksi.
Minggu lalu saya dapat laporan dari klien: upload PDF 20 MB lewat endpoint generate laporan selalu timeout setelah 30 detik, padahal prosesnya sebenarnya selesai di detik ke-45. Request HTTP-nya sudah putus, tapi CPU server tetap sibuk mengerjakan sesuatu yang hasilnya tidak akan pernah sampai ke user. Ini persis tipe masalah yang seharusnya tidak pernah dikerjakan di dalam request-response cycle. Kalau Anda punya endpoint yang butuh lebih dari beberapa detik untuk memproses sesuatu — konversi file, kirim email massal, generate laporan, panggil API pihak ketiga yang lambat — itu bukan job untuk request handler. Itu job untuk background worker.
Kapan Task Harus Pindah ke Background
Aturan sederhana yang saya pakai sendiri: kalau sebuah operasi memenuhi salah satu dari ini, dia keluar dari request cycle:
- Butuh lebih dari ~1-2 detik di kondisi normal
- Memanggil API eksternal yang bisa lambat atau down
- Sifatnya bukan blokir untuk respons ke user (user tidak perlu menunggu hasilnya saat itu juga)
- Berpotensi dijalankan ulang (retry) tanpa merusak data
Contoh klasik di aplikasi SaaS multi-tenant yang biasa saya kerjakan: generate PDF laporan bulanan, sinkronisasi data ke CRM eksternal, resize gambar yang diupload user, dan kirim email notifikasi. Semua ini tidak ada satupun yang user butuh hasilnya dalam respons HTTP yang sama.
Yang sering jadi miskonsepsi: background job bukan hanya soal "biar cepat". Request yang memicu job tetap cepat memang, tapi manfaat yang lebih besar justru di isolasi kegagalan. Kalau SMTP provider down, Anda tidak mau 50 request API ikut-ikutan timeout karena semua stuck menunggu koneksi email.

Konsep Inti: Queue, Producer, Worker
Arsitektur background job pada dasarnya cuma tiga komponen:
- Producer — kode di API Anda yang menerima request, lalu mendorong job ke queue, lalu langsung balas respons (biasanya
202 Acceptedplus job ID). - Queue — penyimpanan pesan. Di ekosistem Node.js, hampir selalu Redis.
- Worker — proses terpisah yang mengambil job dari queue, mengerjakannya, dan menandai selesai/gagal.
Salah satu keputusan desain yang menurut saya paling sering dijawab salah: prioritas antar-queue. Ada diskusi menarik di komunitas (Background job queues and priorities may be the wrong path) yang mempertanyakan apakah fitur prioritas di job queue malah jadi jebakan — banyak tim memakai prioritas untuk "menambal" worker yang kekurangan kapasitas, bukan menyelesaikan masalah sebenarnya. Saya setuju dengan banyak poinnya: kalau queue email Anda butuh prioritas tinggi agar tidak menumpuk di belakang job generate PDF, solusinya biasanya bukan prioritas, tapi pisahkan jadi dua queue dengan worker terpisah. Isolasi itu jauh lebih mudah dipahami dan di-debug daripada 5 level prioritas di satu queue.

Implementasi dengan BullMQ dan Redis
Saya pakai BullMQ di hampir semua proyek Node.js saya sekarang. Berikut implementasi minimal yang benar-benar bisa jalan.
1. Producer (di dalam API)
// src/jobs/queue.ts
import { Queue } from "bullmq";
export const reportQueue = new Queue("report-generation", {
connection: { host: "localhost", port: 6379 },
});
// Di route handler:
export async function generateReport(req: Request, res: Response) {
const job = await reportQueue.add("generate-pdf", {
tenantId: req.user.tenantId,
month: "2025-05",
format: "pdf",
}, {
attempts: 3,
backoff: { type: "exponential", delay: 5000 },
removeOnComplete: { count: 100 },
removeOnFail: { count: 500 },
});
res.status(202).json({ jobId: job.id, status: "queued" });
}
Perhatikan attempts: 3 dengan backoff eksponensial. Job yang gagal karena API eksternal timeout akan dicoba ulang otomatis setelah 5 detik, lalu 10 detik, lalu 20 detik. Angka ini bukan karangan — itu konfigurasi yang saya pakai untuk job yang memanggil service eksternal dengan SLA fluktuatif.
2. Worker (proses terpisah)
// src/jobs/worker.ts
import { Worker } from "bullmq";
const worker = new Worker("report-generation", async (job) => {
const { tenantId, month } = job.data;
const pdf = await renderReportPdf(tenantId, month);
await uploadToS3(pdf.path, `reports/${tenantId}/${month}.pdf`);
return { url: pdf.url };
}, {
connection: { host: "localhost", port: 6379 },
concurrency: 4,
});
worker.on("failed", (job, err) => {
console.error(`Job ${job?.id} gagal (attempt ${job?.attemptsMade}):`, err.message);
});
Jalankan worker ini sebagai proses terpisah (ts-node src/jobs/worker.ts atau entrypoint Docker container sendiri). Ini penting: worker yang crash karena OOM saat render PDF besar tidak boleh menyeret API ikut mati.
3. Cek status job
const job = await reportQueue.getJob(jobId);
if (!job) return res.status(404).json({ error: "Job tidak ditemukan" });
const state = await job.getState(); // completed | failed | active | waiting
res.json({ state, result: job.returnvalue });
Kesalahan yang Saya Kena (Supaya Anda Tidak Perlu)
Job yang tidak idempotent
Ini kesalahan pertama yang saya kena di produksi. Retry adalah fitur inti job queue — tapi retry pada job yang tidak idempotent berarti email dikirim 3x atau invoice dibuat dobel. Setelah dua kali kena masalah ini, sekarang saya selalu bertanya sebelum menulis job: "kalau job ini jalan dua kali, apa yang rusak?" Kalau jawabannya "sesuatu", tambahkan guard — misalnya cek apakah laporan bulan itu sudah pernah dibuat sebelum eksekusi.
Satu queue untuk semua jenis job
Sudah saya bahas di atas, tapi layak diulang: email transaksional dan export data 1 juta baris jangan pernah share queue. Email user akan nunggu di belakang export yang jalan 20 menit. Pisahkan queue, pisahkan worker.
Fire-and-forget tanpa observability
Job yang gagal secara diam-diam adalah mimpi buruk. Minimal pasang handler failed yang kirim alert ke Slack/Loki. Lebih baik lagi, pantau metrik waiting count — queue yang terus membesar berarti worker kewalahan, dan Anda ingin tahu itu sebelum user komplain.
Lupa cleanup job selesai
Default BullMQ menyimpan riwayat job di Redis selamanya. Tanpa removeOnComplete, Redis saya pernah bengkak 2 GB cuma dari riwayat job. Set removeOnComplete: { count: 100 } seperti di contoh di atas.
Trade-off: Kapan Ini Overkill
Jujur, tidak semua aplikasi butuh Redis + worker terpisah. Kalau aplikasi Anda punya satu jenis task berat yang jarang terjadi (misalnya import CSV sekali seminggu oleh admin), solusi sederhana seperti menandai task di PostgreSQL dan memprosesnya dengan cron/interval bisa lebih dari cukup — tanpa infrastruktur tambahan. Pattern "polling tabel" ini jelek secara elegan tapi sering lebih tepat untuk skala kecil, apalagi kalau Anda sudah paham strategi merestrukturisasi legacy code tanpa mengganggu bisnis — tambah kolom status di tabel itu murah dan mudah di-migrate.
Di sisi lain, kalau kebutuhan Anda sudah kompleks sampai perlu workflow multi-step dengan step yang menunggu step lain, tools seperti Inngest (platform background job dan workflow untuk developer) layak dievaluasi sebelum Anda membangun orkestrasi sendiri. Untuk workflow sederhana berantai, saya biasanya tetap cukup dengan BullMQ + parent-child job.
Satu catatan tambahan soal scheduling: kalau Anda butuh job berkala (misalnya rekap harian), pisahkan antara scheduler dan executor. Ada pola lama di komunitas Ruby yang membahas ini (contoh diskusi Clockwork untuk menjadwalkan ActiveJob) — prinsipnya tetap sama di Node.js: cron hanya mendorong job ke queue, worker yang mengeksekusi. Jangan pernah taruh logika bisnis langsung di cron.
Takeaway Praktis
Kalau mau mulai besok, ini urutan yang saya sarankan:
- Identifikasi endpoint Anda yang paling sering timeout — itu kandidat pertama.
- Pasang Redis + BullMQ, buat satu queue per kategori job (jangan satu untuk semua).
- Set
attempts: 3+ backoff eksponensial, dan pastikan setiap job idempotent sebelum retry diaktifkan. - Jalankan worker sebagai proses/container terpisah dari API.
- Pasang alert pada job gagal dan pantau panjang antrean.
Dan satu hal terakhir yang berhubungan dengan API secara umum: desain endpoint yang memicu background job tetap harus mengikuti konvensi API yang rapi — balas 202 Accepted dengan job ID, sediakan endpoint status. Kalau Anda belum punya standar untuk itu, tulisan saya tentang best practice REST API design untuk skala produksi membahas pola async endpoint dengan lebih detail.
Background job bukan silver bullet, tapi untuk task berat, ini adalah salah satu investasi infrastruktur dengan return paling cepat yang bisa Anda lakukan — biasanya dalam hitungan hari, bukan minggu.
Sumber
- Background job queues and priorities may be the wrong path — argumen kenapa prioritas queue sering jadi tambalan yang salah; mendasari rekomendasi saya memisahkan queue per kategori
- Inngest — Developer platform for background jobs and workflows — contoh platform managed untuk workflow multi-step sebagai alternatif membangun orkestrasi sendiri
- Clockwork to queue ActiveJob background jobs — pola memisahkan scheduler dan executor di background job
Tomi Hartanto
Senior Software Engineer
Backend developer dengan 8+ tahun pengalaman membangun sistem scalable. Menulis tentang arsitektur backend, database, dan DevOps.
Artikel Terkait

N+1 Query Problem di Prisma dan Laravel Eloquent: Studi Kasus dan Cara Saya Memperbaikinya

Struktur Folder Proyek Backend yang Scalable: Peluang dari Sistem Enterprise yang Saya Kerjakan

Validasi Input di Backend: Kenapa Never Trust the Client Bukan Sekadar Slogan
Tomi Hartanto
Senior Software EngineerBackend developer dengan 8+ tahun pengalaman membangun sistem scalable. Menulis tentang arsitektur backend, database, dan DevOps.