Module 01 · Lesson 3 of 14Free
Server concurrency: handling many requests at once
- Level
- Beginner
- Topic
- System design fundamentals
- Reading time
- 15 min
When you finish this page you can
- Distinguish requests in progress from work running on CPU cores.
- Explain how thread pools, event loops and lightweight threads handle waiting.
- Estimate worker and CPU demand from a request’s timing.
- Recognise when concurrency moves a bottleneck rather than removing it.
Builds on 1 lesson
Start with lesson 215 minVideo lesson · coming soon
At the start of a festival sale, receives 1,000 product-page requests in one second. Its app server has 8 CPU cores. If every request needs its own core, most customers must wait before anything happens.
But a request does not need a core for its entire lifetime.
How can those cores keep many customers’ requests moving, and what eventually limits them?
Waiting is not CPU work
Follow one product-page request from the request lifecycle:
| Work | Example time |
|---|---|
| Read the request and check access | 2 ms |
| Wait for the database | 40 ms |
| Wait for the pricing service | 50 ms |
| Build the response | 3 ms |
| Total | 95 ms |
These are Kartly’s teaching-scenario timings, not measured production results. The database and pricing calls happen sequentially.
The app uses its CPU for only 5 ms. During the remaining time, other systems are working or data is travelling.
A request waits for the database for 40 ms. Must a CPU core stay reserved for that request?Show answer
means several requests remain in progress. means work executes at the same moment on different cores.
For our calculations, assume each core executes one CPU task at a time. Waiting requests do not count against that limit.
The server needs somewhere to keep unfinished requests. The important choice is what else each waiting request holds.
A worker can remain occupied
A executes application instructions. A keeps a fixed number of reusable threads.
In a blocking request handler, one thread handles a request from start to finish. With , the thread stops until the requested data arrives. It remains assigned to that request, but another runnable thread can use the CPU. Platform threads are comparatively scarce resources.2
Imagine Kartly has a pool of 200 threads. A free thread takes the next request. When every thread is occupied, incoming work waits in a .
The request’s path is straightforward:
- Take a request from the queue.
- Check access.
- Ask the database and wait.
- Ask pricing and wait.
- Build the response, then return the thread to the pool.
The pool avoids creating a new thread for every arrival. Its limit is equally clear: a thread waiting on pricing cannot take another request.
More threads can overlap more waiting. They cannot make the database answer sooner or create more CPU time.
Parking keeps the worker available
An runs ready pieces of work on one thread. With , a request can pause without holding that thread.
For network operations, the runtime asks the operating system to monitor connections. When data becomes ready, the runtime runs the corresponding callback. It does not need one thread checking each connection repeatedly.1
Here is Kartly’s database wait under that model:
- Request A starts its query.
- The runtime keeps A’s saved execution state and connection information.
- A pauses, leaving the loop available for request B.
- Database data becomes available.
- The loop resumes A when it can run its continuation.
A continuation is the work that remains after the pause.
The database still takes 40 ms in this scenario. Parking changes who can use the waiting time, not how long the query takes.
An event-loop callback runs synchronously until it returns or yields control. A long calculation on that loop delays other callbacks, including callbacks for completed network operations.1
That creates a choice: keep simple blocking handlers, or make waiting cheaper.
Choose the model for the workload
Should Kartly replace its blocking thread pool?
Keep the blocking thread pool.
Each waiting request keeps a platform thread. This preserves straightforward handlers, but worker demand rises when dependencies slow down.
Use event loops.
Waiting requests release the loop thread. The team needs compatible asynchronous libraries and must keep long calculations off the loop.
Use lightweight threads.
Supported waits can release the underlying operating-system thread without changing the handler into explicit asynchronous steps. Runtime support and library behaviour still matter.
Why this choice
For the scenario below, a pool of 200 workers can accommodate the estimated demand. Keep it until measurements show that worker occupancy, memory or operational cost justifies a change.
For a new asynchronous Python or TypeScript service, event loops are a reasonable starting point. For an existing Java service, virtual threads offer another path.
Java 21’s virtual threads are managed by the runtime. Supported blocking operations can suspend the virtual thread and free its underlying platform thread. They improve the scale of waiting-heavy work, not the speed of CPU calculations.2
The choice changes when the workload changes. CPU-heavy processing needs CPU capacity and bounded parallel execution, not merely cheaper waiting.
A separate has its own memory. Multiple processes are another way to place work on several cores, with stronger memory separation and extra memory cost. They can contain thread pools or event loops; process separation and request scheduling are different decisions.2
We can now estimate whether workers or cores limit Kartly first.
Count occupied workers and cores separately
Assume sustained arrivals rather than a single burst. Also assume timings stay unchanged under load.
The useful relationship is : average work in progress equals arrival rate multiplied by average time in the system. Use matching boundaries: handler time gives requests inside handlers, not requests still waiting outside them.
Estimate occupied handlers
1,000 requests/s × 0.095 s/request = 95 requests
About 95 handlers are occupied on average. A blocking pool needs more than that to allow for uneven arrivals and timing variation.
Estimate app CPU demand
1,000 requests/s × 0.005 CPU-s/request = 5 CPU-s/s
The workload consumes the equivalent of five fully busy cores. Eight cores leave some room under these assumptions.
Check the pool ceiling
200 workers ÷ 0.095 s/request ≈ 2,100 requests/s
This is the pool’s idealised ceiling, not the server’s capacity. Other resources may run out first.
Check the CPU ceiling
8 CPU-s/s ÷ 0.005 CPU-s/request = 1,600 requests/s
Even this ignores scheduling and other overhead. Adding threads cannot reach the pool ceiling when the app needs more CPU than the server has.
For this scenario, the pool is not the first predicted constraint. Keep it and measure actual behaviour before changing architecture.
These averages do not predict how long the last request in a sudden burst waits. The next lesson, Latency and throughput, separates response time from completion rate.
Async workers still need a limit
This small experiment isolates waiting. It models the database and pricing delays with timers, omitting CPU work and network failures.
The app owns two asynchronous worker functions. Each claims one request before pausing. Both run on one event loop; they are not two operating-system threads.
import asyncio
async def main() -> None:
pending = iter(range(6))
async def worker() -> None:
for request_id in pending:
print("start", request_id)
await asyncio.sleep(0.040)
await asyncio.sleep(0.050)
print("done", request_id)
await asyncio.gather(worker(), worker())
if __name__ == "__main__":
asyncio.run(main())async function main(): Promise<void> {
const pending: Iterator<number> = [0, 1, 2, 3, 4, 5].values();
const sleep = (ms: number): Promise<void> =>
new Promise<void>((resolve) => setTimeout(resolve, ms));
async function worker(): Promise<void> {
for (;;) {
const item = pending.next();
if (item.done) return;
const requestId: number = item.value;
console.log("start", requestId);
await sleep(40);
await sleep(50);
console.log("done", requestId);
}
}
await Promise.all([worker(), worker()]);
}
void main().catch(console.error);Predict the output before running it. Two requests start before either finishes. Completion ordering is not a guarantee.
There is no race between claiming an ID and entering the first wait: each worker runs that part without yielding. Sharing this iterator across actual threads would require separate analysis.
Change the worker count to one. Waiting no longer overlaps between requests. Changing it to six overlaps all six waits, but would also increase simultaneous dependency calls.
In real handlers, use asynchronous network clients instead of timers. await does not make a synchronous calculation or blocking library call non-blocking.1
The example bounds work for a finite batch. A live server also needs a limit on incoming work.
A slow dependency fills every model
Suppose total handler time rises to one second, mostly waiting. Assume the dependencies could otherwise sustain the incoming rate.
A blocking pool fills
200 workers ÷ 1 s/request = 200 requests/s
With 1,000 arrivals each second, a pool of 200 falls behind.
Waiting work accumulates
1,000 arrivals/s − 200 completions/s = 800 queued requests/s
Without rejection or , customers accumulate in the queue. Their product pages remain unfinished.
Parking changes resource demand
1,000 requests/s × 1 s/request = 1,000 requests in progress
An event-loop design might hold this many requests without needing that many threads. It still needs memory, connections and dependency capacity.
PostgreSQL limits concurrent connections. Raising that limit also increases some resource allocations; it does not create unlimited query capacity.3
Kartly should bound admitted work rather than move an unlimited waiting line into memory.
| Component | Responsibility and state | Talks to | Failure response |
|---|---|---|---|
| Admission | Holds a bounded waiting list | Client, app | Reject excess work |
| App scheduler | Holds active request state | Admission, dependencies | Deadline ends stalled work |
| PostgreSQL | Stores product data | App | App returns an error if unavailable |
| Pricing | Supplies current prices | App | App returns an error if unavailable |
For this product-page path, there is no business-data write. The app reads product data, requests pricing, then returns the assembled response.
For this lesson’s design, an expired request stops waiting and releases its app-side slot. Remote work may continue unless cancellation reaches the dependency.
Track queue length, occupied handlers, dependency waiting time and CPU use. These distinguish “no worker available” from “no CPU available”.
When the dependency recovers, bounded pending work can drain. Capacity estimates alone cannot promise what survives a failure.
Where this shows up
- Design: a URL shortener on a single server: distinguish database waiting from CPU work.
- Design: a food delivery API: keep many tracking connections open without assigning each a platform thread.
- Design: a notification service: bound simultaneous calls to delivery providers.
After sizing these resources, Kartly must decide what customers can expect when one fails; Core system properties develops those guarantees.
Common mistakes
- Treating worker occupancy as CPU usage. A fully occupied blocking pool can spend most of its time waiting.
- Treating parked requests as free. They still retain state and may hold scarce dependency resources.
- Adding threads without identifying the limit. More overlap helps waiting-heavy work only while downstream capacity remains.
- Putting long calculations on the loop. Other ready requests cannot resume until that work yields or finishes.1
Check your understanding
Kartly has 300 requests in progress but only 8 cores. Does that prove the server is overloaded?Show answer
A blocking pool has 100 workers. Each handler occupies a worker for 0.2 seconds. What is its idealised completion ceiling?Show answer
After switching to an event loop, database connection waits increase. Did the event loop fail to provide concurrency?Show answer
A request performs a 200 ms calculation on the loop before its first await. Can another request on that loop run during the calculation?Show answer
Summary
- A few cores keep many requests moving by running ready work while other requests wait.
- Blocking pools retain a thread per active handler; event loops and supported lightweight-thread waits release the underlying worker.
- Average requests in progress equal requests per second multiplied by average request time.
- Calculate worker occupancy and CPU demand separately; the first exhausted resource limits completion rate.
- Bound waiting work and dependency concurrency. Cheaper waiting does not create more downstream capacity.