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.

Before: BullMQ setup (4 files)
// 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

After: Runlater (1 file)
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.

BullMQ
await queue.add("send-reminder", { userId: "123" }, {
  delay: 60 * 60 * 1000, // 1 hour in ms
  attempts: 3,
  backoff: { type: "exponential", delay: 5000 },
})
Runlater
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.

BullMQ
// 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 })
Runlater
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.

BullMQ
await queue.add("process-upload", {
  fileUrl: "https://s3.../file.csv",
  userId: "123",
})
Runlater
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.

BullMQ
const pdfQueue = new Queue("pdf-generation", { connection })

new Worker("pdf-generation", handler, {
  connection,
  concurrency: 1, // Sequential processing
})

await pdfQueue.add("generate", { invoiceId: "456" })
Runlater
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.

BullMQ
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 }),
  })
})
Runlater
await rl.send("https://api.example.com/heavy-work", {
  body: { data: "..." },
  callback_url: "https://myapp.com/api/job-done",
})

Migration checklist

  1. 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.
  2. 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-xyz endpoint that does the work and returns a status code.
  3. Replace queue.add() with rl.send() / rl.delay(). Swap each queue.add() call with the matching Runlater SDK method.
  4. Replace repeatable jobs with rl.cron(). Move cron patterns to Runlater. Use rl.sync() to manage all recurring tasks declaratively.
  5. Delete the infrastructure. Remove the Redis instance, worker process, queue definitions, connection config, and BullMQ dependency. Remove the worker from your deployment scripts.
  6. Protect your endpoints. Your API routes are now public URLs. Verify requests come from Runlater by checking the webhook signature in the x-runlater-signature header.

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.

Ready to simplify? Create a free account, install runlater-js, and migrate your first queue in minutes.

Back to all guides