Open-source project
kibertoad/toad-scheduler avatar
kibertoad/toad-scheduler

toad-scheduler runs timers in your process, and the interesting decisions are about when to let the process exit

In-memory Node.js and browser job scheduler

614 stars31 forksTypeScriptMIT

At a glance

What is it?
kibertoad/toad-scheduler is an in-memory TypeScript job scheduler for Node 18 and modern browsers, with interval, long interval and cron jobs, an async task variant, overrun prevention, per job ids and an unref option that keeps the scheduler from holding the event loop open. It is MIT licensed and depends on croner for cron expressions.
Who is it for?
Use toad-scheduler for the housekeeping jobs that live inside one process, such as polling, cache sweeps and periodic cleanup, especially in a service that already has an HTTP server keeping the event loop alive. Do not use it as a durable queue, because nothing it holds survives a restart and there is no store behind it.
Can I use it commercially?
Yes. MIT is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
Is it still maintained?
Yes. The repository last received commits 1 day ago.
What is it written in?
Mainly TypeScript, according to GitHub's language statistics.

Answers come from the project's GitHub data, last synced on October 22, 2026, and from our analysis. They are not legal advice.

Editorial analysis

A timer inside your process, with no store behind it

toad-scheduler is an in-memory TypeScript job scheduler that repeatedly executes given tasks within specified intervals of time, with cron syntax supported when you need it. Node.js 18 and newer and modern browsers are supported.

The in-memory part is the whole story, and it is worth being blunt about it. There is no persistence layer, no external store and no broker. A job is a timer and a callback held in memory, and it disappears when the process exits. That is a feature for the class of work this library is for, and a disqualification for anything else.

The target use is the boring but real problem of periodic work inside a running service: poll a database for new rows, sweep a cache, refresh a token, send a heartbeat. Work that belongs to one process, that is fine to lose on a restart because the next start will schedule it again, and that would otherwise tempt you to write a setTimeout loop in every module.

The public surface is small. You create a ToadScheduler, you create a task, you create a job that owns the task and an interval, and you hand the job to the scheduler. Jobs are then addressable by id, they report a status, and the scheduler can be stopped when the application shuts down.

The project is MIT licensed, published on npm as toad-scheduler, and the package is built with pnpm, with the package manager pinned to pnpm 11.1.1.

Three job types, one task abstraction

The library exposes three kinds of job, and the difference between them is about how the interval is measured rather than about what the task does.

SimpleIntervalJob is the basic one, driven by a plain interval in seconds. LongIntervalJob exists for the cases where the interval is long enough to hit the limits of the platform timer. CronJob handles cron style scheduling.

The reason LongIntervalJob exists is specific and interesting. The documentation explains that runImmediately is honoured equally on SimpleIntervalJob and on LongIntervalJob, both for short intervals and for the long, time-eating path used when the interval exceeds the cap that setInterval runs into, which it puts at roughly 24.85 days. A scheduler that only used setInterval would silently misbehave for anything scheduled further out than that, so the long path exists to cover it.

The task side is deliberately thin. A Task is a name plus a callback, and the callback can be synchronous. That is all the scheduler needs: it owns the timing, and it owns the lifecycle, and the callback is the work.

Cron support comes from the croner package, which is the only runtime dependency in package.json. The CronJob constructor takes a cronExpression alongside the task, and the documentation gives an every two seconds expression as the example. The unref option is passed through to croner for cron jobs, so the same lifetime control applies across all three job types.

The library is written in TypeScript, with the source in lib/ and a single index.ts entry point, and the published package contains the built dist output.

AsyncTask exists to stop unhandled rejections

The documentation is unusually direct about asynchronous tasks, and the reason is not style, it is correctness.

A plain Task takes a callback, and if that callback returns a promise that rejects, nothing in the scheduler is watching it. The failure becomes an unhandled rejection, which in modern Node terminates the process by default. So the library provides AsyncTask, which takes the same function plus an error handler, and the documentation says plainly that to avoid unhandled rejections you should use AsyncTask if your task is asynchronous.

There is a second rule attached to it. The documentation recommends using promise chains instead of async and await inside the task definition, in order to avoid memory leaks, and links to a talk on common promise mistakes.

That recommendation is the one place where the library asks you to write in a style that most of modern JavaScript has moved away from, and it is worth understanding rather than obeying blindly. The reasoning behind it is about promises that are created and abandoned rather than awaited, which is a real source of retention in long running processes. If your task is a single awaited call, the pattern may not matter; if your task fires several promises and does not await them, it probably does.

Error handling is asynchronous too. The documentation says error handlers can be asynchronous and return a promise, and that in that case an additional catch block is attached to them, so that if there is an error while resolving that promise, the logging error is logged using the default error handler, which is console.error.

That is a small detail with a large consequence: a failing error handler does not take the process down, it logs.

runImmediately comes with a specific ordering guarantee

Running a task once as soon as the job starts is a common need, and the way the library implements it is documented in more detail than you would expect.

Setting runImmediately to true on a SimpleIntervalJob or a LongIntervalJob makes the task fire once as part of start, which happens when you call addSimpleIntervalJob or addLongIntervalJob, and then again on every interval tick.

The guarantee is about ordering: the underlying interval timer is set up before the immediate run is fired. The documentation explains why that matters. If your task calls job.stop, or scheduler.stopById, on its very first run, the just scheduled timer is still active and gets cleared, so no further runs happen.

The example shows the pattern, with a job id and a task that does its initial work and then stops itself:

ts
const scheduler = new ToadScheduler()
const jobId = 'one-shot-bootstrap'
const task = new Task('bootstrap', () => {
  doInitialWork()
  scheduler.stopById(jobId)
})
const job = new SimpleIntervalJob(
  { seconds: 60, runImmediately: true },
  task,
  { id: jobId },
)
scheduler.addSimpleIntervalJob(job)

Without the ordering guarantee, that task would stop a job that had not yet been scheduled, and the timer would keep firing into a stopped job. This is the kind of detail that separates a scheduler with a stated contract from one where the behaviour depends on the order of two internal calls.

Note the shape of the constructor as well. The interval options and the runImmediately flag go in the first argument, the task in the second, and the job options such as the id in the third. That three argument form is used consistently across the examples, with the id in the options object and the runImmediately flag in the interval options.

preventOverrun is the guard against a slow task overlapping itself

If a task takes longer than the interval, the naive behaviour is to run it again while the first run is still going, which for anything that writes to a database or calls an external service means concurrent execution of the same work.

The preventOverrun option exists for that case. The documentation describes it as preventing a second instance of a task from being fired while the first one is still executing, and the example sets it in the job options alongside an id:

js
import { ToadScheduler, SimpleIntervalJob, Task } from 'toad-scheduler';

const scheduler = new ToadScheduler();

const task = new Task('simple task', () => {
    // if this task runs long, second one won't be started until this one concludes
	console.log('Task triggered');
});

const job = new SimpleIntervalJob(
	{ seconds: 20, runImmediately: true },
	task,
    { 
        id: 'id_1',
        preventOverrun: true,
    }
);

//create and start jobs
scheduler.addSimpleIntervalJob(job);

The important detail is where the flag lives. It is not in the interval options and not on the task, it is in the third argument with the id, which means it is a property of the job as a whole rather than of one particular run configuration.

This is the option that separates toad-scheduler from the setTimeout loops it replaces. A raw timer has no idea whether the previous invocation has finished, and a scheduler that offers no way to express that constraint pushes the problem into application code, where it is usually solved with a flag variable that nobody remembers to reset on the error path.

It is worth noting what the option does not do. It does not queue the missed run, and it does not extend the interval, it simply does not start a second instance while one is in flight. For a polling task that is exactly right. For a task where every tick must be honoured, this library is the wrong shape and you want a queue with real backpressure instead of a timer.

unref decides whether the scheduler keeps your process alive

This is the option with the longest documentation section in the project, and it is about process lifetime rather than about scheduling.

By default, scheduler timers keep the Node.js process alive, so the process will not exit while jobs are scheduled. If you want the scheduler to never be the reason your process stays up, you can opt in to unref, either per job or scheduler-wide:

js
import { ToadScheduler, SimpleIntervalJob, Task } from 'toad-scheduler';

// per job
const job = new SimpleIntervalJob(
    { minutes: 5 },
    new Task('cache sweep', () => { /* ... */ }),
    { unref: true }
);

// or scheduler-wide default (per-job option always wins over it)
const scheduler = new ToadScheduler({ unref: true });
scheduler.addSimpleIntervalJob(job);

The per job option always wins over the scheduler wide default, and unref is supported by all job types, SimpleIntervalJob, LongIntervalJob including its long path, and CronJob where it is passed through to croner. In browsers it is a no-op, because browser timers have no ref semantics.

The documentation then states when to use it and, unusually helpfully, when not to. Use it when the scheduler runs inside an application that has other things keeping the event loop alive, such as an HTTP server, a queue consumer or a database connection pool. The process can then shut down naturally once those are closed, without having to order scheduler.stop calls carefully or force a process exit.

Do not use it in a standalone worker script where the scheduler's timers are the only thing keeping the process alive. With unref set to true, such a process exits immediately after startup and no task ever runs. That is also why the option is off by default.

This is the sort of guidance that saves an afternoon. A scheduler library that keeps the process alive by default is the right default for a worker, and the wrong default for a library embedded in a web server, and the documentation tells you both cases instead of leaving you to discover the failure in production.

Ids give you status, targeted stops and a way to remove jobs

Attaching ids to jobs is what turns a collection of timers into something you can manage at run time.

The documentation describes ids as helpful in projects that run a lot of tasks, and especially if you want to target some of the tasks specifically, for example in order to stop or restart them or to check their status. The example registers two jobs with different intervals and then operates on them by id:

js
import { ToadScheduler, SimpleIntervalJob, Task } from 'toad-scheduler';

const scheduler = new ToadScheduler();

const task = new Task('simple task', () => {
	console.log('Task triggered');
});

const job1 = new SimpleIntervalJob(
	{ seconds: 20, runImmediately: true },
	task,
    { id: 'id_1' }
);

const job2 = new SimpleIntervalJob(
	{ seconds: 15, runImmediately: true },
	task,
    { id: 'id_2' }
);

//create and start jobs
scheduler.addSimpleIntervalJob(job1);
scheduler.addSimpleIntervalJob(job2);

// stop job with ID: id_2
scheduler.stopById('id_2');

// remove job with ID: id_1
scheduler.removeById('id_1');

// check status of jobs
console.log(scheduler.getById('id_1').getStatus()); // returns Error (job not found)

console.log(scheduler.getById('id_2').getStatus()); // returns "stopped" and can be started again

There are four operations here. stopById stops a job while keeping it registered. removeById removes it from the scheduler entirely. getById looks one up, and getStatus reports what the job is doing, with the documentation showing that a job that was stopped can be started again. The status check on a job that was removed returns an error, and the example prints that case explicitly.

The distinction between stopping and removing is the useful part. A stopped job is a job you can restart, which is what you want for a task that is paused, and a removed job is one that no longer exists, which is what you want when its configuration has been superseded.

The maintenance picture for the project itself is reasonable. The repository is not archived and the last push was on 2026-10-19. The published tags are v4.0.0 from 2026-04-27, 3.0.1 from 2024-02-01 and 3.0.0 from 2023-06-15, and the package.json records version 4.2.0, so the source tree is ahead of the newest tag, as is normal for a library that tags on its own schedule.

The dependency list is minimal, with croner as the only runtime dependency and everything else in devDependencies, including jest for the unit tests and karma with ChromeHeadless for the browser tests, which is the mechanism behind the claim that modern browsers are supported rather than merely hoped for.

Editorial conclusion

Use toad-scheduler for the housekeeping jobs that live inside one process, such as polling, cache sweeps and periodic cleanup, especially in a service that already has an HTTP server keeping the event loop alive. Do not use it as a durable queue, because nothing it holds survives a restart and there is no store behind it. Before adopting, decide whether your process should exit on its own, because unref is the right choice for a library hosted in a server and the wrong choice for a standalone worker script, and read the promise chain guidance before you write your first async task.

Frequently asked questions

What is toad-scheduler and what does it do?

It is an in-memory TypeScript job scheduler that repeatedly executes tasks within specified intervals, with cron syntax supported. Node.js 18 and newer and modern browsers are supported, and nothing is persisted, so jobs live only as long as the process.

How do I install toad-scheduler and run a first job?

Install it with npm i toad-scheduler, then create a ToadScheduler, a Task and a SimpleIntervalJob with an interval, and register the job with addSimpleIntervalJob. Call scheduler.stop() when the application shuts down.

Why does toad-scheduler have an AsyncTask?

Because a plain Task callback that returns a rejecting promise would produce an unhandled rejection. AsyncTask takes an error handler as well, and the documentation also recommends promise chains rather than async and await inside task definitions to avoid memory leaks.

What does preventOverrun do in toad-scheduler?

It prevents a second instance of a task from being fired while the first one is still executing. It is set in the job options alongside the job id, and it does not queue the missed run.

When should I use the unref option in toad-scheduler?

When the scheduler runs inside an application with other things keeping the event loop alive, such as an HTTP server or a queue consumer, so the process can exit naturally. Do not use it in a standalone worker script where the scheduler timers are the only thing keeping the process alive, because the process would exit at startup and no task would run.

Does toad-scheduler support cron expressions?

Yes, through the CronJob class with a cronExpression option, backed by the croner package, which is the only runtime dependency. The unref option is passed through to croner for cron jobs.

Official sources

  1. Issues
  2. kibertoad/toad-scheduler on GitHub
  3. License: MIT
  4. README
  5. Releases
Add this badge to your README

If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.

Add this badge to your README

markdown
[![Hysen Labs](https://hysenlabs.com/badge/kibertoad-toad-scheduler.svg)](https://hysenlabs.com/projects/kibertoad-toad-scheduler)