Part 1 · Foundations Before System Design
Processes, Threads & Concurrency for Backend Engineers
Most backend work is waiting. How one server juggles thousands of requests — processes, threads, event loops and lightweight threads — and the race conditions, deadlocks and pool limits that appear the moment it does.
On this page
The Problem
Your API works perfectly on your laptop with one user. Then you launch, and 5,000 people use it at the same moment. Suddenly:
- Requests pile up and time out.
- Memory usage explodes.
- Some users see another user's cart.
- A balance goes negative even though your code "checks" it first.
None of these are ordinary logic bugs. They are concurrency problems. Concurrency means handling many tasks during the same period of time. These problems come from how one server handles many things at once.
Before you can scale to many servers, you have to understand how one server handles thousands of requests at the same time. That depends on processes, threads, and the way your language and framework handle waiting.
The Core Idea
Think about a restaurant kitchen.
- A process is a running program with its own private memory. It is like a separate kitchen. It has its own space, equipment and ingredients. Two kitchens share nothing unless they pass food through a window on purpose. They are safe from each other, but expensive to build.
- A thread is one path of work that runs inside a process and shares the process's memory. It is like a cook inside a kitchen. Several cooks share the same fridge and stove. This is efficient, but two cooks can grab the same pan at once and cause a mess.
- Concurrency is one cook handling several dishes by switching between them: stir the soup, flip the pancake, check the oven.
- Parallelism is several cooks working at the same time.
- Difference in one line: a process is a separate kitchen, a thread is a cook in the kitchen. Concurrency is about switching between tasks, parallelism is about running tasks at the same instant.
Concurrency is about dealing with many things at once. Parallelism is about doing many things at once. (Rob Pike)
One more key idea: most backend work is waiting. A request might need 2 ms of CPU and then wait 50 ms for the database. A good cook does not stand and stare at the oven. They start the next dish. Good servers do not sit idle while waiting either. (CPU means the processor, the part of the computer that does the calculations.)
How It Works
Process vs thread
| Process | Thread | |
|---|---|---|
| Memory | Own private memory | Shares memory with other threads in the same process |
| Creation cost | Heavy (megabytes of memory, slower to start) | Lighter |
| Communication | Harder (pipes, sockets or shared files) | Easy (shared variables), but risky |
| If it crashes | Other processes are fine | Can take down the whole process |
| Example | Each Chrome tab is (roughly) its own process | Threads in one Java web server handling requests |
Context switching. A CPU core runs one thing at a time. The operating system (OS) quickly switches between threads, so it looks like they all run together. This switch is called a context switch. Each switch costs time, because the OS must save one thread's state and load another's. With a few threads, this cost is tiny. With 10,000 threads, the CPU can spend a big share of its time just switching.
CPU-bound vs I/O-bound work
- CPU-bound work is work where the processor is the slow part, because it is busy all the time: resizing images, encrypting data, video encoding, machine learning. More CPU cores help.
- I/O-bound work is work where most of the time is spent waiting for input or output (I/O): database queries, calls to other APIs, reading files. Handling more waiting requests at once helps. More CPU does not.
Most web backends are I/O-bound. This is why many modern tools (Node.js, NGINX, Go, async Python) are built to wait efficiently.
Blocking vs non-blocking I/O
- Blocking I/O. The thread calls
db.query()and stops until the answer comes back. It is simple to write. To serve 1,000 waiting requests at once, you need about 1,000 threads. - Non-blocking I/O. The thread says "start this query and tell me when it is done", then moves on to other work. The operating system tells it when the data is ready, using tools like
epollon Linux orkqueueon macOS. One thread can manage thousands of connections. An event loop is a loop that waits for such "ready" events and runs a small piece of code for each one.
- •Thread 1: work → wait for DB → work
- •Thread 2: work → wait for DB → work
- •Thread 3: work → wait for DB → work
- −Many threads, mostly idle, each holding memory
- +Code reads top to bottom
- •One thread: req1, req2, req3 … req1 done, req3 done, req2 done
- +Thousands of connections on one thread
- +Tiny memory per connection
- −One CPU-heavy task freezes everyone
Around 1999, engineers asked, "How can one server handle 10,000 connections at once?" This became known as the C10K problem (C10K means 10 thousand connections). Non-blocking I/O and event loops were the answer.
How popular servers handle concurrency
1. Process per request (old CGI, early PHP setups). Each request starts a new process. It is safe and simple, but very heavy. It is rarely used this way today.
2. Thread per request with a thread pool (Java Spring/Tomcat, older Ruby and Python servers). A thread pool is a fixed group of ready threads that are reused. With a pool of, say, 200 threads, each request borrows a thread until it finishes.
- Easy to write and debug, since the code reads top to bottom.
- If all 200 threads are waiting on a slow database, request 201 must wait in a queue.
3. Event loop (Node.js, NGINX, Redis). A single thread runs a loop: take the next ready event, run a short piece of code, repeat. Slow work is started and the result comes back later. It comes back as a callback (a function that is called when the work is done), a promise (an object that stands for a future result) or an await (a keyword that waits for a promise without blocking the loop).
- Excellent for I/O-heavy work with many connections.
- The golden rule: never block the event loop. One heavy calculation in Node.js stops every user's request until it finishes.
// ❌ Blocks every user for secondsapp.get("/report", (req, res) => { const result = heavyCalculation(); // CPU work on the event loop res.json(result);});// ✅ Waiting is fine – the loop serves other users meanwhileapp.get("/user/:id", async (req, res) => { const user = await db.findUser(req.params.id); // I/O, non-blocking res.json(user);});4. Lightweight threads (Go goroutines, Java 21 virtual threads, Erlang/Elixir processes). You write simple blocking-style code, but the runtime (the system that runs your program) uses only a few real OS threads and switches cheaply between very lightweight tasks. A goroutine (Go's lightweight task) starts with only about 2 KB of memory, so one server can run hundreds of thousands of them.
- You get both: easy code and high concurrency.
5. Async/await (Python asyncio, Rust Tokio, C#). This works like the event loop, but the code is easier to read. You mark each waiting point with await, and the function pauses there without blocking other work.
Multiple processes for multiple cores. A single-threaded event loop uses only one CPU core (a core is one processing unit inside the CPU). So in production you run several copies:
- Node.js runs one process per core (for example, with PM2 or cluster mode).
- NGINX runs one worker process per core.
- Python web apps run several Gunicorn or Uvicorn workers.
Race conditions: when threads collide
When threads share memory, you cannot predict the order in which they run. A race condition is a bug where the result depends on which thread happens to run first. Here is the classic example:
- read balance
- 100
- read balance
- 100A has not written yet
- ↻ 100 ≥ 80? yes
- write 20
- ↻ 100 ≥ 80? yes
- write 20
- ₹160 withdrawn, balance shows ₹20 💸 — fix: one atomic UPDATE … WHERE balance >= 80
Each thread's code was "correct", but together they give a wrong result. This is a race condition. The same bug appears as:
- two people booking the last seat,
- overselling the last item in stock,
- a counter losing updates.
Fixes:
- Locks (mutexes). A lock is a "key" that only one thread can hold at a time. Only the thread with the key may enter the "critical section" (the code that touches the shared data). This is correct but reduces parallelism.
- Atomic operations. An atomic operation is one that cannot be cut in the middle by another thread. Do read-check-write as one unbreakable step. Examples:
counter.incrementAndGet(), or RedisINCR. - Let the database do it. For example,
UPDATE accounts SET balance = balance - 80 WHERE id = 1 AND balance >= 80. The database runs this as one safe step, and you check how many rows changed. If zero rows changed, the balance was too low. - Avoid shared state. Give each request its own data, and let parts of the system talk through messages or queues.
- 1Let the database do itUPDATE … SET balance = balance − 80 WHERE balance >= 80, then check rows changed
- 2Atomic operationsRedis INCR, compare-and-swap, counter.incrementAndGet()
- 3Locks (mutexes)one thread in the critical section at a time — correct, but makes work happen one by one
- 4Avoid shared stateeach request owns its data; talk through messages and queues
Important: race conditions do not only happen inside one server. Say you have 10 servers behind a load balancer (a server that shares requests between them). In-memory locks in your code no longer help, because each server has its own memory. You need protection in the database, or a distributed lock (a lock that all servers share). This is a big theme in later lessons.
Deadlocks
A deadlock happens when two threads each hold something the other needs, and both wait forever:
Thread A: holds Lock 1, waits for Lock 2Thread B: holds Lock 2, waits for Lock 1→ both stuck foreverThe simplest prevention is to always take locks in the same order. Adding timeouts to lock waits also helps. Difference in one line: in a race condition the result is wrong, in a deadlock nothing moves at all.
Pools: bounded is better than unlimited
Servers use pools. A pool is a fixed set of ready-to-use resources that are borrowed and given back: thread pools, database connection pools, worker pools. Why not just create a new thread or connection whenever you need one?
- Creating them is slow.
- If creation has no limit, a traffic spike makes you create thousands. You run out of memory and crash, and every user is down.
A bounded pool (one with a maximum size) with a queue works better under heavy load. Some requests wait or are rejected, but the server survives.
Pool size is a real design decision. A useful rule is Little's Law (more in a later lesson): the average number in use = arrival rate × time each one is held. If you get 200 requests per second and each holds a database connection for 50 ms, then on average 200 × 0.05 = 10 connections are in use.
A note on Python's GIL
Standard Python (CPython, the usual Python program) has a Global Interpreter Lock (GIL). The GIL is a lock that lets only one thread run Python code at a time. So:
- Threads help Python with I/O-bound work, because the lock is released while waiting.
- Threads do not help much with CPU-bound work.
That is why Python apps use multiple processes (Gunicorn workers, multiprocessing) or async to scale. Newer Python versions (3.13 and later) offer an optional "free-threaded" build without the GIL, but the GIL is still the default and most production code still assumes it.
Trade-offs
| Model | Pros | Cons | Good for |
|---|---|---|---|
| Thread per request | Simple code, easy debugging | Memory used by each thread; limited by pool size | Classic business apps, moderate traffic |
| Event loop | Huge numbers of connections, low memory | Must never block; CPU work hurts everyone | Proxies, real-time apps, I/O-heavy APIs |
| Lightweight threads | Simple code and high concurrency | Runtime-specific; still need care with shared data | High-traffic services (Go, Elixir, Java 21) |
| Multiple processes | Uses all cores, crash isolation | More memory, no shared state | Scaling single-threaded runtimes |
When adding threads makes things worse:
- when the slow part is the database, not your server (more threads only means more queries waiting),
- when the work is CPU-bound and you already use every core,
- when threads fight over the same lock.
In the Real World
NGINX uses a small number of worker processes (usually one per CPU core). Each runs an event loop that handles thousands of connections. This is why NGINX can serve a huge number of connections with little memory. Apache's traditional model used a process or thread per connection and needed far more memory for the same load.
Node.js made event loops popular for application code. Companies like Netflix and PayPal have used Node.js for I/O-heavy web layers. A classic Node.js problem is one slow JSON parse, or one badly written regular expression (a text-matching pattern), that blocks the loop and makes the whole service stop responding.
Redis (a very fast in-memory data store) runs commands on a single main thread. That sounds limiting, but it keeps data in memory and usually does not wait for the disk, so it handles a very large number of operations per second. Being single-threaded also means every command is naturally atomic: there are no race conditions between commands inside Redis.
Discord and WhatsApp rely on Erlang/Elixir, whose lightweight processes let one machine hold a huge number of connected users. WhatsApp reported about 2 million connections on a single server (in 2012) with a very small engineering team.
Go at scale. Many cloud tools, including Docker, Kubernetes and much of Cloudflare's and Uber's backend infrastructure, are written in Go, largely because goroutines make high-concurrency servers simple to write.
Ticket booking (think concert or train tickets). When thousands of people click "Book" on the last few seats at the same second, race conditions cost real money. These systems use database locks, atomic updates and queues so that each seat is sold only once.
Interview Questions
Q1What is the difference between a process and a thread?
A process has its own private memory. Threads live inside a process and share its memory. Threads are cheaper to create and talk to each other through shared variables. But a bug in one thread can damage or crash the whole process. Processes are isolated from each other, so one crash does not affect the others.
Q2Concurrency vs parallelism?
Concurrency means a program handles many tasks in the same period by switching between them, even on one core. Parallelism means several tasks really run at the same instant on several cores. An event loop is concurrent but not parallel. A thread pool on an 8-core machine can be both.
Q3Why is Node.js good for I/O-heavy work but bad for CPU-heavy work?
Its single event-loop thread starts an I/O task and moves on, so thousands of requests can wait cheaply. But CPU-heavy work runs on that same thread and blocks every other request until it finishes. Put CPU work in worker threads, separate processes or a job queue.
Q4How would you prevent two users from booking the last seat?
Do not rely on a check in memory, because it cannot work with many servers. Let the database decide in one atomic step. Use a conditional UPDATE (… WHERE seats_left > 0) and check how many rows changed. Or use a UNIQUE constraint on (show_id, seat_no). Or lock the row with SELECT … FOR UPDATE inside a transaction.
Q5Why should thread and connection pools be bounded?
With no limit, a traffic spike uses up all the memory and crashes the server for everyone. A bounded pool makes extra requests wait in a queue or fail fast, so the server keeps working at its real capacity. Size it with Little's Law: arrival rate × time each request holds the resource.
Key Takeaways
- A process has its own memory. Threads share memory inside a process: cheaper, but riskier.
- Most backends are I/O-bound, so the key question is how a server handles waiting.
- There are three main approaches:
- Thread pools: simple code, but each waiting request holds a thread.
- Event loops: efficient, but never block them.
- Lightweight threads: simple code and high concurrency.
- Race conditions happen when shared data is read and written without protection. Fix them with atomic operations, locks, or the database, and remember that in-memory locks do not work across multiple servers.
- Always use bounded pools. Unlimited threads or connections crash servers during spikes.
Further Reading
- Operating Systems: Three Easy Pieces by Remzi and Andrea Arpaci-Dusseau (free online; the chapters on processes, threads and locks)
- "The C10K problem" by Dan Kegel
- The Node.js guide "The Node.js Event Loop"
- Rob Pike's talk "Concurrency Is Not Parallelism"
- Java Concurrency in Practice by Brian Goetz et al.
Published by Jainik · Last updated · How these lessons are made
Keep learning
Related series