Hello Engineers, in this article we will see how Redis can be used as a job scheduler.
Most of us mainly use Redis to cache very frequently accessed data, but beyond data caching, Redis has lots of other applications as well. Some of them are listed below:
- As a scheduler (topic of the current article)
- As a cron service
- As a message queue like SQS
- For message delivery with publish/subscribe (Pub/Sub) in real-time applications
So in this article, we will see its job scheduler use case.
We'll build the examples with Node.js (TypeScript) and BullMQ, but the concepts apply to any language with a Redis job library.
Why Redis?
- Cost perspective
Say you're a small or medium-sized business whose application handles up to a million requests a day, and you already use Redis for caching. Adding a job queue costs you almost nothing: no new service to provision, no new bill, and no new system for your team to learn and monitor. At roughly 12 requests per second on average, your workload uses a tiny fraction of what a single Redis instance can handle. (Reusing your cache instance? See the configuration tip below.)
- Precise and flexible timing
Jobs fire within milliseconds of their scheduled time, and delays can range from a few milliseconds to weeks. Many managed alternatives have limits here. AWS SQS, for example, caps message delays at 15 minutes.
- Speed
Because Redis works in memory, scheduling and claiming a job typically takes under a millisecond. A single instance can handle tens of thousands of job operations per second, which is far more than most applications need.
- Mature ecosystem
You rarely need to build a scheduler yourself. Battle-tested libraries exist for most languages: BullMQ for Node.js, Sidekiq for Ruby, Celery and RQ for Python, and Asynq for Go. They provide retries with backoff, attempt limits, priorities, rate limiting, repeatable jobs, and monitoring dashboards out of the box.
- Simple mental model
The core pattern is easy to understand and debug: a sorted set where each job's score is derived from the time it should run. You can inspect pending jobs with a single command (ZRANGE bull:<queue>:delayed 0 -1 WITHSCORES), which makes troubleshooting straightforward compared to black-box managed services. (BullMQ stores the run time multiplied by 4096, so divide a score by 4096 to get the timestamp in milliseconds.)
- One tool, many jobs
Job scheduling usually doesn't come alone. You often also need rate limiting (so you don't flood a customer's webhook endpoint), distributed locks, deduplication, and caching. Redis handles all of these, so a single piece of infrastructure covers several needs.
- Portability
Redis runs anywhere: locally in Docker, on any cloud, or through managed services like AWS ElastiCache, Google Memorystore, and Azure Cache for Redis. Unlike cloud-specific options such as Google Cloud Tasks or AWS SQS, it doesn't lock you into one provider.
Configuration tip: memory policy
If you reuse your caching Redis for jobs (see point 1), check one setting first: caches and queues want opposite settings. A cache typically uses an eviction policy like allkeys-lru, which deletes old keys when memory fills up. A queue needs noeviction, or your jobs can silently disappear. So either run the queue on a separate Redis instance, or make sure the shared instance is configured for the queue's needs. (A separate database inside the same instance is not enough: the eviction policy applies to the whole instance, so cache traffic can still evict your jobs.)
What does that setting actually mean?
-
maxmemory-policyis the Redis setting that decides what happens when Redis reaches its memory limit (maxmemory). Options includeallkeys-lru(delete the least recently used keys),volatile-lru(delete the least recently used keys that have a TTL), and others. -
noevictionmeans Redis never deletes data to free memory. Once the limit is reached, it rejects new writes with an error (OOM command not allowed when used memory > 'maxmemory'), but keeps everything already stored. That's the safe behavior for a job queue, since your jobs won't vanish. -
noevictionis Redis's own built-in default, so a fresh Redis (including the official Docker image) is already queue-safe. The risk comes when a shared instance is switched toallkeys-lrufor caching. - Two things to keep in mind: keys that have a TTL still expire as normal, because expiry is not eviction. And by default
maxmemoryis0(no limit), so Redis keeps using memory until the machine runs out. In production, setmaxmemoryexplicitly.
Does Redis work for large applications as well? Yes, of course, but you can't run Redis as a plain instance, because at large scale durability matters: a crash or restart without the right persistence settings can lose jobs.
At this scale, a bad configuration can lose all the scheduled jobs, causing permanent data loss.
Check the "Can I use it for very large applications?" section in Part 4 for more details.
Code Example
Now it's time to see Redis in action.
In this demo, we'll use BullMQ (a Node.js job queue library built on Redis) to schedule a job with a delay and process it later.
Workspace setup
Let's set up the workspace first, so you can run every example yourself.
Tested with Node.js 22, BullMQ 6.3, ioredis 6.0, TypeScript 7.0 and Redis 7.
1. Install Node.js
Install Node.js 22 or later (a current LTS release) from nodejs.org, then check it:
node --version 2. Create the project and install the packages
mkdir bullmq-demo cd bullmq-demo npm init -y npm install bullmq ioredis npm install -D typescript tsx @types/node -
bullmq— the job queue library. -
ioredis— the Redis client BullMQ uses. Since BullMQ 6 it is an optional dependency, so you must install it yourself. Without it, the worker crashes on start withBullMQ could not load the optional 'ioredis' package. -
typescript,@types/node— the TypeScript compiler and Node.js types. -
tsx— runs.tsfiles directly, without a build step.
3. Add tsconfig.json
Create tsconfig.json in the project root:
{ "compilerOptions": { "target": "ES2022", "module": "nodenext", "moduleResolution": "nodenext", "types": ["node"], "strict": true, "esModuleInterop": true, "skipLibCheck": true, "noEmit": true }, "include": ["src"] } -
"types": ["node"]is required since TypeScript 6, which no longer loads@types/nodeautomatically. Without it you get errors likeCannot find name 'process'. -
"noEmit": true, becausetsxruns the code;tscis only used to type-check:
npx tsc --noEmit 4. Start Redis
BullMQ needs a running Redis server. Pick one of the two options below.
Option A — plain Docker command
# start Redis in the background on port 6379 docker run -d --name bullmq-redis -p 6379:6379 redis:7-alpine # check it is up (should print PONG) docker exec bullmq-redis redis-cli ping # when you are done: stop and remove the container docker stop bullmq-redis docker rm bullmq-redis Option B — Docker Compose
Create docker-compose.yaml in the project root:
services: redis: image: redis:7-alpine # Turn on the append-only file, so scheduled jobs survive a restart # (the image's default is periodic snapshots only; see Part 4) command: redis-server --appendonly yes ports: - '6379:6379' volumes: - redis-data:/data volumes: redis-data: Then:
# start Redis in the background docker compose up -d # check it is up (should print PONG) docker compose exec redis redis-cli ping # when you are done: stop it (add -v to also delete the stored data) docker compose down Port 6379 already in use? Another Redis is probably running on your machine. Stop it, or map a different host port (
-p 6380:6379, or"6380:6379"in the compose file) and setport: 6380inconnection.ts.The
redis:7-alpineimage usesmaxmemory-policy noevictionby default, which is the setting a job queue needs (see the configuration tip in Part 1).
maxmemory-policyis the Redis setting that decides what happens when Redis reaches its memory limit (maxmemory). Options includeallkeys-lru(delete the least recently used keys),volatile-lru(delete the least recently used keys that have a TTL), and others.noevictionmeans Redis never deletes data to free memory. Once the limit is reached, it rejects new writes with an error (OOM command not allowed when used memory > 'maxmemory'), but keeps everything already stored.- So if you run that image without changing anything, Redis will never delete your keys to free memory. That's the safe behavior for a job queue, since your jobs won't vanish. (
noevictionis actually Redis's own built-in default; the image just doesn't override it.)- Two things to keep in mind: keys that have a TTL still expire as normal, because expiry is not eviction. And by default
maxmemoryis0(no limit), so Redis keeps using memory until the container or machine runs out. In production, setmaxmemoryexplicitly, so you get the clear OOM error above instead of the process being killed.
5. Project structure
By the end of this part, your project will look like this:
bullmq-demo/ docker-compose.yaml (only if you use Option B) package.json tsconfig.json src/ connection.ts worker.ts scheduler.ts workerManualRetry.ts The code
First, connection.ts. It contains the shared connection and the job data interface required for this demo.
// src/connection.ts // Shared Redis connection for the article demo export const connection = { host: '127.0.0.1', port: 6379, }; export const QUEUE_NAME = 'demo-reminders'; // Shape of the job body (what the scheduler passes as `data`). // The retry settings travel WITH the job, so the manual retry worker // (workerManualRetry.ts) can decide on its own whether to reschedule and how // long to wait. export interface ReminderJobData { orderId: string; reason: string; attempt: number; // which attempt this is, 1-based maxAttempts: number; // how many attempts to make in total before giving up retryDelayMs: number; // how long to wait before the next attempt } Now worker.ts.
The worker.ts is basically a long-running program: it keeps listening to the queue, and processes each job once it becomes ready (here, after its delay has elapsed).
// src/worker.ts import { Worker } from 'bullmq'; import { connection, QUEUE_NAME } from './connection'; // The WORKER is the consumer side of the queue: it pulls jobs from Redis and processes them. // Basically the worker contains the business logic to process the job's data, like sending emails, calling APIs and the database // It does not reschedule or mark the job itself: returning normally marks the job completed, and throwing marks it failed — BullMQ then retries it according to the job's attempts/backoff options // Keep it running in one terminal, then schedule jobs from another. // The worker continuously pulls jobs for processing // // A Worker is bound to exactly ONE queue. If you have several queues you need to create a separate worker instance for every queue name // The first argument is the queue name (Example: "DEMO_QUEUE") // The second argument is the async function which contains the business logic // The third argument is the connection configuration const worker = new Worker( QUEUE_NAME, async (job) => { // job.id — unique id of this job in the queue // job.name — the job's name (set by queue.add('reminder', ...) in // scheduler.ts); use it to route different job types handled // by the same worker // job.data — the payload passed when the job was scheduled console.log('Processing queue:', QUEUE_NAME); console.log(`[${new Date().toLocaleTimeString()}] processing job ${job.id}:`, job.data); }, { connection }, ); // Lifecycle events. The worker keeps a long-lived connection to Redis and emits // an event for every job it finishes. worker.on('completed', (job) => console.log(` done: ${job.id}`)); // job succeeded worker.on('failed', (job, err) => console.error(` failed: ${job?.id} — ${err.message}`)); // job threw // Redis connection problems are reported here. Without this listener, BullMQ // prints a raw stack trace on every reconnect attempt. worker.on('error', (err) => console.error(' worker error:', err.message)); Now scheduler.ts.
The scheduler is used to add jobs to the queue (and to cancel them before they run).
Run it to add a job to the queue.
// src/scheduler.ts import { Queue } from 'bullmq'; import { connection, QUEUE_NAME } from './connection'; // A Queue is the producer-side handle to a queue in Redis: you use it to ADD // jobs and to look them up / remove them. It does not process anything — that's // the worker's job. // arg 1 — queue name (must match the worker's queue name) // arg 2 — options, including the Redis connection const queue = new Queue(QUEUE_NAME, { connection }); // Redis connection problems are reported here. Without this listener, BullMQ // prints a raw stack trace on every reconnect attempt. queue.on('error', (err) => console.error('queue error:', err.message)); // The SCHEDULER enqueues a delayed job, or cancels one before it runs. // npx tsx src/scheduler.ts -> schedule a job (fires in 5s) // npx tsx src/scheduler.ts cancel <id> -> cancel that job async function main() { // Read CLI args: e.g. `... scheduler.ts cancel 42` -> action="cancel", jobId="42" const [action, jobId] = process.argv.slice(2); if (action === 'cancel') { // getJob(id) looks a job up by id. Returns the Job, or undefined if it has // already run or was removed. const job = await queue.getJob(jobId); if (job) { // remove() deletes the job from the queue. For a delayed job that hasn't // run yet, this cancels it — the worker will never see it. await job.remove(); console.log(`cancelled job ${jobId} — it will not run`); } else { console.log(`no job ${jobId} found (already ran or removed)`); } } else { const delayMs = 5000; // add() puts a new job on the queue. // arg 1 — job name ('reminder'): a label the worker can switch on to // handle different job types on the same queue. // arg 2 — data: the payload, must be JSON-serializable. The worker reads // this as job.data. The `attempt` / `maxAttempts` / `retryDelayMs` // fields are only used by the manual retry worker // (workerManualRetry.ts); the basic worker just logs them. // arg 3 — options: here `delay` (ms) holds the job back so it only becomes // available to the worker after that time. Other options live here // too: `attempts`, `backoff`, `jobId`, `priority`, etc. (Recurring // jobs are not an add() option — use queue.upsertJobScheduler().) const job = await queue.add( 'reminder', { orderId: 'order-123', reason: 'unpaid', attempt: 1, maxAttempts: 3, retryDelayMs: 3000, }, { delay: delayMs }, ); console.log(`scheduled job ${job.id} to run in ${delayMs / 1000}s`); console.log(`cancel it with: npx tsx src/scheduler.ts cancel ${job.id}`); } // Close the connection so this short-lived script can exit (the worker, by // contrast, stays open to keep processing). await queue.close(); } main(); In the above example, you can see how Redis can be used for simple job scheduling.
Now, what if your job fails during processing?
You need to retry it. In the next example, I'll show you how to retry a failed job.
Manual job retry example
Here the job body carries the retry settings, and the worker reschedules the job itself when the work fails.
// src/workerManualRetry.ts // // MANUAL RETRY EXAMPLE — an alternative to worker.ts. // // Instead of letting BullMQ retry (via `attempts` + `backoff` in the job // options), the retry settings travel in the JOB BODY and this worker decides // for itself whether to try again. On failure it adds a NEW job to the same // queue with attempt + 1, delayed by retryDelayMs. // // Run EITHER this worker OR worker.ts — not both. They consume the same queue, // so if both run they will compete for the same jobs. // // Trade-offs vs BullMQ's built-in retry: // * the retry is a NEW job with a NEW id (built-in retry reuses the same job // id and increments job.attemptsMade) // * because we CATCH the error, BullMQ never sees a failure — every attempt // is recorded as 'completed' and the 'failed' event never fires // Use this only when you need that control; otherwise prefer `attempts` + `backoff`. import { Queue, Worker } from 'bullmq'; import { connection, QUEUE_NAME, ReminderJobData } from './connection'; // A Queue handle, so the worker can put the NEXT attempt back on the same queue. // Only needed because we are retrying manually. const queue = new Queue(QUEUE_NAME, { connection }); queue.on('error', (err) => console.error(' queue error:', err.message)); // Simulated work — this is the part that can fail in real life (SMTP down, // payment API timing out, etc.). It fails on attempt 1 and succeeds from // attempt 2, so the retry path is easy to watch in the demo. async function sendReminder(data: ReminderJobData): Promise<void> { if (data.attempt === 1) { throw new Error('payment provider timed out'); } console.log(` reminder sent for ${data.orderId} (${data.reason})`); } const worker = new Worker<ReminderJobData>( QUEUE_NAME, async (job) => { // The retry settings come from the job body, not the job options. const { attempt, maxAttempts, retryDelayMs } = job.data; console.log('Processing queue:', QUEUE_NAME); console.log( `[${new Date().toLocaleTimeString()}] job ${job.id} — attempt ${attempt}/${maxAttempts}`, ); try { await sendReminder(job.data); console.log(` success on attempt ${attempt}`); } catch (err) { const reason = (err as Error).message; if (attempt < maxAttempts) { // RESCHEDULE: add a NEW job to the same queue carrying an incremented // attempt counter, delayed by retryDelayMs. That delayed job becomes // "attempt 2", and this one finishes normally. // arg 1 — same job name, so the same worker logic handles it // arg 2 — the same body, with attempt bumped by 1 // arg 3 — delay: wait this long before the retry becomes available await queue.add(job.name, { ...job.data, attempt: attempt + 1 }, { delay: retryDelayMs }); console.log( ` attempt ${attempt} failed (${reason}) — retrying in ${retryDelayMs / 1000}s as attempt ${attempt + 1}`, ); } else { // No attempts left: stop here instead of scheduling another job. console.error(` attempt ${attempt} failed (${reason}) — no attempts left, giving up`); } } }, { connection }, ); // NOTE: 'completed' fires for every attempt here — including the ones whose // work failed — because the error was caught above. 'failed' only fires for // errors we did not catch. worker.on('completed', (job) => console.log(` done: ${job.id}`)); worker.on('failed', (job, err) => console.error(` failed: ${job?.id} — ${err.message}`)); // Redis connection problems are reported here. Without these listeners, BullMQ // prints a raw stack trace on every reconnect attempt. worker.on('error', (err) => console.error(' worker error:', err.message)); From demo to a real app
In Part 2, we scheduled a job, cancelled it, and retried a failed job manually.
In this part, we'll see:
- The easier, recommended way to retry: BullMQ's built-in retry.
- A real example from an Express app: unpaid order reminders.
- A few tips before you take it to production.
Built-in retry (the recommended way)
With the manual retry, the worker caught the error and added a new job by itself.
BullMQ can do this for you: set attempts and backoff when you add the job, and simply throw from the worker when the work fails. BullMQ then retries the same job automatically.
In src/scheduler.ts, add attempts and backoff to the job options:
const job = await queue.add( 'reminder', { orderId: 'order-123', reason: 'unpaid', attempt: 1, maxAttempts: 3, retryDelayMs: 3000, }, { delay: delayMs, attempts: 3, backoff: { type: 'exponential', delay: 1000 } }, ); -
attempts: 3— try the job at most 3 times in total (the first try + 2 retries). -
backoff— how long to wait between tries.exponentialwithdelay: 1000waits 1s before the 2nd try and 2s before the 3rd (the wait doubles each time). Usetype: 'fixed'to wait the same time every try.
The
attempt,maxAttemptsandretryDelayMsfields in the body are only used by the manual retry worker; the built-in retry ignores them.
Now the worker. Notice there is no try/catch and no re-adding of the job:
// src/workerBuiltInRetry.ts // // BUILT-IN RETRY EXAMPLE — the recommended way. // // The worker simply THROWS when the work fails. BullMQ sees the failure and // retries the SAME job automatically, using the `attempts` and `backoff` // options given when the job was added (see scheduler.ts). // // Run EITHER this worker OR worker.ts / workerManualRetry.ts — not more than // one. They consume the same queue, so they would compete for the same jobs. import { Worker } from 'bullmq'; import { connection, QUEUE_NAME } from './connection'; // Simulated work — fails on the first attempt and succeeds on the second, so // the retry is easy to watch. Source:https://dev.to/tarun_koshti_122/beyond-caching-scheduling-jobs-with-redis-nodejs-and-bullmq-31a5