JoSk
"JoSk" is a Node.js task manager for horizontally scaled apps: clusters, multi-server setups, and multi-threaded instances on one or many machines or data centers. It works the same in a single-instance app.
"JoSk" mimics the native setTimeout and setInterval API and supports CRON expressions. Tasks sync between all app instances through Redis, MongoDB, PostgreSQL, or a custom adapter. Storage-level leases and atomic claims give each due tick to one instance; delivery guarantees depend on the scheduling method.
Note: JoSk is the server-only package.
ToC
- Main features
- Prerequisites
- Install as NPM package
- API
- Execution semantics
- TypeScript
- Examples
- Prefix mapping
- Operational FAQ
- Migration guides
- Notes
- Running tests
- Why "JoSk"
- Support
Main features
- 🏢 Synchronize single task across multiple servers;
- 🔏 Read locking to avoid simultaneous task executions across complex infrastructure;
- 📦 Zero dependencies, written from scratch for top performance;
- 👨🔬 ~97% tests coverage;
- 💪 Bulletproof design, built-in retries, and "zombie" task recovery 🧟🔫.
Prerequisites
node@>=14.21.3. Newer drivers need newer Node; see tested runtimesredis-server@>=5.0.0or a single-writer KeyDB/Valkey server forRedisAdapter, withredis@^4orredis@^5. CI targets selected standalone images; see the Redis guide for exact coverage.mongod@>=4.4forMongoAdapter, with the officialmongodbNPM package (only the official driver is tested)postgres@>=12forPostgresAdapter, withpg@>=8.0.3(pg@7does not connect on Node 14+;pg@8.20declares Node 16+)bun@>=1.1.0(optional), see Bun runtime
Older releases compatibility
node@<14.21.3— usejosk@^5(Node 12 fails to parse?.in 6.x)mongod@<4.0.0— usejosk@=1.1.0node@<14.20.0— usejosk@=3.0.2node@<8.9.0— usejosk@=1.1.0
Install:
npm install josk --save
1// ES Module Style 2import { JoSk, RedisAdapter, MongoAdapter, PostgresAdapter } from 'josk'; 3 4// CommonJS 5const { JoSk, RedisAdapter, MongoAdapter, PostgresAdapter } = require('josk');
Bun runtime
Since v6.0.0
JoSk runs unmodified on Bun >=1.1.0 with the same mongodb, pg, and redis drivers. Install with bun add josk. Node and Bun processes can share one prefix, since leases and claims live in storage. npm run test:bun runs the Jest suite under bun:test, see Running tests.
Agent Skill
JoSk ships an Agent Skill with the public API, adapter setup, execution semantics, CRON and handler patterns, and common pitfalls. It works with 50+ AI coding agents; pass -a claude-code to target one.
# Install the JoSk skill globally: npx skills add veliovgroup/josk -g # Or install the JoSk skill into the current project: npx skills add veliovgroup/josk
The skill is distributed via GitHub and is not part of the npm tarball.
API:
new JoSk(opts)
opts.adapter{RedisAdapter|MongoAdapter|PostgresAdapter} - [Required] Instance of adapter or customopts.debug{Boolean} - [Optional] Enable debugging messages, useful during developmentopts.autoClear{Boolean} - [Optional] Remove stored tasks that have no handler registered in this instance. Such tasks appear after a task is renamed or removed from code, or when instances with different codebases share one storage. Default:falseopts.zombieTime{Number} - [Optional] Interval recovery hold in milliseconds, used when a handler never callsready()or its process dies. Caught handler errors go toonErrorand complete the run; they do not wait for zombie recovery. Keep it above the slowest legitimate handler runtime plus margin; below60000is not recommended. Default:900000(15 minutes)opts.lockLeaseTime{Number} - [Optional] Scheduler lease TTL in milliseconds. Default:min(zombieTime, 30000). Any value is raised to at least2 * maxRevolvingDelay + 1000; a non-positive or non-finite value throws. See v6.3 migration notesopts.execute{String} - [Optional]batchdrains all due tasks under one scheduler lease;oneclaims one task per lease. Default:batchopts.concurrency{Number} - [Optional] Maximum handlers running in parallel in this instance. Use a positive integer when handlers share rate-limited resources;Infinitydisables the cap. Default:Infinityopts.lockOwnerId{String} - [Optional] Stable owner id used as the prefix of scheduler lease tokens, for observability. Default:josk-plus a random UUID per instanceopts.minRevolvingDelay{Number} - [Optional] Minimum delay between scheduler polls in milliseconds. Default:128opts.maxRevolvingDelay{Number} - [Optional] Maximum delay between scheduler polls in milliseconds. Default:768opts.onError{Function} - [Optional] Informational hook, called instead of throwing exceptions. Default:false. Called with two arguments:title{String}details{Object}details.description{String}details.error{Mix}details.uid{String|null} - Internaluid, suitable for.clearInterval()and.clearTimeout();nullwhen the error is not tied to a taskdetails.task{Mix} - Present only for malformed-task errors; the offending task payload
opts.onExecuted{Function} - [Optional] Informational hook, called when a handler signals completion, including after a caught error. It does not indicate success. Default:false. Called with two arguments:uid{String} -uidpassed into.setImmediate(),.setTimeout(), orsetInterval()methodsdetails{Object}details.uid{String} - Internaluid, suitable for.clearInterval()and.clearTimeout()details.date{Date} - Execution timestamp as JS {Date}details.delay{Number} - Executiondelay(e.g.intervalfor.setInterval())details.timestamp{Number} - Execution timestamp as unix {Number}
Hook throws and async rejections are logged and isolated from scheduler execution.
new RedisAdapter(opts)
Since v5.0.0
opts.client{RedisClient} - [Required] Connected client, e.g. fromawait createClient().connect()orcreateCluster()opts.prefix{String} - [Optional] use to create multiple named instancesopts.resetOnInit{Boolean} - [Optional] (use with caution) Delete all tasks for thisprefixon init. For single-instance apps that need a clean start after a crash. Default:falseopts.useHashTags{Boolean} - [Optional] Use hash-tag keys (josk:{prefix}:*) so all adapter keys live in one slot. Required for Redis Cluster; the constructor throws for a cluster client without it. Default:false(josk:prefix:*)
new MongoAdapter(opts)
Since v5.0.0
opts.db{Db} - [Required]Dbinstance fromMongoClient#db()opts.prefix{String} - [Optional] use to create multiple named instancesopts.lockCollectionName{String} - [Optional] Lock collection name. Default:__JobTasks__.lock, shared by all JoSk instances. Use a separate name for JoSk 6 while JoSk 5 services still use the default, see MongoDB guideopts.resetOnInit{Boolean} - [Optional] (use with caution) Delete all tasks for thisprefixon init. For single-instance apps that need a clean start after a crash. Default:false
new PostgresAdapter(opts)
Since v6.0.0
opts.client{Pool|Client} - [Required]pgclient with.query()method. UsePoolfor long-running appsopts.prefix{String} - [Optional] Isolated scheduler namespace in the same database. Default:defaultopts.resetOnInit{Boolean} - [Optional] (use with caution) Delete tasks and locks for thisprefixon init. Default:false
Initialization
JoSk has no dependencies. Install the driver for your adapter: redis, mongodb, or pg. To use other storage, write a custom adapter.
Redis Adapter
RedisAdapter keeps due timestamps in a sorted set and task payloads in a hash, and claims due tasks with Lua scripts.
- Use one writable primary. Do not route JoSk reads or writes to replicas; claims must be visible to all instances at once.
- For Redis Cluster, pass
useHashTags: true. - KeyDB, Valkey, multi-data-center setups, and CI coverage are in the Redis guide.
1import { JoSk, RedisAdapter } from 'josk'; 2import { createClient } from 'redis'; 3 4const redisClient = await createClient({ 5 url: 'redis://127.0.0.1:6379' 6}).connect(); 7 8const jobs = new JoSk({ 9 adapter: new RedisAdapter({ 10 client: redisClient, 11 prefix: 'app-scheduler', 12 // useHashTags: true, // Redis Cluster; KeyDB/Valkey Cluster not CI-tested 13 }), 14 onError(reason, details) { 15 // Catches exceptions thrown inside scheduled tasks 16 console.log(reason, details.error); 17 } 18});
MongoDB Adapter
MongoAdapter creates one task collection per prefix (__JobTasks__<prefix>) and one lock collection (__JobTasks__.lock) shared by all prefixes.
1import { JoSk, MongoAdapter } from 'josk'; 2import { MongoClient } from 'mongodb'; 3 4const client = new MongoClient('mongodb://127.0.0.1:27017'); 5// Use a separate DB from the "main" one to avoid lock contention 6const mongoDb = client.db('joskdb'); 7const jobs = new JoSk({ 8 adapter: new MongoAdapter({ 9 db: mongoDb, 10 prefix: 'cluster-scheduler', 11 }), 12 onError(reason, details) { 13 // Catches exceptions thrown inside scheduled tasks 14 console.log(reason, details.error); 15 } 16});
PostgreSQL Adapter
Since v6.0.0
PostgresAdapter creates and migrates the josk_tasks, josk_locks, and josk_meta tables on init, in the client's current database and schema. Table names are fixed; prefix isolates namespaces.
- Pass
pg.Pool(recommended) or a connectedpg.Client. Share the app's pool, or give the scheduler a small dedicated one. Schema setup runs in one transaction underpg_advisory_xact_lock, so it also works behind PgBouncer in transaction mode. - Use one writable primary. Do not route JoSk reads or writes to replicas.
- Prefer a dedicated database or schema.
- Keep
resetOnInit: falsein clustered production. - Both execute modes claim with
FOR UPDATE SKIP LOCKED;batchtakes 100 rows per query,onetakes 1 row per lease.
1import { JoSk, PostgresAdapter } from 'josk'; 2import { Pool } from 'pg'; 3 4const pool = new Pool({ 5 connectionString: 'postgres://user:pass@localhost:5432/joskdb' 6}); 7 8const jobs = new JoSk({ 9 adapter: new PostgresAdapter({ 10 client: pool, 11 prefix: 'cluster-scheduler', 12 }), 13 onError(reason, details) { 14 // Catches exceptions thrown inside scheduled tasks 15 console.log(reason, details.error); 16 } 17});
Create the first task
1jobs.setInterval((ready) => { 2 /* ...code here... */ 3 ready(); 4}, 60 * 60000, 'task1h'); // every hour 5 6// No need to call ready() when the handler takes no arguments and returns a Promise 7jobs.setInterval(async () => { 8 await asyncMethod(); 9}, 30 * 60000, 'asyncTask30m'); // every 30 mins
uid identifies a task across the cluster. Use a different uid for each schedule, even for the same function:
1jobs.setInterval(task, 60000, 'task-1m'); // every minute 2jobs.setInterval(task, 2 * 60000, 'task-2m'); // every two minutes
setInterval(func, delay, uid)
func{Function} - Function to call on schedule. Receivesreadyas the first argumentdelay{Number} - Delay for the first run of a new task and interval between further executions in millisecondsuid{String} - Unique app-wide task id- Returns: {
Promise<string>}
The next run is scheduled when the handler calls ready(), so a run never overlaps the previous one:
1jobs.setInterval((ready) => { 2 /* ...code... */ 3 ready(); 4}, 60 * 60000, 'task1h'); // every hour + handler runtime 5 6jobs.setInterval((ready) => { 7 ready(); // schedule the next run first 8 /* ...code... */ 9}, 60 * 60000, 'task1hStrict'); // every hour, the next run may start before this one ends
With callback APIs, call ready() on every path, including errors:
1jobs.setInterval((ready) => { 2 asyncCall((error, result) => { 3 if (error) { 4 ready(); // <-- Always call `ready()`, even if the call failed 5 return; 6 } 7 8 waitForSomethingElse(result, () => { 9 ready(); // <-- End of the full execution 10 }); 11 }); 12}, 0, 'longRunningTask'); // runs again as soon as the previous run is finished
When the app restarts and calls setInterval() again for a stored task, JoSk picks the next run from the stored state. A task is claimed while an instance runs it and has not yet called ready():
| Stored task | Next run |
|---|---|
None, or not claimed with a different delay | now + delay |
Not claimed, same delay | Earlier of the stored executeAt and now + delay. A past-due task runs on the first poll after boot |
| Claimed | Unchanged. It stays held until ready() or zombieTime expires, even if delay changed |
To restart an unclaimed interval's countdown, call clearInterval() before registering it again. setTimeout() and setImmediate() always schedule from registration time.
ready() argument forms
ready returns Promise<boolean> and accepts:
ready()— schedule the next interval run atnow + delay.ready(date)—Date; schedule the next interval run at that moment. A date in the past falls back tonow + delay. Use with CRON.ready(timestamp)— ms-epoch number; same asready(date).ready(callback)— Node-style(error, success) => void. It fires before the storage update; await the returned Promise to wait for persistence.
Dates and timestamps only apply to setInterval. setTimeout and setImmediate tasks are removed from storage before their handler runs.
Calling ready() twice rejects the returned Promise (or passes an error to the callback) with "Resolution method is overspecified". Call it at most once per run.
When a handler takes no arguments (async () => { … } or () => doSomething()), JoSk calls ready() when its returned Promise settles.
setTimeout(func, delay, uid)
func{Function} - Function to call afterdelay. Receivesreadyas the first argumentdelay{Number} - Delay in millisecondsuid{String} - Unique app-wide task id- Returns: {
Promise<string>}
Runs at most once across the cluster. Use it where a duplicate run is worse than a missed one.
1jobs.setTimeout((ready) => { 2 asyncCall(() => { 3 /* ...code... */ 4 ready(); 5 }); 6}, 60000, 'taskIn1m'); 7 8jobs.setTimeout(async () => { 9 await asyncMethod(); 10}, 60000, 'asyncTaskIn1m');
setImmediate(func, uid)
func{Function} - Function to execute. Receivesreadyas the first argumentuid{String} - Unique app-wide task id- Returns: {
Promise<string>}
Runs once, as soon as the next scheduler poll claims it. At most once across the cluster, like setTimeout.
1jobs.setImmediate((ready) => { 2 /* ...code... */ 3 ready(); 4}, 'syncTask'); 5 6jobs.setImmediate(async () => { 7 await asyncMethod(); 8}, 'asyncTask');
clearInterval(timerId)
timerId{String|Promise<string>} — Timer id returned fromJoSk#setInterval()method- Returns: {
Promise<boolean>}truewhen task is successfully cleared, orfalsewhen task was not found
1const timer = await jobs.setInterval(func, 34789, 'unique-taskid'); 2await jobs.clearInterval(timer);
clearTimeout(timerId)
timerId{String|Promise<string>} — Timer id returned fromJoSk#setTimeout()method- Returns: {
Promise<boolean>}truewhen task is successfully cleared, orfalsewhen task was not found
1const timer = await jobs.setTimeout(func, 34789, 'unique-taskid'); 2await jobs.clearTimeout(timer);
destroy()
- Returns: {boolean}
trueif instance successfully destroyed,falseif instance already destroyed
Stops this instance's scheduler. After destroy(), set* methods resolve '' and report to onError (or to the debug log with debug: true); pause() and resume() return false. ping(), clearTimeout(), clearInterval(), and shutdown() still work.
destroy() does not wait for running handlers. Tasks this instance claimed but had not started go back to storage. An interval killed mid-run is recovered by another instance after zombieTime; use shutdown() before process exit to avoid that wait.
shutdown(opts)
Since v6.4.0
opts.timeout{Number} - [Optional] Milliseconds to wait for running handlers to callready(). Default:10000- Returns: {
Promise<boolean>}trueif every running handler finished withintimeout
Calls destroy(), waits for running handlers, then hands unfinished interval claims back to storage so another instance runs them on its next poll instead of after zombieTime. When timeout expires, JoSk reports every unfinished handler to onError (or console.error), hands current interval claims back, and abandons one-shot tasks to preserve at-most-once behavior. Keep at-most-once handlers idempotent, or set timeout longer than the longest one-shot handler; keep it below your platform's termination grace period. Repeated calls, late ready() calls, and timing headroom are covered in shutdown details.
1const shutdown = async () => { 2 await jobs.shutdown({ timeout: 10000 }); 3 process.exit(0); 4}; 5 6process.on('SIGTERM', shutdown); 7process.on('SIGINT', shutdown);
ping()
- Returns: {
Promise<object>}
Checks scheduler readiness and the storage connection.
1const pingResult = await jobs.ping(); 2// OK: { status: 'OK', code: 200, statusCode: 200 } 3// Failed: { status: 'Internal Server Error', code: 500, statusCode: 500, error: ErrorObject } 4// MongoAdapter can also return { status: 'Service Unavailable', code: 503, statusCode: 503, error: ErrorObject }
pause()
- Signature:
pause()orpause(timerId) - Returns: {boolean}
trueif pause state changed,falseif already paused for that scope or the instance is destroyed
For multi-instance setups with long-running handlers only. Other instances keep running tasks while this one is paused. Single-instance apps and short handlers gain nothing.
pause()— this instance stops taking the scheduler lease. Running handlers continue, and tasks stay in storage.pause(timerId)— when this instance claims that task, it hands it back due in 2 seconds without running the handler, so another instance can run it. Pass the timer id returned byset*; a bareuidthrows.
1if (loadSheddingActive()) { 2 jobs.pause(); // stop competing while saturated 3} 4const heavy = await jobs.setInterval(worker, 60_000, 'heavy-sync'); 5jobs.pause(heavy); // only skip this task on this instance
resume()
- Signature:
resume()orresume(timerId) - Returns: {boolean}
trueif pause cleared,falseif not paused or the instance is destroyed
resume() clears the global pause; resume(timerId) clears a per-task pause. The instance competes again on its next poll.
1jobs.resume(); // global 2jobs.resume(heavy); // per timer id from set*
Queue-polling pattern: after winning a tick, claim rows from your own queue, call pause(), call ready(), process the rows, then resume() in finally. JoSk releases the tick quickly and this instance stops competing until local work ends. Full examples: skills/josk/references/patterns.md.
Execution semantics
| Method | Guarantee | Notes |
|---|---|---|
setTimeout, setImmediate | At-most-once across the cluster | Task is removed from storage before the handler runs. If the process dies mid-run, the run is lost. |
setInterval | At-least-once per tick (until cleared) | Task stays in storage while running. If ready() is not called within zombieTime, another instance may claim and run it again. Make handlers idempotent. |
zombieTime (default 15 minutes) is the safety net for stuck handlers. Set it above your slowest legitimate handler plus storage latency. Restarting an app does not shorten a running interval's hold. So after an unclean kill, recovery waits the full zombieTime, not delay, but a rolling deploy cannot start a second copy of a handler that is still running. Call shutdown() on SIGTERM to hand running claims back instead of leaving them to zombieTime. JoSk skips a late ready() write when a newer same-uid run starts in the same process. Built-in adapters also fence writes across instances; custom adapters need equivalent fencing for that guarantee. See adapter-api.md and monitoring and recovery.
execute: 'batch'(default) claims all due tasks under one lease, for throughput.execute: 'one'claims one task per lease, for smaller bursts and fairer spread across instances.concurrencycaps parallel handlers in this instance. DefaultInfinity, like Node's timers. Set a limit when handlers share DB connections or API rate limits.pause()andpause(timerId)are per process. Combine them withconcurrencyandexecute: 'one'when one instance should back off during long local work.
TypeScript
JoSk ships declarations for ESM (index.d.ts) and CommonJS (index.d.cts). They don't import redis or mongodb, so unused drivers are not needed. RedisAdapter and MongoAdapter keep your client's full type on adapter.client / adapter.db. The JoSkAdapter interface is exported for custom adapters, and RedisAdapterOption, MongoAdapterOption, PostgresAdapterOption for building adapter configuration.
1import { JoSk, RedisAdapter } from 'josk'; 2import type { JoSkAdapter, JoSkOption, JoSkOnError } from 'josk'; 3import { createClient } from 'redis'; 4 5const onError: JoSkOnError = async (title, details) => { 6 console.error(title, details.error, details.uid); 7}; 8 9const adapter: JoSkAdapter = new RedisAdapter({ 10 client: await createClient({ url: process.env.REDIS_URL }).connect(), 11 prefix: 'cluster-scheduler' 12}); 13 14const options: JoSkOption = { adapter, execute: 'batch', concurrency: 16, onError }; 15const jobs = new JoSk(options);
Examples
CRON
Use cron-parser@^5 to compute each next run and pass it to ready(date). cron-parser@5 needs Node 18+; on older Node use cron-parser@4 and parser.parseExpression(cronExpr):
1import { CronExpressionParser } from 'cron-parser'; 2 3const setCron = (uid, cronExpr, task) => { 4 const nextRun = () => CronExpressionParser.parse(cronExpr).next().toDate(); 5 6 return jobs.setInterval(async (ready) => { 7 try { 8 await task(); 9 } finally { 10 await ready(nextRun()); // schedule the next run even if the task fails 11 } 12 }, Math.max(0, +nextRun() - Date.now()), uid); 13}; 14 15await setCron('daily-report', '0 9 * * *', () => sendReport());
Errors thrown by task still reach onError. For second-level CRON expressions, raise minRevolvingDelay / maxRevolvingDelay (e.g. 512 / 1000) to cut storage reads.
Pass arguments
Wrap the task in a closure:
1const task = (arg1, arg2, ready) => { 2 //... code here 3 ready(); 4}; 5 6jobs.setInterval((ready) => task({ key: 'value' }, 'A', ready), 60 * 60000, 'taskA'); 7jobs.setInterval((ready) => task({ key: 'other' }, 'B', ready), 60 * 60000, 'taskB');
Clean up old tasks
For development and tests. Commands for Redis, MongoDB, and PostgreSQL: docs/testing.md.
MongoDB connection fine tuning
Replica-set tuning, dedicated-DB advice, the index list, and Mongo-compatible service notes: docs/mongodb.md.
Notes
- Use JoSk when several copies of an app run the same repeating tasks and each due tick must run once cluster-wide, not once per instance. Examples: email and SMS queues, long polling, periodic sync.
- Keep task intervals at 2 seconds or more when timing matters. Shorter tasks may overlap with the storage round-trip plus poll delay.
delay: 0is valid and runs the task again afterready(). - Accuracy is
delay ± (maxRevolvingDelay + storage latency): about ±0.8s plus storage latency with defaults. Tighter bounds cost more storage reads. - Poll delays are random within
minRevolvingDelay..maxRevolvingDelayso instances don't hit storage at the same moment. Defaults (128..768) suit a 3-server setup. A higherminRevolvingDelayreduces storage reads and writes.
Running tests
Setup, full and targeted suites, coverage, and the Bun runner: docs/testing.md. Quickstart:
REDIS_URL="…" MONGO_URL="…" PG_URL="…" npm test
Why JoSk?
JoSk is Job-Task - Is randomly generated name by "uniq" project
Support our open source contribution:
- Try 🌉 Bridge CDN - A SEO-focused alternative to Cloudflare. CDN, DNS, IndexNow, Prerender, SEO, Edge Computing.
- Upload and share files using ☄️ meteor-files.com — Continue interrupted file uploads without losing any progress. There is nothing that will stop Meteor from delivering your file to the desired destination
- Use ▲ ostr.io for Server Monitoring, Web Analytics, WebSec, Web-CRON and SEO Pre-rendering of a website
- Star on GitHub
- Star on NPM
- Star on Atmosphere
- Sponsor via GitHub
- Support via PayPal