26.2. CPU-Bound vs I/O-Bound Work
Table of Contents
Understanding CPU-Bound vs I/O-Bound Work
When you design and optimize backends, you must know what kind of work your application is doing. Almost every performance decision, from choosing async to adding more CPUs, depends on whether your workload is CPU-bound or I/O-bound.
This chapter explains the difference, shows how it impacts Python and web backends, and gives you practical patterns and code examples.
Key idea:
To improve performance, first identify if your code is limited by CPU speed (CPU-bound) or by waiting for input/output (I/O-bound).
Optimizing the wrong thing can waste time and even make performance worse.
Basic Definitions
What is CPU-bound work?
CPU-bound work is work where the main bottleneck is the processor doing calculations.
Examples:
- Heavy number crunching
- Image or video processing
- Encryption or compression
- Complex data transformations in memory
- Running large algorithms like machine learning, pathfinding, etc.
In CPU-bound code, you usually have:
- Long loops that do calculations
- Very little waiting for network, disk, or database
- High CPU usage during the task
What is I/O-bound work?
I/O-bound work is work where the main bottleneck is waiting on external systems.
"I/O" stands for Input/Output, like:
- Network (HTTP requests to other APIs, database queries)
- Disk (reading or writing files)
- External services (email provider, payment gateway, S3, Redis)
In I/O-bound code, you usually have:
- Short bursts of CPU work
- A lot of time spent waiting for I/O operations to finish
- Lower CPU usage, but longer total time due to waiting
Comparing CPU-bound and I/O-bound
| Aspect | CPU-bound | I/O-bound |
|---|---|---|
| Main bottleneck | CPU speed / calculations | Waiting for external resources |
| Typical usage | 80–100% CPU during processing | Low to medium CPU, but long waits |
| Examples | Image resize, encryption, math-heavy API | DB queries, HTTP calls, file uploads/downloads |
| Good solutions | More CPUs, multiprocessing, optimize code | Async I/O, concurrency, caching, faster I/O systems |
| Python threads help? | Usually no (GIL limits) | Yes, can overlap waiting time |
| Async/await helps? | No, CPU still the limit | Yes, can handle many concurrent I/O tasks efficiently |
How to Recognize CPU-Bound vs I/O-Bound in Backends
You rarely see "pure" CPU-bound or "pure" I/O-bound systems. Usually, your endpoint does some combination.
A simple HTTP request in a backend might:
- Parse JSON from the client (CPU)
- Validate data (some CPU)
- Query the database (I/O)
- Process the result (CPU)
- Call a third-party API (I/O)
- Format the response (CPU)
You want to know what dominates the total time.
Rough mental checks
Ask these questions about a slow endpoint:
- Does CPU usage spike to 100% for your process?
- Yes, and stays high during requests: probably CPU-bound.
- No, CPU is low but requests are slow: probably I/O-bound.
- What happens if you run more concurrent requests?
- If latency gets much worse, and CPU is maxed out: CPU-bound.
- If latency grows slowly and CPU is low, but DB or network is busy: I/O-bound.
- If you optimize queries or network calls, do things improve a lot?
- Yes: likely I/O-bound.
- No, but optimizing CPU-heavy functions helps: likely CPU-bound.
Simple Python Examples
A CPU-bound example
This function calculates many square roots. It uses CPU but no external I/O.
import math
def cpu_bound_task(n: int) -> float:
result = 0.0
for i in range(1, n):
result += math.sqrt(i)
return result
result = cpu_bound_task(10_000_000)Characteristics:
- Long running loop
- Only uses CPU and memory
- No network or disk access
An I/O-bound example
This function makes HTTP requests to another service.
import requests
def io_bound_task(urls):
results = []
for url in urls:
response = requests.get(url) # Network I/O
results.append(response.text)
return results
urls = ["https://example.com"] * 10
data = io_bound_task(urls)Characteristics:
- Each
requests.getspends most of its time waiting - CPU is mostly idle while waiting for responses
Concurrency and the Python GIL
Python has a Global Interpreter Lock (GIL) that affects how CPU-bound and I/O-bound tasks behave with threads.
How threads behave
- For I/O-bound tasks, threads work well, because:
- When a thread waits on I/O, it gives back the GIL
- Another thread can run and start or process other I/O
- You can perform many I/O operations "at the same time"
- For CPU-bound tasks, threads in CPython usually do not speed things up, because:
- Only one thread can execute Python bytecode at a time due to the GIL
- Threads take turns using the CPU
- Total CPU time is similar, with extra overhead from thread switching
Important rule:
- Use threads or async for I/O-bound tasks, to overlap waiting time.
- Use multiprocessing or multiple processes for CPU-bound tasks, to use multiple CPU cores.
I/O-Bound Work and Async in Backends
Modern Python backends, like FastAPI, are often built around async I/O to handle many concurrent clients.
I/O-bound example with async
Using httpx and asyncio to fetch many URLs:
import asyncio
import httpx
async def fetch(client: httpx.AsyncClient, url: str) -> str:
response = await client.get(url) # I/O, does not block event loop
return response.text
async def main(urls):
async with httpx.AsyncClient() as client:
tasks = [fetch(client, url) for url in urls]
results = await asyncio.gather(*tasks)
return results
urls = ["https://example.com"] * 10
asyncio.run(main(urls))Here:
- Each
await client.get(url)releases control - While one request waits for the network, others can run
- A single Python process can handle many concurrent I/O-bound tasks
This is ideal for:
- Calling databases with async drivers
- Talking to other services over HTTP
- Reading and writing files without blocking the server
CPU-Bound Work and Multiprocessing
Async and threads do not fix CPU limits. For CPU-heavy tasks you need multiple processes or external workers.
CPU-bound example with multiprocessing
import math
from multiprocessing import Pool, cpu_count
def cpu_bound_task(n: int) -> float:
result = 0.0
for i in range(1, n):
result += math.sqrt(i)
return result
if __name__ == "__main__":
n = 10_000_000
tasks = [n] * cpu_count()
with Pool() as pool:
results = pool.map(cpu_bound_task, tasks)
print(sum(results))Here:
- Each process has its own Python interpreter and its own GIL
- Multiple CPU cores can run your code truly in parallel
- This can speed up CPU-bound tasks almost linearly with core count, until you hit other limits
Web Backend Examples
Example 1: Pure I/O-bound endpoint
# FastAPI-like pseudocode
from fastapi import FastAPI
import httpx
app = FastAPI()
@app.get("/weather")
async def get_weather(city: str):
async with httpx.AsyncClient() as client:
r = await client.get(
"https://api.weather.example.com",
params={"city": city}
)
data = r.json()
# small CPU work to format result
return {"city": city, "temp": data["temperature"]}Characteristics:
- Most time spent calling external weather API
- Very little CPU work per request
- Many such requests can be handled concurrently with async
Example 2: Mixed CPU-bound and I/O-bound
from fastapi import FastAPI
import httpx
import hashlib
app = FastAPI()
def heavy_hash(data: bytes, rounds: int = 100_000) -> str:
digest = data
for _ in range(rounds):
digest = hashlib.sha256(digest).digest()
return digest.hex()
@app.post("/process")
async def process_file(file_url: str):
async with httpx.AsyncClient() as client:
r = await client.get(file_url) # I/O-bound part
content = r.content
hash_value = heavy_hash(content) # CPU-bound part
return {"hash": hash_value}Here:
- Download is I/O-bound
- Hash calculation is CPU-bound
- If many clients use this endpoint, CPU may become the bottleneck
- You might move
heavy_hashto a background worker process
Choosing the Right Tool
Summary table of strategies
| Workload type | Good strategies | Less effective strategies |
|---|---|---|
| I/O-bound | Async I/O, threads, faster network/disk, caching | Multiprocessing alone, more CPU cores only |
| CPU-bound | Multiprocessing, more CPU cores, algorithm tuning | Async/await alone, many threads in CPython |
| Mixed (most APIs) | Async for I/O, offload CPU-heavy parts to workers | Doing everything in a single request thread |
Offloading CPU work in backends
For CPU-heavy operations in a web API:
- Do not block the main request handler with long CPU work
- Use:
- Background workers with Celery or RQ
- Separate microservices for CPU-heavy processing
- Message queues (Redis, RabbitMQ) to hand off tasks
Pattern:
- API receives request
- API enqueues a CPU-heavy job to a worker
- API responds quickly with a job ID
- Client checks job status or uses webhooks
This turns a CPU-bound synchronous endpoint into an I/O-bound flow, where the main service mostly deals with I/O: queue messages, job status, etc.
Measuring and Confirming the Bottleneck
Do not guess. You should measure.
Common approaches:
- Profilers for CPU usage
- Show which functions use the most CPU time
- Application metrics
- Track request latency, DB query times, external calls times
- System metrics
- CPU utilization
- Disk I/O
- Network throughput
If:
- CPU is high, and function-level profiling shows hotspots, your app is CPU-bound.
- CPU is low, but DB or network time per request is high, your app is I/O-bound.
Practical rule:
Always measure where the time goes, then label your main bottleneck as CPU-bound or I/O-bound.
Only then choose between:
- Async/threads and I/O optimizations
- Or multiprocessing/CPU scaling and algorithm optimizations.
How This Connects to Performance and Scalability
Later topics in performance and scalability, like:
- Asynchronous programming
- Database optimization and indexing
- Connection pooling
- Caching
- Load balancing and horizontal scaling
all depend on this distinction.
In practice:
- If your bottleneck is I/O, you often get huge wins from:
- Async I/O
- Better queries
- Caching
- Pooling and connection reuse
- If your bottleneck is CPU, you often get huge wins from:
- Using more processes / more machines
- Optimizing algorithms
- Offloading heavy work to background workers
Understanding CPU-bound vs I/O-bound work is the foundation for choosing the right performance strategy for your backend.
Views: 6
KAHIBARO