TypeScript

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

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 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:

Bash
npm install @hokify/agenda

Connecting to MongoDB

Create one Agenda instance for the whole app and give it a collection to store jobs in:

Typescript
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.

Typescript
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:

Typescript
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:

Typescript
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:

Typescript
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:

Typescript
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:

Typescript
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:

Typescript
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 jobs collection gets slow as it grows.
  • Monitoring: wire fail events 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.

Go further
Live cohort on Maven

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 cohort

Keep reading