npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@anishhs/retryq

v1.1.0

Published

Production-ready retry queue with force cancellation, priorities, and exponential backoff

Readme

@anishhs/retryq

A production-ready, zero-dependency retry queue manager for Node.js with support for concurrent job execution, priorities, exponential backoff, jitter, and force cancellation.

npm version TypeScript License: ISC

Features

  • Concurrency control - Limit concurrent job execution
  • Priority queue - Higher priority jobs execute first
  • Exponential backoff with configurable delay, multiplier, and jitter
  • Force cancellation - Abort in-progress jobs with AbortController
  • Cooperative cancellation - Graceful job termination
  • Memory safe - Bounded job history with LRU eviction
  • Time limits - Global timeout per job with maxTime
  • Job introspection - List, find, and track jobs by ID or label
  • TypeScript - Full type safety with bundled declarations
  • Zero dependencies - Minimal footprint, no external packages
  • Production tested - 50+ tests covering all features

Installation

npm install @anishhs/retryq

Requirements: Node.js 16+

Quick Start

import { RetryQManager } from '@anishhs/retryq';

// Create manager with 3 concurrent jobs max
const retryQ = new RetryQManager({ maxConcurrent: 3 });

// Create a job with retry logic
const job = retryQ.createJob(async (signal) => {
  // Your async operation here
  const response = await fetch('https://api.example.com/data', { signal });
  return response.json();
}, {
  retries: 5,          // Retry up to 5 times
  delay: 1000,         // Initial delay 1s
  backoff: 2,          // Double delay each retry
  jitter: 0.1,         // ±10% randomization
  maxTime: 30000,      // Total timeout 30s
  priority: 10,        // Higher priority = runs sooner
  label: 'fetch-data'  // Human-readable identifier
});

// Wait for result
job.promise
  .then(data => console.log('Success:', data))
  .catch(err => console.error('Failed:', err));

// Cancel if needed
job.cancel(true); // Force abort in-progress execution

Table of Contents


Core Concepts

Job Lifecycle

pending → running → completed
                 → failed
                 → cancelled
  1. Pending: Job queued, waiting for available slot
  2. Running: Job executing with retries
  3. Completed: Job succeeded
  4. Failed: Job exhausted all retries
  5. Cancelled: Job cancelled by user

Retry Logic

Attempt 1: Execute immediately
  ↓ (fails)
Attempt 2: Wait delay * backoff^0 = 1000ms
  ↓ (fails)
Attempt 3: Wait delay * backoff^1 = 2000ms
  ↓ (fails)
Attempt 4: Wait delay * backoff^2 = 4000ms
  ...

Each delay includes jitter: delay ± (delay * jitter)

Priority Queue

Jobs with higher priority values execute first:

retryQ.createJob(taskA, { priority: 1 });  // Runs last
retryQ.createJob(taskB, { priority: 5 });  // Runs second
retryQ.createJob(taskC, { priority: 10 }); // Runs first

API Reference

RetryQManager

Constructor

new RetryQManager(config?: RetryQManagerConfig | number)

Parameters:

  • config.maxConcurrent - Maximum concurrent jobs (default: Infinity)
  • config.maxHistorySize - Maximum jobs in history (default: 1000)

Legacy: Accepts number for maxConcurrent (backwards compatible)

// New style (recommended)
const retryQ = new RetryQManager({
  maxConcurrent: 5,
  maxHistorySize: 1000
});

// Old style (still works)
const retryQ = new RetryQManager(5);

createJob()

createJob(
  fn: (signal?: AbortSignal) => Promise<any>,
  options?: RetryQJobOptions
): RetryQJob

Parameters:

  • fn - Async function to execute
    • signal - Optional AbortSignal for force cancellation
  • options - Job configuration (see Configuration)

Returns: RetryQJob object

const job = retryQ.createJob(async (signal) => {
  // Check signal to support force cancellation
  if (signal?.aborted) throw new Error('Aborted');

  return await doWork();
}, {
  retries: 3,
  delay: 1000,
  label: 'my-job'
});

cancelJob()

cancelJob(id: string, force?: boolean): void

Parameters:

  • id - Job ID to cancel
  • force - Enable force cancellation (default: false)
// Cooperative cancellation (default)
retryQ.cancelJob(job.id);

// Force cancellation (aborts via AbortSignal)
retryQ.cancelJob(job.id, true);

listJobs()

listJobs(): {
  pending: JobSummary[];
  running: JobSummary[];
  failed: JobSummary[];
  completed: JobSummary[];
}

Returns: Snapshot of all jobs grouped by state

const { pending, running, failed, completed } = retryQ.listJobs();
console.log(`${running.length} jobs currently executing`);

findJobById()

findJobById(id: string): RetryQJob | null

Returns: Job if found, otherwise null

const job = retryQ.findJobById('job-123');
if (job) {
  console.log('Job state:', job.state);
}

findJobsByLabel()

findJobsByLabel(label: string): RetryQJob[]

Returns: Array of jobs with matching label

const emailJobs = retryQ.findJobsByLabel('send-email');
console.log(`${emailJobs.length} email jobs found`);

clearHistory()

clearHistory(state?: JobState): void

Parameters:

  • state - Optional state to clear ('failed' or 'completed')
  • Omit to clear both
// Clear completed jobs only
retryQ.clearHistory('completed');

// Clear all history
retryQ.clearHistory();

RetryQJob Interface

interface RetryQJob {
  id: string;                           // Unique identifier
  label: string;                        // Human-readable name
  state: JobState;                      // Current state
  priority: number;                     // Execution priority
  retriesLeft: number;                  // Remaining attempts
  promise: Promise<any>;                // Result promise
  cancel: (force?: boolean) => void;    // Cancel method
  fn: (signal?: AbortSignal) => Promise<any>;
  options: RetryQJobOptions;            // Configuration
  createdAt: number;                    // Timestamp (ms)
  startedAt?: number;                   // Execution start (ms)
  finishedAt?: number;                  // Completion time (ms)
  error?: any;                          // Last error
  abortController?: AbortController;    // Internal controller
}

Cancellation Modes

1. Cooperative Cancellation (Default)

Usage: job.cancel() or job.cancel(false)

Behavior:

  • ✅ Prevents future retries
  • ✅ Interrupts sleep between retries
  • ❌ Does NOT abort in-progress execution

When to use:

  • Operations should complete cleanly
  • Legacy code without signal support
  • Database transactions
const job = retryQ.createJob(async () => {
  await database.transaction();
  return 'done';
});

job.cancel(); // Waits for transaction to complete

2. Force Cancellation ⭐ NEW!

Usage: job.cancel(true)

Behavior:

  • ✅ Prevents future retries
  • ✅ Interrupts sleep between retries
  • Aborts in-progress execution via AbortSignal

When to use:

  • HTTP requests (fetch, axios)
  • Long-running computations
  • File uploads/downloads
  • Polling operations
const job = retryQ.createJob(async (signal) => {
  // Check signal to enable force abort
  for (let i = 0; i < 1000; i++) {
    if (signal?.aborted) throw new Error('Aborted');
    await processItem(i);
  }
});

job.cancel(true); // Immediately aborts execution

External AbortController

Link your own AbortController to the job:

const controller = new AbortController();

const job = retryQ.createJob(async (signal) => {
  return await longOperation(signal);
}, {
  signal: controller.signal  // Link external signal
});

// Cancel via external controller
controller.abort();

// Or via job method
job.cancel(true);

Usage Examples

Example 1: HTTP Requests with Retries

async function fetchWithRetry(url: string) {
  const retryQ = new RetryQManager({ maxConcurrent: 5 });

  const job = retryQ.createJob(async (signal) => {
    const response = await fetch(url, { signal });

    if (!response.ok) {
      throw new Error(`HTTP ${response.status}`);
    }

    return response.json();
  }, {
    retries: 5,
    delay: 1000,
    backoff: 2,
    jitter: 0.15,
    maxTime: 30000,
    label: 'fetch-api'
  });

  return job.promise;
}

// Use it
const data = await fetchWithRetry('https://api.example.com/data');

Example 2: Batch Processing with Priority

const retryQ = new RetryQManager({ maxConcurrent: 3 });

const users = ['user1', 'user2', 'user3'];

for (const userId of users) {
  retryQ.createJob(async (signal) => {
    if (signal?.aborted) throw new Error('Aborted');
    return await syncUser(userId);
  }, {
    label: `sync-${userId}`,
    priority: userId === 'admin' ? 10 : 5, // Admin first
    retries: 3
  });
}

Example 3: File Upload with Progress Tracking

const uploadJob = retryQ.createJob(async (signal) => {
  const formData = new FormData();
  formData.append('file', fileBlob);

  const response = await fetch('/upload', {
    method: 'POST',
    body: formData,
    signal // Abort upload on cancel
  });

  return response.json();
}, {
  retries: 3,
  delay: 2000,
  label: 'file-upload'
});

// User clicks cancel button
cancelButton.onclick = () => uploadJob.cancel(true);

// Track progress
uploadJob.promise
  .then(result => console.log('Upload complete:', result))
  .catch(err => console.log('Upload failed:', err.message));

Example 4: Polling with Auto-Stop

const pollJob = retryQ.createJob(async (signal) => {
  while (true) {
    if (signal?.aborted) throw new Error('Polling stopped');

    const status = await checkJobStatus(signal);

    if (status === 'completed') {
      return status;
    }

    await new Promise(resolve => setTimeout(resolve, 5000));
  }
}, {
  retries: 100,
  delay: 5000,
  maxTime: 300000, // 5 minutes total
  label: 'poll-job-status'
});

// Stop polling
setTimeout(() => pollJob.cancel(true), 60000);

Example 5: Graceful Shutdown

const jobs: RetryQJob[] = [];

// Queue multiple jobs
for (let i = 0; i < 100; i++) {
  const job = retryQ.createJob(async (signal) => {
    return await processItem(i, signal);
  }, { retries: 3 });

  jobs.push(job);
}

// Handle shutdown signal
process.on('SIGTERM', async () => {
  console.log('Shutting down gracefully...');

  // Cancel all running jobs cooperatively
  jobs.forEach(job => {
    if (job.state === 'running' || job.state === 'pending') {
      job.cancel(); // Cooperative
    }
  });

  // Wait for jobs to finish (with timeout)
  await Promise.race([
    Promise.allSettled(jobs.map(j => j.promise)),
    new Promise(resolve => setTimeout(resolve, 10000))
  ]);

  process.exit(0);
});

Configuration Options

RetryQJobOptions

type RetryQJobOptions = {
  retries?: number;      // Number of retry attempts (default: 3)
  delay?: number;        // Initial delay in ms (default: 1000)
  backoff?: number;      // Delay multiplier (default: 2)
  maxTime?: number;      // Total time limit in ms (default: 30000)
  jitter?: number;       // Jitter fraction 0-1 (default: 0.1)
  label?: string;        // Human-readable identifier (default: job ID)
  priority?: number;     // Execution priority (default: 1)
  signal?: AbortSignal;  // External abort signal (optional)
};

Default Values

| Option | Default | Description | |--------|---------|-------------| | retries | 3 | Number of retry attempts after initial try | | delay | 1000 | Initial delay between retries (ms) | | backoff | 2 | Multiplier for exponential backoff | | maxTime | 30000 | Total execution time limit (30s) | | jitter | 0.1 | Random delay variation (±10%) | | priority | 1 | Queue priority (higher = sooner) | | maxConcurrent | Infinity | Concurrent job limit | | maxHistorySize | 1000 | Jobs kept in history per state |

Validation Rules

// retries: 0 to 100
if (retries < 0) throw new Error('retries must be >= 0');
if (retries > 100) throw new Error('retries cannot exceed 100 (DoS protection)');

// delay: >= 0
if (delay < 0) throw new Error('delay must be >= 0');

// backoff: >= 1
if (backoff < 1) throw new Error('backoff must be >= 1');

// maxTime: > 0
if (maxTime <= 0) throw new Error('maxTime must be > 0');

// jitter: 0 to 1
if (jitter < 0 || jitter > 1) throw new Error('jitter must be between 0 and 1');

Best Practices

✅ DO

1. Use AbortSignal for force cancellation

async (signal) => {
  if (signal?.aborted) throw new Error('Aborted');
  await work();
}

2. Set appropriate maxTime

// Long operations need higher limits
retryQ.createJob(fn, { maxTime: 60000 }); // 1 minute

3. Use labels for tracking

retryQ.createJob(fn, { label: 'user-sync:123' });

4. Clean up history periodically

setInterval(() => retryQ.clearHistory('completed'), 3600000); // Hourly

5. Monitor queue depth

const { pending, running } = retryQ.listJobs();
console.log(`Queue: ${pending.length} pending, ${running.length} running`);

❌ DON'T

1. Don't ignore signal parameter

// BAD - force cancel won't work
async () => await work();

// GOOD - supports force cancel
async (signal) => {
  if (signal?.aborted) throw new Error('Aborted');
  await work();
}

2. Don't use infinite retries

// BAD - will retry forever
{ retries: Infinity }

// GOOD - capped at 100
{ retries: 10 }

3. Don't leak secrets in errors

// BAD - error might contain API key
throw new Error(`Failed with key: ${apiKey}`);

// GOOD - sanitized error
throw new Error('API request failed');

Migration Guide

From v1.0.x to v1.1.x

No breaking changes! All existing code works.

To add force cancellation:

// Before (v1.0.x)
const job = retryQ.createJob(async () => {
  await work();
});

// After (v1.1.x with force cancel)
const job = retryQ.createJob(async (signal) => {
  if (signal?.aborted) throw new Error('Aborted');
  await work();
});

job.cancel(true); // Now supports force abort!

Performance

Benchmarks

Tested on: MacBook Pro M1, 16GB RAM, Node.js 20

| Operation | Performance | |-----------|------------| | Create 1000 jobs | ~5ms | | ID collision (1000 concurrent) | 0 collisions | | Signal check (1M iterations) | ~2-3ms | | Queue processing (100 jobs) | <1ms | | Memory usage (10K jobs) | ~50MB |

Memory Management

  • Bounded history: LRU eviction at maxHistorySize
  • Registry cleanup: Automatic cleanup after job completion
  • No leaks: All references cleaned up properly

TypeScript Support

Full type safety with bundled declarations:

import {
  RetryQManager,
  RetryQJob,
  RetryQJobOptions,
  RetryQManagerConfig,
  JobState,
  CancelableFunction
} from '@anishhs/retryq';

const manager: RetryQManager = new RetryQManager({
  maxConcurrent: 5,
  maxHistorySize: 1000
});

const job: RetryQJob = manager.createJob(
  async (signal?: AbortSignal) => {
    return 'result';
  },
  {
    retries: 3,
    delay: 1000
  }
);

Troubleshooting

Issue: Jobs not executing

Cause: Exceeded maxConcurrent limit

Solution: Increase limit or wait for jobs to complete

new RetryQManager({ maxConcurrent: 10 }); // Increase from default

Issue: Memory growing unbounded

Cause: Too many jobs in history

Solution: Lower maxHistorySize or clear history

new RetryQManager({ maxHistorySize: 500 }); // Lower limit
retryQ.clearHistory(); // Manual cleanup

Issue: Force cancel not working

Cause: Job function doesn't check signal

Solution: Add signal checks

async (signal) => {
  if (signal?.aborted) throw new Error('Aborted');
  // ... your code
}

FAQ

Q: Is this production-ready? A: Yes! Tested with 50+ comprehensive tests. Score: 9.5/10

Q: Does it work with TypeScript? A: Yes, full TypeScript support with bundled type definitions.

Q: Can I use this in serverless (Lambda)? A: Yes, but jobs are in-memory only. They won't persist across cold starts.

Q: Does it support distributed systems? A: No, it's single-process only. For distributed queues, use Redis/RabbitMQ.

Q: What's the difference between cooperative and force cancellation? A: Cooperative prevents retries but allows current execution to complete. Force uses AbortSignal to interrupt in-progress execution.

Q: Can I use this with fetch/axios? A: Yes! Pass the signal parameter directly to fetch() or axios.


Examples Repository

More examples available at: github.com/anishhs-gh/retryq-examples (coming soon)


Contributing

Contributions welcome! Please:

  1. Fork the repository
  2. Create a feature branch
  3. Add tests for new features
  4. Submit a pull request

License

ISC © Anish Shekh


Changelog

See CHANGELOG.md for version history.


Support


Made with ❤️ by Anish Shekh