Memindahkan pekerjaan berat keluar dari request handler: BullMQ dengan Redis untuk job queue, worker process terpisah, retry dan error handling, serta cron untuk pekerjaan terjadwal.

Sejauh ini semua pekerjaan berjalan di dalam request handler — dari membaca database sampai mengirim response. Episode 22 memperkenalkan pemisahan penting: pekerjaan yang tidak boleh memblokir request. Kirim email, generate laporan PDF, resize gambar, sinkronisasi dengan layanan lain — semuanya butuh waktu dan tidak perlu membuat client menunggu.
Mengapa queue, bukan sekadar Promise fire-and-forget? Karena pekerjaan latar belakang butuh tiga hal yang tidak dimiliki Promise biasa: persistensi (pekerjaan tidak hilang saat server restart), retry (gagal otomatis diulang), dan ketersediaan antar instance. BullMQ di atas Redis menyediakan semuanya — dan Redis sudah kalian miliki dari episode 20-21.
Tiga komponen dengan peran berbeda:
queue.add).Pemisahan producer dan worker membuat request handler tetap cepat — ia hanya menambahkan job dan langsung mengembalikan response.
npm install bullmqimport { Queue } from "bullmq"
import { config } from "../config.js"
export const emailQueue = new Queue("email", {
connection: { url: config.redisUrl },
})
export const sendEmailJob = async (to, subject, html) => {
await emailQueue.add(
"send-email",
{ to, subject, html },
{
attempts: 3,
backoff: { type: "exponential", delay: 5000 },
}
)
}Dua opsi job yang menentukan ketahanan:
attempts: 3 — job dicoba ulang hingga 3 kali saat gagal.backoff — jeda antar percobaan makin lama (5s, 10s, ...) agar layanan yang error sempat pulih.import { sendEmailJob } from "../queues/emailQueue.js"
export const register = async (req, res) => {
const user = await User.create(req.body)
await sendEmailJob(user.email, "Selamat datang!", "<p>Terima kasih...</p>")
res.status(201).json({ data: user })
}Perhatikan: handler mengembalikan 201 segera — email dikirim asinkron oleh worker. Latensi request tidak tersentuh pekerjaan yang butuh detik.
Worker berjalan di proses sendiri (file entry point terpisah), sehingga crash di worker tidak memengaruhi server HTTP:
import { Worker } from "bullmq"
import { config } from "../config.js"
const worker = new Worker(
"email",
async (job) => {
const { to, subject, html } = job.data
await sendMail({ to, subject, html })
console.log(`email ${job.id} terkirim ke ${to}`)
},
{ connection: { url: config.redisUrl }, concurrency: 5 }
)
worker.on("completed", (job) => {
console.log(`job ${job.id} selesai`)
})
worker.on("failed", (job, err) => {
console.error(`job ${job.id} gagal: ${err.message}`)
})
console.log("email worker siap")concurrency: 5 — worker memproses 5 job sekaligus (tidak berurutan).completed/failed memberi visibilitas (dipasang ke logging episode 17 di produksi).{
"scripts": {
"dev": "node --watch src/server.js",
"dev:worker": "node --watch src/workers/emailWorker.js",
"start:worker": "node src/workers/emailWorker.js"
}
}Di produksi, worker dijalankan sebagai proses terpisah (PM2 — episode 24). Karena worker stateless (hanya bergantung pada job di Redis), menambah instance worker = menambah paralelisme.
Note
Memisahkan worker ke proses berbeda adalah keputusan arsitektur penting: request handler tidak pernah terblokir pekerjaan berat, dan skala worker bisa dinaikkan/turunkan terpisah dari skala server HTTP. Ini pola dasar yang sama di balik semua sistem job modern.
BullMQ memisahkan dua jenis kegagalan:
attempts: 1.export const EMAIL_VALIDATION = "EMAIL_VALIDATION"
worker = new Worker(
"email",
async (job) => {
const { to, html } = job.data
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(to)) {
const err = new Error("Email tidak valid")
err.name = EMAIL_VALIDATION
throw err
}
await sendMail({ to, html })
},
{
connection: { url: config.redisUrl },
removeOnFail: { count: 100 },
}
)Perhatikan removeOnFail: { count: 100 } — BullMQ default menyimpan seluruh job gagal; tanpa batas, Redis penuh oleh job yang gagal. Atur batas retensi.
Beberapa pekerjaan berjalan terjadwal — ringkasan harian, pembersihan data, pembuatan laporan. BullMQ mendukung repeatable job:
import { Queue } from "bullmq"
import { config } from "../config.js"
export const reportQueue = new Queue("report", {
connection: { url: config.redisUrl },
})
export const scheduleDailyReport = async () => {
await reportQueue.add(
"daily-report",
{ date: new Date().toISOString().slice(0, 10) },
{
repeat: { pattern: "0 6 * * *" },
jobId: "daily-report",
}
)
}pattern: "0 6 * * *" adalah cron — setiap hari pukul 06:00. jobId unik mencegah duplikasi (menambahkan job dengan id yang sama = update, bukan duplikat).
Data job adalah input seperti halnya body request — validasi dengan zod (episode 13) di dalam worker:
import { z } from "zod"
const reportSchema = z.object({
date: z.string().date(),
scope: z.enum(["daily", "weekly"]).default("daily"),
})
async function processReport(job) {
const { date, scope } = reportSchema.parse(job.data)
// ...
}Worker yang memvalidasi inputnya sendiri mencegah job rusak memproses data tidak terduga — dan menghentikan lebih awal, bukan gagal di tengah jalan.
Warning
Jangan pernah menjalankan pekerjaan berat langsung di dalam handler hanya karena "sementara". Thread utama Node.js adalah single-threaded untuk JavaScript — satu job sinkron yang lambat akan menunda semua request lain. Queue memindahkan beban ini ke proses yang tepat.
Promise yang tidak di-await bisa hilang saat proses restart. Queue menyimpan job di Redis — restart server tidak kehilangan pekerjaan.
Menjalankan worker di server.js membuat server HTTP dan worker berebut resource. Pisahkan proses (file terpisah, PM2 terpisah).
Job gagal tanpa removeOnFail memenuhi Redis selamanya. Batasi retensi dan pantau queue (Bull Board / metrics episode 25).
Repeatable job yang ditambahkan dari banyak instance bisa terdaftar banyak kali — jobId unik mencegah ini. Selalu set jobId untuk repeatable job.
Episode 22 memindahkan pekerjaan berat keluar dari request: BullMQ dengan Redis sebagai antrian persisten, producer Express yang mengantre job, worker proses terpisah dengan concurrency, retry dengan backoff, dan cron untuk pekerjaan terjadwal.
Inti yang harus dibawa pulang:
queue.add) memisahkan request handler dari pekerjaan berat.concurrency.removeOnFail mencegah Redis penuh job gagal.jobId unik agar tidak terduplikasi lintas instance.Di episode 23 selanjutnya kita akan menyempurnakan API list: pagination, filtering & versioning — limit/offset dan cursor, filter dan sort, serta API versioning yang benar. Sampai jumpa di episode 23!