A tiny, dependency-free task queue for Node.js that executes tasks with priority ordering, concurrency control, and completion delays. Perfect for managing asynchronous workflows with sophisticated timing requirements.
- Smart scheduling - Dynamic timeout-based scheduling for optimal performance
- Task coalescing - Intelligent merging of similar tasks for efficiency
- Priority-based execution - Higher priority tasks run first
- Concurrency control - Limit simultaneous task execution
- Completion delays - Configurable delays between task completions
- Delay bypass - Urgent tasks can skip active delay periods
- Timeout support - Automatic task cancellation on timeout
- Dual API support - Both callback and Promise-based APIs
- AbortController integration - Cooperative cancellation support
- Dual ESM/CJS exports - Works with both module systems
- TypeScript ready - Full type definitions included
- Zero dependencies - Lightweight and fast
npm install @cldmv/holdmytaskThe library provides flexible import options for different use cases:
// Named importimport{HoldMyTask}from"@cldmv/holdmytask";// Default importimportHoldMyTaskfrom"@cldmv/holdmytask";// Import everythingimport*asHoldMyTaskLibfrom"@cldmv/holdmytask";// Uses optimized distribution filesimport{HoldMyTask}from"@cldmv/holdmytask/main";// Conditional: uses source files in development, dist files in productionFor familiarity with other queue systems, several aliases are available:
import{HoldMyTask,// Original class namequeue,// Lower-case alias for common conventionsQueue,// Standard queue namingTaskManager,// Task management systemsTaskQueue,// Task-specific queuingQueueManager,// Queue management systemsTaskProcessor// Task processing systems}from"@cldmv/holdmytask";// All aliases are functionally identicalconstmyQueue=newqueue({concurrency: 5});conststdQueue=newQueue({concurrency: 5});constmanager=newTaskManager({priorities: {high: {delay: 100}}});import{HoldMyTask}from"@cldmv/holdmytask/src";// Always uses source files - bypasses devcheckNote: The main import automatically runs environment checks in development mode to ensure proper configuration.
import{HoldMyTask}from"@cldmv/holdmytask";constqueue=newHoldMyTask({concurrency: 2,delays: {1: 100,2: 200},// 100ms delay after priority 1 tasks, 200ms after priority 2coalescingWindowDuration: 200,// Group similar tasks within 200mscoalescingMaxDelay: 1000// Force execution after 1000ms max});// Enqueue a taskqueue.enqueue(async(signal)=>{// Your task logic herereturn"task result";},(error,result)=>{if(error){console.error("Task failed:",error);}else{console.log("Task completed:",result);}},{priority: 1,timeout: 5000});// Coalescing example - multiple similar tasks become oneconstresults=awaitPromise.all([queue.enqueue(async()=>updateUI(),{coalescingKey: "ui.update"}),queue.enqueue(async()=>updateUI(),{coalescingKey: "ui.update"}),queue.enqueue(async()=>updateUI(),{coalescingKey: "ui.update"})]);// Only one updateUI() call executes, all three promises resolve with the same resultFor modern async/await codebases, you can omit the callback to get a Promise:
import{HoldMyTask}from"@cldmv/holdmytask";constqueue=newHoldMyTask({concurrency: 2});// Promise-based tasktry{constresult=awaitqueue.enqueue(async(signal)=>{// Task logic with abort supportif(signal.aborted)thrownewError("Aborted");return"task result";},{priority: 1,timeout: 5000});console.log("Task completed:",result);}catch(error){console.error("Task failed:",error.message);}// The returned Promise is also a task handleconstpromise=queue.enqueue(()=>"simple task");console.log("Task ID:",promise.id);promise.cancel();// Cancel the taskYou can mix callback and promise styles in the same queue:
// Callback-basedqueue.enqueue(task1,callback,options);// Promise-basedconstresult=awaitqueue.enqueue(task2,options);HoldMyTask works excellently with async constructor patterns for classes that need initialization:
classAsyncService{constructor(options={}){this.queue=newHoldMyTask({concurrency: options.concurrency||2,maxQueue: -1// Unlimited queue for initialization tasks});this.ready=this.initialize();}asyncinitialize(){// Initialization tasks that need to complete before the service is readyawaitthis.queue.enqueue(async()=>{this.config=awaitthis.loadConfiguration();});awaitthis.queue.enqueue(async()=>{this.database=awaitthis.connectToDatabase();});awaitthis.queue.enqueue(async()=>{this.cache=awaitthis.initializeCache();});returnthis;}asyncprocessData(data){// Ensure service is initialized before processingawaitthis.ready;returnthis.queue.enqueue(async()=>{// Process data using initialized resourcesreturnthis.database.save(awaitthis.transformData(data));});}// Clean shutdownasyncshutdown(){awaitthis.queue.drain();// Wait for all tasks to completeawaitthis.database.close();}}// Usageconstservice=newAsyncService({concurrency: 5});awaitservice.ready;// Wait for initializationconstresult=awaitservice.processData(someData);This pattern is particularly useful for:
- Database connections: Initialize connections before accepting work
- Configuration loading: Load settings before processing tasks
- Resource acquisition: Set up required resources in a controlled manner
- Dependency injection: Initialize dependencies in the correct order
constqueue=newHoldMyTask(options?)For async initialization that allows event listeners to be attached before any initialization events can fire, use sync: false:
// Create instance with async initializationconstqueuePromise=newHoldMyTask({concurrency: 5,maxQueue: 100,sync: false// Enable async initialization});// Attach event listeners before initialization completesqueuePromise.on("error",(err)=>console.error("Queue error:",err));queuePromise.on("warning",(warning)=>console.warn("Warning:",warning.message));// Wait for initialization to completeconstqueue=awaitqueuePromise;This pattern is particularly useful when you need to handle initialization errors or warnings through event listeners rather than try/catch blocks.
Options:
concurrency(number, default: 1) - Maximum concurrent taskssmartScheduling(boolean, default: true) - Use dynamic timeout scheduling for better performancetick(number, default: 25) - Scheduler tick interval in milliseconds (used when smartScheduling is false)autoStart(boolean, default: true) - Whether to start processing immediatelydefaultPriority(number, default: 0) - Default task prioritymaxQueue(number, default: Infinity) - Maximum queued tasks. Use-1for unlimited queue capacity (equivalent toInfinity)delays(object, default: {}) - DEPRECATED: Priority-to-delay mapping for completion delays (useprioritiesinstead)priorities(object, default: {}) - Priority-specific configuration:{ [priority]: { concurrency, postDelay, startDelay } }concurrency(number) - Maximum concurrent tasks for this priority (defaults to global concurrency limit)postDelay(number) - Delay after task completion before next task of same prioritystartDelay(number) - Delay before task execution (pre-execution delay)
coalescing(object) - Enhanced coalescing configurationdefaults(object) - Default settings for all coalescing keyswindowDuration(number, default: 200) - Window duration in millisecondsmaxDelay(number, default: 1000) - Maximum delay before forcing executiondelay(number) - Default completion delay for coalescing tasksstart(number) - Default start delay for coalescing tasksresolveAllPromises(boolean, default: true) - Whether all promises resolve with resultmultipleCallbacks(boolean, default: false) - Whether to call multiple callbacks
keys(object) - Per-key configuration overrides:{ [key]: { windowDuration, maxDelay, delay, start, ... } }
coalescingWindowDuration(number, default: 200) - DEPRECATED: Usecoalescing.defaults.windowDurationcoalescingMaxDelay(number, default: 1000) - DEPRECATED: Usecoalescing.defaults.maxDelaycoalescingResolveAllPromises(boolean, default: true) - DEPRECATED: Usecoalescing.defaults.resolveAllPromisesonError(function) - Global error handlernow(function) - Injectable clock for testing
Adds a task to the queue.
Parameters:
task(function) - The task function that receives anAbortSignaland returns a value or Promisecallback(function, optional) - Called with(error, result)when task completes. If omitted, returns a Promise.options(object) - Task options
Task Options:
priority(number) - Task priority (higher = more important)delay(number) - Override completion delay for this task (use -1 to bypass delays)bypassDelay(boolean) - If true, skip any active delay period and start immediatelytimeout(number) - Timeout in millisecondssignal(AbortSignal) - External abort signaltimestamp(number) - Absolute execution timestampstart(number) - Milliseconds from now when the task should be ready to run (convenience for timestamp calculation)coalescingKey(string) - Tasks with the same coalescing key can be merged for efficiencymustRunBy(number) - Absolute timestamp by which the task must execute (overrides coalescing delays)metadata(any) - Custom metadata attached to the task. Individual metadata is always directly accessible via the returned task handle
Returns: TaskHandle object with:
id- Unique task identifiercancel(reason?)- Cancel the taskstatus()- Get current statusstartedAt- When task startedfinishedAt- When task finishedresult- Task result (if completed)error- Task error (if failed)metadata- Custom metadata attached to this taskcoalescingInfo- Coalescing group information (null for non-coalesced tasks)
When callback is omitted, returns a Promise that resolves with the task result or rejects with an error.
Every task handle provides direct access to its metadata and coalescing information:
// Create a task with metadataconsttask=queue.enqueue(async()=>{return"task completed";},{metadata: {userId: 123,action: "save",document: "report.pdf"},coalescingKey: "user-actions"});// Direct access to metadata - works for both regular and coalesced tasksconsole.log(task.metadata);// Output: { userId: 123, action: 'save', document: 'report.pdf' }// For coalesced tasks, get information about the coalescing groupif(task.coalescingInfo){console.log("Coalescing key:",task.coalescingInfo.coalescingKey);console.log("Group size:",task.coalescingInfo.taskCount);console.log("All task metadata in group:",task.coalescingInfo.allTaskMetadata);}// Works the same way with Promise APIconstresult=awaittask;console.log("Still accessible after completion:",task.metadata);Key Points:
task.metadata- Returns the original metadata you attached to the tasktask.coalescingInfo- Returns coalescing group details (null for non-coalesced tasks)- Available on both callback-style task handles and Promise-style task handles
- Accessible before, during, and after task execution
- For coalesced tasks, each task retains its individual metadata
For compatibility with common queue system naming conventions, the following aliases are available:
schedule(task, callback?, options?)- Alias forenqueue()add(task, callback?, options?)- Alias forenqueue()
// These are all equivalent:queue.enqueue(task,options);queue.schedule(task,options);queue.add(task,options);Tasks can be assigned custom IDs for easier tracking and management:
// Assign custom IDconsttask=queue.enqueue(myTask,{id: "user-action-123"});console.log(task.id);// 'user-action-123'// Task management by IDqueue.has("user-action-123");// trueconsttask=queue.get("user-action-123");// task object or nullqueue.cancel("user-action-123","User cancelled");// returns true if cancelled// Uniqueness is enforcedqueue.enqueue(task1,{id: "duplicate"});queue.enqueue(task2,{id: "duplicate"});// throws: Task ID "duplicate" already existsID Management Methods:
has(id)- Check if a task with the given ID existsget(id)- Get task object by ID (returns null if not found)cancel(id, reason?)- Cancel task by ID (returns boolean success)
Method Aliases (for backward compatibility):
hasTask(id)- Alias forhas(id)getTask(id)- Alias forget(id)cancelTask(id, reason?)- Alias forcancel(id, reason?)
pause()- Pause task executionresume()- Resume task executionclear()- Cancel all pending taskssize()- Get total queued taskslength()- Alias forsize()- get total queued tasksinflight()- Get currently running tasksdestroy()- Destroy the queue and cancel all tasksshutdown()- Alias fordestroy()- convenient for common queue system namingnow()- Get current timestamp (useful for testing with custom clocks)
configurePriority(priority, config)- Configure or update priority-specific settingspriority(string|number) - Priority level to configureconfig(object) - Configuration:{ delay?, maxDelay?, start? }
configureCoalescingKey(key, config)- Configure or update coalescing key settingskey(string) - Coalescing key to configureconfig(object) - Configuration:{ windowDuration?, maxDelay?, delay?, start?, multipleCallbacks?, resolveAllPromises? }
getPriorityConfig(priority, taskOptions?)- Get effective configuration for a specific prioritygetPriorityConfigurations()- Get all configured priorities and their settingsgetCoalescingConfig(coalescingKey, taskOptions?)- Get effective configuration for a specific coalescing keygetCoalescingConfigurations()- Get all configured coalescing keys and their settings
getCoalescingGroup(coalescingKey, groupId?)- Get detailed information about coalescing groupscoalescingKey(string) - The coalescing key to querygroupId(string, optional) - Specific group ID, or omit to get all groups for the key- Returns group info with task details and metadata
getCoalescingGroupMetadata(coalescingKey, groupId?)- Get metadata from all tasks in coalescing groups- Returns array of
{ taskId, groupId, metadata }objects
- Returns array of
getCoalescingGroupsSummary()- Get summary of all active coalescing groups- Returns object with
{ [coalescingKey]: { groupCount, totalTasks } }
- Returns object with
findCoalescingGroupByTaskId(taskId)- Find which coalescing group contains a specific task- Returns group info including the task's metadata and other group members
Note: For most use cases, the direct task.metadata and task.coalescingInfo properties provide easier access to task information. The coalescing group inspection methods are useful for bulk operations, monitoring, debugging, or when you need to query groups by coalescing key without having individual task references.
// Example: Direct access to task metadata and coalescing infoconsttask1=queue.enqueue(async()=>"result",{coalescingKey: "user-action",metadata: {userId: 123,action: "save"}});consttask2=queue.enqueue(async()=>"result",{coalescingKey: "user-action",metadata: {userId: 456,action: "delete"}});// Direct access - no additional API calls needed!console.log("Task 1 metadata:",task1.metadata);// { userId: 123, action: 'save' }console.log("Task 2 metadata:",task2.metadata);// { userId: 456, action: 'delete' }// Access coalescing information directly from the taskconsole.log("Coalescing info:",task1.coalescingInfo);// {// coalescingKey: 'user-action',// groupId: '1',// representativeId: '3',// taskCount: 2,// allTaskMetadata: [// { id: '1', metadata: { userId: 123, action: 'save' }},// { id: '2', metadata: { userId: 456, action: 'delete' }}// ]// }// Advanced: Use inspection methods for bulk operationsconstmetadata=queue.getCoalescingGroupMetadata("user-action");constgroupInfo=queue.getCoalescingGroup("user-action");consttaskGroup=queue.findCoalescingGroupByTaskId(task1.id);For debugging queue behavior, troubleshooting scheduler issues, or monitoring queue state in production, HoldMyTask provides comprehensive inspection methods:
// Log comprehensive queue state to consolequeue.debugLog();// Basic summaryqueue.debugLog(true);// Detailed task information// Example output:// === HoldMyTask Debug Information ===// Timestamp: 2025-11-14T17:45:32.123Z// Status: ACTIVE// Smart Scheduling: ON// Concurrency: 2/5//// --- Queue State ---// Total Tasks: 7// Pending: 3// Ready: 2// Running: 2//// --- Timer State ---// Interval: None// Timeout: Active (15)// Next Run: 1250ms// Get comprehensive queue stateconststate=queue.inspect();console.log("Total tasks:",state.totals.totalTasks);console.log("Running tasks:",state.queues.running.tasks);console.log("Next scheduled run:",state.timers.nextRunIn,"ms");// Inspect just the tasksconsttasks=queue.inspectTasks();console.log("Tasks by priority:",tasks.byPriority);console.log("Tasks by coalescing key:",tasks.byCoalescingKey);// Inspect scheduler stateconstscheduler=queue.inspectScheduler();console.log("Can run task now:",scheduler.nextTask?.canRunNow);console.log("Available slots:",scheduler.availableSlots);console.log("Active delays:",scheduler.delays.count);// Inspect timers specificallyconsttimers=queue.inspectTimers();console.log("Scheduler interval active:",timers.schedulerInterval.active);console.log("Next run in:",timers.nextRunIn,"ms");inspect()- Get comprehensive queue state including tasks, timers, scheduler, and coalescing informationinspectTasks()- Get detailed task information grouped by status, priority, and coalescing keyinspectScheduler()- Get scheduler state, timing information, and next task detailsinspectTimers()- Get timer state and scheduling informationdebugLog(detailed?)- Log formatted debug information to console
Use Cases:
- Development: Debug why tasks aren't running or understand queue behavior
- Production Monitoring: Track queue health and performance metrics
- Troubleshooting: Identify scheduler issues, timer problems, or concurrency bottlenecks
- Testing: Verify queue state in automated tests
// Example: Debug scheduler restart issueconstqueue=newHoldMyTask({concurrency: 2});// Add some tasksqueue.enqueue(async()=>"task1");queue.enqueue(async()=>"task2",{priority: 2,start: 5000});// Check initial stateconsole.log("Initial state:");queue.debugLog();// Wait for drainqueue.on("drain",()=>{console.log("After drain:");queue.debugLog();// Add new taskqueue.enqueue(async()=>"task3");console.log("After adding new task:");queue.debugLog();});queue.on("start",(task)=>{/* task started */});queue.on("success",(task)=>{/* task completed successfully */});queue.on("error",(task)=>{/* task failed */});queue.on("cancel",(task,reason)=>{/* task cancelled */});queue.on("drain",()=>{/* all tasks completed */});queue.on("warning",(warning)=>{/* deprecation or configuration warning */});When deprecated configuration options are used, HoldMyTask emits warning events to help with migration:
constqueue=newHoldMyTask({delays: {1: 100},// Deprecated optioncoalescingWindowDuration: 200// Deprecated option});queue.on("warning",(warning)=>{console.warn(`Deprecation Warning: ${warning.message}`);console.warn(`Use: ${warning.replacement}`);console.warn(`In: ${warning.source}`);});// Output:// Deprecation Warning: Option 'delays' is deprecated// Use: priorities: { 1: { delay: 100 }}// In: constructor optionsUnderstanding how concurrency and delays work together is crucial for optimal queue behavior:
constqueue=newHoldMyTask({concurrency: 3,// Up to 3 tasks can run simultaneouslydelays: {1: 500,2: 1000}});Key Behaviors:
- Delays are Global: When any task completes, its priority delay affects ALL subsequent task starts
- Concurrency Slots: Multiple tasks can run simultaneously until delay blocks new starts
- Independent Completion: Running tasks complete independently; delays only affect new starts
// Timeline with concurrency: 2, delays: { 1: 1000 }// 10:00:00 - Start: TaskA (pri 1), TaskB (pri 1) - both running// 10:00:02 - TaskA completes → 1000ms delay starts, TaskB still running// 10:00:03 - TaskC (pri 1) ready but blocked by delay// 10:00:04 - TaskB completes → extends delay to 10:00:05 (1000ms from TaskB completion)// 10:00:05 - Delay expires, TaskC and TaskD can start (fills 2 slots again)Best Practices:
- Use shorter delays with higher concurrency for throughput with gentle rate limiting
- Use longer delays with lower concurrency for strict rate limiting and resource protection
- Monitor
inflight()to understand how many tasks are actively running vs waiting
HoldMyTask uses a sophisticated dual-heap scheduling system for optimal performance:
🔄 Dual-Heap System:
- Pending Heap: Time-ordered queue for tasks waiting for their scheduled time (
readyAt) - Ready Heap: Priority-ordered queue for tasks ready to execute now
📊 Scheduling Flow:
1. New tasks → Pending Heap (ordered by readyAt timestamp)
2. Scheduler tick → Move ready tasks: Pending → Ready Heap
3. Execution → Take highest priority from Ready Heap
4. Completion → Apply delays, emit events, schedule next tick
⏰ Smart Timing:
- Adaptive scheduling: Uses intervals for immediate tasks, timeouts for distant tasks
- Precision timing: Sub-millisecond accuracy with injectable clock for testing
- Efficient scanning: O(log n) heap operations for thousands of tasks
🚀 Performance Characteristics:
- Enqueue: O(log n) - Insert into priority heap
- Dequeue: O(log n) - Extract from priority heap
- Scheduler tick: O(k log n) where k = ready tasks
- Memory: O(n) - Minimal overhead per task
This architecture enables handling thousands of tasks with precise timing control while maintaining excellent performance.
For scenarios requiring unlimited task queuing, you can set maxQueue to -1:
constqueue=newHoldMyTask({concurrency: 5,maxQueue: -1// Unlimited queue capacity (equivalent to Infinity)});// Now you can enqueue unlimited tasks without hitting queue limitsfor(leti=0;i<100000;i++){queue.enqueue(async()=>processTask(i));}Use Cases:
- Batch processing: When processing large datasets where queue size is unpredictable
- Event-driven systems: Where task volume can spike dramatically
- Data pipelines: For ETL operations with variable input sizes
- Development/testing: When you need to stress-test with large task volumes
Memory Considerations:
While -1 allows unlimited queuing, be mindful of memory usage with very large task sets. Each queued task consumes memory until executed.
Tasks execute in priority order (highest first):
Callback API:
queue.enqueue(task1,callback,{priority: 1});queue.enqueue(task2,callback,{priority: 10});// Runs firstqueue.enqueue(task3,callback,{priority: 5});Promise API:
awaitqueue.enqueue(task1,{priority: 1});awaitqueue.enqueue(task2,{priority: 10});// Runs firstawaitqueue.enqueue(task3,{priority: 5});HoldMyTask supports comprehensive priority and coalescing configuration for sophisticated timing control:
constqueue=newHoldMyTask({concurrency: 3,// Priority-specific defaults (replaces legacy delays)priorities: {1: {delay: 200,start: 0},// High priority: 200ms delay, immediate start2: {delay: 100,start: 25},// Medium priority: 100ms delay, 25ms start delay3: {delay: 50,start: 50}// Low priority: 50ms delay, 50ms start delay},// Enhanced coalescing with per-key settingscoalescing: {defaults: {windowDuration: 200,maxDelay: 1000,delay: 75,// Default completion delay for coalescing tasksstart: 25,// Default start delay for coalescing tasksresolveAllPromises: true},keys: {"ui.update": {windowDuration: 100,maxDelay: 500,delay: 25,// Fast UI updatesstart: 0},"api.batch": {windowDuration: 1000,maxDelay: 5000,delay: 200,// Slower API operationsstart: 100}}}});// Dynamic configurationqueue.configurePriority(4,{delay: 300,start: 75});queue.configureCoalescingKey("data.sync",{windowDuration: 800,maxDelay: 3000,delay: 150,start: 50});// Get configuration informationconstpriority4Config=queue.getPriorityConfig(4);console.log(`Priority 4: ${priority4Config.delay}ms delay, ${priority4Config.start}ms start delay`);constdataSyncConfig=queue.getCoalescingConfig("data.sync");console.log(`Data sync: ${dataSyncConfig.windowDuration}ms window, ${dataSyncConfig.maxDelay}ms max delay`);// Get all configurationsconstallPriorities=queue.getPriorityConfigurations();constallCoalescingKeys=queue.getCoalescingConfigurations();console.log("All priority configs:",allPriorities);console.log("All coalescing configs:",allCoalescingKeys);Configuration Hierarchy:
- Task-level options (highest priority)
- Coalescing key configuration
- Priority defaults
- Coalescing defaults
- System defaults (lowest priority)
Backward Compatibility: Legacy delays options are automatically converted to the new priorities format.
By default, HoldMyTask uses intelligent dynamic scheduling that calculates optimal timeout intervals based on when tasks should become ready. This provides significant performance improvements over traditional polling.
Benefits:
- 31x performance improvement in typical scenarios
- Precise timing - tasks execute exactly when ready
- CPU efficient - no constant polling overhead
- Dynamic adaptation - adjusts to task timing patterns
// Smart scheduling enabled by defaultconstqueue=newHoldMyTask();// Traditional polling mode (for compatibility)constlegacyQueue=newHoldMyTask({smartScheduling: false,tick: 25// polling interval in ms});How it works:
- Calculates the next task ready time
- Sets a precise timeout for that moment
- Includes healing mechanism to prevent scheduler stalls
- Falls back gracefully on complex timing scenarios
HoldMyTask supports granular concurrency control at the priority level, allowing you to fine-tune how many tasks of each priority can run simultaneously while still respecting the global concurrency limit.
- Database operations - Run only 1 critical database migration at a time
- API rate limiting - Limit API calls per priority to avoid overwhelming services
- Resource management - Control expensive operations while allowing lightweight tasks
- Flexible scaling - Different priorities can have different concurrency characteristics
constqueue=newHoldMyTask({concurrency: 8,// Global maximum: 8 total tasks across all prioritiespriorities: {1: {concurrency: 1,// Critical: Only 1 at a time (database migrations, etc.)postDelay: 100,// 100ms delay after completionstartDelay: 0// No pre-execution delay},2: {concurrency: 3,// Important: Up to 3 at a time (API calls, file processing)postDelay: 200,// 200ms delay after completionstartDelay: 50// 50ms pre-execution delay},3: {concurrency: 5,// Background: Up to 5 at a time (cleanup, logging, etc.)postDelay: 0,// No post-completion delaystartDelay: 100// 100ms pre-execution delay}// Priority 4+ tasks: No specific limit, use global concurrency}});- Global Limit First - The total running tasks never exceed the global
concurrencysetting - Per-Priority Limits - Tasks of each priority respect their individual
concurrencylimit - Dynamic Scheduling - If one priority is at its limit, other priorities can still start tasks
- Automatic Fallback - Priorities without
concurrencysettings use the global limit
// Database migration (priority 1): Only 1 can run at a timeawaitqueue.enqueue(async()=>{awaitrunDatabaseMigration();},{priority: 1});// API calls (priority 2): Up to 3 can run concurrentlyawaitqueue.enqueue(async()=>{returnawaitfetchUserData(userId);},{priority: 2});// Background cleanup (priority 3): Up to 5 can run concurrentlyawaitqueue.enqueue(async()=>{awaitcleanupTempFiles();},{priority: 3});// High-priority urgent task (priority 10): Uses global concurrency limitawaitqueue.enqueue(async()=>{awaithandleEmergencyAlert();},{priority: 10});// Check current concurrency stateconstinspection=queue.inspect();console.log("Global concurrency:",inspection.scheduler.currentConcurrency+"/"+inspection.scheduler.concurrency);// Per-priority concurrency informationObject.entries(inspection.scheduler.priorityConcurrency).forEach(([priority,info])=>{console.log(`Priority ${priority}: ${info.running}/${info.limit} (${info.available} available)`);});// Or use the scheduler-specific inspectionconstscheduler=queue.inspectScheduler();console.log("Priority concurrency limits:",scheduler.priorityConcurrency);Global concurrency: 6/8
Priority 1: 1/1 (0 available)
Priority 2: 2/3 (1 available)
Priority 3: 3/5 (2 available)
Priority delays create "cool-down" periods after task completion based on the completed task's priority. This helps prevent resource overwhelming, implement rate limiting, and control timing between operations.
- When a task completes, the delay for its priority level is enforced
- ALL subsequent tasks (any priority) must wait for the delay period to expire
- Delays apply globally - they affect the entire queue, not just tasks of the same priority
- Tasks can override delays on a per-task basis or bypass delays entirely
constqueue=newHoldMyTask({concurrency: 1,delays: {1: 1000,// 1 second delay after priority 1 tasks complete2: 500,// 500ms delay after priority 2 tasks complete3: 0// No delay after priority 3 tasks (explicit)}});// Task A (priority 1) completes at 10:00:05// → Next task can't start until 10:00:06 (1000ms delay)// Task B (priority 2) completes at 10:00:07// → Next task can't start until 10:00:07.5 (500ms delay)// Override delay for specific taskqueue.enqueue(task,callback,{priority: 1,delay: 200});// Uses 200ms instead of 1000ms// Set zero delay for specific taskqueue.enqueue(task,callback,{priority: 1,delay: 0});// No delay after this taskWhen urgent tasks need to execute immediately, bypassing active delay periods, use the bypassDelay option or delay: -1 syntax. This is perfect for emergency situations, high-priority interrupts, or critical system tasks.
constqueue=newHoldMyTask({concurrency: 1,delays: {1: 1000}// 1 second delay after priority 1 tasks});// Timeline example:// 10:00:00 - Task A (priority 1) completes → 1000ms delay starts// 10:00:00.1 - Normal task B enqueued → must wait until 10:01:00// 10:00:00.2 - Urgent task C enqueued with bypass → can start now (bypasses delay)queue.enqueue(normalTaskB,callback,{priority: 1});// Waits for delayqueue.enqueue(urgentTaskC,callback,{priority: 1,bypassDelay: true// Skips the 1000ms delay, can start now});// Alternative bypass syntaxqueue.enqueue(emergencyTaskD,callback,{priority: 1,delay: -1// Same as bypassDelay: true});constqueue=newHoldMyTask({concurrency: 2,delays: {1: 800,2: 400}});// Multiple tasks with different bypass behaviorqueue.enqueue(taskA,callback,{priority: 1});// Completes, starts 800ms delayqueue.enqueue(taskB,callback,{priority: 2});// Waits for 800ms delayqueue.enqueue(taskC,callback,{priority: 2,bypassDelay: true});// Bypasses delay, can start nowqueue.enqueue(taskD,callback,{priority: 1});// Waits for taskC's completion delay// Execution order: taskA → taskC (bypass) → taskB → taskDCritical Notes:
- ✅ Bypass affects START timing only - bypassed tasks still apply their own completion delays
- ✅ Maintains priority ordering - bypass doesn't override natural priority rules
- ✅ Scans entire queue - finds bypass tasks even when normal tasks are blocked
- ✅ Concurrency aware - works correctly with multiple concurrent execution slots
⚠️ Use sparingly - frequent bypassing defeats the purpose of delay-based rate limiting
The coalescing system allows multiple similar tasks to be intelligently merged, reducing redundant operations while ensuring all promises resolve with accurate results. This is perfect for scenarios like UI updates, API calls, or device commands where only the final result matters.
When tasks with the same coalescingKey are enqueued within a time window, they get merged into groups:
- First task creates a new coalescing group with a time window
- Subsequent tasks with the same key join the existing group (if within the window)
- One representative task executes for the entire group
- All promises in the group resolve with the same result
start delays or timestamp scheduling. Tasks that start too early may see intermediate states rather than final results.
The most reliable pattern for coalescing with state consistency is the "fire-and-forget with embedded updates" approach:
❌ WRONG - Parallel Enqueueing:
// DON'T DO THIS - creates race conditionsfunctionvolumeUp(){constvolumePromise=queue.enqueue(changeVolume,{priority: 1});constupdatePromise=queue.enqueue(updateUI,{coalescingKey: "ui"});// May see stale statereturnPromise.all([volumePromise,updatePromise]);}✅ CORRECT - Embedded Update Pattern:
// DO THIS - guarantees state consistencyfunctionvolumeUp(){returnqueue.enqueue(async()=>{// 1. Perform the main operationconstresult=awaitchangeVolume();// 2. AFTER main operation completes, enqueue the update FROM WITHINqueue.enqueue(async()=>updateUI(),{coalescingKey: "ui"});// Always sees final statereturnresult;// Consumer gets immediate response},{priority: 1});}Key Benefits:
- ✅ 100% State Accuracy: Updates only trigger after main operations complete
- ✅ Fire-and-Forget: Consumers get immediate responses, no waiting
- ✅ Optimal Coalescing: Multiple updates coalesce naturally when triggered close together
- ✅ No Race Conditions: Sequential execution guarantees consistent state
constqueue=newHoldMyTask({concurrency: 1,coalescingWindowDuration: 200,// 200ms window for grouping taskscoalescingMaxDelay: 1000,// Maximum 1000ms delay before forcing executioncoalescingResolveAllPromises: true// All promises get the result (default: true)});// Device volume control scenarioasyncfunctionupdateVolume(change){returnqueue.enqueue(async()=>{// Realistic device operations take timeconstcurrentVolume=awaitdevice.getVolume();// ~50-200msconstnewVolume=Math.max(0,Math.min(100,currentVolume+change));awaitdevice.setVolume(newVolume);// ~100-500msreturnnewVolume;},{coalescingKey: "volume.update",// Tasks with same key get groupedpriority: 1,delay: 100// 100ms delay after completion});}// User rapidly presses volume up 5 times within coalescing windowconstresults=awaitPromise.all([updateVolume(1),// Creates new groupupdateVolume(1),// Joins existing group (if within 200ms window)updateVolume(1),// Joins existing group (if within 200ms window)updateVolume(1),// Joins existing group (if within 200ms window)updateVolume(1)// Joins existing group (if within 200ms window)]);// If all tasks coalesce: ONE device.setVolume() call with final state// If timing spreads them: Multiple groups, each sees state at execution timeconsole.log(results);// Could be [55, 55, 55, 55, 55] or [51, 53, 55, 55, 55] depending on timingThe correct pattern for coalescing with updates is to enqueue update tasks from within the main tasks after they complete. This ensures 100% accuracy and proper state consistency:
constqueue=newHoldMyTask({concurrency: 2,// Allow volume and update tasks to run concurrentlycoalescingWindowDuration: 200,coalescingMaxDelay: 1000});// Volume commands using fire-and-forget patternfunctionvolumeUp(amount=1){constcommandId=Date.now();// Fire-and-forget: return promise but consumer doesn't await itreturnqueue.enqueue(async()=>{console.log(`Executing volume command ${commandId}`);// Realistic device operation takes timeconstresult=awaitdevice.increaseVolume(amount);// 200-1000ms// AFTER volume completes, enqueue update task FROM WITHINconsole.log(`Volume complete, triggering UI update`);queue.enqueue(async()=>{console.log(`Updating UI for final volume`);// UI operation sees accurate final stateconstvolume=awaitdevice.getVolume();// ~50msupdateVolumeDisplay(volume);// ~10-100msreturnvolume;},{coalescingKey: "volume.ui.update",// UI updates get coalescedpriority: 5,// Lower priority than volume commandsdelay: 100// Brief delay after UI updates});returnresult;},{priority: 1,// High priority for user actionsdelay: 100// Brief delay after volume operations});}// Consumer usage - fire and forgetvolumeUp(1);// Optimistic: assume it will work, don't waitvolumeUp(1);// Multiple rapid callsvolumeUp(1);// Each triggers its own update after completingvolumeUp(1);// Updates get coalesced if close togethervolumeUp(1);// Final state is always accurate// Result: 5 volume commands execute sequentially// Update commands coalesce (e.g., 5 → 3 actual updates)// UI always shows accurate final state because updates only trigger after volume changes// Consumer gets immediate response, no waiting for completionThe same coalescingKey can have multiple active groups based on timing windows:
// API batch processingasyncfunctionprocessDataBatch(data){returnqueue.enqueue(async()=>{console.log(`Processing batch with ${data.length} items`);returnawaitapi.processBatch(data);},{coalescingKey: "api.batch.process",priority: 2,start: 100// 100ms delay allows grouping});}// Timeline:// 0ms: processDataBatch(data1) → creates Group A (window: 0-200ms)// 50ms: processDataBatch(data2) → joins Group A// 250ms: processDataBatch(data3) → creates Group B (window: 250-450ms)// 300ms: processDataBatch(data4) → joins Group B// 500ms: processDataBatch(data5) → creates Group C (window: 500-700ms)// Result: 3 separate API calls, each processing coalesced batchesFor precise scheduling, use timestamp instead of start:
constqueue=newHoldMyTask({coalescingWindowDuration: 300,coalescingMaxDelay: 2000});// Schedule all updates for the same exact timeconstscheduleTime=Date.now()+1000;// 1 second from nowconstpromises=[];for(leti=0;i<10;i++){promises.push(queue.enqueue(async()=>{console.log("Executing coalesced batch update");returnawaitperformBatchUpdate();},{coalescingKey: "batch.update",timestamp: scheduleTime,// All scheduled for same timepriority: 3}));}// All 10 tasks coalesce into 1 execution at exactly scheduleTimeconstresults=awaitPromise.all(promises);console.log(`10 tasks became 1 execution, all got result:`,results[0]);Control maximum delay with mustRunBy to ensure tasks don't wait too long:
constqueue=newHoldMyTask({coalescingWindowDuration: 500,// Try to group for 500mscoalescingMaxDelay: 2000// But never wait more than 2 seconds});// Critical system updatesasyncfunctioncriticalSystemUpdate(updateData){returnqueue.enqueue(async()=>{console.log("Executing critical system update");returnawaitapplySystemUpdate(updateData);},{coalescingKey: "system.critical.update",priority: 1,timestamp: Date.now()+300,// Prefer 300ms delaymustRunBy: Date.now()+1500// Must run within 1.5 seconds});}// Even if coalescing window suggests waiting longer,// the task will execute by the mustRunBy deadlineControl how promises resolve within coalescing groups:
constqueue=newHoldMyTask({coalescingResolveAllPromises: true,// Default: all promises get the resultcoalescingWindowDuration: 200});// Mode 1: All promises resolve (default behavior)constresults1=awaitPromise.all([queue.enqueue(task,{coalescingKey: "test"}),queue.enqueue(task,{coalescingKey: "test"}),queue.enqueue(task,{coalescingKey: "test"})]);// All three promises resolve with the same result// Mode 2: Only representative promise resolvesconstqueue2=newHoldMyTask({coalescingResolveAllPromises: false,coalescingWindowDuration: 200});const[result1,result2,result3]=awaitPromise.all([queue2.enqueue(task,{coalescingKey: "test"}),// Resolves with resultqueue2.enqueue(task,{coalescingKey: "test"}),// Resolves with undefinedqueue2.enqueue(task,{coalescingKey: "test"})// Resolves with undefined]);// Only the first (representative) promise gets the actual resultclassVolumeController{constructor(){this.queue=newHoldMyTask({concurrency: 2,// Allow volume and update tasks concurrentlycoalescingWindowDuration: 200,coalescingMaxDelay: 1000});}volumeUp(amount=1){// Fire-and-forget pattern: consumer gets immediate responsereturnthis.queue.enqueue(async()=>{// Realistic device operations take timeconstcurrentVolume=awaitthis.device.getVolume();// ~50-200msconstnewVolume=Math.min(100,currentVolume+amount);awaitthis.device.setVolume(newVolume);// ~100-500ms// AFTER volume change completes, enqueue UI update FROM WITHINthis.queue.enqueue(async()=>{constvolume=awaitthis.device.getVolume();// ~50msthis.ui.updateVolumeDisplay(volume);// ~10-100msreturnvolume;},{coalescingKey: "volume.ui.update",// Updates coalesce togetherpriority: 3,// Lower priority than volume changesdelay: 100// Brief delay after UI updates});returnnewVolume;},{priority: 1,// High priority for user actionsdelay: 50// Brief delay between volume operations});}}classAPIBatcher{constructor(){this.queue=newHoldMyTask({concurrency: 2,coalescingWindowDuration: 300,coalescingMaxDelay: 1500});}asyncsubmitData(data){returnthis.queue.enqueue(async()=>{// All data submissions in the time window get batchedconstbatchData=this.collectBatchData();// Implementation detailconstresult=awaitthis.api.submitBatch(batchData);returnresult.find((item)=>item.id===data.id);},{coalescingKey: `batch.${data.category}`,// Batch by categorypriority: 2,metadata: { data,category: data.category}});}}Real-world performance improvements with the embedded update pattern:
// Fire-and-forget pattern with embedded coalescing updatesconstqueue=newHoldMyTask({concurrency: 2,coalescingWindowDuration: 200,coalescingMaxDelay: 1000});// Volume control example - realistic embedded patternfunctionprocessVolumeCommands(){// Consumer fires 100 rapid volume commands (fire-and-forget)constpromises=[];for(leti=0;i<100;i++){promises.push(queue.enqueue(async()=>{// Main operation: change volume (200-500ms each)constresult=awaitdevice.changeVolume(1);// Embedded update: triggered AFTER volume change (coalesces automatically)queue.enqueue(async()=>updateVolumeDisplay(),// 50-100ms each{coalescingKey: "volume.display"});returnresult;// Immediate response to consumer},{priority: 1}));}// Consumer gets immediate responses, doesn't wait for display updatesreturnpromises;// All resolve quickly with volume results}// Results with embedded coalescing:// - 100 volume operations execute (necessary work)// - ~5-15 display updates execute (95-85% coalescing efficiency)// - 100% accuracy: displays always show final state// - Fire-and-forget: consumers get immediate responses// - Total time: ~25-30 seconds (vs 50+ seconds without coalescing)Tasks automatically timeout and either call the callback with an error or reject the promise:
Callback API:
queue.enqueue(async(signal)=>{// Long-running taskawaitsomeAsyncOperation();return"result";},(error,result)=>{if(error?.type==="timeout"){console.log("Task timed out!");}},{timeout: 5000}// 5 second timeout);Promise API:
try{constresult=awaitqueue.enqueue(async(signal)=>{// Long-running taskawaitsomeAsyncOperation();return"result";},{timeout: 5000}// 5 second timeout);console.log("Task completed:",result);}catch(error){if(error.message.includes("timed out")){console.log("Task timed out!");}}The library uses AbortController for cooperative task cancellation. This allows tasks to be cancelled gracefully without forcing termination.
- Timeout Cancellation: When a task exceeds its
timeout, anAbortControlleris aborted - External Cancellation: You can pass your own
AbortSignalto cancel tasks - Cooperative: Tasks must check the signal and respond to cancellation
Your task functions receive an AbortSignal as the first parameter. Always check this signal to support cancellation:
Callback API:
// ❌ Bad - doesn't support cancellationqueue.enqueue(async()=>{awaitfetch("https://api.example.com/data");// Can't be cancelledreturn"result";},callback);// ✅ Good - supports cancellationqueue.enqueue(async(signal)=>{constresponse=awaitfetch("https://api.example.com/data",{
signal // Pass the signal to fetch});returnresponse.json();},callback);Promise API:
// ❌ Bad - doesn't support cancellationconstresult=awaitqueue.enqueue(async()=>{awaitfetch("https://api.example.com/data");// Can't be cancelledreturn"result";});// ✅ Good - supports cancellationconstresult=awaitqueue.enqueue(async(signal)=>{constresponse=awaitfetch("https://api.example.com/data",{
signal // Pass the signal to fetch});returnresponse.json();});For custom cancellation logic:
Callback API:
queue.enqueue(async(signal)=>{// Check signal at operation pointsif(signal.aborted){thrownewError("Task was cancelled");}constresult1=awaitsomeOperation();if(signal.aborted){thrownewError("Task was cancelled");}constresult2=awaitanotherOperation();return{ result1, result2 };},callback,{timeout: 10000});Promise API:
try{constresult=awaitqueue.enqueue(async(signal)=>{// Check signal at operation pointsif(signal.aborted){thrownewError("Task was cancelled");}constresult1=awaitsomeOperation();if(signal.aborted){thrownewError("Task was cancelled");}constresult2=awaitanotherOperation();return{ result1, result2 };},{timeout: 10000});console.log("Task completed:",result);}catch(error){console.log("Task failed:",error.message);}Cancel tasks using your own AbortController:
Callback API:
constcontroller=newAbortController();queue.enqueue(task,callback,{signal: controller.signal,timeout: 5000});// Cancel the task externallycontroller.abort();Promise API:
constcontroller=newAbortController();constpromise=queue.enqueue(task,{signal: controller.signal,timeout: 5000});// Cancel the task externallycontroller.abort();try{constresult=awaitpromise;console.log("Task completed:",result);}catch(error){console.log("Task cancelled:",error.message);}- Always accept the signal parameter in your task functions
- Pass the signal to async operations that support it (fetch, timers, etc.)
- Check
signal.abortedat logical breakpoints in long-running tasks - Throw errors when detecting cancellation
- Use descriptive error messages for cancelled tasks
- Single-threaded: Cannot forcibly terminate synchronous JavaScript code
- Cooperative: Tasks must actively check and respond to the abort signal
- Async operations: Only cancellable if the underlying operation supports AbortSignal
import{HoldMyTask}from"@cldmv/holdmytask";constqueue=newHoldMyTask({concurrency: 3});functionprocessUser(userId){returnqueue.enqueue(async(signal)=>{constuser=awaitfetchUser(userId,{ signal });awaitsendEmail(user.email,"Welcome!",{ signal });returnuser;},(error,user)=>{if(error){console.error(`Failed to process user ${userId}:`,error);}else{console.log(`Processed user: ${user.name}`);}},{priority: 1,timeout: 30000});}constqueue=newHoldMyTask({concurrency: 1,delays: {1: 1000,// 1 second between high-priority tasks2: 100,// 100ms between medium-priority tasks3: 0// No delay for low-priority tasks}});// High priority - runs immediately, 1s delay afterqueue.enqueue(highPriorityTask,callback,{priority: 1});// Medium priority - waits for high priority delay, then 100ms between thesequeue.enqueue(mediumTask1,callback,{priority: 2});queue.enqueue(mediumTask2,callback,{priority: 2});constqueue=newHoldMyTask({concurrency: 5,delays: {1: 50}// Small delay between batches});asyncfunctionprocessBatch(items){constresults=[];for(constitemofitems){queue.enqueue(async(signal)=>{returnawaitprocessItem(item,{ signal });},(error,result)=>{if(error){console.error(`Item ${item.id} failed:`,error);}else{results.push(result);}},{priority: 1,timeout: 10000});}// Wait for all to completeawaitnewPromise((resolve)=>queue.on("drain",resolve));returnresults;}Real-world scenario: API rate limiting with emergency override capability.
constapiQueue=newHoldMyTask({concurrency: 2,delays: {1: 2000,// 2 second delay between API calls (rate limiting)2: 5000,// 5 second delay for heavy operations9: 0// No delay for monitoring tasks}});// Regular API operationsapiQueue.enqueue(fetchUserData,handleResponse,{priority: 1});apiQueue.enqueue(syncDatabase,handleResponse,{priority: 2});// System monitoring (high priority, no delays)apiQueue.enqueue(healthCheck,handleResponse,{priority: 9});// EMERGENCY: Critical security alert needs immediate processing// This bypasses any active delays and runs immediatelyapiQueue.enqueue(processSecurityAlert,handleEmergency,{priority: 1,bypassDelay: true,// Skip delay, run NOWtimeout: 10000,// 10 second timeout for critical taskmetadata: {urgency: "critical",alertId: "SEC-001"}});// Alternative syntax for bypassapiQueue.enqueue(emergencyShutdown,handleEmergency,{priority: 1,delay: -1,// Same as bypassDelay: truemetadata: {action: "shutdown"}});functionhandleEmergency(error,result){if(error){console.error("Emergency task failed:",error);// Implement fallback procedures}else{console.log("Emergency handled:",result);// Log to incident management system}}// Monitor queue statussetInterval(()=>{console.log(`Queue: ${apiQueue.size()} total, ${apiQueue.inflight()} running`);},1000);# Run tests
npm test# Run tests with coverage
npm run test:coverage
# Run tests in watch mode
npm run test:run
# Lint code
npm run lint
# Build for publishing
npm run buildFor testing, you can inject a custom clock function and use the now() method to get the current time according to your queue:
letmockTime=Date.now();constqueue=newHoldMyTask({now: ()=>mockTime// Control time in tests});// In tests, you can advance timemockTime+=1000;// Advance by 1 second// The queue's now() method respects your custom clockconsole.log(queue.now());// Returns mockTime valueconsole.log(Date.now());// Returns actual system time// Useful for testing time-based behaviorqueue.enqueue(task,{timestamp: queue.now()+5000});// 5 seconds from mock timeApache License 2.0 - see LICENSE file for details.
Contributions welcome! Please ensure:
- All tests pass
- Code follows existing style
- New features include tests
- Documentation is updated
Made with ❤️ for robust task management