
Semaphore Pattern in JavaScript
Published on 5th July 2025
The Semaphore Pattern in JavaScript: Controlling Concurrent Access
Have you ever found yourself in a situation where you need to limit how many operations can run simultaneously? Maybe you're making API calls to a service that has rate limits, or you're processing files but don't want to overwhelm your system's memory. This is exactly where the semaphore pattern comes into play.
The semaphore pattern is a synchronization primitive that helps control access to a limited resource by maintaining a count of available permits. Think of it like a bouncer at a club - only a certain number of people can be inside at once, and when someone leaves, another person can enter.
What is a Semaphore?
A semaphore is essentially a counter that can be incremented and decremented atomically. When you want to access a resource, you first need to acquire a permit from the semaphore. If no permits are available, your operation waits until one becomes free.
The concept was introduced by Dutch computer scientist Edsger Dijkstra in 1965, and it's been a fundamental tool in concurrent programming ever since. While JavaScript is single-threaded, the semaphore pattern is still incredibly useful for managing asynchronous operations and controlling access to external resources.
Implementing a Semaphore in JavaScript
Let's start by building a simple semaphore implementation:
class Semaphore {
constructor(maxConcurrency) {
this.maxConcurrency = maxConcurrency
this.currentConcurrency = 0
this.queue = []
}
async acquire() {
return new Promise((resolve) => {
if (this.currentConcurrency < this.maxConcurrency) {
this.currentConcurrency++
resolve()
} else {
this.queue.push(resolve)
}
})
}
release() {
this.currentConcurrency--
if (this.queue.length > 0) {
const next = this.queue.shift()
this.currentConcurrency++
next()
}
}
}
This implementation is straightforward but functional. Let me show you a more robust version that handles edge cases and provides better error handling:
class Semaphore {
constructor(maxConcurrency) {
if (maxConcurrency <= 0) {
throw new Error('Max concurrency must be greater than 0')
}
this.maxConcurrency = maxConcurrency
this.currentConcurrency = 0
this.queue = []
this.released = false
}
async acquire() {
if (this.released) {
throw new Error('Semaphore has been released')
}
return new Promise((resolve, reject) => {
const tryAcquire = () => {
if (this.released) {
reject(new Error('Semaphore has been released'))
return
}
if (this.currentConcurrency < this.maxConcurrency) {
this.currentConcurrency++
resolve()
} else {
this.queue.push(tryAcquire)
}
}
tryAcquire()
})
}
release() {
if (this.released) {
throw new Error('Semaphore has been released')
}
this.currentConcurrency--
if (this.queue.length > 0) {
const next = this.queue.shift()
this.currentConcurrency++
next()
}
}
async execute(fn) {
await this.acquire()
try {
return await fn()
} finally {
this.release()
}
}
releaseAll() {
this.released = true
this.queue.forEach((resolve) => resolve())
this.queue = []
}
}
The execute method is particularly useful as it automatically handles the acquire/release cycle for you, ensuring that permits are always returned even if an error occurs.
Real-World Use Cases
1. API Rate Limiting
One of the most common use cases for semaphores is controlling API requests to avoid hitting rate limits:
const apiSemaphore = new Semaphore(5) // Only 5 concurrent requests
async function makeApiCall(endpoint) {
return apiSemaphore.execute(async () => {
const response = await fetch(endpoint)
return response.json()
})
}
// Usage
const promises = Array.from({ length: 20 }, (_, i) =>
makeApiCall(`/api/data/${i}`),
)
const results = await Promise.all(promises)
In this example, even though we're making 20 API calls, only 5 will run concurrently. The rest will wait in the queue until a slot becomes available.
2. Database Connection Pooling
When working with databases, you might want to limit the number of concurrent connections:
const dbSemaphore = new Semaphore(10) // Max 10 concurrent DB operations
async function executeQuery(query, params) {
return dbSemaphore.execute(async () => {
const connection = await getDatabaseConnection()
try {
return await connection.query(query, params)
} finally {
connection.release()
}
})
}
3. File Processing
When processing large files, you might want to limit memory usage by controlling how many files are processed simultaneously:
const fileSemaphore = new Semaphore(3) // Process 3 files at a time
async function processFile(filePath) {
return fileSemaphore.execute(async () => {
const content = await fs.readFile(filePath, 'utf8')
const processed = await heavyProcessing(content)
await fs.writeFile(`${filePath}.processed`, processed)
})
}
// Process multiple files
const files = ['file1.txt', 'file2.txt', 'file3.txt', 'file4.txt', 'file5.txt']
const results = await Promise.all(files.map(processFile))
4. Image Processing
When resizing or processing images, you might want to limit concurrent operations to prevent memory issues:
const imageSemaphore = new Semaphore(2) // Process 2 images at a time
async function resizeImage(imagePath, width, height) {
return imageSemaphore.execute(async () => {
const image = await sharp(imagePath)
return image.resize(width, height).toBuffer()
})
}
Advanced Patterns
Semaphore with Timeout
Sometimes you want to wait for a permit, but not forever. Here's how you can add timeout functionality:
class SemaphoreWithTimeout extends Semaphore {
async acquire(timeout = 5000) {
return Promise.race([
super.acquire(),
new Promise((_, reject) =>
setTimeout(() => reject(new Error('Acquire timeout')), timeout),
),
])
}
}
Weighted Semaphore
In some cases, different operations might consume different amounts of resources. A weighted semaphore allows you to specify how many permits each operation needs:
class WeightedSemaphore {
constructor(maxWeight) {
this.maxWeight = maxWeight
this.currentWeight = 0
this.queue = []
}
async acquire(weight = 1) {
return new Promise((resolve) => {
const tryAcquire = () => {
if (this.currentWeight + weight <= this.maxWeight) {
this.currentWeight += weight
resolve()
} else {
this.queue.push({ weight, resolve, tryAcquire })
}
}
tryAcquire()
})
}
release(weight = 1) {
this.currentWeight -= weight
// Try to process queued items
for (let i = 0; i < this.queue.length; i++) {
const item = this.queue[i]
if (this.currentWeight + item.weight <= this.maxWeight) {
this.queue.splice(i, 1)
this.currentWeight += item.weight
item.resolve()
i-- // Recheck this index
}
}
}
}
When to Use Semaphores
Semaphores are most useful when you need to:
- Limit concurrent access to external resources - APIs, databases, file systems
- Control memory usage - Processing large files or images
- Implement rate limiting - Respecting API quotas or service limits
- Manage connection pools - Database connections, network connections
- Prevent resource exhaustion - Any scenario where too many concurrent operations could overwhelm your system
When to Avoid Semaphores
Semaphores aren't always the right tool. Consider alternatives when:
- You need simple counting - Use a regular counter or atomic operations
- You're dealing with synchronous operations - JavaScript's single-threaded nature means you don't need semaphores for purely synchronous code
- You need complex coordination - Consider using channels, queues, or event-driven patterns
- Performance is critical - Semaphores add overhead; for high-performance scenarios, consider other approaches
- You need fairness guarantees - The basic semaphore doesn't guarantee FIFO ordering
Common Pitfalls and Best Practices
1. Always Release Permits
The most common mistake is forgetting to release permits, which can lead to deadlocks:
// BAD - might not release if error occurs
async function badExample() {
await semaphore.acquire()
const result = await riskyOperation()
semaphore.release() // This might not execute if riskyOperation throws
return result
}
// GOOD - use try/finally or the execute method
async function goodExample() {
return semaphore.execute(async () => {
return await riskyOperation()
})
}
2. Handle Errors Properly
Make sure your semaphore implementation handles errors gracefully:
// Add error handling to your semaphore usage
async function robustExample() {
try {
return await semaphore.execute(async () => {
return await riskyOperation()
})
} catch (error) {
console.error('Operation failed:', error)
throw error
}
}
3. Choose Appropriate Limits
Setting the wrong concurrency limit can hurt performance:
// Too low - underutilizes resources
const semaphore = new Semaphore(1) // Only one operation at a time
// Too high - might overwhelm the system
const semaphore = new Semaphore(1000) // Too many concurrent operations
// Just right - based on system capabilities and resource limits
const semaphore = new Semaphore(10) // Reasonable for most scenarios
4. Monitor Performance
Keep an eye on how your semaphore is performing:
class MonitoredSemaphore extends Semaphore {
constructor(maxConcurrency) {
super(maxConcurrency)
this.stats = {
totalAcquisitions: 0,
totalWaitTime: 0,
maxQueueLength: 0,
}
}
async acquire() {
const startTime = Date.now()
this.stats.totalAcquisitions++
await super.acquire()
const waitTime = Date.now() - startTime
this.stats.totalWaitTime += waitTime
this.stats.maxQueueLength = Math.max(
this.stats.maxQueueLength,
this.queue.length,
)
}
getStats() {
return {
...this.stats,
averageWaitTime:
this.stats.totalAcquisitions > 0
? this.stats.totalWaitTime / this.stats.totalAcquisitions
: 0,
}
}
}
Performance Considerations
Semaphores do add some overhead to your operations. Here are some tips for optimizing performance:
- Use appropriate concurrency limits - Too low and you underutilize resources; too high and you might overwhelm the system
- Consider batching operations - Instead of limiting individual operations, batch them together
- Use timeouts - Prevent operations from waiting indefinitely
- Monitor queue lengths - If the queue is consistently long, consider increasing the concurrency limit
- Profile your application - Measure the impact of semaphores on your specific use case
Conclusion
The semaphore pattern is a powerful tool for controlling concurrent access to limited resources in JavaScript applications. While JavaScript's single-threaded nature means we don't need semaphores for basic synchronization, they're incredibly useful for managing asynchronous operations and external resource access.
The key is understanding when semaphores add value and when they're unnecessary overhead. For rate limiting, connection pooling, and resource management, semaphores provide a clean, predictable way to control concurrency.
Remember to always use the execute method or proper try/finally blocks to ensure permits are released, and monitor your semaphore usage to ensure you're not creating bottlenecks in your application.
The semaphore pattern might seem like a simple concept, but mastering it can significantly improve the reliability and performance of your JavaScript applications. Whether you're building a web scraper, an API client, or a file processing system, understanding when and how to use semaphores will make you a better developer.