Hello Engineers, in this article we will see how we can use Redis as a cron service to trigger scripts or APIs at a specific time.
In my last article, I showed how we can use Redis as a job scheduler to schedule delayed jobs and retry failed operations; you can check it here.
To be precise, Redis itself doesn't have a cron feature. A job library like BullMQ (for Node.js) builds it on top of Redis: it stores the schedule in Redis and always keeps the next run waiting there as a delayed job.
We'll build the example with Node.js (TypeScript) and BullMQ: a report generator that runs every night at midnight, aggregates the day's orders, and saves the result in MongoDB.
Part 1: Why Redis?
1. Single platform
If you are already using Redis in your application stack, you can use it as a cron service as well; no additional service is required.
2. Cost
Let's say you are a small enterprise, your workload is simple, and you are already using Redis. In that case, you can extend the Redis functionality and use it as a cron service.
You don't need to pay extra for any third-party service.
3. Runs once, even with many app instances
A cron library like node-cron runs inside your app process. If you run 3 instances of your app, the job runs 3 times: 3 reports, 3 emails. With BullMQ, the schedule lives in Redis and each run is given to exactly one worker, so it runs once, however many instances you have.
4. Survives restarts and deploys
An in-process schedule (node-cron, setInterval) lives in memory. If your app is down at the scheduled time, that run never happens. A BullMQ schedule is stored in Redis, so a deploy or a crash doesn't lose it, and the pending run starts as soon as a worker is back. (For this, Redis itself must keep the data across a restart; we'll configure that in Part 2.)
5. Retries built in
If a run fails, for example because the database is down for a moment, BullMQ retries it with attempts and backoff, the same as any other job. A plain cron just runs your script; if it fails, it stays failed unless you build the retry yourself.
6. You can see what is going on
The schedule and its next run time are stored in Redis, so you can check them any time with queue.getJobSchedulers(). Every run is a normal job, so completed and failed runs are kept as well, and you can see what ran and why something failed.
7. Time zone support
You can give the schedule a time zone: { pattern: '0 0 * * *', tz: 'Asia/Kolkata' } runs at midnight in India, whatever time zone your server uses. A server's crontab uses the server's time zone by default, which is often UTC in the cloud.
One thing to know before you start: if your app is down when a run is due, BullMQ runs it once when the app comes back, but it doesn't run every missed one. If your app was down for 3 runs, you get 1 late run, not 3. We'll see this in action later in this article.
Part 2: Setup
Let's set up everything end to end, so you can run every example yourself. If you already have Redis running, still read step 5: a cron needs two Redis settings that a default or a cache Redis may not have.
Tested with Node.js 22, BullMQ 6.3, ioredis 6.0, TypeScript 7.0 and Redis 7.4.
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-cron-demo
cd bullmq-cron-demo
npm init -y
npm install bullmq ioredis
npm install -D typescript tsx @types/node
-
bullmq: the job queue library. Its Job Schedulers are the cron part, and it already includes the cron parser, so you don't neednode-cronor any other cron 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
Pick one of the two options below. Both turn on persistence (AOF), set a memory limit with the noeviction policy, and keep the data in a Docker volume; step 5 explains why.
Option A: plain Docker command
# start Redis in the background on port 6379, with AOF on and a memory limit
docker run -d --name cron-redis -p 6379:6379 -v cron-redis-data:/data redis:7-alpine redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy noeviction
# check it is up (should print PONG)
docker exec cron-redis redis-cli ping
# when you are done: stop and remove the container (the data stays in the volume)
docker stop cron-redis
docker rm cron-redis
Option B: Docker Compose
Create docker-compose.yaml in the project root. The mongo service is only needed for the full order report app later in this article; the Redis-only examples work without it.
services:
redis:
image: redis:7-alpine
# Turn on the append-only file, so scheduled jobs survive a restart or crash
# (the image's default is periodic snapshots only). Cap memory at 256 MB and
# never evict keys when it's full, so jobs and schedules are never deleted.
command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy noeviction
ports:
- "6379:6379"
volumes:
- redis-data:/data
mongo:
image: mongodb/mongodb-community-server:8.0-ubi9
ports:
- "27017:27017"
volumes:
- mongo-data:/data/db
volumes:
mongo-data:
redis-data:
Then:
# start Redis (and MongoDB) in the background
docker compose up -d
# check Redis 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 use that port in your connection settings.
5. Configure Redis for a cron
The schedule, its next run, and the history of past runs are just keys in Redis. Two settings decide whether those keys can disappear: persistence (what happens on a restart) and the eviction policy (what happens when memory is full).
5.1 Persistence: turn on AOF
Redis keeps its data in memory. By default it saves it to disk only now and then, as snapshots: after 1 hour if at least 1 key changed, after 5 minutes if 100 changed, after 1 minute if 10,000 changed. The official Docker image uses the same default, and the append-only file (AOF) is off. So if Redis restarts, it can come back without your schedule.
Your app won't notice. It creates the schedule only when it starts, so until the next app restart, no run happens and no error is shown.
With AOF on, Redis writes every change to a file on disk and replays it after a restart. In Docker, that is the --appendonly yes in the commands above. In your own redis.conf:
appendonly yes
appendfsync everysec # write to disk every second: a crash loses at most ~1 second
everysec is already the default; it's there so you know the trade-off.
5.2 Memory limit and eviction policy
What is eviction? When Redis reaches its memory limit, it can free memory by deleting keys. That is called eviction. Two settings control it:
-
maxmemoryis the memory limit. The default is0, which means no limit: Redis keeps using memory until the container or machine runs out. -
maxmemory-policydecides what Redis does when it reaches that limit.
Redis 7 has 8 policies:
| Policy | When memory is full, Redis... | Safe for a cron? |
|---|---|---|
noeviction |
deletes nothing, and rejects new writes with an error | Yes |
allkeys-lru |
deletes the least recently used keys | No |
allkeys-lfu |
deletes the least frequently used keys | No |
allkeys-random |
deletes random keys | No |
volatile-lru |
deletes the least recently used keys that have a TTL | No |
volatile-lfu |
deletes the least frequently used keys that have a TTL | No |
volatile-random |
deletes random keys that have a TTL | No |
volatile-ttl |
deletes the keys with a TTL that expire soonest | No |
Why noeviction? A nightly schedule is used once a day, so for an allkeys-* policy it looks like the oldest, least used key in Redis: the first one to go. I tested it: with allkeys-lru and a 3 MB limit, I filled Redis with cache keys. Redis evicted 1,937 keys, and every key of the schedule was among them. queue.getJobSchedulers() returned an empty list, and there was no error anywhere. The cron simply stopped.
The volatile-* policies look safer, because the schedule has no TTL. But BullMQ also uses some keys with a TTL, for example the lock on a running job. If Redis evicts that lock, BullMQ can treat the job as stalled and run it again. So use noeviction for a cron, nothing else.
What happens with noeviction when memory is full? Redis rejects new writes with OOM command not allowed when used memory > 'maxmemory'. I tested this too: queue.add() failed with that error and the worker logged it, but nothing was deleted. The schedule was still there, and the runs went back to normal as soon as memory was freed. An error you can see is much better than a cron that silently disappears.
Some things to keep in mind:
-
Set
maxmemory(the--maxmemory 256mbabove). Without a limit, Redis grows until the container or machine runs out of memory, and the process can be killed instead of giving you the clear OOM error. Pick a size that fits your data with headroom; 256 MB is just for this demo. -
Clean up finished runs. By default, BullMQ keeps completed and failed jobs forever, and every run of a cron is a new job. Set
removeOnCompleteandremoveOnFailon the job (we'll do this in the example), so old runs don't fill your memory. -
A separate database is not enough. If you share one Redis instance between your cache and your cron, putting the cron in another database (
db: 1) doesn't help: the eviction policy applies to the whole instance. Use a separate Redis instance, or set the shared one tonoeviction. -
TTLs still work.
noevictiononly stops eviction. Keys with a TTL still expire as normal, because expiry is not eviction.
How to set it. In Docker, add the flags to the command, as in both options above: --maxmemory 256mb --maxmemory-policy noeviction. In your own redis.conf:
maxmemory 256mb
maxmemory-policy noeviction
You can also change it on a running Redis with redis-cli CONFIG SET maxmemory-policy noeviction, but that change is lost on the next restart. Put it in the command or in redis.conf as well.
6. Check your Redis
Run these against your Redis (with Docker Compose, prefix them with docker compose exec redis):
redis-cli CONFIG GET appendonly # should be: yes
redis-cli CONFIG GET maxmemory-policy # should be: noeviction
redis-cli CONFIG GET maxmemory # 0 = no limit; set one in production
redis-cli INFO memory # used_memory_human = how much is used now
redis-cli INFO stats # evicted_keys should stay 0
7. Using a managed Redis?
Managed services (AWS ElastiCache, Google Memorystore, Azure Cache for Redis, and so on) handle persistence in their own way, and their default eviction policy is not always noeviction. Check both settings in your provider's configuration before you put a cron on it.
Part 3: Code Example
Now let's see the cron in action. We'll write three small files: a scheduler that creates the cron, a worker that runs it, and a shared file with the names. Redis only, no database yet.
So you don't have to wait until midnight, this example runs every 10 seconds. The real order report in the next part runs every night; only the pattern is different.
Project structure
By the end of this part, your project will look like this:
bullmq-cron-demo/
docker-compose.yaml (only if you used Option B in Part 2)
package.json
tsconfig.json
src/
connection.ts
cronQueue.ts
cronScheduler.ts
cronWorker.ts
1. The shared files
connection.ts holds the Redis connection:
// src/connection.ts
export const connection = { host: '127.0.0.1', port: 6379 };
cronQueue.ts holds the names that the scheduler and the worker must agree on:
// src/cronQueue.ts
// Shared names for the cron example (cronScheduler.ts + cronWorker.ts). Uses
// the same Redis connection as the other examples (connection.ts).
export const CRON_QUEUE_NAME = 'demo-reports';
// The scheduler's id. Upserting the same id again updates this one scheduler;
// a different id creates a second one.
export const SCHEDULER_ID = 'demo-report';
2. The scheduler
In BullMQ, a cron is called a Job Scheduler. You create it with queue.upsertJobScheduler(). The scheduler is stored in Redis, and BullMQ always keeps its next run waiting there as a normal delayed job. When that run starts, BullMQ adds the one after it.
// src/cronScheduler.ts
import { Queue } from 'bullmq';
import { connection } from './connection';
import { CRON_QUEUE_NAME, SCHEDULER_ID } from './cronQueue';
const queue = new Queue(CRON_QUEUE_NAME, { connection });
queue.on('error', (err) => console.error('queue error:', err.message));
// Creates, lists or removes a recurring job (a Job Scheduler).
// npx tsx src/cronScheduler.ts -> create or update it
// npx tsx src/cronScheduler.ts list -> show all schedulers
// npx tsx src/cronScheduler.ts remove -> stop it for good
async function main() {
const [action] = process.argv.slice(2);
if (action === 'remove') {
// Removes the scheduler and its next pending run. Nothing runs after this.
await queue.removeJobScheduler(SCHEDULER_ID);
console.log(`removed scheduler ${SCHEDULER_ID}`);
} else if (action === 'list') {
const schedulers = await queue.getJobSchedulers();
for (const s of schedulers) {
console.log(`${s.key}: '${s.pattern}' (${s.tz}), next run ${new Date(s.next!).toLocaleTimeString()}`);
}
} else {
// upsertJobScheduler() = "insert or update". Run this script as many times
// as you like: there is still only ONE scheduler with this id in Redis.
// arg 1 — scheduler id: the key that makes the upsert safe to repeat.
// arg 2 — when to run:
// `pattern` is a cron expression. This one has 6 fields, the first
// is seconds: '*/10 * * * * *' = every 10 seconds. With 5 fields
// ('0 0 * * *' = every day at midnight) there are no seconds.
// `tz` is the time zone the pattern is read in.
// arg 3 — the job template: every run is a normal job with this name,
// data and options (here: BullMQ's built-in retry).
const firstJob = await queue.upsertJobScheduler(
SCHEDULER_ID,
{ pattern: '*/10 * * * * *', tz: 'Asia/Kolkata' },
{
name: 'report',
data: { type: 'order-summary' },
opts: { attempts: 3, backoff: { type: 'exponential', delay: 1000 } },
},
);
console.log(`scheduler ${SCHEDULER_ID} saved, next run: ${firstJob.id}`);
}
// The schedule lives in Redis, not in this process, so the script can exit.
await queue.close();
}
main();
A few things to notice:
-
The script exits. Unlike
node-cron, nothing has to keep running for the schedule to exist. It is stored in Redis, and any worker connected to that Redis will run it. -
It's an upsert, not an add. The scheduler id (
demo-report) is the key. Running the script again updates the same scheduler instead of creating a second one. That's why it's safe to call this on every app start, as we'll do in the real app. -
Every run is a normal job. So everything you know about jobs works here too: retries with
attempts+backoff,removeOnComplete/removeOnFail, and thefailedevent.
3. The worker
A scheduled run is a normal job, so a normal Worker processes it. Nothing cron-specific here, except one field:
// src/cronWorker.ts
import { Worker } from 'bullmq';
import { connection } from './connection';
import { CRON_QUEUE_NAME } from './cronQueue';
// A scheduled run is a normal job, so a normal Worker processes it. Start this
// in one or more terminals: each run is still processed only once, by one of
// them.
const worker = new Worker(
CRON_QUEUE_NAME,
async (job) => {
// `opts.prevMillis` is the time this run was SCHEDULED for. Use it (not
// "now") to decide what period the run covers: a late run or a retry then
// still covers the right period.
const scheduledFor = new Date(job.opts.prevMillis ?? job.timestamp);
console.log(
`[${new Date().toLocaleTimeString()}] ${job.name} for ${scheduledFor.toLocaleTimeString()}:`,
job.data,
);
},
{ connection },
);
worker.on('failed', (job, err) => console.error(` failed: ${job?.id} — ${err.message}`));
worker.on('error', (err) => console.error(' worker error:', err.message));
job.opts.prevMillis is the time the run was scheduled for, not the time it actually started. They are usually the same, but not always: if your worker was busy, restarting, or the run is being retried, it starts later. For a report, "which day does this run cover?" must come from the scheduled time, otherwise a run that starts at 00:00:03 or a retry at 00:05 could report the wrong period.
4. Run it
Start the worker in one terminal and keep it running:
npx tsx src/cronWorker.ts
In a second terminal, create the scheduler:
npx tsx src/cronScheduler.ts
scheduler demo-report saved, next run: repeat:demo-report:1792310610000
The first run's job id is repeat:<scheduler id>:<run time in milliseconds>. Because the run time is part of the id, the same run can never be added twice.
Now the worker prints a run every 10 seconds:
[1:33:30 PM] report for 1:33:30 PM: { type: 'order-summary' }
[1:33:40 PM] report for 1:33:40 PM: { type: 'order-summary' }
[1:33:50 PM] report for 1:33:50 PM: { type: 'order-summary' }
5. Run the scheduler again
Run npx tsx src/cronScheduler.ts a second time, and then list the schedulers:
npx tsx src/cronScheduler.ts
npx tsx src/cronScheduler.ts list
scheduler demo-report saved, next run: repeat:demo-report:1792310610000
demo-report: '*/10 * * * * *' (Asia/Kolkata), next run 1:33:30 PM
Same next run, and still one scheduler. The worker keeps printing one line every 10 seconds, not two.
6. Start a second worker
Start npx tsx src/cronWorker.ts in a third terminal. This is what happens when you run several instances of your app. Here's what both workers printed:
worker 1: [1:34:00 PM] report for 1:34:00 PM: { type: 'order-summary' }
worker 2: [1:34:10 PM] report for 1:34:10 PM: { type: 'order-summary' }
worker 1: [1:34:20 PM] report for 1:34:20 PM: { type: 'order-summary' }
worker 2: [1:34:30 PM] report for 1:34:30 PM: { type: 'order-summary' }
Each run went to exactly one of them. With node-cron in two app instances, every line would show up twice.
7. Stop it
npx tsx src/cronScheduler.ts remove
npx tsx src/cronScheduler.ts list
removed scheduler demo-report
The list is empty and the workers stop printing. Stopping the workers alone would not do this: the scheduler stays in Redis, and its next run waits there until a worker comes back.
Cron pattern cheat sheet
A pattern has 5 fields: minute, hour, day of month, month, day of week. Add a sixth field at the front for seconds. The times below are in the scheduler's tz.
| Pattern | Runs |
|---|---|
*/10 * * * * * |
every 10 seconds (6 fields) |
*/15 * * * * |
every 15 minutes |
0 * * * * |
every hour, on the hour |
0 0 * * * |
every day at midnight |
30 9 * * 1-5 |
Monday to Friday at 09:30 |
0 0 * * 0 |
every Sunday at midnight |
0 0 1 * * |
the first day of every month |
If you only need a fixed interval and don't care about clock times, use every (in milliseconds) instead of pattern, for example { every: 60_000 }. You can't use both in the same scheduler.
Part 4: Real example: a daily order report
Now let's see how this looks in a real app. It's the Express + MongoDB order app from my last article: customers create orders, pay for them, and BullMQ reminds them while an order is unpaid. Now we add a cron to it, with this rule:
Every night at midnight (India time), count the orders of the last 24 hours by status, sum their amounts, count the reminders that failed, and save the result as one document in the
analyticscollection.
The full source code is on GitHub (link at the end). Here are the key parts.
1. The schedule config
Like the queue settings in the last article, the schedule lives in one config file, so you can change it without touching the logic:
// src/constants/appConstants.ts
export const SCHEDULER_CONFIG: Record<SchedulerName, SchedulerSettings> = {
[SCHEDULER_NAMES.ORDER_REPORTS]: {
schedulerId: 'daily-order-report',
jobName: 'order-report',
// Cron fields: minute hour day-of-month month day-of-week.
// '0 0 * * *' = every day at 00:00 in `tz`. For testing, use '* * * * *'
// (every minute) and set periodMs to 60_000.
pattern: '0 0 * * *',
tz: 'Asia/Kolkata',
// One report covers the `periodMs` before its scheduled run time. Keep it
// at 7 days or less: failed reminders are only kept for 7 days.
periodMs: 24 * 60 * 60 * 1000,
attempts: 3,
backoff: { type: 'exponential', delay: 1_000 },
removeOnComplete: { age: 7 * 24 * 60 * 60 },
removeOnFail: { age: 30 * 24 * 60 * 60 },
},
};
-
pattern+tz: every day at 00:00, India time, whatever time zone the server uses. -
periodMs: how much time one report covers. It must match the pattern: a daily pattern covers 24 hours. If you change one, change the other. -
attempts+backoff: the same built-in retry as any other job. -
removeOnComplete/removeOnFail: every run is a new job, so without these, BullMQ would keep every run in Redis forever. Here completed runs are kept for 7 days and failed ones for 30 days, so you have time to debug them.
2. Create the schedule on every app start
The app creates the schedule when it starts. Because it's an upsert, this is safe on every restart, and in every instance:
// src/services/queue.ts
export async function upsertScheduler(name: SchedulerName) {
const { schedulerId, jobName, pattern, tz, attempts, backoff, removeOnComplete, removeOnFail } =
SCHEDULER_CONFIG[name];
return getQueue(name).upsertJobScheduler(
schedulerId,
{ pattern, tz },
{ name: jobName, opts: { attempts, backoff, removeOnComplete, removeOnFail } },
);
}
// src/index.ts (main)
// Safe on every start: upserting keeps one schedule, however many times (or
// in however many instances) the app starts.
await upsertScheduler(SCHEDULER_NAMES.ORDER_REPORTS);
With this, the schedule always follows the code: change the pattern in the config, restart the app, and the existing scheduler is updated.
3. The worker
A normal worker on the report queue, running in the same process as the API (in production you could run it as its own process):
// src/services/reportWorker.ts
export const reportWorker = new Worker(SCHEDULER_NAMES.ORDER_REPORTS, processOrderReport, {
connection: config.redis,
});
4. The processor: the right period, saved once
// src/services/orderReport.ts
export async function processOrderReport(job: Job): Promise<void> {
// The period ends at the run's SCHEDULED time, not at "now": a run that
// starts late, or a retry, still reports the same period. A Job Scheduler
// stores that time in `opts.prevMillis`; a manual run (added with
// queue.add) uses the time it was added.
const periodEnd = new Date(job.opts.prevMillis ?? job.timestamp);
const periodStart = new Date(periodEnd.getTime() - periodMs);
const [orders, failedReminders] = await Promise.all([
summariseOrders(periodStart, periodEnd),
countFailedReminders(periodStart, periodEnd),
]);
// Upsert on (reportType, periodEnd): running the same period twice
// overwrites the report instead of creating a second one.
const report = await AnalyticsModel.findOneAndUpdate(
{ reportType: REPORT_TYPE, periodEnd },
{
periodStart,
tz,
...orders,
failedReminders,
trigger: job.repeatJobKey ? 'schedule' : 'manual',
generatedAt: new Date(),
},
{ upsert: true, new: true },
);
// ... logs the report
}
Two things are happening here:
-
The period comes from the scheduled time. The midnight run reports 00:00 yesterday to 00:00 today, even if it starts a few seconds late or is retried 5 minutes later. If it used
new Date()instead, a retry would report a slightly different period, and some orders would be counted twice or not at all. - The save is an upsert. If the same period is processed again (a retry after the save, or a manual re-run), the report is overwritten, not duplicated. It's the same idea as the deterministic job id in the last article: make doing it twice harmless.
job.repeatJobKey is only set on runs created by a scheduler, so the report also records whether it came from the schedule or from a manual run.
5. What goes into the report
The orders are counted with one MongoDB aggregation:
// src/services/orderReport.ts
// Orders created in the period, counted and summed by their current status.
async function summariseOrders(from: Date, to: Date) {
const groups: { _id: 'pending' | 'paid'; count: number; amount: number }[] =
await OrderModel.aggregate([
{ $match: { createdAt: { $gte: from, $lt: to } } },
{ $group: { _id: '$status', count: { $sum: 1 }, amount: { $sum: '$amount' } } },
]);
const orders = { total: 0, pending: 0, paid: 0 };
const amount = { total: 0, pending: 0, paid: 0 };
for (const group of groups) {
orders[group._id] = group.count;
amount[group._id] = group.amount;
orders.total += group.count;
amount.total += group.amount;
}
return { orders, amount };
}
The failed reminders come from BullMQ itself. A reminder job that failed all its tries stays in the queue's failed set, with the time it failed in finishedOn:
// src/services/orderReport.ts
// Reminder jobs that failed all their tries during the period. BullMQ keeps
// them in the queue's failed set (for 7 days, see `removeOnFail`), with the
// time they failed in `finishedOn`.
async function countFailedReminders(from: Date, to: Date): Promise<number> {
const failed = await getFailedJobs(QUEUE_NAMES.ORDER_REMINDERS);
return failed.filter(
(job) => job.finishedOn && job.finishedOn >= from.getTime() && job.finishedOn < to.getTime(),
).length;
}
That's why the config says to keep periodMs at 7 days or less: the reminder queue keeps failed jobs for only 7 days, so a longer period would miss some of them.
6. One report per period
A unique index backs up the upsert from step 4: even if two runs of the same period save at the same moment, MongoDB keeps only one document for that period:
// src/models/analytics.ts
// One report per type and period. The job saves with an upsert on this key, so
// a retry or a second run overwrites the report instead of adding a duplicate.
analyticsSchema.index({ reportType: 1, periodEnd: 1 }, { unique: true });
7. The API
Three small endpoints, so you can see what the cron is doing (the code is in the repo):
| Endpoint | What it does |
|---|---|
GET /reports |
The latest reports, newest first (?limit=, default 10) |
GET /reports/schedule |
The schedule as stored in Redis, with its next run time |
POST /reports/run |
Runs a report now, outside the schedule; returns 202 + job id |
Run it
Clone the repo and start it (the README has the details):
docker compose up -d
cp .env.example .env
npm install
npm run dev
So you don't wait until midnight, set the testing values first, as the comment in appConstants.ts says: pattern: '* * * * *' and periodMs: 60_000. Then create three orders and pay for one:
curl -X POST localhost:3000/orders -H "Content-Type: application/json" -d '{"item":"book","amount":100}'
curl -X POST localhost:3000/orders -H "Content-Type: application/json" -d '{"item":"pen","amount":20}'
curl -X POST localhost:3000/orders -H "Content-Type: application/json" -d '{"item":"bag","amount":500}'
curl -X POST localhost:3000/orders/<id of the bag order>/pay
At the next full minute, the app logs the report (the reminder lines come from the reminder feature of the last article):
MongoDB connected
Redis connected
Order report scheduled: '* * * * *' (Asia/Kolkata), next run 2026-10-18T08:21:00.000Z
API listening on http://localhost:3000
Reminder delay: 30000ms
Reminder 1/3: order 6ad48147f3aa32fe4a0013d4 is still unpaid
Reminder 1/3: order 6ad48147f3aa32fe4a0013d7 is still unpaid
Order report 2026-10-18T08:20:00.000Z to 2026-10-18T08:21:00.000Z: {"orders":{"total":3,"pending":2,"paid":1},"amount":{"total":620,"pending":120,"paid":500},"failedReminders":0}
And it's saved in MongoDB:
curl "localhost:3000/reports?limit=1"
{
"reports": [
{
"orders": { "total": 3, "pending": 2, "paid": 1 },
"amount": { "total": 620, "pending": 120, "paid": 500 },
"_id": "6ad4816caf408fe868b6b431",
"reportType": "order-summary",
"periodEnd": "2026-10-18T08:21:00.000Z",
"__v": 0,
"failedReminders": 0,
"generatedAt": "2026-10-18T08:21:00.069Z",
"periodStart": "2026-10-18T08:20:00.000Z",
"trigger": "schedule",
"tz": "Asia/Kolkata"
}
]
}
Notice the period: exactly 08:20:00 to 08:21:00, the scheduled minute, even though the report was generated at 08:21:00.069.
A manual run goes through the same queue and worker:
curl -X POST localhost:3000/reports/run
{"jobId":"21"}
Order report 2026-10-18T08:20:00.134Z to 2026-10-18T08:21:00.134Z: {"orders":{"total":3,"pending":2,"paid":1},"amount":{"total":620,"pending":120,"paid":500},"failedReminders":0}
A manual run has no scheduled time, so its period ends when it was added (08:21:00.134). When you are done testing, set the pattern and periodMs back to the daily values.
Part 5: Gotchas
Everything in this part I hit or tested while building the example. None of these shows an error, which is exactly why they are worth knowing before you go to production.
First, one thing about how BullMQ works, because most of the gotchas come from it: a scheduler does not run anything by itself. It always keeps exactly one next run waiting in Redis. When a worker picks up that run, the worker adds the next one. So the schedule only moves forward while a worker is running.
1. Missed runs are not caught up
I stopped the app at 10:56:16 and started it again at 10:59:07, with the every-minute testing pattern. The runs for 10:57, 10:58 and 10:59 were missed. This is what the app logged on start:
Order report 2026-10-10T10:56:00.000Z to 2026-10-10T10:57:00.000Z: {"orders":{"total":0,"pending":0,"paid":0},...}
Order report scheduled: '* * * * *' (Asia/Kolkata), next run 2026-10-10T11:00:00.000Z
One late run (the one that was already waiting in Redis), then straight to 11:00. The other missed runs never happen, so those periods have no report. It doesn't matter how long the app was down: a week later, the app started with one 8-day-old run, and then continued normally.
For many crons this is what you want: a cleanup job doesn't need to run 7 times after a week of downtime. But for a report, a gap is a missing day.
What to do: if every period must have a result, don't rely on the cron alone. For example, on start (or in the run itself), compare the latest saved report with the periods that should exist, and generate the missing ones. Because our report is saved with an upsert on its period, generating a period again is harmless.
2. A slow run can overlap the next one
What if a run takes longer than the time between two runs? I tested it with a scheduler every 5 seconds, a run that takes 12 seconds, and two workers:
2:40:05 PM worker 1 START run for 2:40:05 PM
2:40:10 PM worker 2 START run for 2:40:10 PM
2:40:17 PM worker 1 END run for 2:40:05 PM
From 2:40:10 to 2:40:17, two runs were running at the same time. With several app instances (or a worker with concurrency above 1), this is normal: each run is a separate job, and a free worker takes it.
What to do: either make the run safe to overlap (like our upsert), or allow only one job at a time on that queue:
await queue.setGlobalConcurrency(1);
Same test with that line:
2:41:05 PM worker 2 START run for 2:41:05 PM
2:41:17 PM worker 2 END run for 2:41:05 PM
2:41:17 PM worker 2 START run for 2:41:10 PM
2:41:29 PM worker 2 END run for 2:41:10 PM
2:41:29 PM worker 2 START run for 2:41:20 PM
No overlap any more, but look at the times: the runs start late, and the run for 2:41:15 never happened. That's gotcha 1 again: when the run for 2:41:10 was finally picked up at 2:41:17, BullMQ added the next run for the next matching time after that moment, which was 2:41:20. The global concurrency applies to the whole queue, so give the cron its own queue, as we did.
3. Changing the scheduler id leaves the old one running
Changing the pattern is safe: the upsert updates the existing scheduler. But if you change the id (for example daily-order-report → daily-order-report-v2), BullMQ sees a new scheduler, and the old one stays in Redis. This is what queue.getJobSchedulers() returned after I upserted a new id:
daily-order-report-v2 '0 0 * * *'
daily-order-report '*/5 * * * *'
Two schedulers on the same queue, and both keep running. With the same pattern, every report would run twice.
What to do: treat the scheduler id like a database key, and don't rename it casually. If you have to, remove the old one:
await queue.removeJobScheduler('daily-order-report');
4. Deleting the code doesn't delete the schedule
The schedule lives in Redis, not in your code. If you remove the upsertJobScheduler() call from your app, or stop all workers, the scheduler is still there. The next time any worker connects to that queue, it starts running again. That's how the 8-day-old run in gotcha 1 happened.
What to do: to really stop a cron, call queue.removeJobScheduler(id), as in the remove command of Part 3.
5. Change the pattern and the period together
Our report covers the periodMs before its scheduled time. When I changed the pattern from every minute to every 5 minutes but left periodMs at 60 seconds, the 11:00 run reported this:
Order report 2026-10-10T10:59:00.000Z to 2026-10-10T11:00:00.000Z: ...
Only the last minute of the 5. With these settings, 4 of every 5 minutes would never be in any report.
What to do: keep the two values next to each other in your config with a comment, like our appConstants.ts, and change them together.
6. Daylight saving time
India has no daylight saving time, so Asia/Kolkata is easy. In a time zone that has it, the clock jumps. I checked a daily schedule in America/New_York with the same cron parser BullMQ uses (in 2026, the clocks jump forward on 8 March and back on 1 November):
'30 2 * * *' (02:30):
Sat, Mar 07, 02:30 AM EST
Sun, Mar 08, 03:30 AM EDT <- 02:30 doesn't exist that day, so it runs at 03:30
Mon, Mar 09, 02:30 AM EDT
'0 0 * * *' (midnight):
Sun, Mar 08, 12:00 AM EST
Mon, Mar 09, 12:00 AM EDT <- only 23 hours after the previous run
And when the clocks go back, a 01:30 run (a time that happens twice that night) runs only once.
So the runs themselves are fine, but on those two days a "day" is 23 or 25 hours. A fixed periodMs of 24 hours would then miss one hour, or count one hour twice.
What to do: if your time zone has daylight saving time, don't schedule in the hours when the clocks change (in the US, between 01:00 and 03:00), and compute the period from the previous scheduled run instead of a fixed periodMs.
7. Redis settings
The last gotcha is the one we fixed in Part 2: with the wrong eviction policy, Redis deletes the schedule when memory fills up, and without AOF, a Redis restart can lose it. In both cases, the cron just stops, with no error. Check appendonly and maxmemory-policy on every Redis you put a cron on.
Wrapping up
In this article, we saw:
- why Redis (with BullMQ) is a good fit for a cron (Part 1),
- how to set up Node.js and Redis end to end, including persistence and the eviction policy (Part 2),
- how to create, run, list and remove a Job Scheduler (Part 3),
- how this looks in a real Express app: a daily order report (Part 4),
- and the gotchas that don't show any error (Part 5).
If you already run Redis, you probably don't need a separate cron service: one upsertJobScheduler() call gives you a schedule that runs once across all your app instances, survives restarts, and retries on failure.
When is it not the right tool? If you don't run Redis at all, adding it only for a few cron jobs may be more work than a managed scheduler from your cloud provider. And if every single run must happen, even after downtime, remember gotcha 1: you'll need to fill the gaps yourself.
The complete source code is on GitHub: tarun-koshti/Redis-BullMq
Thanks for reading!
Top comments (0)