Library
9 min read

Processes vs threads vs async

Published

Three ways a server can juggle many pieces of work, and they differ in what they share and what they cost.

  • A process is a running program with its own private memory. Think of separate flats in a building: each has its own kitchen, and a fire in one does not spread to the others.
  • A thread runs inside a process and shares its memory with the other threads. Flatmates sharing one kitchen: cheap to coordinate, easy to get in each other's way, and one person leaving the gas on affects everyone.
  • An async task (a coroutine, a promise chain, a goroutine) is a unit of work the runtime schedules itself, many per thread. One very organised flatmate keeping twenty recipes going on one stove, switching only when a dish needs to simmer.
Your answer

Your Node.js app runs on 50 pods, each with a pool of 20 database connections. Postgres starts refusing connections and its memory climbs, while Redis, serving the same app, has 5,000 connections and is fine. Why do the two databases react so differently to many connections?

Serving many connections
Strong isolation: a crash or memory corruption kills one connection, not the server. Each process costs megabytes and a fork, so thousands of them strain memory. Postgres works this way, which is why it caps connections and why poolers exist.

What each costs#

ProcessOS threadGoroutine / virtual threadAsync task (event loop)
Memory eachMegabytesStack reserved per thread (1 MB in Java, 8 MB virtual on Linux)Starts around 2 KB (Go) and growsHundreds of bytes to a few KB
Created byOS, forkOSLanguage runtimeLanguage runtime
SwitchingOS context switch, microsecondsOS context switch, microsecondsRuntime switch, much cheaperA function returning to the loop
Shares memoryNo (needs IPC)YesYesYes, but one runs at a time
A crash takes downItselfThe whole processThe whole processThe whole process
Runs on many coresYesYesYes (runtime spreads them)No, one core per loop
Memory to hold 10,000 open connections
10000
Process each (about 10 MB, like a Postgres backend)100.0 GB
OS thread each (about 1 MB of stack actually used)10.0 GB
Goroutine each (about 8 KB once grown a little)80 MB
Event loop entry each (about 4 KB)40 MB
2,500x
more memory for a process per connection than an event loop

Rough figures, and real usage depends on what each connection holds, but the orders of magnitude are right. This gap is the C10K problem from 1999, and the reason event loops and lightweight runtimes took over network servers.

How real systems combine them#

Almost nothing uses only one model. Nginx runs one worker process per core, each with its own event loop: processes for parallelism and isolation, event loops for concurrency. Node.js in production is the same shape: one process per core or per container, each an event loop. Go runs goroutines over a thread per core, so you write blocking style code and get both. Chrome uses a process per site so a malicious page cannot read another tab's memory, and threads inside each. Postgres forks a process per connection for isolation, which is why a pooler like PgBouncer sits in front: thousands of app connections share a few dozen real backend processes.

Blocking inside async code

An event loop only works if every callback returns quickly. A synchronous file read, a CPU heavy loop, bcrypt at a high cost factor, or a big JSON.parse in a Node handler blocks every other connection on that process. In Python, calling the blocking requests library inside an async def does the same. Use async drivers, and push CPU work to worker threads or another service.

ChoiceWhat you gainWhat you payPick it when
ProcessesIsolation of memory and crashes, true parallelism, simple mental model.Heavy per unit, sharing data needs IPC or an external store.Isolation matters (browsers, databases, sandboxes), or one worker per core.
ThreadsParallel on many cores, shared memory for caches and pools.Locks and races, a crash kills the process, heavier than async tasks.CPU parallel work in one process, thread pools for blocking libraries.
Async / event loopHuge numbers of I/O bound tasks for very little memory, no locks in a single thread.One core per loop, any blocking call stalls everyone, coloured functions (async spreads).Network servers, proxies, gateways, chat and streaming.
Goroutines / virtual threadsBlocking style code with async level cheapness and multi core parallelism.Still shared memory and races, runtime specific behaviour.Go services, Java 21+ servers handling many concurrent requests.
Names you will hear
  • Node.jsEvent loop (libuv), worker threads for CPU work
  • GoGoroutines on a thread per core
  • NGINXProcess per core, event loop in each
  • PostgreSQLProcess per connection, pool with PgBouncer
  • RedisSingle threaded event loop for commands
  • Pythonasyncio, threads with the GIL, multiprocessing

In the interview#

Interviewer
Why do people put PgBouncer in front of Postgres?
Tests whether you know the process model, not just the tool.
You
Postgres forks a backend process per connection, each costing several megabytes plus scheduling overhead, so a few thousand connections hurt even if most are idle. With many app pods, each holding its own pool, you get there fast. PgBouncer accepts thousands of cheap client connections and multiplexes them onto a small number of real Postgres connections, usually in transaction mode, so a backend is only borrowed for the length of a transaction.
Process per connection is the root cause. The pooler is the consequence.
Interviewer
Redis is single threaded. Isn't that a bottleneck?
You
Each command is tiny and in memory, so one core does hundreds of thousands per second, and single threaded execution makes every command atomic without locks. Network I/O can use extra threads since Redis 6. When one core is not enough, you shard with Redis Cluster rather than add threads.
Which model fits?
A browser keeping one tab's crash from killing the others
A proxy holding 100,000 mostly idle WebSocket connections
Compressing 500 files using all 8 cores in one program
A Go API making three downstream calls per request

Checkpoint#

Checkpoint

1. Why does Postgres struggle with thousands of connections when Redis does not?

2. What happens if one thread in a multi threaded server crashes with a segfault?

3. Why does a long CPU computation hurt a Node.js server so badly?

Worth memorising
  • Process: own memory, isolated, heavy. Thread: shared memory, shared fate.
  • Async task: KBs, cooperative, one core per loop. Never block it.
  • Goroutines and virtual threads: cheap tasks over a thread per core.
  • Real servers combine: process per core, event loop inside (Nginx, Node).
  • Postgres is process per connection, so pool with PgBouncer.
Say this in 60 seconds

A process is a running program with its own memory: strong isolation of crashes and memory, true parallelism, but megabytes per process and IPC to share data. A thread lives inside a process and shares its memory: cheaper and easy to share caches, but needing locks, and one crash kills the whole process. An async task is a lightweight unit the runtime schedules cooperatively on an event loop: a few kilobytes each, no locks in one thread, ideal for thousands of I/O bound connections, but limited to one core per loop and stalled by any blocking call. Goroutines and Java virtual threads give blocking style code with async level cheapness spread across cores. Real systems combine them: Nginx and Node run a process per core with an event loop inside, Chrome isolates sites in processes, and Postgres forks a process per connection, which is why it needs a pooler like PgBouncer while single threaded Redis handles thousands of connections.