@asaidimu/utils-sync
v2.1.0
Published
A collection of sync utilities.
Readme
@asaidimu/utils-sync
Synchronization primitives for TypeScript/JavaScript – Mutex, Once, and Serializer utilities with fine‑grained concurrency control.
Table of Contents
- Overview & Features
- Installation & Setup
- Usage Documentation
- Project Architecture
- Development & Contributing
- Additional Information
Overview & Features
Modern JavaScript applications often face subtle concurrency issues – race conditions, duplicate work, or unexpected interleaving of async operations. @asaidimu/utils-sync provides three battle‑tested primitives to tame asynchronous chaos:
Mutex– A mutual exclusion lock that allows only one task at a time to access a shared resource. Configurable handoff scheduling (microtask vs macrotask) prevents microtask starvation under heavy contention.Once– Guarantees that a given asynchronous operation runs exactly once, even when called concurrently from many places. Ideal for lazy initialisation, cache population, or one‑time setup. Supports optional retry on failure and sync/async functions.Serializer– Processes queued tasks sequentially (FIFO) while maintaining the last successful result. Built‑in backpressure protection and the ability to permanently close the queue. Perfect for rate‑limited APIs, write serialisation, or sequential job processing.
All utilities are written in strict TypeScript, fully typed, and come with zero runtime dependencies.
Key Features
- Mutual exclusion –
Mutexwith optional timeout and queue capacity limits. - Configurable yield behaviour – Choose
"macrotask"(default, prevents starvation) or"microtask"(zero‑delay handoff) per instance. - Once‑only execution –
Oncededuplicates concurrent calls, caches success/failure, and optionally retries on error. - Sequential task processing –
Serializermaintains order, provides last‑result peeking, and can be closed permanently. - Timeout support – All operations accept a timeout parameter (lock acquisition + execution).
- Backpressure – Configurable queue size to prevent uncontrolled growth.
- Tiny & focused – No external dependencies, tree‑shakeable exports.
- First‑class TypeScript – Generics, error types, and accurate return types.
Installation & Setup
Prerequisites
- Node.js 18+ (or any modern environment with
Promise,queueMicrotask, andsetTimeout) - TypeScript 4.7+ (if using types, but not required)
Installation
npm install @asaidimu/utils-syncpnpm add @asaidimu/utils-syncyarn add @asaidimu/utils-syncVerification
After installation, you can test that the library works correctly:
import { Mutex } from '@asaidimu/utils-sync';
const mutex = new Mutex();
console.log(mutex.locked()); // falseIf the import runs without errors, the package is ready.
Usage Documentation
All examples assume ES module import syntax:
import { Mutex, Once, Serializer } from '@asaidimu/utils-sync';For CommonJS:
const { Mutex, Once, Serializer } = require('@asaidimu/utils-sync');Mutex
A mutual exclusion lock. Use it to protect critical sections where only one async operation should run at a time.
Basic example
const mutex = new Mutex();
async function criticalSection() {
await mutex.lock();
try {
// Only one caller executes this block at a time
await doSomething();
} finally {
mutex.unlock();
}
}With timeout
try {
await mutex.lock(1000); // wait max 1 second
// ... work ...
mutex.unlock();
} catch (err) {
if (err instanceof TimeoutError) {
console.log('Could not acquire lock in time');
}
}Non‑blocking attempt
if (mutex.tryLock()) {
try {
// lock acquired immediately
} finally {
mutex.unlock();
}
} else {
// lock was already held – do something else
}Options
| Option | Type | Default | Description |
|--------------|--------------------------|---------------|-----------------------------------------------------------------------------------------------|
| capacity | number | Infinity | Max pending waiters. If exceeded, lock() throws an error. |
| yieldMode | "macrotask" | "microtask" | "macrotask" | "macrotask" yields via setTimeout(…,0) (prevents starvation). "microtask" uses queueMicrotask for lower latency. |
API
| Method | Return type | Description |
|------------------------------|-----------------------|---------------------------------------------------------------------------------------------------------|
| lock(timeout?: number) | Promise<void> | Acquire lock, waiting if necessary. Throws TimeoutError if timeout elapses or queue is full. |
| tryLock() | boolean | Attempt to acquire lock without waiting. Returns true if acquired. |
| unlock() | void | Release the lock. Throws if not locked. Schedules next waiter according to yieldMode. |
| locked() | boolean | Returns true if the lock is currently held. |
| pending() | number | Number of tasks waiting for the lock. |
Once
Guarantees a function runs exactly once, even when many callers invoke do() concurrently. The result (or error) is cached and returned to all future callers.
Basic example
const once = new Once<string>();
async function getConfig() {
const result = await once.do(async () => {
const res = await fetch('/api/config');
return res.json();
});
// result.value contains the config, or result.error if failed
return result.value;
}With retry on failure
const once = new Once<string>({ retry: true });
// If the first attempt fails, the next call will retry
await once.do(failingFn); // fails, but _done = false
await once.do(successFn); // runs again, succeeds, caches resultSynchronous functions
const once = new Once<number>();
const result = await once.do(() => 42); // works with sync returnChecking state without awaiting
if (once.ready()) {
const { value, error } = once.peek();
// safely inspect cached result
}Options
| Option | Type | Default | Description |
|-----------|-----------|---------|----------------------------------------------------------------------------------|
| retry | boolean | false | If true, a failed execution does not mark the instance as done – next call will retry. |
| throws | boolean | false | If true, the do() method will throw the error instead of returning it in the result object. |
API
| Method | Return type | Description |
|-----------------------------------|-------------------------------------|---------------------------------------------------------------------------------------------------|
| do(fn, timeout?) | Promise<OnceResult<T>> | Executes fn once. Returns { value, error } (unless throws:true). Timeout covers lock + execution. |
| ready() | boolean | true if operation has completed (success or non‑retryable failure) and no execution is running. |
| running() | boolean | true if the operation is currently executing. |
| peek() | OnceResult<T> | Returns current cached { value, error } without waiting. |
| get() | T \| null | Returns cached value if done, otherwise throws. Throws cached error if present. |
| reset() | void | Clears state – next do() will run again. |
| done() | boolean | true if finished (success or final failure). |
| current() | Promise<OnceResult<T>> \| null | Returns the underlying promise if running, otherwise null. |
Serializer
Processes tasks sequentially (FIFO order). Each task runs only after all previous tasks have completed. Use it to serialise writes to a file, throttle API calls, or enforce ordering.
Basic example
const serializer = new Serializer<string>();
async function log(message: string) {
const result = await serializer.do(async () => {
await appendToFile('log.txt', message);
return message;
});
return result.value; // last successful result
}Handling failures
Even if a task fails, the serializer continues processing the next queued tasks:
await serializer.do(failingFn); // returns { error: ... }
await serializer.do(successfulFn); // still runsPeeking at the last result
const { value, error } = serializer.peek();Closing the serializer permanently
serializer.close();
const result = await serializer.do(anyFn);
// result.error instanceof SerializerExecutionDoneOptions
| Option | Type | Default | Description |
|--------------|--------------------------|------------|------------------------------------------------------------------|
| capacity | number | 1000 | Max pending tasks. When full, do() returns an error immediately. |
| yieldMode | "macrotask" | "microtask" | "macrotask" | Handoff scheduling for the internal mutex. Default prevents microtask starvation. |
API
| Method | Return type | Description |
|-------------------------------|--------------------------------------|------------------------------------------------------------------------------------------------------|
| do(fn, timeout?) | Promise<SerializerResult<T\|null>> | Enqueues fn. Returns { value, error }. If closed or queue full, error is SerializerExecutionDone. |
| peek() | SerializerResult<T\|null> | Returns the last successful result or last error. |
| close() | void | Permanently closes the serializer. All subsequent do() calls fail immediately. |
| pending() | number | Number of tasks waiting in the queue. |
| running() | boolean | true if a task is currently executing. |
Project Architecture
The library is written in TypeScript and follows a simple, functional‑object design. Each class is independent and does not rely on shared global state.
Core Components
Mutex– Implements the lock with a FIFO waiter queue. Handoff uses eithersetTimeout(macrotask) orqueueMicrotaskto give callers control over fairness vs. latency.Once– Built on top ofMutexwithmicrotaskyield mode for minimal overhead. Tracks execution state (_done,_value,_error) and returns a cached promise to concurrent callers.Serializer– Also usesMutex(defaultmacrotaskyield) to serialise work. Maintains the last result and supports backpressure viacapacity.
Data Flow
- Mutex – Callers invoke
lock(). If unlocked, they acquire immediately. Otherwise they are added towaiters. Whenunlock()is called, the next waiter is scheduled according toyieldMode. - Once – First caller acquires the mutex, runs the function, and stores the promise. Later callers see the existing promise and await it directly (no mutex contention). After completion, the promise is cleared and
_doneis set. - Serializer – Each
do()call attempts to lock the internal mutex. Only one task holds the lock at a time. When a task finishes (success or error), the lock is released, allowing the next queued task to run.
Extension Points
The library is designed to be used as‑is, but you can easily compose the primitives:
- Use
Mutexto build your own synchronisation patterns (e.g., read‑write locks). - Extend
OnceorSerializerby subclassing (both are standard ES6 classes). - Replace the underlying promise scheduling by providing a custom
Mutexwith differentyieldModelogic (though the built‑in modes cover most needs).
Development & Contributing
Development Setup
git clone https://github.com/asaidimu/erp-utils.git
cd erp-utils/src/sync
npm installScripts
| Command | Description |
|----------------------|--------------------------------------------------|
| npm test | Run tests once (Vitest) |
| npm run test:watch | Run tests in watch mode |
| npm run test:browser | Run tests in a browser environment (Vitest) |
Testing
Tests are written with Vitest and cover:
Once– deduplication, retry behaviour, state transitions, error handling.Serializer– FIFO ordering, backpressure, closing, error resilience.Mutex– locking, timeout, capacity, yield modes (implicitly tested via Serializer and Once).
To run the full suite:
npm testContributing Guidelines
- Fork the repository and create a feature branch.
- Write tests for any new functionality or bug fixes.
- Ensure existing tests pass (
npm test). - Follow the existing code style (Prettier / ESLint – see root of monorepo).
- Commit messages should follow Conventional Commits (e.g.,
feat: add timeout to Mutex). - Open a Pull Request against the
mainbranch.
Issue Reporting
Report bugs or request features via GitHub Issues. Please include:
- A clear description of the problem.
- Minimal code to reproduce (if bug).
- Environment details (Node version, package manager, OS).
Additional Information
Troubleshooting
| Problem | Possible solution |
|----------------------------------------------|-------------------------------------------------------------------------------------------------------|
| Mutex lock never resolves | Check that unlock() is always called (e.g., use try/finally). |
| Serializer tasks stop running | Did you call close()? Once closed, all new tasks fail immediately. |
| Once returns stale error even after retry | Ensure retry: true is set. Without it, a failure marks _done = true and never retries. |
| TimeoutError when queue seems small | Increase capacity in Mutex or Serializer options. |
| Microtask starvation in high‑contention code | Set yieldMode: "macrotask" (default for Serializer and Mutex). Once uses microtask by design. |
FAQ
Q: Can I use Once with a synchronous function?
Yes – Once.do() accepts both () => Promise<T> and () => T. Synchronous return values are automatically wrapped in a resolved promise.
Q: What happens if Once.do() times out?
The timeout applies to the entire operation (including waiting for the mutex and execution). If a timeout occurs, the do() call rejects with TimeoutError, but the background execution (if already started) continues. Future callers will receive the final result.
Q: Is Serializer safe for long‑running tasks?
Absolutely. Tasks run sequentially, so a long task will delay subsequent tasks. Use timeout if you need to enforce a maximum wait per task.
Q: Can I reuse a Once instance after a non‑retryable failure?
Yes – call reset() to clear the cached error and allow a fresh execution.
Q: Does Mutex re‑entrant?
No – attempting to lock() from the same execution context that already holds the lock will deadlock. Use a single lock acquisition per critical section.
Changelog & Roadmap
See the CHANGELOG.md for version history. Future plans include:
Semaphoreimplementation.AsyncConditionvariable.DebouncedSerializerfor coalescing rapid consecutive calls.
License
MIT © Saidimu
Acknowledgments
Inspired by similar synchronisation primitives in Rust (Mutex, OnceCell), Go (sync.Mutex), and the classic async patterns of the JavaScript ecosystem.
