Erlang / Erlang Basics Interview questions
1. What is Erlang?
Erlang is a functional, concurrent programming language built by Ericsson in the late 1980s to run telecom switches that had to stay up for years without a reboot. It compiles to bytecode that runs on its own virtual machine, the BEAM, rather than the OS running Erlang code natively.
The language pairs immutable data and pattern matching with a lightweight process model, so a single Erlang node can juggle hundreds of thousands of concurrent, isolated units of work. Because processes share nothing and talk only through messages, a bug in one process rarely takes down the rest of the system.
Today it powers systems where uptime matters more than raw throughput — messaging platforms like WhatsApp, telecom signaling, and financial trading gateways are common examples.
2. What is the BEAM virtual machine?
The BEAM (Bjorn's Erlang Abstract Machine) is the runtime that executes compiled Erlang bytecode. It is not a general JIT like the JVM's HotSpot; instead it is tuned specifically for running huge numbers of tiny, isolated processes.
Key jobs the BEAM handles for you:
- Scheduling millions of lightweight processes across OS threads
- Per-process garbage collection, so one process pausing to collect never stalls another
- Preemptive scheduling based on reduction counts, so no single process can hog a core
- Loading and swapping compiled modules while the system keeps running
Because each process has its own small heap and stack, BEAM garbage collection pauses are typically measured in microseconds and affect only that one process, not the whole node.
3. What are processes in Erlang?
An Erlang process is the basic unit of concurrency — not an OS process, but a lightweight, BEAM-managed unit with its own stack, heap, and mailbox. Spawning one costs only a few hundred bytes and microseconds, so it's normal for a running system to have tens of thousands of them at once.
Processes share no memory. The only way for two of them to interact is by sending messages, which get copied into the receiver's mailbox. This isolation is what makes one process crashing harmless to everything else.
Pid = spawn(fun() -> io:format("hello from a process~n") end).
spawn/1 returns a PID immediately; the function body runs concurrently and independently
of the caller.
4. What is the actor model in Erlang?
The actor model treats every concurrent unit as an actor: an isolated entity that has its own state, receives messages one at a time from a mailbox, and reacts by updating its own state, sending messages to other actors, or spawning new ones. Erlang's process model is a direct, production-grade implementation of this idea.
Three properties make it work in practice:
- Isolation — no shared mutable state between actors
- Asynchronous messaging — sends never block the sender
- Location transparency — sending to a PID looks the same whether the actor is local or on another node
Because there's no shared memory to lock, there are no mutexes or deadlocks between actors — the classic concurrency bugs of thread-and-lock programming simply don't apply at this level.
5. What are atoms in Erlang?
An atom is a constant whose only value is its own name — think of it as a symbolic label rather
than a variable. Atoms are written lowercase (ok, error, undefined) or
quoted if they contain spaces or start with a capital ('My Atom').
They're used everywhere as tags: function results like {ok, Value} or
{error, Reason}, process states, and message types all lean on atoms so the receiving code can
pattern-match on a clear, self-documenting label instead of a raw number or string.
Atoms are stored once in a global atom table and compared by reference, so matching on them is essentially an integer comparison — fast, regardless of how long the atom's name is.
6. What are tuples in Erlang?
A tuple is a fixed-size, ordered container written with curly braces, like
{ok, 42} or {point, 3, 5}. Once created, its size and the position of each element
never change — you build a new tuple rather than mutate an existing one.
Tuples are the standard way to bundle a small, known number of related values, especially as tagged return values:
{ok, Result} = file:read_file("data.txt"). {error, Reason} = file:read_file("missing.txt").
Because the shape is fixed, pattern matching against a tuple is a compile-time-checkable, constant-time operation — the runtime just checks the tag and unpacks the known positions, unlike walking a list.
7. What are lists in Erlang?
A list is a variable-length, ordered sequence written in square brackets, such as
[1, 2, 3] or [apple, banana]. Internally it's a singly linked chain of cons cells
— each cell holds a head element and a pointer to the rest of the list, ending in the empty list
[].
That linked structure is why prepending is cheap and appending to the end is not:
[Head | Tail] = [1, 2, 3], % Head = 1, Tail = [2, 3] New = [0 | [1, 2, 3]]. % O(1) prepend -> [0,1,2,3]
Because lists have no fixed length, they're the natural fit for sequences of unknown or varying size, while tuples suit fixed-shape data. Most list processing in Erlang is written recursively, walking the head/tail structure one element at a time, or via list comprehensions.
8. What is pattern matching in Erlang?
Pattern matching is Erlang's core mechanism for both destructuring data and controlling program flow. The
= operator isn't assignment in the usual sense — it asserts that the left-hand pattern must
match the right-hand value, binding any unbound variables in the process.
{ok, Value} = {ok, 42}, % Value becomes 42 {ok, Value2} = {error, oops} % fails: patterns don't match -> exception
The same mechanism drives function clause selection and case expressions: Erlang tries each
clause's pattern in order and runs the first one that matches, extracting variables as it goes.
describe(0) -> "zero"; describe(N) when N > 0 -> "positive"; describe(_) -> "negative".
This removes the need for most manual type-checking or field-extraction code you'd otherwise write by hand.
9. Define immutability in Erlang?
In Erlang, once a variable is bound to a value it can never be reassigned within that scope — there is no mutation, only rebinding through a new variable name or a new function call. This applies to every data structure: lists, tuples, and maps are all persistent; "changing" one actually builds a new copy with the edit applied.
X = 5, X = 6. %% fails: X is already bound to 5, this is NOT reassignment
Immutability removes an entire category of bugs — no function can silently corrupt data another function is holding a reference to, and no two processes can race over the same mutable cell, since there isn't one.
The tradeoff is that "updating" a large structure means allocating a new version of it, so Erlang leans on structural sharing and per-process garbage collection to keep that cheap in practice.
10. What is a PID?
A PID (process identifier) is the opaque reference every Erlang process gets when it's spawned. You
can't inspect or construct one by hand — you receive it from spawn/1 (or a related
function) and use it as the destination for messages.
Pid = spawn(fun loop/0), Pid ! {greet, "hi there"}.
PIDs work the same whether the target process lives on the local node or on a remote one in a distributed Erlang cluster — sending a message to a remote PID looks identical to sending to a local one, which is what gives Erlang its location-transparent messaging.
A PID remains valid only for the life of that process; once the process exits, the PID becomes a dead reference and sending to it is silently a no-op (unless the sender is linked or monitoring it).
11. What is a module in Erlang?
A module is Erlang's unit of code organization — a single .erl file compiling to
one unit that groups related functions under a shared name. The file starts with a -module(Name).
declaration matching the filename, and an -export([...]). attribute listing which functions
outside code may call.
-module(math_utils). -export([square/1]). square(N) -> N * N.
Functions not listed in -export are private to the module. Calling a function in another
module uses the Module:Function(Args) syntax, e.g. math_utils:square(4).
Modules can also be hot-swapped: the BEAM can load a new version of a module into a running system while old processes still executing the previous version finish naturally, which underpins Erlang's live upgrade story.
12. What are guards used for in Erlang?
A guard is an extra boolean condition attached to a function clause or case branch with the
when keyword. It runs after the pattern matches structurally, letting you filter on values rather
than just shape.
classify(N) when N < 0 -> negative; classify(0) -> zero; classify(N) when N > 0 -> positive.
Guards are intentionally restricted to a small set of safe, side-effect-free built-ins (comparisons,
arithmetic, type checks like is_integer/1, and a few guard-only BIFs) — you cannot call
arbitrary functions in a guard. This restriction guarantees a guard can never crash or block, so the runtime can
always safely evaluate it while deciding which clause to run.
13. What is OTP?
OTP (Open Telecom Platform) is the standard library and set of design principles bundled with Erlang for building reliable systems. It's not a separate product — it ships with the language and supplies battle-tested behaviors (reusable process templates) so teams stop reinventing server loops, supervision trees, and finite state machines by hand.
The core building blocks include:
gen_server— generic client/server processsupervisor— monitors and restarts child processesgen_statem— generic finite state machineapplication— packaging and lifecycle for a deployable unit
Using OTP behaviors means your business logic plugs into a well-tested process skeleton instead of hand-rolling message loops, which is why virtually all production Erlang systems are built on top of it.
14. What is a gen_server?
gen_server is the OTP behavior for the common client/server pattern: a process that holds
state and responds to requests. Instead of writing your own receive loop, you implement a small set of
callbacks and the behavior handles the process mechanics.
init(Args) -> {ok, State}. handle_call(Request, From, State) -> {reply, Reply, NewState}. handle_cast(Msg, State) -> {noreply, NewState}.
handle_call/3 is for synchronous requests where the caller waits for a reply;
handle_cast/2 is for fire-and-forget messages. Callers interact through
gen_server:call/2 and gen_server:cast/2 rather than sending raw messages, which keeps
the protocol consistent and lets OTP add timeouts, tracing, and supervision for free.
15. What is a supervisor?
A supervisor is an OTP process whose only job is to start, monitor, and restart a set of child processes according to a restart strategy. It doesn't do business logic itself — it exists purely to keep its children alive.
init([]) -> {ok, {{one_for_one, 5, 10}, [{worker1, {my_worker, start_link, []}, permanent, 5000, worker, [my_worker]}]}}.
Common strategies include one_for_one (restart only the child that died),
one_for_all (restart every sibling if one dies), and rest_for_one (restart the failed
child and any children started after it). Chaining supervisors into a tree, with workers as leaves, is what
gives Erlang systems their self-healing behavior after a crash.
16. Describe the "let it crash" philosophy?
"Let it crash" means Erlang code generally doesn't try to defensively handle every possible failure inline. Instead, when a process hits an unexpected state, it's allowed to terminate, and a supervisor detects that exit and restarts it fresh.
The reasoning: trying to anticipate and recover from every edge case inline bloats code and often masks bugs
by leaving the process in a half-corrupted state. Restarting from a clean init/1 is usually safer
and simpler than trying to patch a process back to a known-good state after something goes wrong.
This doesn't mean errors are ignored — it means error handling is separated from business logic and pushed up to a supervisor, which is exactly what supervision trees are designed for. It's why gen_server callbacks are often written for the "happy path" and let genuinely exceptional input simply crash the process.
17. What are records in Erlang?
A record is a compile-time convenience for naming the fields of a tuple, so you write
Person#person.name instead of remembering that the name sits at tuple position 2. Under the hood a
record is still a plain tuple with the record name as its first element.
-record(person, {name, age = 0}). P = #person{name = "Ada", age = 34}, Age = P#person.age.
Records are defined with -record(Name, {Field1, Field2 = Default, ...}), usually in a header
file so multiple modules can share the definition. Because they compile down to tuple access, using records
costs nothing at runtime over a raw tuple — the benefit is purely readability and safer field access at
compile time.
18. What is message passing?
Message passing is how Erlang processes exchange information, since they share no memory. The
! operator sends a message asynchronously into the target process's mailbox, and
receive pulls a matching message out of the caller's own mailbox.
Pid ! {self(), {add, 2, 3}}, receive {ok, Result} -> Result after 5000 -> timeout end.
Sending never blocks the sender, even if the receiving process is busy — the message just waits in the
mailbox. receive scans the mailbox for the first message matching one of its patterns, skipping
over (but not discarding) messages that don't match, and can specify an after clause to avoid
waiting forever.
19. What is hot code loading?
Hot code loading (or hot code swapping) is the BEAM's ability to load a new version of a module into a running system without stopping it. The runtime can keep two versions of a module in memory at once: the old version, still running for processes mid-execution, and the new version for anything freshly called.
Processes that were running old code keep running it until they make a fully-qualified call back into their
own module (e.g. ?MODULE:loop(State)), at which point they switch to the new version. This
"purge on next reference" model is why long-running server loops are typically written to explicitly call
back into themselves.
This capability is what let early telecom switches be patched for bug fixes without dropping active calls, and it's still used today for rolling upgrades in systems that can't tolerate downtime.
20. What are binaries/bitstrings in Erlang?
A bitstring is a sequence of raw bits; a binary is the common special case whose length is a multiple of 8 bits (whole bytes). They're the go-to type for network data, files, and any bulk binary payload, written between double angle brackets.
Bin = <<1, 2, 3>>, <<A:8, B:8, Rest/binary>> = Bin. %% A = 1, B = 2, Rest = <<3>>
The bit-syntax pattern above is one of Erlang's standout features: you can match and slice binary protocol fields — fixed widths, variable-length trailing sections, even non-byte-aligned bit widths — directly in a pattern, without manual bit-shifting code.
Binaries over 64 bytes are also reference-counted and stored off the process heap, so passing a large binary between processes doesn't require copying its contents, only the reference.
21. What is ETS?
ETS (Erlang Term Storage) is an in-memory table built into the BEAM for storing large amounts of data that any process can read or write directly, bypassing normal message passing. It's how Erlang handles shared state that would be awkward to keep inside a single process's mailbox loop.
Tab = ets:new(my_table, [set, public]), ets:insert(Tab, {key1, "value1"}), ets:lookup(Tab, key1). %% -> [{key1, "value1"}]
Tables come in four flavors — set, ordered_set, bag, and
duplicate_bag — controlling whether keys are unique and whether entries are ordered. Access
is close to O(1) for set/bag tables since they're hash-based, which makes ETS a
common choice for caches and lookup tables shared across many processes.
22. What is Mnesia?
Mnesia is Erlang's built-in distributed database, layered on top of ETS/DETS storage. Unlike ETS, Mnesia adds transactions, replication across nodes, and optional disk persistence, so multiple nodes in a cluster can share and safely update the same tables.
mnesia:create_table(person, [{attributes, record_info(fields, person)}]), mnesia:transaction(fun() -> mnesia:write(#person{name = "Ada", age = 34}) end).
Because table rows are ordinary Erlang records/terms and queries are written in Erlang itself (or via
mnesia:select/2 match specifications), there's no separate query language to learn the way SQL
sits apart from application code.
It fits well for cluster-local configuration and session data where you want ACID-ish transactions without standing up an external database, though very large datasets or complex relational queries are usually better served by a dedicated external database.
23. What are list comprehensions?
A list comprehension builds a new list by describing it declaratively: a result expression, a generator that walks a source list, and optional filters, all in one line, instead of writing an explicit recursive function.
Squares = [X * X || X <- [1, 2, 3, 4], X rem 2 =:= 0]. %% Squares = [4, 16]
Reading that right to left: take each X from the list, keep only the ones where
X rem 2 =:= 0 (the filter), then apply X * X to build the result. Multiple generators
and filters can be chained, and comprehensions can also build binaries with << ... ||
syntax.
They're commonly reached for whenever you'd otherwise write a small "map + filter" recursive helper, since the intent reads clearly at a glance.
24. What is a behavior in Erlang?
A behavior is a formalized process pattern: OTP defines the generic, reusable parts (the message loop, error handling, standard API), and you supply a callback module that fills in the domain-specific parts. It's conceptually close to an interface plus a template method pattern from OOP.
-module(my_server). -behaviour(gen_server). init(Args) -> {ok, Args}. handle_call(_Req, _From, State) -> {reply, ok, State}.
The -behaviour(gen_server) attribute tells the compiler which callbacks your module must
export, and it will warn you at compile time if one is missing. The built-in behaviors are
gen_server, gen_statem, gen_event, and supervisor, though
you can also define custom behaviors for your own reusable patterns.
25. What are ports in Erlang?
A port is Erlang's built-in mechanism for talking to an external OS process or native code, treated from the Erlang side just like another process you can send messages to and receive messages from. It's the standard, safe way to shell out to a C program, a script, or any external executable.
Port = open_port({spawn, "sort"}, [binary]), Port ! {self(), {command, <<"banana\napple\n">>}}.
Communication happens over the external program's standard input/output, with Erlang handling the byte framing. Crucially, if the external program crashes, only the port dies — it cannot corrupt or crash the BEAM itself, which is what makes ports a safer integration point than loading foreign code directly in-process.
26. What is a NIF (Native Implemented Function)?
A NIF is a function implemented in C (or another native language) and loaded directly into the BEAM, callable from Erlang exactly like an ordinary function. It's used when pure Erlang is too slow for a specific hot path, such as heavy numeric or cryptographic work.
-module(math_nif). -export([fast_add/2]). -on_load(init/0). init() -> erlang:load_nif("./math_nif", 0). fast_add(_A, _B) -> erlang:nif_error(not_loaded).
The catch: a NIF runs inside the scheduler thread, not as a separate lightweight process, so a slow or crashing NIF can stall that scheduler or even bring down the entire node — it doesn't get the isolation a regular Erlang process gets. Because of that risk, NIFs are meant for short, fast operations, with long-running native work usually handed off to a port or a dirty scheduler instead.
27. How do you spawn a process in Erlang?
The base function is spawn/1 (or spawn/3 for a module/function/args form), which
starts a new process running the given function and immediately returns its PID to the caller — there's
no waiting for it to finish.
Pid1 = spawn(fun() -> loop(0) end), Pid2 = spawn(counter, loop, [0]).
In practice, raw spawn/1 is rarely used directly in production code. Instead, teams reach for
spawn_link/1 (to tie the new process's failure to the caller's), or more commonly
gen_server:start_link/3, so the new process is registered with a supervisor and follows OTP's
standard startup contract instead of being an unsupervised, untracked process.
28. How does Erlang achieve concurrency without shared memory?
Instead of multiple threads reading and writing the same memory region, each Erlang process owns its own private heap and stack, and the only channel between processes is copying messages into a mailbox. There's nothing to lock because there's nothing shared to race over.
Concretely, the BEAM's scheduler runs many lightweight processes across a small pool of OS threads (usually one per core), switching between them based on a reduction counter rather than OS-level time slicing. Since each process's data is private, the scheduler can safely preempt and resume any process without worrying about partial writes another process might see.
The cost of this design is that sharing large data between processes means copying it (unless it's a reference-counted off-heap binary), which is a deliberate trade: safety and fault isolation over the raw throughput of shared-memory threading.
29. What is the difference between spawn and spawn_link?
Both start a new process and return its PID, but they differ in what happens when that new process terminates abnormally.
| spawn | spawn_link |
| Caller and new process are independent; if the child crashes, the caller is unaffected. | Caller and new process are linked; if either crashes, the other receives an exit signal too. |
| You must set up monitoring manually to be notified of a crash. | By default, an abnormal exit propagates and kills the linked process unless it's trapping exits. |
Because a crash silently propagating can be exactly what you want (fail the whole group together) or exactly
what you don't (an isolated worker), OTP supervisors use spawn_link internally combined with
process_flag(trap_exit, true), so the supervisor is notified of the crash instead of being killed
by it.
30. What is the difference between a list and a tuple?
Both are ordered Erlang data structures, but they're built and used very differently.
| List | Tuple |
| Variable length; a linked chain of head/tail cons cells. | Fixed length, decided at creation; contiguous positions. |
Cheap to prepend to ([X|List]); processed recursively. |
Cheap to access any known position directly, e.g. element(2, T). |
| Good for sequences of unknown/variable size. | Good for fixed-shape records like {ok, Value}. |
A rule of thumb: if you don't know how many elements you'll have, or the count will vary, use a list. If the shape and number of fields is fixed and known up front, a tuple (often tagged with a leading atom) is the better fit.
31. Why is Erlang considered fault-tolerant?
Fault tolerance in Erlang comes from combining process isolation with supervision, not from trying to prevent every possible bug. Because each process has its own heap and crashes independently, a failure in one part of the system doesn't corrupt shared state or take down unrelated processes.
On top of that isolation, supervisors watch for process exits and restart them according to a defined strategy, so the system as a whole recovers automatically from failures that would otherwise require manual intervention. This is often summarized as designing for failure rather than around it — the system expects things to crash and has a built-in recovery path, instead of assuming failures can all be prevented.
This combination is why Erlang systems can report "nine nines" style uptime figures — not because the code never has bugs, but because individual bugs stay contained and self-heal quickly.
32. How does pattern matching differ from equality comparison?
Equality comparison (== or =:=) asks a yes/no question: are these two already-known
values the same? Pattern matching (=, function clauses, case) does more — it
can also bind unbound variables as a side effect of a successful match, and it can check structural
shape rather than a single scalar value.
5 =:= 5. %% true, plain equality {ok, X} = {ok, 42}. %% match succeeds AND binds X = 42 {ok, X} = {error, _}. %% match fails (badmatch), not just "false"
Another subtlety: a failed equality check just evaluates to false, while a failed pattern match
(outside a guarded context) raises a badmatch exception. That's why pattern matching is used for
control flow — a mismatch is treated as an exceptional condition, not a normal boolean outcome.
33. When should you use ETS instead of process state?
Keeping data inside a single process's state works fine when only that process needs it, or when access is naturally serialized through that process's mailbox. ETS becomes the better fit once many processes need concurrent read/write access to the same data, because going through one process's mailbox would turn it into a bottleneck that every reader has to queue behind.
Typical ETS use cases: a shared cache read by hundreds of request-handling processes, a session table, or a routing table that changes occasionally but is read constantly. Process state is the better fit when the data is truly private to one actor's logic, or when you want writes serialized one-at-a-time as a deliberate correctness guarantee (e.g. a bank account balance).
The tradeoff: ETS reads/writes bypass message-passing isolation, so multiple processes can race on the same
key unless you design your access pattern (or use ets:update_counter/3 style atomic ops)
carefully.
34. How do you handle errors in Erlang without try/catch?
Erlang's idiomatic style leans on tagged return values for expected, recoverable failure conditions,
reserving exceptions (and try/catch) for genuinely unexpected situations. A function that can
fail in an "ordinary" way typically returns {ok, Value} or {error, Reason}, and the
caller pattern-matches on the outcome.
case file:read_file("config.txt") of {ok, Data} -> process(Data); {error, Reason} -> log_error(Reason) end.
This keeps the "unhappy path" visible in the type of the return value rather than hidden in a control-flow jump, and it composes naturally with pattern matching in function clauses. Genuinely exceptional, programmer-error-style failures (a failed match, a bad arithmetic operation) are left to crash the process and get handled by a supervisor instead — which is the "let it crash" side of Erlang's error strategy.
35. Why doesn't Erlang have mutable variables?
Erlang was designed around concurrent, isolated processes from day one, and mutable shared variables are exactly what makes concurrent programming dangerous in most other languages — race conditions, torn reads, and the need for locks all stem from two threads touching the same mutable cell.
By making every binding permanent once set, Erlang removes that entire hazard class at the language level: there's no mutable cell for two processes to race over, so there's nothing to protect with a mutex. It also makes reasoning about a function's behavior simpler, since a variable's value can't have silently changed between two lines that reference it.
The design does shift work elsewhere: "updating" a value means creating a new binding (often via recursion carrying an accumulator, or storing the "current" version in ETS/process state), rather than assigning in place. That's a deliberate trade of some ergonomic convenience for much simpler concurrent reasoning.
36. What is the difference between gen_server and gen_statem?
Both are OTP behaviors for a stateful process, but they model that state differently.
| gen_server | gen_statem |
| State is an arbitrary term you pass between callbacks; behavior is the same regardless of what that term holds. | State is explicitly modeled as one of a set of named states, with transitions defined between them. |
| Good fit when logic is mostly "handle this request, update some data." | Good fit when the process genuinely moves through distinct modes, e.g. connecting → authenticating → ready. |
You can hand-roll a state machine inside a gen_server using a state-tagged tuple and a big
case, but as the number of states and valid transitions grows, gen_statem's explicit
state/event/action model tends to keep the logic easier to follow and harder to get into an invalid
transition.
37. How do you implement a simple supervision tree?
A supervision tree starts with a top-level supervisor whose init/1 callback
declares a restart strategy and a child specification list; each child can itself be a worker or another
supervisor, nesting arbitrarily deep.
init([]) -> SupFlags = #{strategy => one_for_one, intensity => 5, period => 10}, Children = [ #{id => cache_worker, start => {cache_worker, start_link, []}, restart => permanent, type => worker} ], {ok, {SupFlags, Children}}.
Starting the top supervisor with supervisor:start_link/3 automatically starts every declared
child in order. If cache_worker crashes, the one_for_one strategy restarts just that
child (up to 5 times per 10 seconds, per intensity/period); exceeding that threshold
escalates the crash to the supervisor's own parent.
38. What happens when a linked process crashes?
When a linked process terminates abnormally, the BEAM sends an exit signal carrying the exit reason to every process it's linked to. What happens next depends on whether the receiving process is trapping exits.
process_flag(trap_exit, true), Pid = spawn_link(fun() -> exit(boom) end), receive {'EXIT', Pid, Reason} -> io:format("child died: ~p~n", [Reason]) end.
By default, a process receiving a non-normal exit signal also terminates with that same reason, propagating
the failure outward — which is exactly how a crash in one worker can cascade up to a supervisor. If the
receiving process has called process_flag(trap_exit, true), the exit signal instead arrives as an
ordinary {'EXIT', Pid, Reason} message it can pattern-match on and react to, which is precisely
how supervisors detect child crashes without being killed by them.
39. When would you choose Mnesia over ETS?
Reach for Mnesia when data needs to survive beyond a single node, or beyond a single BEAM restart, or must be updated as part of a transaction spanning multiple tables. Reach for plain ETS when the data is node-local, disposable if the node restarts, and speed matters more than durability or cross-node consistency.
| ETS | Mnesia |
| In-memory only, single node. | Optional disk persistence, replicated across cluster nodes. |
| No built-in transactions across keys/tables. | ACID-style transactions across multiple tables. |
| Slightly lower overhead per operation. | Extra overhead for transaction coordination and replication. |
A common pattern is actually using both: ETS as a fast local cache in front of Mnesia's authoritative, replicated copy, refreshing the cache when the underlying data changes.
40. How do you use the observer tool to inspect a running system?
observer is OTP's built-in GUI for peering into a live BEAM node: process list with memory and
message queue length per process, supervision tree visualization, ETS table browser, and application
overview, all without adding any extra code to the system being inspected.
1> observer:start().
Running that in a shell attached to (or connected to) the node you want to inspect opens the GUI. The Applications tab is often the fastest way to spot trouble: it renders the live supervision tree, so a worker stuck restarting in a crash loop, or a process with a mailbox that keeps growing, is visible at a glance rather than something you have to dig for with ad hoc shell commands.
For headless/remote servers without a GUI, the same information is available via shell functions like
i/0, process_info/2, and ets:i/0, or via the newer
observer_cli terminal-based alternative.
41. Explain the execution flow of a gen_server call?
gen_server:call/2 looks like a normal function call, but underneath it's a request/reply
protocol built out of plain message passing plus a monitor for safety.
sequenceDiagram
participant Caller
participant GenServer as gen_server process
Caller->>GenServer: {'$gen_call', {Caller,Ref}, Request}
Note over GenServer: handle_call/3 runs
GenServer-->>Caller: {Ref, Reply}
Note over Caller: call/2 unwraps Reply and returns it
Step by step: the caller sends a tagged message containing its own PID and a fresh unique reference, then
sets up a monitor on the server and blocks in a receive waiting for a reply tagged with that same
reference (with a default 5-second timeout). The server's handle_call/3 callback runs, returning
{reply, Reply, NewState}, and the framework sends Reply back tagged with the original
reference. The monitor exists so that if the server dies mid-call, the caller gets a 'DOWN' message
instead of hanging forever.
42. Explain the internal working of the BEAM scheduler?
The BEAM typically runs one scheduler thread per CPU core, and each scheduler maintains its own run queue of processes ready to execute. Rather than relying on OS-level time slicing, the BEAM preempts processes based on a reduction count — roughly one reduction per function call/operation — giving each process a budget (around 2000 reductions) before it's paused and put back at the end of the run queue.
flowchart LR
A[Run queue: Process A, B, C] --> B[Scheduler picks Process A]
B --> C{Reduction budget exhausted or process waits?}
C -->|Yes| D[Process A suspended, requeued]
C -->|No, finished naturally| E[Process A removed from queue]
D --> A
This makes scheduling fair and predictable regardless of what an individual process does — a process stuck in a tight loop can't starve others, since it gets preempted at the reduction boundary just like everyone else. Schedulers also periodically rebalance work across cores via work stealing, so a scheduler that runs out of ready processes can pull some from a busier neighbor's queue instead of sitting idle.
43. How can you optimize message passing between heavy processes?
The main cost in Erlang message passing is copying the message into the receiver's mailbox, so optimization mostly means reducing what gets copied and how often.
- Use off-heap binaries for large payloads — binaries over 64 bytes are reference-counted, so passing one only copies a small reference, not the data itself.
- Batch messages instead of sending many tiny ones; fewer, larger messages amortize per-message scheduling and copy overhead.
- Avoid large process state that gets copied unnecessarily during garbage collection triggered by message arrival.
- Watch mailbox growth — a slow consumer with a growing mailbox means the sender is
outpacing the receiver; back-pressure (e.g. via
gen_server:callinstead ofcast) can force the sender to wait rather than flood the queue.
For very hot paths, keeping data in ETS and passing only a key/reference between processes avoids the message-copy cost entirely, at the price of losing per-message isolation for that data.
44. Explain the lifecycle of a supervised process?
A supervised worker follows a well-defined path from start to eventual restart or shutdown, orchestrated entirely by its supervisor.
stateDiagram-v2
[*] --> Starting: supervisor calls start_link
Starting --> Running: init/1 returns {ok, State}
Running --> Crashed: unhandled error / exit
Running --> Terminating: normal shutdown requested
Crashed --> Starting: supervisor restarts per strategy
Crashed --> GivingUp: restart intensity exceeded
Terminating --> [*]
GivingUp --> [*]: supervisor itself terminates/escalates
On start_link, the supervisor blocks until init/1 replies (so a failed startup is
caught immediately, not silently). Once running, the process handles calls/casts/info messages until it either
shuts down normally, or crashes. A crash triggers the configured restart strategy
(one_for_one/one_for_all/rest_for_one); if crashes happen faster than the
configured intensity/period allows, the supervisor gives up and escalates the failure to its own parent instead
of looping forever.
45. How do you troubleshoot process mailbox overflow?
A growing mailbox means a process is receiving messages faster than it processes them, which if left unchecked can exhaust memory and eventually crash the node. The first step is confirming which process(es) are affected.
Pid = whereis(my_worker), {message_queue_len, Len} = process_info(Pid, message_queue_len). %% or, node-wide, via recon: recon:proc_count(message_queue_len, 5).
Once you've identified the culprit, common causes and fixes include:
- Slow receive loop — the process is doing too much per message; profile and optimize the hot path, or offload work to helper processes.
- Unbounded producer — switch producers from
casttocallso they block and naturally throttle instead of flooding the mailbox. - Selective receive scanning cost — a
receivewith a narrow pattern has to skip past unmatched messages already in the queue; a mismatched, ever-growing backlog of unrelated messages makes each receive progressively slower.
Tools like observer's process view, or the recon library's
proc_count/2, make it straightforward to spot the offending process across a whole running node
rather than checking processes one at a time.
46. What is the difference between synchronous and asynchronous message passing in OTP?
Both ultimately use the same underlying ! primitive, but OTP's gen_server:call and
gen_server:cast wrap it with very different guarantees about waiting for a result.
| call (synchronous) | cast (asynchronous) |
| Caller blocks until a reply arrives or it times out. | Caller sends and continues immediately; no reply is expected. |
| Caller gets a monitor, so a server crash surfaces as an error rather than an infinite wait. | No monitor by default; if the message is lost or the server crashes, the caller has no way to know. |
| Naturally provides back-pressure — a slow server slows down its callers. | No back-pressure; a fast producer can flood a slow server's mailbox. |
The rule of thumb: use call when the caller needs a result or needs to know the operation
actually happened; use cast for fire-and-forget notifications where losing one occasionally, or
not knowing immediately if the target is alive, is acceptable.
47. Which is better for state management, ETS or process dictionaries, and why?
A process dictionary (put/2, get/1) is per-process mutable key-value
storage that bypasses Erlang's usual immutability, private to that one process only. ETS is a separate
in-memory table that can be shared and accessed concurrently across many processes.
ETS is generally the better choice whenever more than one process needs the data, since the process
dictionary is invisible outside its owning process and can't be shared without message passing anyway. Even
within a single process, most experienced Erlang developers avoid the process dictionary except for narrow
cases (some tracing/debugging helpers, or caching a value purely local to that process's own logic), because
it silently breaks the functional, traceable style the rest of the language encourages — a function
reading get(some_key) has a hidden dependency that isn't visible in its arguments.
So: ETS for anything shared or sizeable; process dictionary only for small, genuinely process-local state where passing it explicitly through function arguments would be needlessly awkward.
48. How does distributed Erlang handle node failures?
Distributed Erlang connects multiple BEAM nodes into a cluster where PIDs, links, and monitors all work transparently across node boundaries — sending to a remote PID looks identical to sending locally. Node health is tracked through a mesh of TCP connections, each node maintaining a heartbeat with the others.
sequenceDiagram
participant A as Node A
participant B as Node B
A->>B: monitor_node(B, true)
Note over A,B: Heartbeat over TCP
B--xA: Connection lost / node down
A->>A: receives {nodedown, 'B@host'}
When a remote node becomes unreachable (crash, network partition, or explicit shutdown), any local process
that called monitor_node/2 on it receives a {nodedown, Node} message, and any
processes linked to PIDs on that node get exit signals just as if those remote processes had crashed locally
— the same link/monitor semantics that work within one node extend across the cluster.
Because Erlang can't distinguish "the remote node crashed" from "the network partitioned," distributed systems built on it still need an explicit strategy (e.g. quorum-based tools, or accepting eventual reconciliation) for split-brain scenarios — the platform gives you the failure notification, not a built-in resolution to network partitions.
