9 min read
On this page

Processes Basics

Everything interesting about Elixir comes from processes. Not OS processes — BEAM processes. They are the language's unit of concurrency, isolation, and fault tolerance. Discord runs millions of them on every node. WhatsApp famously had two million concurrent connections per server in 2012. Processes are how Elixir does what it does, and you cannot understand the rest of the language without understanding them.

What a BEAM Process Is

A BEAM process is a tiny, isolated unit of computation managed by the Erlang virtual machine. It has its own heap, its own stack, and a mailbox for receiving messages. Crucially, it shares no memory with any other process. The only way to communicate with a process is to send it a message.

This sounds expensive. It is not. A new process starts at around 300 bytes of memory. The BEAM can spawn millions of them on a single node and switch between them with sub-microsecond latency. They are fundamentally cheaper than OS threads (which are typically 1 MB minimum stack) and even cheaper than Go's goroutines (a few KB).

# Spawn a process that prints a message
pid = spawn(fn -> IO.puts("hello from #{inspect(self())}") end)
# hello from #PID<0.123.0>

spawn/1 takes a function and runs it in a new process. It returns the process identifier (a pid), which is how you reference that process from elsewhere. The function runs concurrently with the caller. When the function returns, the process exits.

self/0

Inside any Elixir code, self/0 returns the pid of the current process.

iex> self()
#PID<0.108.0>

iex> spawn(fn -> IO.inspect(self(), label: "child") end)
child: #PID<0.143.0>

Every line of Elixir code runs inside some process, even if you never spawned one explicitly. IEx itself is a process. Phoenix request handlers run in their own per-request process. ExUnit tests run in test processes. Processes are everywhere.

Process.alive?/1

You can check whether a process is still running.

pid = spawn(fn -> :timer.sleep(1000) end)
Process.alive?(pid)   # true
:timer.sleep(2000)
Process.alive?(pid)   # false

This is rarely the right primitive in production code — by the time you check, the answer might already be wrong. Monitors and links (covered in the next topic) are how you reliably react to processes dying.

Spawning a Process That Does Real Work

A trivial example to get the feel of it:

defmodule Worker do
  def run(n) do
    for i <- 1..n do
      IO.puts("#{inspect(self())}: #{i}")
      :timer.sleep(100)
    end
  end
end

pid = spawn(fn -> Worker.run(5) end)
IO.puts("spawned #{inspect(pid)}")

The Worker.run/1 call runs in its own process. The caller continues immediately after spawn/1 returns. If you spawn ten of these, ten processes run their five-iteration loops concurrently, interleaving output.

You can also spawn by module/function/args:

spawn(Worker, :run, [5])

Same effect, just a different way to specify the work. The MFA (module-function-args) form is what you use most often once your work lives in named modules rather than ad-hoc anonymous functions.

Processes Are Cheap

This is the part that takes time to internalize if you come from a language where threads are expensive. Spawning a process is roughly as cheap as allocating a struct. You do not need a pool. You do not need to worry about reusing them. You just spawn one for each unit of work and let the BEAM handle the rest.

# Spawn a million processes, each doing nothing in particular.
pids = for _ <- 1..1_000_000, do: spawn(fn -> :timer.sleep(60_000) end)
length(pids)
# 1000000

This works on a laptop. It uses a few hundred megabytes. The BEAM is happy. Try doing the same with OS threads in Java or Python and you will hit a system limit long before a million.

This cheapness is what makes "one process per connection" or "one process per active user" reasonable design patterns in Elixir, where in other languages you would build connection pools and request queues.

Processes vs OS Threads

The differences are worth listing because they shape every design decision in BEAM systems:

Memory. OS thread: 1 MB minimum stack (often 8 MB on Linux). BEAM process: ~300 bytes initial, grows as needed.

Scheduling. OS thread: scheduled by the kernel, context switches go through ring 0. BEAM process: scheduled by the VM in user space, switches are cheap function calls.

Sharing. OS thread: shares heap and globals with siblings. BEAM process: shares nothing. All communication is message passing.

Failure isolation. OS thread: a crash takes down the process (and often the program). BEAM process: a crash is contained to that process. The rest of the system keeps running.

Count. OS threads: thousands at most. BEAM processes: millions, easily.

This is why BEAM systems have a different shape from threaded systems. Instead of "minimize concurrency, maximize each unit of work," you write "lots of small processes, each doing one small thing, all communicating by messages."

The Actor Model, Roughly

Elixir's concurrency is based on the actor model, which Carl Hewitt formalized in 1973 and Erlang adopted in the 1980s. The core ideas:

  • An actor is an isolated entity with private state.
  • Actors communicate only by sending each other messages.
  • An actor processes messages one at a time.
  • An actor can create more actors.

In Elixir, "actor" is a process. Private state lives in the process's heap or in the parameters of a recursive receive loop. Messages go through send/2 and receive. The model maps almost one-to-one.

The big practical consequence: there are no race conditions on a process's private state. Only one message is processed at a time, sequentially. You never need a mutex or a lock to protect data inside a single process. All synchronization happens through the message queue.

You still have to think about consistency across processes — if two processes hold related state, you have a distributed system in miniature, with the usual challenges. But within one process, sequential reasoning works.

BEAM Scheduling

The BEAM runs N schedulers, one per CPU core by default. Each scheduler is an OS thread that picks ready processes from a run queue and executes them. A process runs for some number of "reductions" (roughly, function calls) before being preempted and put back on the queue.

This is preemptive scheduling at the language level, not at the OS level. A process cannot starve the system by running a tight loop — it gets preempted after about 4000 reductions (the exact number changes between OTP versions). This is why you can have one process running an infinite recursion next to a million others, and they all make progress.

I/O does not block the scheduler. When a process makes a blocking call, the BEAM schedules it out and runs other processes on the same scheduler. This is why a Cowboy or Bandit web server can handle ten thousand simultaneous connections without breaking a sweat — each connection is a process, and the scheduler keeps them all advancing without OS-level overhead.

The scheduler also does work-stealing across cores, NUMA-aware placement on big machines, and handles GC per-process so a long collection in one process never pauses the whole system. Most of the time you do not need to think about any of this. It just works. But understanding that there are N schedulers helps you reason about why CPU-bound work in one process does not block I/O-bound work in another.

A Worked Example

Let us build a tiny example that uses two processes:

defmodule Counter do
  def loop(count) do
    receive do
      :inc -> loop(count + 1)
      {:get, from} ->
        send(from, {:count, count})
        loop(count)
    end
  end
end

counter = spawn(fn -> Counter.loop(0) end)

send(counter, :inc)
send(counter, :inc)
send(counter, :inc)
send(counter, {:get, self()})

receive do
  {:count, n} -> IO.puts("count is #{n}")
end
# count is 3

This is the actor model in 15 lines. Counter.loop/1 recursively calls itself, holding state in the count parameter. Messages drive transitions. The state never leaks outside the process. Other processes interact by sending messages and receiving replies.

In real Elixir code, you would use GenServer instead of writing the loop by hand — it is the standard abstraction for stateful processes and gives you supervision, hot reloading, and observability for free. But under the hood, every GenServer is exactly this pattern: a process running a receive loop that holds state in its parameters.

Why Isolation Matters in Practice

The promise of process isolation is more than a theoretical property — it is what lets WhatsApp restart a single misbehaving connection without dropping the other 1,999,999. It is what lets a Phoenix LiveView session crash from a malformed event without affecting any other user's session. The whole shape of a BEAM application — small processes, lots of them, supervisors restarting failures — comes from the fact that you can trust failure to stay local.

A common mistake when porting a system to Elixir is to recreate the global-state habits from threaded languages: a single big process handling all requests, with all state in one place. This works, but it gives up most of what makes the BEAM valuable. The first time a malformed message crashes that one big process, every in-flight request fails. Spreading work across many small processes turns that catastrophe into a single restart.

Common Pitfalls

Treating processes like threads. They are not. You do not pool them, you do not minimize their count, you do not worry about their startup cost. You spawn one whenever spawning helps. The BEAM will handle scheduling.

Holding large terms across many processes. Each process has its own heap. If you copy a 1 MB binary into each of 100,000 processes, that is 100 GB. Use ETS or a single owning process for shared large data — there are exceptions like large binaries, which are reference-counted off-heap.

Spinning a tight loop without a receive or yield. A process that never makes a function call beyond its loop body might never hit the reduction limit. In practice this is rare — almost any real work will yield naturally — but if you write def loop, do: loop(), the BEAM still preempts on the function call.

Forgetting that self() changes meaning across processes. Inside a spawn(fn -> ... end) block, self() is the new process. Outside, it is the caller. Sending self() to the spawned process so it can reply is a common pattern — pass the parent's pid in as a parameter, do not call self() from inside the spawned function expecting to get the parent.

Assuming Process.alive?/1 is a useful guarantee. It tells you the truth at the instant of the call. By the time you act on the answer, the answer may have changed. Use links or monitors for reliable lifecycle reactions.

Key Takeaways

  • A BEAM process is a tiny, isolated unit of computation with its own heap, mailbox, and pid.
  • Processes share no memory. They communicate only by sending messages.
  • spawn/1 creates a new process. self/0 returns the current process's pid.
  • Processes are extremely cheap — millions per node, ~300 bytes each — so you spawn freely.
  • The BEAM scheduler runs N OS threads (one per core) and preempts processes after a bounded number of reductions, preventing starvation.
  • The actor model maps directly to Elixir: each process holds private state, processes one message at a time, and communicates with others only through its mailbox.