Task Scheduling with AgendaJS in Node.js
Every non-trivial Node.js app eventually needs to run something on a schedule. Send emails at midnight. Retry failed payments every hour. Clean up expired...
17 Apr 2024

Every non-trivial Node.js app eventually needs to run something on a schedule. Send emails at midnight. Retry failed payments every hour. Clean up expired sessions daily.
AgendaJS handles this. It is a job scheduling library backed by MongoDB, so your jobs survive server restarts because they are persisted in the database, not in memory.
Why not just setInterval or node-cron?
setInterval and node-cron keep their schedule in memory. Restart the process and every pending job is gone. Run two instances behind a load balancer and both fire the same job, so your users get two emails instead of one.
A database-backed scheduler fixes both problems:
- Persistence: jobs live in MongoDB, so a deploy or crash does not lose them.
- Distribution: Agenda locks each job while it runs, so only one worker in a cluster picks it up.
- Visibility: every job is a document you can query, retry, or cancel.
That is the trade-off. You take on a MongoDB dependency in exchange for durability and coordination. For anything that must not silently disappear, it is worth it.
Installing
The original agenda package is largely unmaintained. Use @hokify/agenda, the actively maintained fork that ships its own TypeScript types:
npm install @hokify/agenda
Connecting to MongoDB
Create one Agenda instance for the whole app and give it a collection to store jobs in:
import { Agenda } from "@hokify/agenda";
export const agenda = new Agenda({
db: {
address: process.env.MONGO_URL ?? "mongodb://localhost:27017/app",
collection: "jobs",
},
processEvery: "30 seconds",
maxConcurrency: 20,
defaultConcurrency: 5,
defaultLockLifetime: 10_000,
});
processEvery is how often Agenda scans MongoDB for due jobs. maxConcurrency caps how many jobs run at once across all types, and defaultLockLifetime is how long a job stays locked before another worker assumes it died and retries it. Set the lock comfortably longer than your slowest job.
Defining a job
A job is a name and an async handler. Keep the handler thin: pull data off job.attrs.data and delegate to a real function you can test on its own.
import { agenda } from "./agenda";
import { sendWelcomeEmail } from "./email";
agenda.define<{ userId: string }>("send welcome email", async (job) => {
const { userId } = job.attrs.data;
await sendWelcomeEmail(userId);
});
Scheduling jobs
Start the processing loop once, then schedule work against it. There are three verbs:
await agenda.start();
// Run once, right now.
await agenda.now("send welcome email", { userId: "u_123" });
// Run once, at a point in the future.
await agenda.schedule("in 20 minutes", "send welcome email", { userId: "u_123" });
// Run on a recurring schedule (human interval or cron).
await agenda.every("1 day", "clean up sessions");
await agenda.every("0 6 * * *", "send morning digest");
every is idempotent by job name, so calling it on every boot re-defines the same recurring job rather than stacking duplicates.
Concurrency, priority, and locking
In a cluster you usually want a specific job to run in one place at a time, or to limit how many run together. Set these per definition:
agenda.define(
"generate report",
async (job) => {
await buildReport(job.attrs.data.month);
},
{ concurrency: 1, lockLifetime: 60_000, priority: "high" },
);
concurrency: 1 means one report at a time even with ten workers. priority decides the order when several jobs are due at once.
Handling failures and retries
Agenda emits events for the full lifecycle. Listen for failures and decide whether to retry, alert, or give up:
agenda.on("fail:generate report", async (err, job) => {
const attempts = job.attrs.failCount ?? 0;
if (attempts < 3) {
job.schedule("in 2 minutes");
await job.save();
return;
}
await notifyOnCall(`generate report gave up after ${attempts} tries: ${err.message}`);
});
Back off and cap the retries. A job that fails forever and retries every 30 seconds is how you turn one bug into a pager storm.
Shutting down gracefully
If you kill the process mid-job, the lock only releases after lockLifetime. Stop Agenda cleanly on shutdown so in-flight jobs finish and locks clear:
async function shutdown() {
await agenda.stop();
process.exit(0);
}
process.on("SIGTERM", shutdown);
process.on("SIGINT", shutdown);
Testing scheduled jobs
The title promised tests, and this is where most scheduler code goes untested. Two layers cover it well.
1. Unit test the handler logic. Because the handler just unwraps job.attrs.data and calls a plain function, test that function directly, with no Agenda and no MongoDB. Inject its dependencies so you can assert on them:
import { expect, it, vi } from "vitest";
import { sendWelcomeEmail } from "./email";
it("emails the user's address", async () => {
const mailer = { send: vi.fn() };
await sendWelcomeEmail("u_123", mailer);
expect(mailer.send).toHaveBeenCalledWith(
expect.objectContaining({ to: "ada@example.com" }),
);
});
2. Integration test the wiring with an in-memory MongoDB, so CI needs no external database:
import { MongoMemoryServer } from "mongodb-memory-server";
import { Agenda } from "@hokify/agenda";
import { afterAll, beforeAll, expect, it, vi } from "vitest";
let mongod: MongoMemoryServer;
let agenda: Agenda;
beforeAll(async () => {
mongod = await MongoMemoryServer.create();
agenda = new Agenda({ db: { address: mongod.getUri(), collection: "jobs" } });
await agenda.start();
});
afterAll(async () => {
await agenda.stop();
await mongod.stop();
});
it("runs a scheduled job with its data", async () => {
const handler = vi.fn(async () => {});
agenda.define("greet", handler);
await agenda.now("greet", { name: "Ada" });
await vi.waitFor(() => expect(handler).toHaveBeenCalledTimes(1));
expect(handler.mock.calls[0][0].attrs.data).toEqual({ name: "Ada" });
});
mongodb-memory-server downloads a real MongoDB binary and runs it in a temp directory, so the integration test exercises the actual persistence and locking path without a shared database.
Going to production
A few things matter once this is live:
- Indexes: Agenda creates its own indexes, but confirm they exist. An unindexed
jobscollection gets slow as it grows. - Monitoring: wire
failevents to your alerting, and consider a dashboard like Agendash to see queued and failed jobs. - Cleanup: completed one-off jobs stick around. Purge old ones periodically with
agenda.cancel({ ... }). - When to switch: Agenda is great when you already run MongoDB and your volume is moderate. If you need tens of thousands of jobs per second, reach for a Redis-backed queue like BullMQ instead.
Wrapping up
AgendaJS turns "run this later" into a durable, queryable, cluster-safe operation instead of a timer a restart can erase. Define thin handlers, schedule with now, schedule, and every, guard against runaway retries, shut down cleanly, and test both the handler logic and the wiring. That is the whole system, and it will hold up in production.
From Senior to Staff: Master the Architecture Skills That Get You Promoted
Go from shaky in design reviews to the engineer everyone trusts to architect the hard stuff.
View the live cohortKeep reading
- The Frontend Toolchain Is Now Written in Rust and Go. What That Means for You
- oxlint vs ESLint: Why I Replaced ESLint with oxlint
- SystemJS Is Dead. Native ESM Finally Won.
- Replace Axios with a Simple Custom Fetch Wrapper (Production-Ready Guide)
- Stop Shipping ChatGPT Wrappers. Ship an Agent in TypeScript, or Don't Bother.
- What Developers Are Saying About React 19: Pros and Cons