Threads vs Processes
Separate houses or roommates? How the OS isolates memory, schedules threads and pays for context switches — how Node and Go use both — and how real systems like Chrome, Postgres and MySQL pick a side.
The 30-second version
Every program you run — your editor, a Node server, a Go binary — runs inside a process. Inside each process, one or more threads do the actual work. "Should this be another process or another thread?" is one of the oldest trade-offs in software, and you meet it every time you scale a server.
Isolation vs sharing
The OS runs threads
Node and Go differ
Here's the whole trade-off on one screen. Each row gets its own section below.
| Process | Thread | |
|---|---|---|
| Memory | Its own, private (§02) | Shares its process's memory (§03) |
| If it crashes | Only that process dies | The whole process dies, every thread with it (§03) |
| Switching between them | Slightly more expensive (§05) | Slightly cheaper (§05) |
| Creating one | Slower: roughly a millisecond or more (§06) | Faster: microseconds |
| Talking to others | Through the OS: pipes, sockets — data is copied (§09) | Just read the same variable — but needs a Lock (§03) |
| Famous users | Chrome (a process per site), Postgres (a process per connection) | MySQL (a thread per connection), Java servers (§10) |
A process: one program, its own memory
A program is a file on disk, like /usr/bin/node or your compiled Go binary. A process is that program running. Run node server.js in two terminals and you get two processes from the same program — two houses built from one blueprint.
What the OS gives every process
When a process starts, the Operating system (OS) / kernel sets up a little kit for it and keeps a record of it in its process table:
- An ID. A number called the PID, e.g. 4120, plus the PID of its parent — the process that started it (often your shell).
- Its own memory. Room for the program's code, its global variables, its Heap, and a Call stack for each thread. Private to this process — more on that below.
- Open files and connections. Every file and network Socket it opens gets a small number, a File descriptor.
- Its surroundings. Environment variables, the current directory, and which user it runs as (which decides what it's allowed to do).
- At least one thread — the main thread — to run the code. It can start more (§03).
You can ask a program for its own PID:
package main
import (
"fmt"
"os"
)
func main() {
fmt.Println("my PID:", os.Getpid()) // e.g. 4120
fmt.Println("parent PID:", os.Getppid()) // the shell that started me
}And from the outside, ps lists processes. On Linux you can also ask how many threads each one has:
$ ps -o pid,ppid,nlwp,rss,comm -p 4120,4188
PID PPID NLWP RSS COMMAND
4120 3301 11 48212 node
4188 3301 8 12480 api-serverPID/PPID = the process and its parent. NLWP = number of threads (Linux calls them "lightweight processes"). RSS = RAM in use, in KB. COMMAND = the program. Yes — the "single-threaded" Node process has about 10 threads; §07 explains who they are.
Each process lives in its own address space
Memory is one long row of numbered bytes; a byte's number is its address. Programmers usually write addresses in hexadecimal (base 16), like 0x5000 — the 0x just means "this number is in hex".
Here's the trick: every process gets its own private numbering. Two processes can both store something at address 0x5000 and never collide. Each process sees its own Address space, as if it were the only program on the machine.
This is Virtual memory. The addresses your program uses are virtual: on every memory access, the CPU translates them into real RAM locations using a lookup table the OS keeps per process, the Page table.
0x5000 for their heap, but each one's page table sends it to a different spot in physical RAM. Process A has no address that leads into B's memory. (Here both processes run the same program, so the kernel lets them share its code page: code is read-only, so sharing is safe.)That's where the walls come from. Process A can only use addresses in its own page table, and none of them lead to B's memory. A can't read B's data, can't overwrite it — it can't even name it.
What if a program uses an address that isn't in its page table at all — say, a null pointer (address 0)? The CPU refuses, and the OS kills the process. That's the famous Segmentation fault. (That's what happens in C. JavaScript checks for null first and throws a TypeError, and Go turns a nil pointer into a panic, so you rarely see a real segfault in either.)
Threads: roommates sharing one house
Every process starts with one Thread, the main thread. The program can ask the OS for more. Each new thread runs its own function, side by side with the others, inside the same house.
Shared vs private
| Shared by every thread in the process | One per thread (not protected) |
|---|---|
| The program's code | Its own Call stack (local variables, which function it's in) |
| Global variables and the Heap | Its own registers, including the program counter (where it is in the code) |
| Open files and sockets | Its thread ID |
| The PID, the user, the environment | — |
Why is the stack private? Because each thread is in the middle of different function calls. Thread A might be three calls deep in handleRequest, thread B in writeLog; each needs its own pile of plates. Everything else lives in the one address space they share.
"One per thread" doesn't mean protected: each thread has its own stack, but it sits in the same address space, so another thread with a pointer to it can still read or overwrite it. Step ③ below shows that.
Try it. The same two workers, A and B, first as threads in one process, then as two processes:
users = Map(1,204)
fd 4 → :8080 socket
locals: req, user
registers · PC · SP
locals: req, user
registers · PC · SP
Why share? Speed
- Sharing is free. To give another thread a 100 MB array, you hand it the array's address. Nothing is copied. Between processes, those 100 MB must be copied through the OS (§09).
- Creating is cheap. A new thread doesn't need a new address space, so it starts in microseconds. A new process takes much longer (§06).
- Switching is a bit cheaper. Moving the CPU from one thread to another in the same process skips a step (§05).
The price of sharing
Two roommates can reach for the same egg. When two threads write the same variable at the same time, with nothing coordinating them, you get a Data race — and wrong results that change from run to run.
A worked example. counter is 5, and threads A and B each run counter++. The CPU does that in three steps: read, add 1, write back. If both threads read 5 before either writes, both compute 6 and both write 6. Two increments ran; the counter went up by one. One update is simply lost:
1// Both threads run counter++.2// The CPU does it in three steps:3let tmp = counter; // ① read into a register4tmp = tmp + 1; // ② add5counter = tmp; // ③ write back
counter, which is 5. The right answer afterwards is 7. One line of code, counter++, is really three steps for the CPU.The fix is a Lock (or passing data through a channel), so only one thread runs those three steps at a time. Race Conditions §02 digs into this bug; Async / Await, pitfall #5 and Channels §10 show both fixes.
When one thread crashes, the whole house burns
Suppose one thread has a bug and writes to a bad address. The CPU stops it and the OS steps in. The OS can't know what else that broken thread scribbled over in the shared memory, so nothing in the process can be trusted any more. It does the only safe thing: it kills the whole process, every thread with it.
The same is true of unhandled errors in most runtimes. An uncaught exception on Node's main thread, or a Go panic / recover that nobody recovers in any goroutine, ends the whole process.
Separate processes don't have this problem: a crash stays inside its own walls. The demo below has a second mode with a supervisor: just a parent process whose job is to start workers and restart any that die. (You'll meet real ones in §07, Node's cluster, and §11, Kubernetes.) Send a bad request to each setup:
The OS scheduler: who gets a core
Open your laptop's activity monitor: hundreds of processes and a few thousand threads, on maybe 8 cores. Each CPU core can only run one thread at any instant. How does that work?
They take turns. The part of the OS kernel that decides whose turn it is is the OS scheduler.
Turns, and taking turns away
Each running thread gets a Time slice — a few milliseconds. If it's still running when the slice is up, a Timer interrupt (a hardware alarm) forces the CPU back into the kernel (the CPU stops running your code and runs the OS's code instead), and the scheduler can hand the core to someone else. Pausing a thread that didn't ask to stop is called preemption.
Threads also give up the core voluntarily: whenever they have to wait — for the disk, the network, a lock, a timer — they're set aside until the wait is over. Step through both:
Three states
| State | Means | On a typical server, most threads are… |
|---|---|---|
| Running | On a core right now | At most one per core |
| Ready (runnable) | Could run, waiting for a free core | Few, if the machine is healthy |
| Blocked (sleeping) | Waiting for something else: I/O, a lock, a timer | Here. Thousands of them, costing no CPU at all |
That last column explains a lot. A blocked thread uses no CPU time, but it still holds memory: its stack, its kernel record. That's exactly why one thread per connection got expensive at 10,000 connections (Async / Await §03) — the threads weren't busy, they were just there.
Context switches: what a swap really costs
In §04 the scheduler "swapped" threads on a core. What does that actually involve? A thread's whole in-progress state lives in the core's registers: where it is in the code (the program counter), where its stack is (the stack pointer), and the values it's working on. Swap the registers and you've swapped the thread.
There are two kinds of switch, and you saw both in §04:
- Voluntary — the thread blocked (waiting on disk, network, a lock).
- Involuntary — its time slice ran out and it was preempted.
Step by step
Two words you'll meet in the stepper. Kernel mode: the CPU has a privileged mode for the OS and a restricted one for your code; crossing between them costs a little (User mode vs kernel mode). TLB: a small cache inside the CPU that remembers recent address translations from the page table (§02), so translating is usually instant (TLB).
Step through a switch from thread A to thread B. Then flip the toggle: is B in the same process as A, or a different one?
PC (the program counter: which instruction is next), SP (the stack pointer: where A's stack is) and some data. Thread B is paused; its registers were saved earlier. A belongs to process P1, B to a different process, P2.So the difference between the two cases is exactly one step: a switch to another process also switches the address space (the page table), and the new process finds none of its data in the CPU's caches.
What it costs, in numbers
All numbers are approximate — they vary a lot with the CPU, the kernel version and how much data each thread touches.
| Switch | Direct cost | Then… |
|---|---|---|
| Goroutine → goroutine (Go runtime, no kernel) | ~0.1–0.3 µs | Same thread, same address space — little cache damage |
| Thread → thread, same process | ~1–3 µs | Some shared data may still be in cache |
| Thread → thread, other process | ~2–5 µs | Page-table switch; the new process's data is cold |
The "then…" column is often the bigger cost. After a switch, the new thread's data isn't in the CPU cache, so its first reads go to RAM — around 100 ns each instead of 1–10 ns.
A worked example. One core does 10,000 switches per second, and each costs about 5 µs including the cache refill. That's 10,000 × 5 µs = 50,000 µs = 50 ms of every second — 5% of the core — doing no useful work. At 100,000 switches per second, it's 50%. Try it:
Why goroutines and event loops win here
This is the deeper reason behind Async / Await §03. With a thread per connection, every network wait means a trip into the kernel and a context switch. Go's runtime switches goroutines without entering the kernel at all — it just saves a few registers in user space, roughly ten times cheaper. And Node's event loop doesn't switch at all between callbacks: it's one thread that keeps running.
Measuring it (Linux)
# machine-wide context switches per second: the "cs" column
vmstat 1
# one process: voluntary = it blocked, nonvoluntary = it was preempted
grep ctxt_switches /proc/4120/status
# per thread, once a second (pidstat comes with the sysstat package)
pidstat -wt -p 4120 1Starting processes: fork, exec and spawn
Why two steps instead of one "start this program" call? Because in between, the child is still running your code, so it can set itself up with ordinary function calls: connect its output to a pipe (a one-way byte channel, §09), change directory, drop privileges (switch to a less powerful user). Then it calls exec and becomes the new program, keeping that setup.
Step through what happens when a server runs ls -l. The code is pseudocode — neither Node nor Go lets you call these directly; they do it for you:
1const pid = fork(); // 1 in, 2 out2if (pid === 0) {3 // CHILD4 exec("/bin/ls", ["ls", "-l"]);5 // (never reached)6} else {7 // PARENT8 const status = waitpid(pid);9 console.log("exit:", status);10}
fd 1 is its standard output, connected to the terminal.Three ideas from that walkthrough:
- Copy-on-write: fork doesn't copy memory up front. Parent and child share every page until one of them writes to it; that write causes a Page fault, and the kernel copies just that page. The kernel only copies the page table, not the gigabytes behind it, so forking even a big process is fast (milliseconds, not seconds).
- Exit code: every process ends with a number. 0 = success; anything else = failure.
- Zombie process: a finished child stays in the process table until its parent collects the exit code. Forget to collect it and the zombies pile up.
In real code
package main
import (
"context"
"fmt"
"os/exec"
"time"
)
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
// No shell involved: "ls" is the program, "-l" and "/tmp" its arguments.
cmd := exec.CommandContext(ctx, "ls", "-l", "/tmp")
out, err := cmd.Output() // ← fork + exec + wait, collecting stdout
if err != nil {
fmt.Println("failed:", err) // non-zero exit code, timeout, or not found
return
}
fmt.Print(string(out))
}The child's standard streams — stdin / stdout / stderr — are how parent and child talk by default: Node and Go connect them to pipes, which is how the parent reads ls's output. More on pipes in §09.
Node: one main thread, and three ways out
worker_threads). For other programs, crash isolation or using every core for a server, add processes (child_process, cluster)."Node is single-threaded" is half true. Your JavaScript runs on one thread — the one running the Event loop. But a Node process has other threads too, and you can add more threads or more processes when you need them:
Threads you already have: the libuv pool
Some work the OS can't do in the background with a "wake me when it's done" call (the epoll / kqueue / IOCP trick from Async / Await §03): reading files, looking up hostnames with dns.lookup, slow crypto like pbkdf2 (password hashing), compression with zlib. For those, Node hands the job to the libuv thread pool and calls your callback when a pool thread finishes.
The pool has 4 threads by default. Run 4 slow password hashes at once and every fs.readFile queues behind them. You can raise it with the UV_THREADPOOL_SIZE environment variable (set it before Node starts; max 1024). Network sockets don't use the pool, so ordinary HTTP traffic isn't affected.
worker_threads: more JavaScript threads
A blocking calculation on the main thread freezes everything else (see Async / Await, pitfall #2). A Worker thread runs that code on another thread, so it can use another core:
import { Worker } from "node:worker_threads";
import { readFile } from "node:fs/promises";
// Run hash-worker.js on a new thread; resolve with whatever it sends back.
function hashInWorker(buf) {
return new Promise((resolve, reject) => {
const w = new Worker(new URL("./hash-worker.js", import.meta.url), {
workerData: buf, // ← copied into the worker
});
w.once("message", resolve); // the worker's result
w.once("error", reject); // an uncaught throw inside the worker
w.once("exit", (code) => reject(new Error(`worker exited (${code})`))); // never hang
});
}
// files = the CONTENTS of each file (Buffers), not just their names
const files = await Promise.all(["a.txt", "b.txt", "c.txt", "d.txt"].map((f) => readFile(f)));
// 4 files → 4 threads → up to 4 cores busy at once.
// Fine for a handful; for 1,000 files use a pool (§12, pitfall #1).
const sums = await Promise.all(files.map(hashInWorker));The key fact about workers: each one runs its own V8 isolate — a separate JavaScript world with its own heap and globals. It's an OS thread inside the same process, but JavaScript objects are not shared. Data moves in three ways:
- Copied (the default).
postMessagecopies the value with Structured clone. Fine for small messages; slow for 100 MB. - Transferred.
postMessage(buf, [buf])moves an ArrayBuffer (a block of raw bytes; a NodeBuffersits on top of one) to the other thread with no copy. The sender can't use it any more. - Shared. A SharedArrayBuffer + Atomics is real shared memory: both threads see the same bytes at once — with all the data-race risks from §03.
child_process: other programs
§06 showed execFile and spawn. Use them to run anything that isn't your JavaScript — git, ffmpeg, a Python script — or JavaScript you don't trust. It's a separate process: its own memory, its own crashes. Node's own child_process.fork() starts another Node process with a message channel attached (§09).
cluster: one server, every core
A single Node server uses one core for JavaScript. The cluster starts several copies of the server as separate processes, all accepting connections on the same port:
import cluster from "node:cluster";
import http from "node:http";
import { availableParallelism } from "node:os";
if (cluster.isPrimary) {
// The primary process only manages workers.
for (let i = 0; i < availableParallelism(); i++) cluster.fork(); // one per core
cluster.on("exit", (worker, code) => {
console.log(`worker ${worker.process.pid} died (code ${code}), starting another`);
cluster.fork(); // ← the primary is a supervisor
});
} else {
// Each worker is a full copy of the server, in its own process.
http.createServer((req, res) => res.end(`hi from ${process.pid}\n`)).listen(3000);
}The primary is a Supervisor: when a worker crashes, it starts a new one — exactly the "4 processes + supervisor" mode of the §03 crash demo. But each worker has its own memory: an in-memory cache or session store exists once per worker, not once per server (§12, pitfall #5).
Which one?
| You need to… | Use | Shares JS memory? | A crash takes down… |
|---|---|---|---|
| Read files, hash passwords, gzip | Nothing — the libuv pool does it | — | — |
| Run CPU-heavy JavaScript | A pool of worker_threads | No (only SharedArrayBuffer) | That worker, for a JS error (if you handle 'error'; otherwise the whole process); the whole process for a native crash |
| Run another program | child_process (spawn, execFile) | No | Just the child |
| Use every core for an HTTP server | cluster, or N containers | No | Just that worker |
Go: goroutines on threads, all in one process
A Goroutine is not an OS thread. The Go runtime keeps its own scheduler that puts goroutines onto threads, and the OS scheduler (§04) puts those threads onto cores. Running many tasks on a few threads like this is called M:N scheduling.
How the Go scheduler decides which goroutine runs on which thread — the G · M · P model, the netpoller, work stealing — is covered step by step in Async / Await §09. Here we care about what it means for threads and processes.
Goroutines vs threads, in numbers
All approximate:
| OS thread | Goroutine | |
|---|---|---|
| Stack | Usually 1–8 MB reserved (fixed size) | Starts at ~2 KB, grows as needed |
| Create | ~10 µs or more (a system call) | Well under 1 µs (no system call) |
| Switch | ~1–5 µs, through the kernel (§05) | ~0.1–0.3 µs, inside the runtime |
| Practical count | Thousands | Hundreds of thousands |
The number of threads running Go code at once is GOMAXPROCS, one per core by default. The runtime starts a few extra threads when goroutines get stuck in blocking system calls, so a busy Go server typically shows a dozen or so threads.
What "one process" means for you
- Every goroutine shares one heap. Goroutines are roommates: two of them writing the same map is a Data race (§03). Go often catches this one itself and kills the process (see the third bullet). Use a mutex or channels (Channels §10), and run tests with
go test -race. - One unrecovered panic ends everything. A panic / recover in any goroutine, if not recovered inside that same goroutine, exits the whole program. Go's HTTP server recovers panics in your handlers — but not in goroutines your handlers start (§12, pitfall #4).
- Some crashes can't be recovered at all. "Fatal errors" —
concurrent map writes, running out of memory, a segfault in C code called from Go — kill the process no matter how manyrecover()calls you have.
Threads, when you need to see them
// Pin this goroutine to its current OS thread (and no other goroutine
// runs on that thread). Needed for C libraries or GUI toolkits that
// insist on being called from the same thread every time.
runtime.LockOSThread()
defer runtime.UnlockOSThread()
// Go crashes the program if it ever needs more than this many OS threads
// (default 10,000) — usually a sign of thousands of goroutines stuck in
// blocking system calls or C calls.
debug.SetMaxThreads(10_000) // from "runtime/debug"Processes in Go
Go has no plain fork (§06 explains why). To start another process, use os/exec, as in §06. Real Go systems do use processes for isolation: HashiCorp's go-plugin library — used by Terraform (a tool that creates cloud servers from config files) for its providers, the plugins that talk to each cloud — runs each plugin as a separate process and talks to it over gRPC on a local socket. A buggy provider can crash itself, not Terraform.
Talking: shared memory vs IPC
Threads: share an address
Between threads (and goroutines), you just use the same variable, or pass a pointer — an address in the shared heap. Handing over a 10 MB object costs the same as handing over a number: nanoseconds.
The catch is coordination. Two threads touching the same data need a Lock, or a rule that only one of them owns it at a time — which is what passing it through a Go channel achieves (Channels §01).
Processes: send bytes through the OS
Processes can't share an address (§02), so every option for IPC goes through the kernel:
| Mechanism | Direction | Typical use |
|---|---|---|
| Pipe | One way | Parent ↔ child via stdin/stdout; the shell's ls | grep x |
| Unix domain socket | Two way, same machine | Talking to a local Daemon: Docker (/var/run/docker.sock), Postgres, a Sidecar |
| TCP Socket | Two way, any machine | HTTP, gRPC, databases — the same API across the network |
| Shared memory | Both read and write | Very large data, very low latency; Postgres's buffer cache, Chrome's video frames |
| Signal | A tiny notification | "Please stop" (SIGTERM), "reload config" (SIGHUP); more in §11 |
| Files | Anything | Hand-offs that must survive a restart |
What copying really costs
A pipe carries bytes, not objects. To send an object you must turn it into bytes (Serialization, e.g. JSON.stringify), the kernel copies those bytes in and out, and the other side parses them back:
Concrete numbers (approximate): a 10 MB JSON payload takes tens of milliseconds to stringify and about as long again to parse. Send it 50 times per second and those two steps alone can fill a core. Between threads, the same hand-off is a pointer.
A pipe in practice
Run the Unix sort program as a child, write lines into its stdin, read the sorted result from its stdout:
cmd := exec.Command("sort") // the child process
cmd.Stdin = strings.NewReader("pear\napple\nfig\n") // ← fed into its stdin pipe
out, err := cmd.Output() // ← read from its stdout pipe
if err != nil {
log.Fatal(err)
}
fmt.Print(string(out)) // apple, fig, pear — one per lineNode-to-Node: a message channel
Node's child_process.fork() starts another Node process and adds a message channel on top of a pipe. Messages are serialized (JSON by default) on the way:
import { fork } from "node:child_process";
const child = fork("./child.js"); // a new Node process (path relative to the current directory)
child.on("message", (msg) => {
console.log("child says", msg); // { done: 7 }
child.disconnect(); // close the channel so both can exit
});
child.send({ task: "resize", id: 7 }); // ← serialized, sent through the pipeWhen each wins: Chrome, Postgres, MySQL, Nginx, Redis
The decision table
| If you need… | Lean towards | Because |
|---|---|---|
| One bad request mustn't take down the rest | Processes | A crash stays inside its walls (§03) |
| To run code you don't trust (sites, plugins, user scripts) | Processes | Isolation is enforced by hardware (§02) |
| To share large data or update shared state constantly | Threads | A pointer instead of a copy (§09) |
| Thousands of concurrent connections | Threads, goroutines or an event loop | A process per connection costs MBs each (§04, and Postgres below) |
| Separate memory/CPU limits, independent restarts | Processes | The OS limits and kills per process (§11) |
| To run a different program or language | Processes | The only option |
Now five systems you've probably used, and the side each one picked.
Chrome: a process per site
Chrome runs a browser process (tabs, address bar), a GPU process, a network process, and renderer processes that run each site's HTML and JavaScript. Since Chrome 67 (2018), desktop Chrome gives each site its own renderer process ("Site Isolation").
- Crashes: a tab that crashes shows "Aw, Snap!" — the rest of the browser keeps running.
- Security: web pages are untrusted code. After Spectre showed that JavaScript could read memory within its own process through CPU timing tricks, the only safe answer was to never put two sites in the same process. The address-space walls from §02 became the security boundary.
- The price: memory. Each renderer has its own heap, its own copy of engine data. That's a big part of why Chrome uses so much RAM.
Postgres: a process per connection
Postgres has a main process (the postmaster) that forks a new backend process for every client connection. Isolation is good, and the design dates from when threads were poorly supported across operating systems.
- The price: each backend costs several MB at least (approximate; more with big queries). Thousands of connections means thousands of processes, so busy apps put a Connection pooler like PgBouncer in front.
- The twist: the backends share a large shared memory region (the buffer cache). If one backend dies abnormally — a memory fault, or the OOM killer (§11) — the postmaster can't trust that memory any more — so it terminates every backend and recovers. Every connection drops. Sharing memory weakened the walls (§09).
MySQL: a thread per connection
MySQL runs one process (mysqld) with a thread per connection (some editions add a thread pool). Connections are cheaper than Postgres's, and all threads share caches directly. The flip side is the §03 rule: a crash in any thread takes the whole server down.
Nginx: a few processes, each with an event loop
Nginx runs a master process and a few worker processes — usually one per core — and each worker handles thousands of connections with an event loop. Processes give multi-core use and isolation; the event loop gives scale without a thread per connection. That's the same shape as Node + cluster (§07).
Redis: one main thread, and fork() for snapshots
Redis executes commands on a single main thread, so commands never race with each other (Redis 6+ can add I/O threads for network reading and writing). To save a snapshot to disk without pausing, it calls fork: the child writes the data to disk while the parent keeps serving.
That works because of Copy-on-write (§06): the child sees a frozen copy of memory for free. Only pages the parent changes during the save get duplicated.
Production: limits, OOM kills and containers
A container is a process
There's no "container" object in the Linux kernel. A Container is an ordinary process — your node server.js or Go binary — started with two extra kernel features:
- Namespaces control what it can see: its own list of PIDs (your app is often PID 1 inside), its own network interfaces, its own file system.
- cgroups control how much it can use: CPU time, memory, number of processes.
# 512 MB of RAM, 2 CPUs' worth of time, at most 200 processes + threads
docker run --memory=512m --cpus=2 --pids-limit=200 my-apiThe limits you'll hit
| Limit | Set by | What hitting it looks like |
|---|---|---|
| Open files and sockets | ulimit -n (often 1,024 by default) | EMFILE: too many open files — every connection is an File descriptor |
| Processes and threads | ulimit -u, cgroup pids.max | EAGAIN, "can't create thread"; Go: failed to create new OS thread |
| Memory | cgroup memory limit | The process is killed: exit code 137, Kubernetes says OOMKilled |
| CPU | cgroup CPU limit | Nothing crashes — the process is throttled (paused) for part of every 100 ms, and latency jumps |
On Linux, threads count against the process limits — a thread is a "task" just like a process (§04).
When memory runs out, the kernel's OOM killer picks a victim and sends it SIGKILL. A signal is the OS's way of tapping a process on the shoulder; SIGKILL (signal 9) is the one that can't be caught or ignored: the process just dies (128 + 9 = exit code 137). It kills a whole process, never just one thread — the roommates go down together again. That's why you tell the runtime the budget, so it can clean up before the kernel steps in:
- Node:
--max-old-space-size=384(in MB) caps the JavaScript heap. Set it well below the container limit; buffers and native memory live outside it. - Go:
GOMEMLIMIT=450MiB(Go 1.19+) makes the garbage collector work harder as memory approaches the limit. - Go CPU: Go 1.25+ sets
GOMAXPROCSfrom the container's CPU limit, whengo.modsaysgo 1.25or later (§08). Node's libuv pool size (§07) doesn't adapt; set it yourself if you change it.
Shutting down politely: SIGTERM and PID 1
When Kubernetes (or docker stop) stops a container, it sends SIGTERM — "please finish up" — waits a grace period (30 s by default in Kubernetes), then sends SIGKILL, which can't be caught. Unlike SIGKILL, SIGTERM can be caught. Catch it, stop accepting new requests, finish the in-flight ones, exit:
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, os.Interrupt)
defer stop()
srv := &http.Server{Addr: ":8080", Handler: mux}
go func() {
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Fatal(err)
}
}()
<-ctx.Done() // ← blocks until SIGTERM (or Ctrl-C) arrives
shutdownCtx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
// stop accepting; wait for in-flight requests (max 20 s)
if err := srv.Shutdown(shutdownCtx); err != nil {
log.Printf("forced shutdown: %v", err) // the 20 s ran out
}Here's what the handler buys you, and what happens without one:
Why the PID 1 remark? PID 1 is special: the kernel doesn't apply the default "die" action to signals it hasn't installed a handler for, and it adopts every Orphan process (a child whose parent died), so it's expected to Reap them when they exit — otherwise they stay zombies (§06). Two practical rules:
- Start your app directly (
CMD ["node", "server.js"]), not vianpm startor a shell script that may not forward SIGTERM. - If your app starts child processes, run a tiny init in front of it —
docker run --initortini— to forward signals and reap zombies.
Seeing threads and processes
ps -o pid,nlwp,rss,comm -p 4120 # threads (NLWP) and memory (RSS) of one process
top -H -p 4120 # live CPU use per THREAD of that process
ls /proc/4120/task | wc -l # thread count, straight from the kernel
cat /sys/fs/cgroup/memory.max # inside a container (cgroup v2): your memory limitPitfalls gallery
Six mistakes I've seen in real code reviews, each with the fix.
A new Worker for every request
Each Worker boots a full JavaScript engine (§07): tens of milliseconds and several MB before it does any work. Under load, you spend more time starting workers than using them — and with no limit, memory grows with traffic. Keep a small pool alive and reuse it.
app.post("/thumb", (req, res) => {
// a whole new JS engine per request
const w = new Worker(new URL("./thumb.js", import.meta.url), {
workerData: req.body,
});
w.once("message", (img) => res.send(img));
});import Piscina from "piscina"; // a pool of reusable worker threads
// thumb.js: export default (body) => makeThumbnail(body);
const pool = new Piscina({
filename: new URL("./thumb.js", import.meta.url).href,
});
app.post("/thumb", async (req, res) => {
res.send(await pool.run(req.body)); // waits in a queue if all are busy
});Building a shell command from user input
Node's exec runs its string through a shell (/bin/sh), so anything in name that the shell understands — ;, &&, $(…) — runs too. execFile and spawn start the program directly and pass each argument as-is. Go's exec.Command never uses a shell, unless you write exec.Command("sh", "-c", …) yourself — which brings back the same bug.
import { exec } from "node:child_process";
// name = "cat.png; rm -rf ~" ← runs BOTH commands
exec(`convert ${name} thumb.png`);import { execFile } from "node:child_process";
// No shell: name is ONE argument, whatever it contains.
execFile("convert", [name, "thumb.png"], (err) => {
if (err) console.error(err);
});Start() without Wait()
cmd.Start() launches the child and returns immediately. If nobody calls cmd.Wait(), the finished child stays a zombie (§06), and any pipes you attached (e.g. cmd.StdoutPipe(), which hands you the child's output as a stream) stay open. One per request, and the server eventually runs out of PIDs or file descriptors.
cmd := exec.Command("convert", in, out)
cmd.Start() // runs in the background…
return nil // …and nobody ever waits for itcmd := exec.CommandContext(ctx, "convert", in, out)
if err := cmd.Start(); err != nil {
return err
}
// …other work while it runs…
return cmd.Wait() // collects the exit code, closes pipesA panic in a goroutine you started
Go's HTTP server recovers panics inside your handler. But a goroutine the handler starts is on its own: if it panics, the whole process exits (§08), taking every other request with it. Recover inside the goroutine — and copy what you need from the request, since the handler may have returned by then.
func handler(w http.ResponseWriter, r *http.Request) {
go sendAnalytics(r.URL.Path) // a panic here kills the server
w.Write([]byte("ok"))
}func handler(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path
go func() {
defer func() {
if v := recover(); v != nil {
log.Printf("analytics panicked: %v", v) // the server lives on
}
}()
sendAnalytics(path)
}()
w.Write([]byte("ok"))
}In-memory state with several processes
With cluster, PM2 or several containers, each process has its own copy of every variable (§07). A user logs in on worker 1 and is "logged out" on worker 2. Keep shared state outside the processes, for example in Redis.
const sessions = new Map(); // exists once PER PROCESS
app.post("/login", (req, res) => {
sessions.set(token, user);
// …
});
app.get("/me", (req, res) => res.json(sessions.get(token))); // often undefined// shared by every process (node-redis client)
await redis.set(`session:${token}`, JSON.stringify(user), { EX: 3600 });
const raw = await redis.get(`session:${token}`);
const user = raw && JSON.parse(raw);More busy threads than cores
For CPU-heavy work, threads beyond the number of cores don't add speed; they add context switches (§05). Classic versions: a worker pool of 32 in a container limited to 2 CPUs; a Go service on a version before 1.25 (or whose go.mod says an older go line) using GOMAXPROCS = 64 host cores inside that same container (§08); UV_THREADPOOL_SIZE=128 "to be safe". Size CPU pools to the CPU limit, not the host. (For I/O waits, more threads are fine — they spend their time blocked, §04.)
Check yourself: predict the outcome
Commit to an answer before you click. Each one maps to a section: crashes (§03), memory walls (§02, §07), fork (§06), switching (§05) and per-process state (§07).
func main() {
go func() {
panic("boom")
}()
time.Sleep(time.Second)
fmt.Println("main survived")
}// main.js
import { Worker } from "node:worker_threads";
globalThis.count = 41;
new Worker(new URL("./w.js", import.meta.url));
// w.js
console.log(globalThis.count);// bad.js: throw new Error("boom");
import { Worker } from "node:worker_threads";
const w = new Worker(new URL("./bad.js", import.meta.url));
w.on("error", (e) => console.log("worker died:", e.message));
setTimeout(() => console.log("main alive"), 100);// pseudocode, as in §06 (fork/waitpid aren't real JS functions)
let x = 1;
const pid = fork();
if (pid === 0) { x = 99; exit(0); } // child
waitpid(pid);
console.log(x); // parent prints…// One core does 20,000 context switches per second.
// Each costs ~5 µs (switch + cache warm-up).
// How much of that core goes to switching?// Started with cluster: 4 worker processes, same code.
let hits = 0;
http.createServer((req, res) => {
hits++;
res.end(String(hits));
}).listen(3000);
// 8 requests arrive, spread evenly. The 8th response says…Cheat sheet
| If you need… | JavaScript (Node) | Go |
|---|---|---|
| CPU-heavy work on all cores | A pool of worker_threads (Piscina) | More goroutines |
| Run another program | execFile / spawn from node:child_process | exec.CommandContext + Output() or Start()/Wait() |
| Talk to a child | Its stdin/stdout pipes; fork() + send() for Node children | cmd.Stdin + cmd.Output() |
| Use every core for a server | cluster, or one container per core | Nothing — one process uses all cores |
| Share memory between threads | SharedArrayBuffer + Atomics | Any variable + a mutex or a channel |
| Survive a crash in risky code | Run it in a child process | Run it in a child process (e.g. go-plugin) |
| Stop politely | process.on("SIGTERM", …) + server.close() | signal.NotifyContext + srv.Shutdown() |
| Respect the container budget | --max-old-space-size | GOMEMLIMIT; GOMAXPROCS is automatic in Go 1.25+ (with go 1.25 in go.mod) |