Guide
Elixir fundamentals explained
Elixir is a functional language on the BEAM virtual machine — the same runtime that powers Erlang telecom switches and modern systems like Discord, WhatsApp, and Bleacher Report. Unlike Ruby’s object-oriented GVL model or Go’s goroutines with shared memory, Elixir builds on immutable data, pattern matching, and millions of lightweight processes that communicate by message passing. The Open Telecom Platform (OTP) adds supervision trees so crashed workers restart automatically — “let it crash” is a design principle, not negligence. This guide covers the BEAM runtime, core syntax, GenServer and supervision, Phoenix for HTTP and WebSockets, Ecto for Postgres, LiveView for server-rendered interactivity, a Harbor Fleet real-time status API worked example, a language decision table, common pitfalls, and a production checklist alongside our distributed systems and PostgreSQL references where they overlap.
What Elixir is: BEAM, Erlang, and the runtime model
Elixir compiles to bytecode executed by BEAM (Bogdan/Erlang
Abstract Machine). You invoke it with elixir script.exs or run
releases built with mix release. The VM schedules thousands of
processes across CPU cores with preemptive scheduling — no global interpreter
lock blocking parallel Elixir execution.
Erlang invented the model in the 1980s for fault-tolerant telecom;
Elixir (José Valim, 2012) adds modern syntax, macros, and tooling while
calling the same OTP libraries. Interop is seamless: Elixir modules can call Erlang
functions and vice versa. Both target .beam files and share the hex.pm
package ecosystem.
Why teams choose BEAM
- Soft real-time latency — predictable millisecond response under load; garbage collection per process, not stop-the-world pauses on the whole VM.
- Fault isolation — a bug in one process does not corrupt another’s heap; supervisors restart failed children.
- Hot code upgrades — deploy new modules without dropping connections (used in telecom; less common in web but available).
- Distribution — nodes connect in clusters; processes can spawn on remote nodes transparently when architected for it.
Pin Elixir and Erlang/OTP versions in .tool-versions or your Dockerfile;
use mix as the build tool for deps, tests, and releases.
Syntax essentials: immutability, pattern matching, and pipes
Variables are immutable — rebinding uses = for
pattern matching, not mutation:
{:ok, shipment} = Fleet.get_shipment("sh-42")
# shipment is bound; {:error, :not_found} would raise MatchError
%{status: status} = shipment
# destructures map keys
Pattern matching appears in function heads, case,
and with expressions. Functions are identified by name and
arity (handle/2 vs handle/3), enabling multiple clauses:
defp format_result({:ok, data}), do: Jason.encode!(data)
defp format_result({:error, reason}), do: "error: #{inspect(reason)}"
The pipe operator |> threads the previous expression
as the first argument to the next function — readable data transformation
pipelines:
fleet_id
|> Fleet.list_vehicles()
|> Enum.filter(&(&1.status == :active))
|> Enum.map(&Vehicle.to_json/1)
Atoms, tuples, and structs
- Atoms —
:active,:ok; global constants, cheap equality, never garbage-collected (do not generate atoms from user input). - Tuples — fixed-size containers;
{:ok, value}and{:error, reason}are idiomatic result types. - Structs — maps with a defined schema via
defstruct; Ecto schemas and domain models use them. - Modules —
defmodule Harbor.Fleet.Vehicle do ... end; group functions; usealiasandimportsparingly.
Processes, GenServer, and message passing
A process is not an OS thread — it is a BEAM lightweight
actor with its own mailbox and heap. Spawn with spawn/1 or, preferably,
wrap stateful logic in a GenServer (generic server behaviour from OTP):
defmodule Harbor.Fleet.Tracker do
use GenServer
def start_link(fleet_id), do: GenServer.start_link(__MODULE__, fleet_id, name: via_tuple(fleet_id))
def get_position(fleet_id), do: GenServer.call(via_tuple(fleet_id), :get_position)
@impl true
def init(fleet_id), do: {:ok, %{fleet_id: fleet_id, last_fix: nil}}
@impl true
def handle_call(:get_position, _from, state), do: {:reply, state.last_fix, state}
@impl true
def handle_info({:gps_update, fix}, state), do: {:noreply, %{state | last_fix: fix}}
end
GenServer.call is synchronous (waits for reply);
GenServer.cast is fire-and-forget. Processes send messages with
send(pid, msg). Because there is no shared mutable state, race
conditions on in-memory data disappear — coordination happens through
well-defined messages and transactional databases.
Use Registry or Horde for dynamic process naming across clustered nodes. For simple caching, consider Cachex or ETS tables owned by a GenServer rather than external Redis when a single node suffices.
Supervision trees and fault tolerance
OTP Supervisors start child processes and restart them on failure according to a strategy:
- :one_for_one — restart only the crashed child (most web apps).
- :one_for_all — restart all children if one dies (tightly coupled groups).
- :rest_for_one — restart the failed child and those started after it.
children = [
{Harbor.Fleet.Tracker, fleet_id},
{Harbor.Fleet.Publisher, fleet_id}
]
Supervisor.start_link(children, strategy: :one_for_one)
Your Phoenix application starts a supervision tree in
Application.start/2: the endpoint, PubSub, Ecto repo, and custom
workers. Let it crash means: detect failure quickly, isolate blast
radius, restart clean state — do not wrap everything in defensive
try/rescue that hides bugs. Rescue at boundaries (HTTP layer, job
consumers) and log with structured metadata.
Compare with circuit breakers for external HTTP dependencies — supervisors handle internal process health; circuits handle partner API outages.
Mix, Hex, and project layout
Mix is Elixir’s build tool. mix new harbor_fleet
scaffolds a project; mix deps.get fetches Hex packages;
mix test runs ExUnit; mix phx.new generates a Phoenix app.
- Phoenix — web framework: router, controllers, channels, LiveView.
- Ecto — database wrapper: schemas, changesets, migrations, query DSL.
- Oban — Postgres-backed background jobs with uniqueness and cron plugins.
- Telemetry — metrics events consumed by Prometheus exporters.
- Credo / Dialyzer — style linting and static analysis for typespecs.
Standard Phoenix layout: lib/my_app/ domain logic,
lib/my_app_web/ HTTP boundary, priv/repo/migrations,
test/. Keep business rules in plain modules testable without HTTP
— controllers should parse params, call context functions, and render responses.
Phoenix, Ecto, and LiveView
Phoenix routes HTTP and WebSocket traffic. Define routes in
router.ex; controllers return JSON via json/2 or HTML.
Plugs are middleware pipelines — authentication, request IDs,
body parsing — composable like Rack or Express middleware.
defmodule HarborFleetWeb.StatusController do
use HarborFleetWeb, :controller
def show(conn, %{"fleet_id" => id}) do
case Fleet.get_status(id) do
{:ok, status} -> json(conn, status)
{:error, :not_found} -> send_resp(conn, 404, "")
end
end
end
Ecto maps structs to
PostgreSQL
tables. Changesets validate and cast params before insert/update
— never trust raw maps. Migrations version schema; Ecto.Repo.transaction
wraps multi-step writes. Preload associations to avoid N+1 queries;
Ecto.Query composes type-safe SQL.
LiveView renders interactive UIs server-side over a WebSocket — state lives in a process, diffs push to the browser. Ideal for dashboards, admin panels, and forms without a separate SPA. Pair with WebSocket fundamentals when you need custom channel protocols outside LiveView’s lifecycle.
Concurrency, clustering, and Oban jobs
Phoenix handles concurrent requests with one process per connection (or channel).
CPU-bound work should not block the connection process — delegate to a
Task.Supervisor or Oban job. Oban stores jobs in Postgres (no
Redis required), supports unique constraints for idempotency, and
provides cron via Oban.Plugins.Cron.
- PubSub — Phoenix.PubSub broadcasts events to channels and LiveViews; Redis adapter optional for multi-node.
- libcluster — discovers nodes on Kubernetes DNS or EC2 tags for distributed Erlang.
- Tasks —
Task.async_stream/3for bounded parallel I/O with back-pressure. - Telemetry — attach handlers for
[:phoenix, :endpoint, :stop]duration histograms.
For extreme read throughput, add read replicas via Ecto’s put_dynamic_repo/1
or cache hot keys in ETS with TTL — profile before introducing Redis unless
you need cross-node cache invalidation at scale.
Worked example: Harbor Fleet real-time status API
Harbor Fleet tracks delivery vehicles. Operators need a JSON status endpoint and a live map that updates when GPS fixes arrive — without polling overload or dropped connections during deploys.
- Context module —
Harbor.Fleetexposesget_status/1andingest_gps/2; domain logic stays out of controllers. - GenServer tracker — one
Harbor.Fleet.Trackerper fleet via Registry; stores latest positions in memory; crashes and restarts reload from Postgres on init. - HTTP API —
GET /api/v1/fleets/:id/statusreturns JSON from Tracker or Repo fallback; 404 when fleet unknown. - GPS ingest — partners POST signed payloads; verify HMAC plug; cast to Tracker; persist async via Oban worker for audit trail.
- LiveView map — operators subscribe to
PubSubtopic"fleet:#{id}"; Tracker broadcasts{:position, vehicle_id, lat, lng}on each fix. - Supervision — DynamicSupervisor starts Tracker processes on demand; top-level Application supervisor uses
:one_for_one. - Deploy —
mix releasein Docker; rolling updates behind nginx; runmix ecto.migratebefore traffic shift; cluster nodes with libcluster for shared PubSub when horizontally scaled.
Result: sub-10 ms status reads from memory, live map updates without client polling, and a crashed tracker restarts without taking down the entire API.
Language decision table
| Need | Prefer Elixir | Consider instead |
|---|---|---|
| Real-time WebSockets at high concurrency | Yes — Phoenix Channels and LiveView excel | Node.js with Socket.io if team is JS-only |
| Fault-tolerant telephony or IoT ingestion | Yes — OTP supervision is the core strength | Go for simpler ops if crash recovery is manual anyway |
| CRUD admin with minimal JavaScript | Yes — LiveView reduces SPA surface | Django for Python hiring pool |
| CPU-heavy numeric ML training | No — call Python ports or external services | Python with NumPy/PyTorch |
| Greenfield team with no BEAM experience | Maybe — invest in OTP learning curve | Ruby or Go for faster onboarding |
| Embedded firmware on microcontrollers | No | C, Rust, or Nerves for BEAM-on-device niche |
| Existing Erlang system to extend | Yes — seamless interop | Stay on Erlang if codebase is pure Erlang already |
| Batch ETL on terabyte Parquet files | No | Spark, DuckDB, or Python pandas pipelines |
Common pitfalls
- Atom exhaustion from user input —
String.to_atom/1on dynamic strings leaks atoms; useString.to_existing_atom/1or string keys. - Blocking the connection process — long
GenServer.callchains or synchronous HTTP inside channels stall WebSockets; use casts, Tasks, or Oban. - Missing supervision — bare
spawnwithout a supervisor loses state silently; always wire OTP trees. - Changeset skipped validation — inserting raw maps bypasses Ecto constraints; always pipe through
changeset/2. - N+1 queries in JSON APIs — preload associations in context functions before serialization.
- Cluster cookie exposed —
RELEASE_COOKIEmust stay secret; treat distributed Erlang as a trusted network boundary. - Over-using macros — compile-time magic obscures stack traces; prefer functions and behaviours.
- Ignoring back-pressure — unbounded mailboxes on hot processes; monitor queue length and shed load.
- Single-node PubSub in production — LiveView on multiple nodes needs Redis PubSub or PG2 adapter configured.
Production checklist
- Pin Elixir and OTP in CI and Docker; run
mix testand Credo on every pull request. - Use
mix releasewithruntime_toolsfor observer and remote debugging in staging only. - Configure Ecto pool size from expected concurrent DB usage; use connection pooling (PgBouncer) at scale.
- Oban queues with
uniqueperiods on webhook and payment jobs; monitoroban_jobsstuck states. - Structured JSON logging via LoggerJSON; include
request_idfrom Phoenix plugs. - Expose
/health(liveness) and/ready(DB + critical deps) for Kubernetes probes. - Telemetry metrics exported to Prometheus; alert on endpoint latency p99 and BEAM memory.
- Secrets via environment or vault — never commit
config/prod.secret.exs. - Load-test WebSocket fan-out before launch; verify PubSub adapter handles target node count.
- Document OTP supervision tree in README so on-call engineers know what restarts automatically.
Key takeaways
- Elixir runs on BEAM with immutable data, pattern matching, and message-passing processes — designed for fault tolerance, not shared-memory threads.
- OTP supervisors restart failed workers; pair GenServer state with Postgres for durability across crashes.
- Phoenix + Ecto deliver HTTP, WebSockets, and LiveView UIs with Postgres persistence and idiomatic validation via changesets.
- Oban provides Postgres-backed background jobs without a separate Redis queue for many workloads.
- Choose Elixir for real-time, high-connection services and teams willing to learn OTP; prefer other stacks for heavy numeric compute or embedded constraints.
Related reading
- Go fundamentals explained — alternative for simple concurrent HTTP services
- PostgreSQL fundamentals explained — Ecto’s default database and pooling patterns
- WebSockets and SSE explained — transport layer beneath Phoenix Channels
- Docker fundamentals explained — release images and multi-stage BEAM builds