Replace BullMQ + Redis with One API Call
If your BullMQ workers just make HTTP calls, you don't need a queue. You need Runlater.
What you're removing
A typical BullMQ setup requires a Redis instance, a queue definition, a worker process, and a connection config — all running 24/7 alongside your app. For many teams, this entire stack exists just to send a delayed HTTP request.
// lib/queue.ts — Queue definition import { Queue } from "bullmq" import { connection } from "./redis" export const emailQueue = new Queue("emails", { connection }) // lib/redis.ts — Redis connection export const connection = { host: process.env.REDIS_HOST || "localhost", port: 6379, password: process.env.REDIS_PASSWORD, } // workers/email-worker.ts — Worker process import { Worker } from "bullmq" import { connection } from "../lib/redis" const worker = new Worker( "emails", async (job) => { await fetch("https://api.mailprovider.com/send", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(job.data), }) }, { connection, concurrency: 5, } ) worker.on("failed", (job, err) => { console.error(`Job ${job?.id} failed:`, err) }) // api/signup.ts — Producer import { emailQueue } from "../lib/queue" await emailQueue.add( "welcome", { to: user.email, template: "welcome" }, { delay: 30 * 60 * 1000, attempts: 3 } )
That's a Redis instance to provision, a worker process to keep running, connection management, error handling, and monitoring — all for a delayed HTTP call.
What you're replacing it with
import Runlater from "runlater-js" const rl = new Runlater(process.env.RUNLATER_KEY!) // Send welcome email in 30 minutes await rl.delay("https://api.mailprovider.com/send", { delay: "30m", body: { to: user.email, template: "welcome" }, retries: 3, })
No Redis. No worker process. No connection config. No separate deploy.
What you can delete
| Component | BullMQ | Runlater |
|---|---|---|
| Redis instance | Required | Not needed |
| Worker process | Must run 24/7 | Not needed |
| Queue definitions | One per job type | Not needed |
| Connection management | Host, port, password | One API key |
| Error handling | Custom per worker | Built-in retries |
| Monitoring | Bull Board or custom | Dashboard included |
| Infrastructure cost | Redis hosting ($15-50/mo) | Free tier or €29/mo |
Pattern 1: Delayed jobs
The most common BullMQ pattern — run something after a delay.
await queue.add("send-reminder", { userId: "123" }, { delay: 60 * 60 * 1000, // 1 hour in ms attempts: 3, backoff: { type: "exponential", delay: 5000 }, })
await rl.delay("https://myapp.com/api/send-reminder", { delay: "1h", body: { userId: "123" }, retries: 3, })
Pattern 2: Recurring jobs (cron)
BullMQ repeatable jobs require a running worker to pick them up. Runlater calls your URL on schedule — no worker needed.
// Queue + worker must both be running await queue.add("daily-report", {}, { repeat: { pattern: "0 8 * * *" }, }) // Somewhere else, a worker must be running: new Worker("reports", async (job) => { await fetch("https://myapp.com/api/reports/daily", { method: "POST", }) }, { connection })
await rl.cron("daily-report", { url: "https://myapp.com/api/reports/daily", schedule: "0 8 * * *", })
Pattern 3: Immediate background jobs
Fire-and-forget: offload work from the request cycle and return immediately.
await queue.add("process-upload", { fileUrl: "https://s3.../file.csv", userId: "123", })
await rl.send("https://myapp.com/api/process-upload", { body: { fileUrl: "https://s3.../file.csv", userId: "123", }, retries: 5, timeout: 120000, })
Pattern 4: Named queues with concurrency control
BullMQ lets you limit concurrency per queue. Runlater has named queues that process one job at a time in FIFO order — the next job only starts after the previous one completes.
const pdfQueue = new Queue("pdf-generation", { connection }) new Worker("pdf-generation", handler, { connection, concurrency: 1, // Sequential processing }) await pdfQueue.add("generate", { invoiceId: "456" })
await rl.send("https://myapp.com/api/generate-pdf", { body: { invoiceId: "456" }, queue: "pdf-generation", })
Pattern 5: Callbacks on completion
Need to know when a job finishes? BullMQ requires event listeners on the worker. Runlater POSTs the result to a callback URL automatically.
worker.on("completed", async (job) => { await fetch("https://myapp.com/api/job-done", { method: "POST", body: JSON.stringify({ jobId: job.id, result: job.returnvalue }), }) }) worker.on("failed", async (job, err) => { await fetch("https://myapp.com/api/job-failed", { method: "POST", body: JSON.stringify({ jobId: job?.id, error: err.message }), }) })
await rl.send("https://api.example.com/heavy-work", { body: { data: "..." }, callback_url: "https://myapp.com/api/job-done", })
Migration checklist
- Audit your workers. Open each BullMQ worker file. If the handler makes an HTTP call, it's a candidate for Runlater. If it does in-process work (image resizing, PDF generation), wrap it in an API endpoint first.
-
Create API endpoints for in-process work.
Any worker logic that doesn't already call a URL needs a route.
Add a
POST /api/process-xyzendpoint that does the work and returns a status code. -
Replace queue.add() with rl.send() / rl.delay().
Swap each
queue.add()call with the matching Runlater SDK method. -
Replace repeatable jobs with rl.cron().
Move cron patterns to Runlater. Use
rl.sync()to manage all recurring tasks declaratively. - Delete the infrastructure. Remove the Redis instance, worker process, queue definitions, connection config, and BullMQ dependency. Remove the worker from your deployment scripts.
-
Protect your endpoints.
Your API routes are now public URLs. Verify requests come from Runlater by checking the
webhook signature
in the
x-runlater-signatureheader.
When to keep BullMQ
Runlater replaces BullMQ when your workers make HTTP calls. Keep BullMQ if you need:
- In-process work without an HTTP boundary — heavy computation that can't be wrapped in an API endpoint (rare, but possible)
- Sub-second scheduling — Runlater has minute-level precision
- Job priorities within a queue — BullMQ supports priority levels per job, Runlater processes named queues in FIFO order
For most small-to-medium services, these cases don't apply. If your BullMQ worker is
just fetch(url, options),
you're running Redis for nothing.
runlater-js,
and migrate your first queue in minutes.