"Kalau user harus menunggu sesuatu yang tidak mereka butuh jawabannya sekarang juga, itu bukan bagian dari request — itu bagian dari background job yang belum kamu pisahkan."
Tentang Artikel Ini
toko-api (project yang sama dari artikel-artikel sebelumnya) sekarang bisa menerima order. Tapi bayangkan setiap order masuk juga harus: kirim email konfirmasi, update laporan penjualan, dan notifikasi ke gudang. Kalau semua itu dikerjakan langsung di dalam request POST /orders, response ke user jadi lambat — dan kalau salah satu langkah gagal (server email down, misalnya), seluruh order bisa gagal ikut ter-rollback padahal ordernya sendiri valid.
Artikel ini membahas cara memisahkan pekerjaan yang tidak perlu selesai sebelum response dikirim, memakai job queue dengan BullMQ dan Redis — pola yang dipakai hampir semua sistem production untuk background processing yang reliable.
Setelah selesai, kamu akan bisa:
- Memahami kapan pekerjaan sebaiknya jadi background job, kapan tetap synchronous
- Setup queue dan worker dengan BullMQ
- Menerapkan retry dan idempotency untuk job yang aman diulang
- Membuat delayed job dan repeatable job (pengganti cron sederhana)
- Memonitor queue dengan dashboard visual
- Menangani job yang gagal permanen tanpa kehilangan data
Prasyarat: Sudah menyelesaikan artikel #02 (REST API dengan Node.js & Express). Redis sudah terinstall (lihat artikel #05 — Docker, sudah menyediakan service Redis di docker-compose.yml) atau install lokal.
Daftar Isi
- Kenapa Background Jobs? Masalah Request yang Lambat
- Setup Redis & BullMQ
- Job Pertama: Queue, Worker, Job
- Reliable Processing: Retry, Backoff, Idempotency
- Delayed Job dan Repeatable Job
- Monitoring Queue dengan Bull Board
- Studi Kasus: Integrasi ke toko-api
- Production Considerations
Bab 1: Kenapa Background Jobs? Masalah Request yang Lambat
Request Synchronous yang Membengkak
// ✗ Semua dikerjakan di dalam request — user menunggu semuanya
export async function createOrder(req, res, next) {
try {
const order = await OrderService.create(req.body);
await sendConfirmationEmail(order); // 800ms — panggilan API eksternal
await updateSalesReport(order); // 200ms — query aggregate
await notifyWarehouse(order); // 500ms — panggilan API eksternal lain
res.status(201).json({ success: true, data: order });
// Total: order dibuat dalam 50ms, tapi user menunggu 1550ms
} catch (error) {
next(error);
}
}Tiga masalah di kode ini:
- User menunggu lebih lama dari yang seharusnya — order sudah valid dan tersimpan dalam 50ms, tapi response baru dikirim setelah 1550ms
- Satu kegagalan menggagalkan semuanya — kalau
notifyWarehousetimeout, apakah order-nya batal? Padahal secara bisnis order itu sudah sah - Tidak ada retry — kalau email gagal terkirim karena provider sedang down sebentar, tidak ada mekanisme coba lagi. Email itu hilang selamanya
Kapan Sesuatu Layak Jadi Background Job
Pertanyaan: "Apakah user BUTUH tahu hasilnya sebelum response dikirim?"
Ya → Tetap synchronous
(contoh: validasi stok sebelum konfirmasi order — user perlu tahu SEKARANG kalau stok habis)
Tidak → Kandidat background job
(contoh: kirim email, update laporan, sinkronisasi ke sistem lain)
Order tetap dibuat secara synchronous (user butuh tahu order berhasil atau tidak). Tapi email, laporan, dan notifikasi gudang dipindahkan ke job queue — dikerjakan setelah response dikirim, dengan retry otomatis kalau gagal.
Bab 2: Setup Redis & BullMQ
Kenapa Redis
BullMQ butuh tempat menyimpan state antrian — job mana yang menunggu, mana yang sedang dikerjakan, mana yang gagal. Redis dipilih karena operasinya cepat (in-memory) dan punya struktur data (sorted set, list) yang pas untuk kebutuhan antrian.
# Kalau belum ada Redis — lewat Docker (docker-compose.yml dari artikel #05 sudah punya ini)
docker compose up -d redis
# Atau install lokal
brew install redis
brew services start redisInstall BullMQ
npm install bullmq ioredis// src/queues/connection.js
import { Redis } from 'ioredis';
// maxRetriesPerRequest: null WAJIB untuk BullMQ — tanpa ini, koneksi
// yang terputus sesaat akan membuat job gagal, bukan retry otomatis
export const redisConnection = new Redis(process.env.REDIS_URL, {
maxRetriesPerRequest: null,
});Bab 3: Job Pertama: Queue, Worker, Job
Tiga Konsep Inti
Queue → tempat job "dimasukkan" (dari kode aplikasi/API)
Job → satu unit pekerjaan, punya nama dan data
Worker → proses terpisah yang "mengambil" job dari queue dan menjalankannya
Mendefinisikan Queue
// src/queues/email.queue.js
import { Queue } from 'bullmq';
import { redisConnection } from './connection.js';
export const emailQueue = new Queue('email', {
connection: redisConnection,
});Menambahkan Job ke Queue
// src/controllers/order.controller.js
import { emailQueue } from '../queues/email.queue.js';
export async function createOrder(req, res, next) {
try {
const order = await OrderService.create(req.body);
// Tidak di-await untuk hasil pengiriman — cukup masuk antrian
await emailQueue.add('order-confirmation', {
orderId: order.id,
customerEmail: order.customerEmail,
});
res.status(201).json({ success: true, data: order });
// Response dikirim segera — email dikerjakan terpisah oleh worker
} catch (error) {
next(error);
}
}Worker: Proses yang Mengerjakan Job
// src/workers/email.worker.js
import { Worker } from 'bullmq';
import { redisConnection } from '../queues/connection.js';
import { sendEmail } from '../services/email.service.js';
import { OrderService } from '../services/order.service.js';
const worker = new Worker(
'email', // nama queue yang sama dengan emailQueue
async (job) => {
if (job.name === 'order-confirmation') {
const order = await OrderService.findById(job.data.orderId);
await sendEmail({
to: job.data.customerEmail,
subject: `Konfirmasi Order #${order.id}`,
body: `Terima kasih! Order kamu senilai Rp${order.total} sedang diproses.`,
});
}
},
{ connection: redisConnection }
);
worker.on('completed', (job) => {
console.log(`Job ${job.id} (${job.name}) selesai`);
});
worker.on('failed', (job, err) => {
console.error(`Job ${job.id} (${job.name}) gagal:`, err.message);
});# Worker dijalankan sebagai process terpisah dari API server
node src/workers/email.worker.jsWorker berjalan sebagai process terpisah dari server API. Ini penting — kalau worker crash karena bug di job handler, API tetap melayani request seperti biasa. Di production, keduanya dikelola sebagai dua PM2 process berbeda (dibahas lebih lanjut di Bab 8).
Bab 4: Reliable Processing: Retry, Backoff, Idempotency
Retry Otomatis
await emailQueue.add(
'order-confirmation',
{ orderId: order.id, customerEmail: order.customerEmail },
{
attempts: 5, // coba maksimal 5 kali
backoff: {
type: 'exponential',
delay: 2000, // 2s, 4s, 8s, 16s, 32s
},
}
);Kalau sendEmail melempar error (provider email timeout, misalnya), BullMQ otomatis menjadwalkan ulang job itu dengan delay yang meningkat — bukan langsung mencoba lagi berkali-kali dalam sedetik yang justru bisa memperparah masalah di sisi provider.
Idempotency: Job Harus Aman Dijalankan Dua Kali
Ini bagian yang paling sering diabaikan dan paling penting. Retry berarti job bisa dijalankan lebih dari satu kali untuk data yang sama — job handler-mu harus aman terhadap itu.
// ✗ Tidak idempotent — kalau job retry, customer dapat 2 email
async function sendOrderConfirmation(job) {
await sendEmail({ to: job.data.customerEmail, subject: '...' });
}
// ✓ Idempotent — cek dulu apakah sudah pernah dikirim
async function sendOrderConfirmation(job) {
const order = await OrderService.findById(job.data.orderId);
if (order.confirmationEmailSentAt) {
return; // sudah pernah dikirim, tidak usah kirim lagi
}
await sendEmail({ to: job.data.customerEmail, subject: '...' });
await OrderService.update(order.id, { confirmationEmailSentAt: new Date() });
}Pola yang sama berlaku untuk operasi apapun yang punya efek samping di luar sistem (charge kartu kredit, kirim SMS, update inventory eksternal) — selalu tanyakan "apa yang terjadi kalau job ini jalan dua kali?" sebelum menganggap implementasinya selesai.
jobId: Cegah Duplikasi di Level Queue
// Kalau job dengan jobId yang sama sudah ada di queue, tidak akan ditambahkan lagi
await emailQueue.add(
'order-confirmation',
{ orderId: order.id, customerEmail: order.customerEmail },
{ jobId: `order-confirmation-${order.id}` }
);Berguna untuk mencegah race condition di level aplikasi — misalnya endpoint yang tidak sengaja dipanggil dua kali oleh client yang retry request HTTP-nya sendiri.
Bab 5: Delayed Job dan Repeatable Job
Delayed Job: Jalankan Nanti, Bukan Sekarang
// Kirim email reminder 24 jam setelah order dibuat, kalau belum dibayar
await paymentQueue.add(
'payment-reminder',
{ orderId: order.id },
{ delay: 24 * 60 * 60 * 1000 } // 24 jam dalam milidetik
);Repeatable Job: Pengganti Cron di Dalam Aplikasi
// src/queues/report.queue.js
import { Queue } from 'bullmq';
import { redisConnection } from './connection.js';
export const reportQueue = new Queue('report', { connection: redisConnection });
// Daftarkan sekali saat aplikasi start — BullMQ yang menjadwalkan otomatis
await reportQueue.add(
'daily-sales-report',
{},
{
repeat: { pattern: '0 1 * * *' }, // setiap jam 1 pagi, format cron
jobId: 'daily-sales-report', // jobId tetap — mencegah duplikasi schedule
}
);Kapan pakai ini, kapan tetap pakai node-cron atau cron OS: repeatable job BullMQ unggul kalau job itu juga perlu retry, tracking history, dan monitoring yang sama seperti job biasa. Untuk skrip sederhana yang tidak butuh reliability tinggi, cron biasa masih valid dan lebih sedikit moving part.
Bab 6: Monitoring Queue dengan Bull Board
Kenapa Perlu Dashboard
Tanpa visibilitas, kamu tidak tahu ada job yang menumpuk atau terus gagal sampai user komplain "email konfirmasi saya tidak pernah datang". Bull Board memberi dashboard visual — job apa saja yang aktif, selesai, gagal, dan kenapa.
npm install @bull-board/express @bull-board/api// src/app.js (tambahan)
import { createBullBoard } from '@bull-board/api';
import { BullMQAdapter } from '@bull-board/api/bullMQAdapter.js';
import { ExpressAdapter } from '@bull-board/express';
import { emailQueue } from './queues/email.queue.js';
import { reportQueue } from './queues/report.queue.js';
const serverAdapter = new ExpressAdapter();
serverAdapter.setBasePath('/admin/queues');
createBullBoard({
queues: [new BullMQAdapter(emailQueue), new BullMQAdapter(reportQueue)],
serverAdapter,
});
// Proteksi dengan middleware auth — dashboard ini tidak boleh publik
app.use('/admin/queues', authenticate, requireAdmin, serverAdapter.getRouter());Buka /admin/queues untuk melihat status semua job secara real-time — termasuk stack trace lengkap untuk job yang gagal, tanpa perlu grep log server manual.
Jangan lupa proteksi endpoint ini. Dashboard queue biasanya menampilkan data job mentah (termasuk
job.data, yang bisa berisi informasi customer) — perlakukan seperti endpoint admin lainnya, bukan endpoint publik.
Bab 7: Studi Kasus: Integrasi ke toko-api
Struktur Queue untuk toko-api
src/
├── queues/
│ ├── connection.js
│ ├── email.queue.js
│ └── report.queue.js
├── workers/
│ ├── email.worker.js
│ └── report.worker.js
└── controllers/
└── order.controller.js # menambahkan job, tidak menunggu hasilnya
Worker Laporan Penjualan Harian
// src/workers/report.worker.js
import { Worker } from 'bullmq';
import { redisConnection } from '../queues/connection.js';
import { generateDailySalesReport } from '../services/report.service.js';
const worker = new Worker(
'report',
async (job) => {
if (job.name === 'daily-sales-report') {
const report = await generateDailySalesReport();
// Simpan ke storage, atau kirim lewat email ke owner toko
console.log(`Laporan harian: ${report.totalOrders} order, Rp${report.totalRevenue}`);
}
},
{ connection: redisConnection, concurrency: 1 } // laporan agregat — cukup 1 job berjalan sekaligus
);Menjalankan API Server dan Worker Bersamaan (Development)
// package.json
{
"scripts": {
"dev": "nodemon src/server.js",
"dev:worker": "nodemon src/workers/email.worker.js src/workers/report.worker.js"
}
}Di development, jalankan keduanya di terminal terpisah (atau pakai concurrently). Di production, ini menjadi dua PM2 process berbeda — dibahas di bab berikutnya.
Bab 8: Production Considerations
Concurrency: Berapa Job Paralel per Worker
const worker = new Worker('email', handler, {
connection: redisConnection,
concurrency: 5, // proses maksimal 5 job bersamaan dalam satu worker process
});Angka ini tergantung sifat job — job yang mostly menunggu I/O (panggilan API eksternal seperti email) bisa concurrency tinggi (5-20). Job yang CPU-intensive (generate PDF, resize gambar) sebaiknya concurrency rendah, mendekati jumlah CPU core yang tersedia.
PM2: Worker sebagai Process Terpisah
// ecosystem.config.js (perluasan dari artikel #07)
export default {
apps: [
{
name: 'toko-api',
script: './src/server.js',
instances: 'max',
exec_mode: 'cluster',
},
{
name: 'toko-worker-email',
script: './src/workers/email.worker.js',
instances: 2, // 2 worker process paralel
exec_mode: 'fork', // worker TIDAK pakai cluster mode
},
{
name: 'toko-worker-report',
script: './src/workers/report.worker.js',
instances: 1, // job agregat — cukup satu
exec_mode: 'fork',
},
],
};Worker pakai
exec_mode: 'fork', bukan'cluster'. Cluster mode dirancang untuk server HTTP yang membagi port lewat load balancing — worker BullMQ tidak listen di port manapun, danconcurrencydi kode BullMQ sudah menangani paralelisme di dalam satu process.
Graceful Shutdown
// src/workers/email.worker.js (tambahan)
process.on('SIGTERM', async () => {
console.log('SIGTERM diterima, menyelesaikan job yang sedang berjalan...');
await worker.close(); // tunggu job aktif selesai, jangan terima job baru
process.exit(0);
});Tanpa ini, PM2 reload/restart bisa memutus job di tengah eksekusi — job setengah jalan yang bukan cuma gagal, tapi berpotensi meninggalkan side effect yang tidak konsisten (email terkirim tapi status di database belum terupdate, misalnya).
Dead Letter: Job yang Gagal Permanen
Setelah attempts habis dan job masih gagal, job itu masuk status failed — tidak hilang, tapi juga tidak diproses lagi otomatis.
// Ambil semua job yang gagal permanen untuk investigasi manual
const failedJobs = await emailQueue.getFailed();
for (const job of failedJobs) {
console.log(job.id, job.name, job.failedReason);
}
// Setelah root cause diperbaiki, retry manual
await failedJobs[0].retry();Job yang gagal terus-menerus di production adalah sinyal yang harus masuk ke monitoring/alerting (artikel monitoring-observability) — jangan menunggu ditemukan manual lewat Bull Board.
Penutup
toko-api sekarang memisahkan pekerjaan yang harus selesai sebelum response (buat order) dari yang tidak perlu (email, laporan) — user dapat response lebih cepat, dan pekerjaan background punya retry otomatis alih-alih hilang diam-diam saat gagal.
Checklist Sebelum Bilang Background Job-mu "Production-Ready"
- Job handler idempotent — aman dijalankan dua kali untuk data yang sama
-
attempts+backoffterpasang untuk job yang bisa gagal karena faktor eksternal (API pihak ketiga, network) - Worker berjalan sebagai process terpisah dari API server (
exec_mode: 'fork', bukan bagian dari cluster app) - Graceful shutdown (
SIGTERMhandler) supaya job tidak terputus di tengah jalan saat deploy - Dashboard monitoring (Bull Board) terpasang dan diproteksi auth
- Ada rencana untuk failed job — bukan dibiarkan menumpuk tanpa pernah dicek
Langkah selanjutnya:
- Realtime notification — setelah job selesai, beri tahu user secara langsung tanpa refresh (artikel websocket-realtime)
- Monitoring & alerting — tahu kalau queue mulai menumpuk atau failure rate naik, sebelum user yang mengabari (artikel monitoring-observability)