Controlling Concurrent Access with Node.js semaphore: practical guide
Learn how to use the sema library's acquire/release API to limit parallel async tasks, avoid deadlocks, and verify correct concurrency in Node.js.
22 Jan 2026, 14:25 UTC

Useful answer
You can limit how many async operations run at the same time by using the sema library’s acquire/release API; each acquire call pauses until a token is available and must be paired with a release to avoid deadlock.
Mechanism explained with a worked configuration
The semaphore is created with an integer limit that represents the maximum number of concurrent tokens. When acquire() is called, the promise resolves only after a token is taken; release() returns the token to the pool. Because the library does not auto‑release, you must call release inside a try / finally block or explicitly after the async work finishes.
const Semaphore = require('sema');
// Create a semaphore that allows at most 2 concurrent tasks
const dbSemaphore = new Semaphore(2);
async function fetchUser(id) {
// Acquire a token before using a shared resource
const token = await dbSemaphore.acquire();
try {
console.log(`Task ${id} started`);
// Simulate I/O
await new Promise(r => setTimeout(r, 100));
console.log(`Task ${id} finished`);
} finally {
// Always release the token, even if an error occurs
dbSemaphore.release();
}
}
// Launch five tasks; only two will run concurrently
[1,2,3,4,5].forEach(id => fetchUser(id));
In this example the semaphore’s limit of 2 means only two fetchUser invocations can be inside the try block at any moment. The timestamps printed will show at most two “started” messages overlapping.
When to use a semaphore versus other primitives
A semaphore is appropriate when you need to bound the number of simultaneous operations without implying exclusive ownership. If you need a single‑owner lock, a mutex (limit = 1) is sufficient. If you only need to throttle I/O, a simple counter with setInterval may work, but a semaphore gives you a reliable Promise‑based API and built‑in timeout support.
Common mistakes and practical limits
- Forgetting to call
release()leaves the semaphore permanently locked, causing all subsequentawaitstatements to hang. Always pair everyacquire()with a matchingrelease(), preferably inside afinallyclause. - Omitting
try / finallycan leak the token if the async function throws before reaching the release line, again blocking future callers. - Setting the
limittoo low throttles throughput; too high may oversubscribe the underlying resource (e.g., database connections) and degrade performance. - Using a timeout that is shorter than the typical latency can produce false negatives; choose a timeout that accommodates normal variation.
- Passing a non‑integer value to the constructor silently coerces to 0, effectively disabling concurrency control and allowing unlimited parallelism.
- The library treats waiting
acquire()calls in FIFO order without priority; if your workload needs fairness or priority scheduling, you must implement it yourself. - Each
acquire()creates a new Promise, which adds overhead under high‑throughput loads; batching or reusing token objects can reduce this cost. - In environments with variable network latency, the internal token count may appear to fluctuate; always verify the count after a series of acquire/release cycles.
Verification steps you can run
Run the following commands in a Node.js environment with appropriate permissions (Node ≥ 14). Replace YOUR_SCRIPT.js with the file name you create.
- Create a script that initializes a semaphore with
limit: 2and starts five async tasks as shown above. Executenode YOUR_SCRIPT.js. Verify that the log shows no more than two “started” lines overlapping; this confirms the concurrency limit. - Wrap each
acquire()call intry { … } finally { dbSemaphore.release(); }and introduce a deliberate exception (e.g.,throw new Error('test')) inside thetryblock. Run the script again; the exception should not prevent the token from being released, proving that deadlock does not occur. - Test the timeout feature:
const sem = new Semaphore(1); sem.acquire({ timeout: 10 }).catch(err => console.log('timeout:', err.message));while the semaphore is full. The promise should reject after roughly 10 ms, confirming the timeout works. - Instrument the code to log the internal token count after each
acquire/release. If the count ever goes negative or exceeds the configuredlimit, the implementation is broken. - Measure the actual concurrency by adding a timestamp at the start of each
tryblock and another at the end. After running ten tasks, sort the timestamps and ensure that no more than two intervals overlap, which directly validates the limit.
Summary
The sema library gives you a straightforward way to bound concurrent async work in Node.js. By configuring the limit, using acquire/release inside try / finally, and validating with the verification steps above, you can reliably prevent resource starvation, avoid deadlocks, and measure that the semaphore behaves as expected.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.