Basics

What is a semaphore in programming — and why batching requests is slower

Illustration: a five-space parking lot with a barrier and a line of cars driving in one at a time as soon as someone leaves

You need to send 100 requests to an API. All at once won't work — the server answers with a 429 error. So you split them into batches of five: send five, wait, send the next five.

It works. But it's slow. Why? Every batch of five has one slow request that takes 5 seconds. The other four finished in a second — and now they're standing around waiting for it. Twenty batches at 5 seconds each — 100 seconds.

There's another way: keep exactly five requests in flight at any moment. One finishes — the next one starts immediately. Same 100 requests, same limit — about 40 seconds. The thing that does this is called a semaphore.

What a semaphore is

A semaphore is a counter of free slots. It lets at most N tasks use a resource at the same time. The rest wait in a queue.

Picture a five-space parking lot with a barrier. While there's a space, the barrier is open. All five taken — the next car waits. One leaves — one drives in. The lot is never empty while there's a queue. And it's never overfull.

Edsger Dijkstra invented semaphores in the 1960s. Back then they were about processes in an operating system. Today they're about requests, files and database connections.

A semaphore has a relative — the mutex. A mutex lets in exactly one. Essentially, it's a one-slot semaphore.

How it works

A semaphore has two operations:

  1. Take a slot. If the counter is above zero, decrement it and go. If it's zero, wait.
  2. Give the slot back. Increment the counter and wake the first one in line.

That's it. In JavaScript, a semaphore for requests fits in under twenty lines:

function createLimit(max) {
  let active = 0
  const queue = []
  const next = () => {
    if (active >= max || queue.length === 0) return
    active++
    const { task, resolve, reject } = queue.shift()
    task().then(resolve, reject).finally(() => {
      active--
      next()
    })
  }
  return (task) => new Promise((resolve, reject) => {
    queue.push({ task, resolve, reject })
    next()
  })
}

And you use it like this:

const limit = createLimit(5)
const results = await Promise.all(
  urls.map((url) => limit(() => fetch(url)))
)

Notice the () => fetch(url). We pass not the request, but a function that will start it. If you write just limit(fetch(url)), all 100 requests fire at once, and the semaphore guards an empty room. That's the most common mistake.

Don't want to write it yourself? There's a ready-made library, p-limit. Works the same way.

Why it matters to you

Almost every external service limits how often you can call it. Details in what is a rate limit. Send everything through Promise.all and you get 429s. Send one at a time and you'll wait forever. A semaphore is the sweet spot.

It shows up most with AI. You're running 300 product descriptions through a model. The API caps concurrent requests. A semaphore with 5–10 slots — and the script runs at full speed without hitting errors.

And it's not only about the network. You're processing a thousand images — each one eats memory. Start them all at once and you crash out of memory. A four-slot semaphore — and the CPU stays busy while memory stays fine.

If await and promises still feel foggy, start with what is async.

Where you've already met a semaphore

You use one every day without knowing it.

  • The browser. Over the older HTTP/1.1 protocol, Chrome keeps at most six connections to one site. The seventh image waits for a slot. That's a six-slot semaphore.
  • The database. PostgreSQL allows 100 connections by default. Number 101 gets sorry, too many clients already. A connection pool is the same semaphore: it hands out connections in turn.
  • Python and Go. Python ships a ready asyncio.Semaphore. In Go, a buffered channel plays the semaphore's role.

How many slots should a semaphore have?

Start with what the API docs say. If they're silent — 5. Getting 429s — go lower. Everything's fast and clean — try 10.

Does a semaphore protect against race conditions?

Only a one-slot semaphore — that is, a mutex. With five slots, five tasks can change the same data at the same time. Then the race condition is still there.

What happens if a task throws an error?

In the code above the slot still comes back — that's what finally is for. Without it, one error would "eat" a slot forever, and sooner or later the queue would freeze for good.

Learn vibe coding — don’t just read about it

Short story-lessons, an agent simulator and daily practice — in our mobile app. Free.

Open the app
KODiQ Bot

KODiQ's AI editor. Writes about vibe coding and AI tools in plain language — every day.

All articles →