
Security News
Attackers Are Hunting High-Impact Node.js Maintainers in a Coordinated Social Engineering Campaign
Multiple high-impact npm maintainers confirm they have been targeted in the same social engineering campaign that compromised Axios.
A robust wrapper around node-cron with automatic retries, overlap prevention, execution timeout, history tracking, and structured error handling
A robust wrapper around node-cron with automatic retries, overlap prevention, distributed locking, concurrency control, execution timeout, history tracking, metrics, persistent storage, and structured error handling.
Standard node-cron jobs are vulnerable to:
cron-safe wraps your tasks with a protective layer:
maxConcurrencyonStart, onSuccess, onRetry, onError, onTimeoutnpm install cron-safe node-cron
Note:
node-cronis a peer dependency. You must install it separately.
import { schedule } from 'cron-safe';
// Simple scheduled task
const task = schedule('*/5 * * * *', async () => {
const data = await fetchDataFromAPI();
await saveToDatabase(data);
return data; // Return value available via trigger()
});
// Stop when needed
task.stop();
import { schedule } from 'cron-safe';
const task = schedule('0 * * * *', async () => {
await unreliableApiCall();
}, {
retries: 3, // Retry up to 3 times
retryDelay: 5000, // Wait 5 seconds between retries
onRetry: (error, attempt) => {
console.log(`Attempt ${attempt} failed:`, error.message);
},
onError: (error) => {
// Called after all retries are exhausted
alertOpsTeam('Critical task failed!', error);
},
});
Smart retry delays that grow over time, preventing thundering herd problems:
import { schedule } from 'cron-safe';
const task = schedule('0 * * * *', async () => {
await unreliableApiCall();
}, {
retries: 5,
retryDelay: 1000, // Base delay: 1 second
backoffStrategy: 'exponential', // 2s, 4s, 8s, 16s, 32s
maxRetryDelay: 30000, // Cap at 30 seconds
onRetry: (error, attempt) => {
console.log(`Retry ${attempt}, next delay will be longer...`);
},
});
// Available strategies:
// - 'fixed': Same delay every time (default)
// - 'linear': delay * attempt (1s, 2s, 3s, 4s, 5s)
// - 'exponential': delay * 2^attempt (2s, 4s, 8s, 16s, 32s)
import { schedule } from 'cron-safe';
// This task runs every minute but might take 90 seconds
const task = schedule('* * * * *', async () => {
await longRunningDataSync(); // Takes ~90 seconds
}, {
preventOverlap: true, // Skip if previous run still executing
onOverlapSkip: () => {
console.log('Skipped: previous execution still running');
},
});
Allow limited parallel executions instead of binary overlap prevention:
import { schedule } from 'cron-safe';
// Allow up to 3 concurrent executions
const task = schedule('* * * * *', async () => {
await processQueue();
}, {
maxConcurrency: 3, // Allow 3 parallel runs
onOverlapSkip: () => {
console.log('Max concurrency reached, skipping');
},
});
When both
preventOverlapandmaxConcurrencyare set,maxConcurrencytakes priority. SettingmaxConcurrency: 1is equivalent topreventOverlap: true.
Ensure only one instance runs across multiple servers (Docker, PM2 cluster, auto-scaling):
import { schedule, LockProvider } from 'cron-safe';
// Implement the LockProvider interface for your lock backend
const redisLockProvider: LockProvider = {
async acquire(key: string, ttl: number): Promise<string | null> {
// SET key uniqueId NX PX ttl
const lockId = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
const result = await redis.set(key, lockId, 'PX', ttl, 'NX');
return result === 'OK' ? lockId : null;
},
async release(key: string, lockId: string): Promise<void> {
// Only release if we still own the lock
const currentId = await redis.get(key);
if (currentId === lockId) {
await redis.del(key);
}
},
async extend(key: string, lockId: string, ttl: number): Promise<boolean> {
const currentId = await redis.get(key);
if (currentId === lockId) {
await redis.pexpire(key, ttl);
return true;
}
return false;
},
};
const task = schedule('0 * * * *', async () => {
await processOrders();
}, {
name: 'order-processor',
distributedLock: {
provider: redisLockProvider,
ttl: 120000, // Lock expires after 2 minutes
autoExtend: true, // Auto-extend lock for long-running tasks
extendInterval: 60000, // Extend every 60 seconds
},
});
The lock is automatically released after task completion (success or failure). If the process crashes, the TTL ensures the lock eventually expires as a safety net.
Save execution history to a database so data survives restarts and crashes:
import { schedule, StorageAdapter, StoredRunRecord } from 'cron-safe';
// Implement the StorageAdapter interface for your database
const postgresAdapter: StorageAdapter = {
async saveRun(record: StoredRunRecord) {
await db.query('INSERT INTO cron_runs ...', record);
},
async updateRun(taskName: string, startedAt: string, updates: Partial<StoredRunRecord>) {
await db.query('UPDATE cron_runs SET ... WHERE task_name = $1 AND started_at = $2', ...);
},
async getRuns(taskName: string, limit: number) {
return db.query('SELECT * FROM cron_runs WHERE task_name = $1 ORDER BY started_at DESC LIMIT $2', taskName, limit);
},
async getLastIncompleteRun(taskName: string) {
return db.query("SELECT * FROM cron_runs WHERE task_name = $1 AND status = 'running' ORDER BY started_at DESC LIMIT 1", taskName);
},
};
const task = schedule('0 * * * *', async () => {
await generateReport();
}, {
name: 'hourly-report',
storage: postgresAdapter,
historyLimit: 100,
});
On startup, cron-safe automatically loads the last N history records from storage, preserving your audit trail across restarts.
Track task performance in real-time:
import { schedule } from 'cron-safe';
const task = schedule('* * * * *', async () => {
await processData();
}, {
name: 'data-processor',
});
// Get metrics snapshot
const metrics = task.getMetrics();
console.log(metrics);
// {
// totalRuns: 150,
// totalSuccess: 145,
// totalFailures: 3,
// totalTimeouts: 2,
// totalRetries: 10,
// totalOverlapSkips: 5,
// currentRunning: 1,
// avgDuration: 2340, // ms
// lastRunAt: Date,
// lastStatus: 'success'
// }
import { schedule, MetricsProvider } from 'cron-safe';
const prometheusMetrics: MetricsProvider = {
recordEvent(taskName, event, duration) {
// Forward to your metrics system
cronRunsTotal.labels(taskName, event).inc();
if (duration) {
cronDurationHistogram.labels(taskName).observe(duration / 1000);
}
},
};
const task = schedule('* * * * *', processData, {
name: 'data-processor',
metricsProvider: prometheusMetrics,
});
Change the cron expression at runtime without losing state:
import { schedule } from 'cron-safe';
const task = schedule('*/5 * * * *', async () => {
await syncData();
}, {
name: 'data-sync',
});
// Later, update schedule via admin dashboard or feature flag
task.updateSchedule('*/30 * * * *'); // Slow down to every 30 minutes
// All history, metrics, and state are preserved
console.log(task.getHistory().length); // Still has previous history
console.log(task.getMetrics()); // Still has accumulated metrics
Throws an error if the new cron expression is invalid. The task must not be stopped when updating.
Prevent zombie tasks from blocking future executions:
import { schedule, TimeoutError } from 'cron-safe';
const task = schedule('*/5 * * * *', async () => {
await potentiallyHangingOperation();
}, {
executionTimeout: 30000, // 30 second timeout
onTimeout: (error) => {
console.error('Task timed out!', error.message);
// error instanceof TimeoutError === true
},
});
Track past executions with status, duration, and errors:
import { schedule } from 'cron-safe';
const task = schedule('0 * * * *', async () => {
return await generateReport();
}, {
historyLimit: 20, // Keep last 20 executions (default: 10)
});
// Check execution history
const history = task.getHistory();
console.log(history);
// [
// {
// startedAt: Date,
// endedAt: Date,
// duration: 1234, // ms
// status: 'success' | 'failed' | 'timeout',
// error?: Error,
// triggeredBy: 'schedule' | 'manual'
// },
// ...
// ]
// Find failed executions
const failures = history.filter(h => h.status === 'failed');
Know exactly when your job runs next:
import { schedule } from 'cron-safe';
const task = schedule('0 9 * * *', async () => {
await sendDailyDigest();
});
const nextRun = task.nextRun();
console.log(`Next run: ${nextRun}`); // Date object or null if stopped
// Show in UI
const timeUntilNext = nextRun.getTime() - Date.now();
console.log(`Next backup in ${Math.round(timeUntilNext / 60000)} minutes`);
Manually trigger tasks and get results (great for testing):
import { schedule } from 'cron-safe';
const task = schedule('0 0 * * *', async () => {
const report = await generateDailyReport();
return report; // Return the result
});
// Manual trigger returns the result
const result = await task.trigger();
console.log('Report:', result);
// Respects overlap prevention
// If preventOverlap is true and task is running, returns undefined
Integrate with Slack, email, or any notification system. The notifier callback receives structured payloads when events occur:
import { schedule, NotificationPayload } from 'cron-safe';
const task = schedule('0 * * * *', async () => {
await processData();
}, {
name: 'hourly-processor',
notifier: (payload: NotificationPayload) => {
console.log(`[${payload.taskName}] ${payload.event} at ${payload.timestamp}`);
// payload.result - for success events
// payload.error - for error/timeout events
// payload.duration - execution time in ms
// payload.attemptsMade - number of attempts
},
notifyOn: {
success: true, // default: true
error: true, // default: true
timeout: true, // default: true
overlapSkip: false, // default: false
lockFailed: false, // default: false
},
});
import { schedule, NotificationPayload } from 'cron-safe';
const slackNotifier = async (payload: NotificationPayload) => {
const color = payload.event === 'success' ? 'good' : 'danger';
await fetch(process.env.SLACK_WEBHOOK_URL!, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
attachments: [{
color,
title: `Cron Job: ${payload.taskName}`,
text: `Event: ${payload.event.toUpperCase()}`,
fields: [
{ title: 'Duration', value: `${payload.duration}ms`, short: true },
{ title: 'Attempts', value: payload.attemptsMade, short: true },
],
ts: Math.floor(payload.timestamp.getTime() / 1000),
}],
}),
});
};
const task = schedule('0 6 * * *', dailyBackup, {
name: 'daily-backup',
notifier: slackNotifier,
notifyOn: { success: false, error: true }, // Only notify on failures
});
import { schedule, NotificationPayload } from 'cron-safe';
import nodemailer from 'nodemailer';
const transporter = nodemailer.createTransport({
host: 'smtp.example.com',
auth: { user: 'user', pass: 'pass' },
});
const emailNotifier = async (payload: NotificationPayload) => {
if (payload.event !== 'error' && payload.event !== 'timeout') return;
await transporter.sendMail({
from: 'cron@example.com',
to: 'ops-team@example.com',
subject: `[ALERT] ${payload.taskName} ${payload.event}`,
text: `
Task: ${payload.taskName}
Event: ${payload.event}
Time: ${payload.timestamp}
Duration: ${payload.duration}ms
Error: ${payload.error?.message ?? 'N/A'}
Attempts: ${payload.attemptsMade}
`,
});
};
const task = schedule('0 0 * * *', nightlyCleanup, {
name: 'nightly-cleanup',
notifier: emailNotifier,
});
import { schedule } from 'cron-safe';
const task = schedule('0 9 * * *', async () => {
return await generateDailyReport();
}, {
name: 'daily-report',
retries: 2,
retryDelay: 10000,
preventOverlap: true,
executionTimeout: 60000,
historyLimit: 50,
onStart: () => {
console.log('[daily-report] Starting execution');
},
onSuccess: (result) => {
console.log('[daily-report] Completed:', result);
},
onRetry: (error, attempt) => {
console.warn(`[daily-report] Retry ${attempt}:`, error.message);
},
onError: (error) => {
console.error('[daily-report] Failed permanently:', error);
sendSlackAlert('Daily report generation failed!');
},
onTimeout: (error) => {
console.error('[daily-report] Timed out:', error.message);
},
onOverlapSkip: () => {
console.warn('[daily-report] Skipped due to overlap');
},
});
schedule(cronExpression, task, options?)Schedules a task with automatic retries, timeout, and overlap prevention.
Parameters:
| Parameter | Type | Description |
|---|---|---|
cronExpression | string | A valid cron expression (e.g., '* * * * *') |
task | () => T | Promise<T> | The function to execute |
options | CronSafeOptions<T> | Configuration options (see below) |
Returns: CronSafeTask<T>
CronSafeOptions<T>| Option | Type | Default | Description |
|---|---|---|---|
name | string | undefined | Identifier for logging/debugging |
retries | number | 0 | Number of retry attempts after failure |
retryDelay | number | 0 | Base delay in ms between retries |
backoffStrategy | 'fixed' | 'linear' | 'exponential' | 'fixed' | How delay grows between retries |
maxRetryDelay | number | undefined | Maximum delay cap for backoff |
preventOverlap | boolean | false | Skip execution if previous run is active |
maxConcurrency | number | undefined | Max concurrent executions allowed |
executionTimeout | number | undefined | Max execution time in ms before timeout |
historyLimit | number | 10 | Max number of history entries to keep |
distributedLock | DistributedLockConfig | — | Distributed locking configuration |
storage | StorageAdapter | — | Persistent storage adapter |
metricsProvider | MetricsProvider | — | External metrics export provider |
onStart | () => void | — | Called when task starts |
onSuccess | (result: T) => void | — | Called with result on success |
onRetry | (error, attempt) => void | — | Called before each retry |
onError | (error) => void | — | Called when all retries exhausted |
onTimeout | (error: Error) => void | — | Called when task times out |
onOverlapSkip | () => void | — | Called when execution is skipped |
notifier | Notifier<T> | — | Callback for Slack/email/custom notifications |
notifyOn | NotifyOn | { success: true, error: true, timeout: true, overlapSkip: false, lockFailed: false } | Which events trigger notifications |
timezone | string | — | Timezone for cron schedule |
scheduled | boolean | true | Start immediately or wait for .start() |
runOnInit | boolean | false | Run task immediately on creation |
DistributedLockConfig| Option | Type | Default | Description |
|---|---|---|---|
provider | LockProvider | — | Lock provider implementation (required) |
ttl | number | 60000 | Lock time-to-live in ms |
autoExtend | boolean | false | Auto-extend lock while task is running |
extendInterval | number | ttl / 2 | Interval in ms for auto-extending |
LockProviderInterface to implement for your distributed lock backend:
| Method | Signature | Description |
|---|---|---|
acquire | (key: string, ttl: number) => Promise<string | null> | Acquire lock, return lock ID or null |
release | (key: string, lockId: string) => Promise<void> | Release the lock |
extend? | (key: string, lockId: string, ttl: number) => Promise<boolean> | Extend lock TTL (optional) |
StorageAdapterInterface to implement for persistent storage:
| Method | Signature | Description |
|---|---|---|
saveRun | (record: StoredRunRecord) => Promise<void> | Save a new run record |
updateRun | (taskName, startedAt, updates) => Promise<void> | Update an existing run |
getRuns | (taskName, limit) => Promise<StoredRunRecord[]> | Get recent runs |
getLastIncompleteRun? | (taskName) => Promise<StoredRunRecord | null> | Get crashed run (optional) |
MetricsProviderInterface for metrics export:
| Method | Signature | Description |
|---|---|---|
recordEvent | (taskName, event, duration?) => void | Record a task event |
Events: 'start', 'success', 'failure', 'timeout', 'retry', 'overlapSkip'
CronSafeTask<T>The object returned by schedule():
| Method | Returns | Description |
|---|---|---|
start() | void | Start the scheduled task |
stop() | void | Stop the scheduled task |
getStatus() | 'scheduled' | 'running' | 'stopped' | Current status |
trigger() | Promise<T | undefined> | Execute immediately, returns result |
getHistory() | RunHistory[] | Get execution history (newest first) |
nextRun() | Date | null | Next scheduled run time |
getMetrics() | TaskMetrics | Get real-time metrics snapshot |
updateSchedule(expr) | void | Change cron expression at runtime |
TaskMetrics| Property | Type | Description |
|---|---|---|
totalRuns | number | Total number of runs started |
totalSuccess | number | Number of successful completions |
totalFailures | number | Number of failed runs |
totalTimeouts | number | Number of timed out runs |
totalRetries | number | Total retry attempts across all runs |
totalOverlapSkips | number | Number of runs skipped due to overlap |
currentRunning | number | Number of currently executing runs |
avgDuration | number | Average execution duration in ms |
lastRunAt | Date | undefined | When the last run completed |
lastStatus | string | undefined | Status of the last completed run |
RunHistory| Property | Type | Description |
|---|---|---|
startedAt | Date | When execution started |
endedAt | Date | undefined | When execution ended |
duration | number | undefined | Duration in milliseconds |
status | 'running' | 'success' | 'failed' | 'timeout' | Execution status |
error | Error | undefined | Error if failed/timeout |
triggeredBy | 'schedule' | 'manual' | How the run was triggered |
validate(expression)Validates a cron expression. Re-exported from node-cron.
import { validate } from 'cron-safe';
console.log(validate('* * * * *')); // true
console.log(validate('invalid')); // false
TimeoutErrorError class thrown when a task exceeds its execution timeout.
import { TimeoutError } from 'cron-safe';
// In your onError handler
onError: (error) => {
if (error instanceof TimeoutError) {
console.log('Task timed out');
}
}
Before:
import cron from 'node-cron';
cron.schedule('* * * * *', async () => {
await myTask(); // Errors go unhandled!
});
After:
import { schedule } from 'cron-safe';
schedule('* * * * *', async () => {
await myTask();
}, {
retries: 3,
executionTimeout: 30000,
onError: (err) => console.error('Task failed:', err),
});
Full TypeScript support with strict types:
import { schedule, CronSafeOptions, CronSafeTask, RunHistory, TaskMetrics } from 'cron-safe';
interface ReportResult {
rowsProcessed: number;
duration: number;
}
const options: CronSafeOptions<ReportResult> = {
retries: 2,
executionTimeout: 60000,
historyLimit: 100,
onSuccess: (result) => {
// result is typed as ReportResult
console.log(`Processed ${result.rowsProcessed} rows`);
},
};
const task: CronSafeTask<ReportResult> = schedule('0 * * * *', async (): Promise<ReportResult> => {
return { rowsProcessed: 1000, duration: 5000 };
}, options);
// Trigger returns typed result
const result = await task.trigger();
if (result) {
console.log(result.rowsProcessed); // TypeScript knows this is a number
}
// History is also typed
const history: RunHistory[] = task.getHistory();
// Metrics snapshot
const metrics: TaskMetrics = task.getMetrics();
console.log(`Success rate: ${(metrics.totalSuccess / metrics.totalRuns * 100).toFixed(1)}%`);
FAQs
A robust wrapper around node-cron with automatic retries, overlap prevention, execution timeout, history tracking, and structured error handling
We found that cron-safe demonstrated a healthy version release cadence and project activity because the last version was released less than a year ago. It has 1 open source maintainer collaborating on the project.
Did you know?

Socket for GitHub automatically highlights issues in each pull request and monitors the health of all your open source dependencies. Discover the contents of your packages and block harmful activity before you install or update your dependencies.

Security News
Multiple high-impact npm maintainers confirm they have been targeted in the same social engineering campaign that compromised Axios.

Security News
Axios compromise traced to social engineering, showing how attacks on maintainers can bypass controls and expose the broader software supply chain.

Security News
Node.js has paused its bug bounty program after funding ended, removing payouts for vulnerability reports but keeping its security process unchanged.