lumo

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

  1. Distinguish requests in progress from work running on CPU cores.
  2. Explain how thread pools, event loops and lightweight threads handle waiting.
  3. Estimate worker and CPU demand from a request’s timing.
  4. Recognise when concurrency moves a bottleneck rather than removing it.
  1. Request lifecycle: what happens when you open a website15 min

Video 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.

Check yourselfAnswer in your head first, then reveal
A request waits for the database for 40 ms. Must a CPU core stay reserved for that request?Show answerHide answer
No. The server must remember the request, but its core can run another request. A waiting request may still occupy a worker or a connection, even though it is not using CPU.

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.

Both models overlap requests. They differ in whether waiting holds a worker.

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:

  1. Take a request from the queue.
  2. Check access.
  3. Ask the database and wait.
  4. Ask pricing and wait.
  5. 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:

  1. Request A starts its query.
  2. The runtime keeps A’s saved execution state and connection information.
  3. A pauses, leaving the loop available for request B.
  4. Database data becomes available.
  5. 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.

python
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())
ts
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.

The concurrency model schedules admitted work. Admission limits prevent waiting work from growing without bound.
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

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

Check yourselfAnswer in your head first, then reveal
Kartly has 300 requests in progress but only 8 cores. Does that prove the server is overloaded?Show answerHide answer
No. Most requests may be waiting. Compare CPU demand, occupied workers and dependency limits before concluding that capacity is exhausted.
Check yourselfAnswer in your head first, then reveal
A blocking pool has 100 workers. Each handler occupies a worker for 0.2 seconds. What is its idealised completion ceiling?Show answerHide answer
500 requests per second: 100 divided by 0.2. Not 100 requests per second, because each worker can finish five requests each second under these assumptions.
Check yourselfAnswer in your head first, then reveal
After switching to an event loop, database connection waits increase. Did the event loop fail to provide concurrency?Show answerHide answer
No. It may allow more requests to reach the database at once. The connection limit remains, so bound database concurrency rather than assuming cheaper waiting removes that limit.
Check yourselfAnswer in your head first, then reveal
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 answerHide answer
No. The calculation does not yield control. Move substantial CPU work to bounded parallel workers rather than adding an await after it.

Summary

  1. A few cores keep many requests moving by running ready work while other requests wait.
  2. Blocking pools retain a thread per active handler; event loops and supported lightweight-thread waits release the underlying worker.
  3. Average requests in progress equal requests per second multiplied by average request time.
  4. Calculate worker occupancy and CPU demand separately; the first exhausted resource limits completion rate.
  5. Bound waiting work and dependency concurrency. Cheaper waiting does not create more downstream capacity.

Report a mistake

Tell us what’s wrong in this lesson and we’ll fix it.