Erlang / Erlang Advanced Interview questions
1. What is the difference between linking and monitoring a process?
Both let one process find out when another terminates, but they differ in direction and severity of the notification. A link is bidirectional and, by default, fatal: if either linked process crashes, the other receives an exit signal and dies too, unless it's trapping exits.
A monitor (erlang:monitor(process, Pid)) is unidirectional and never fatal: only the
monitoring process is notified, and that notification always arrives as an ordinary
{'DOWN', Ref, process, Pid, Reason} message rather than an exit signal that could kill it.
| Link | Monitor |
| Bidirectional; both sides affect each other. | Unidirectional; only the caller is notified. |
| Default behavior can kill the other side. | Never kills anything; always a message. |
| Typically used between a supervisor and its children. | Typically used by a client that needs to know a server died, without being tied to its lifecycle. |
2. Why would you choose monitor over link for a client process?
A client that calls into a server it doesn't own — say, a request handler calling a shared cache
process — usually shouldn't die just because that server crashed. Using monitor/2 instead
of link/1 gets you the crash notification without coupling the client's own lifecycle to the
server's.
Ref = erlang:monitor(process, ServerPid), ServerPid ! {self(), get_data}, receive {'DOWN', Ref, process, ServerPid, Reason} -> handle_server_down(Reason); {ok, Data} -> erlang:demonitor(Ref, [flush]), Data end.
Links are the right tool when the two processes are meant to live and die together — a supervisor and
its worker, or two halves of one logical unit. Monitors are the right tool for arm's-length relationships,
like gen_server:call's internal use of a monitor so a caller learns about a dead server without
risking being killed by it.
3. What is a dirty scheduler and when should a NIF use one?
A regular NIF runs inline on a normal BEAM scheduler thread, which is fine for short operations but dangerous for anything that blocks or takes more than roughly a millisecond, since it stalls that entire scheduler and every process waiting behind it. Dirty schedulers are a separate pool of OS threads reserved for long-running native work, so it doesn't compete with the normal schedulers running lightweight Erlang processes.
There are two dirty scheduler pools:
- Dirty CPU schedulers — for CPU-intensive native computation (heavy math, compression).
- Dirty I/O schedulers — for blocking I/O operations (file access, slow syscalls).
A NIF opts in by declaring itself with ERL_NIF_DIRTY_JOB_CPU_BOUND or
ERL_NIF_DIRTY_JOB_IO_BOUND in its registration table, and the runtime routes calls to that function
onto the appropriate dirty pool instead of a regular scheduler.
4. Explain the internal working of Erlang's per-process garbage collector?
Each Erlang process has its own private heap, so garbage collection happens independently, one process at a time, rather than as one global stop-the-world pause across the whole node. When a process's heap fills up (typically triggered by a message arriving or an allocation), the BEAM runs a generational collector scoped to just that process.
flowchart LR
A[Process heap fills] --> B[Minor GC: copy live young data]
B --> C{Data survives multiple minor GCs?}
C -->|Yes| D[Promoted to old generation]
C -->|No| E[Reclaimed]
D --> F[Old generation collected less often]
Young, short-lived data gets collected frequently and cheaply (minor GC); data that survives several collections is promoted to an older generation that's swept less often, on the assumption that data still alive after multiple passes is likely to stay alive. Because each process's heap is small and private, these pauses are usually sub-millisecond and invisible to every other process on the node.
5. Why does Erlang use generational (per-process) garbage collection instead of a single global GC?
A single global collector would need to pause every process on the node at once to safely scan and compact memory, which directly conflicts with Erlang's goal of soft real-time responsiveness — a telecom switch or messaging backend can't tolerate a multi-hundred-millisecond global pause just because one process allocated too much data.
By giving each process its own private heap, the BEAM can collect one process without touching anyone else's memory or execution, keeping collection pauses small, local, and predictable. The generational split (young vs old data) further reduces work per collection, since most allocations in typical Erlang code are short-lived (temporary variables inside a function call), so scanning only the young generation most of the time is far cheaper than rescanning everything.
The tradeoff is that shared, cross-process references aren't possible the way they are in a shared-heap runtime — which is exactly why message passing between processes has to copy data rather than share a pointer.
6. What is EPMD and what role does it play in distributed Erlang?
EPMD (Erlang Port Mapper Daemon) is a small, lightweight process that runs once per host and acts as a name-to-port lookup service for Erlang nodes on that machine. Each node, on startup, registers its name and the TCP port it's listening on with the local EPMD; other nodes trying to connect first ask EPMD "what port is node X on?" before opening the actual inter-node connection.
sequenceDiagram
participant NodeA
participant EPMD
participant NodeB
NodeB->>EPMD: register(nodeb, Port)
NodeA->>EPMD: lookup(nodeb)
EPMD-->>NodeA: Port
NodeA->>NodeB: connect on Port
EPMD itself carries no message traffic between nodes — it's purely a directory service, running on a well-known port (4369) so nodes only need to know a hostname, not a specific port, to find each other. Losing EPMD after nodes are already connected doesn't drop those connections; it only affects new nodes trying to discover an existing one.
7. Why do distributed Erlang nodes require a shared cookie?
The cookie is a shared secret string that every node in a cluster must present to authenticate itself before another node will accept a connection from it — without a matching cookie, a node can't join the cluster, send messages to remote PIDs, or make remote calls.
%% set via command line or ~/.erlang.cookie erl -sname nodea -setcookie mysecret
Without this check, any process capable of reaching a node's distribution port over the network could attach as a full cluster peer and execute arbitrary code, since distributed Erlang gives connected nodes deep capabilities (spawning processes, calling functions remotely). The cookie is a coarse, cluster-wide gate, not a fine-grained per-user authorization system, which is exactly why distributed Erlang is generally recommended only on trusted, isolated networks rather than exposed directly to the public internet.
8. Explain the internal working of the Erlang distribution handshake between two nodes?
When one node connects to another, they don't just open a socket and start exchanging Erlang messages — they first perform a handshake to agree on protocol version and prove they share the same cookie, before the connection is trusted for actual traffic.
sequenceDiagram
participant A as Node A
participant B as Node B
A->>B: send_name (node name, capability flags)
B-->>A: send_status (ok / already connected / not allowed)
B->>A: challenge (random number)
A-->>B: challenge_reply (MD5 digest of cookie+challenge)
B->>A: challenge_ack (MD5 digest confirming match)
Note over A,B: Connection now trusted for normal traffic
The core of the authentication step is a challenge-response exchange: each side generates a random number, combines it with its own cookie, hashes the result, and sends that digest instead of the cookie itself — so the cookie is never transmitted in plaintext over the wire. If the digests don't match on either side, the connection is refused before any application-level messages can flow.
9. What is the two-version code loading rule and what happens when a third version is loaded?
The BEAM keeps at most two versions of a module in memory at once: the current version, which new calls resolve to, and the old version, still executing for any process that was mid-loop when the reload happened. This bounded window is what allows hot code upgrades without an unbounded pile-up of stale code.
code:load_file(my_module). %% current becomes old, new module becomes current %% ... later ... code:load_file(my_module). %% loading a THIRD version
If a third version is loaded while some process is still running the old (now second-oldest) version, the
BEAM has nowhere to put it — so it forcibly kills any process still executing that stale version via
code:purge/1, then promotes the new version to current. This is why long-running loops are written
to periodically make a fully-qualified call back into themselves (e.g. ?MODULE:loop(State)): it
gives them a chance to pick up the newer code before a third reload force-terminates them.
10. Explain the internal working of code:purge/1 and code:soft_purge/1?
Both functions remove the old (previous) version of a module from memory, freeing it up so a future reload has room, but they differ in how they treat processes still executing that old code.
| code:purge/1 | code:soft_purge/1 |
| Unconditionally kills any process still running the old version. | Checks first; if any process is still running the old version, it does nothing and returns false. |
| Guarantees the old version is gone afterward. | Only purges if it's already safe — returns true only when nothing was running it. |
Release upgrade tooling typically calls soft_purge first, in a retry loop, giving in-flight
processes time to naturally cycle back through their own module (and switch to the new version) before
resorting to a hard purge that would forcibly terminate anything still lagging behind.
11. What is a gen_event and when would you choose it over gen_server?
gen_event is the OTP behavior for a pub/sub-style event manager: one process holds a list of
independent handler callback modules, and any event sent to the manager is dispatched in turn to every
registered handler, each with its own private state.
{ok, Pid} = gen_event:start_link(), gen_event:add_handler(Pid, logger_handler, []), gen_event:add_handler(Pid, metrics_handler, []), gen_event:notify(Pid, {order_placed, OrderId}).
Choose gen_event when you have one stream of events that multiple, independently pluggable
consumers need to react to — logging, metrics, and alerting handlers all reacting to the same application
events, added and removed at runtime without touching the code that raises the events. A plain
gen_server is the better fit when there's a single consumer with one coherent piece of state,
rather than a fan-out to many independent listeners.
12. What is the difference between error, exit, and throw in Erlang?
All three raise an exception that unwinds the call stack until caught, but they carry different intent and default severity.
| error/1 | exit/1 | throw/1 |
| Signals a programming/runtime error; includes a stack trace; typically left uncaught to crash the process. | Signals intentional process termination; propagates to linked processes as an exit signal. | A lightweight, expected non-local return; meant to be caught by the same code that anticipated it. |
try error(bad_input) %% class = error catch error:Reason -> handle(Reason); exit:Reason -> handle_exit(Reason); throw:Reason -> handle_throw(Reason) end.
The rule of thumb: use throw for expected, local control flow inside code you also control the
catch for; use error for genuine bugs you want visible in crash logs with a stack trace; use
exit when you specifically want to terminate a process and have that termination propagate to
anything linked to it.
13. Why is exit/1 different from exit/2?
exit(Reason) (arity 1) terminates the calling process itself with the given reason,
exactly like any other uncaught exception, and propagates that reason to any linked processes.
exit(Pid, Reason) (arity 2) instead sends an exit signal to another process, asking it to
terminate, without affecting the caller at all.
exit(normal). %% terminates the current process exit(OtherPid, kill). %% asks OtherPid to terminate; caller keeps running
A special case worth knowing: exit(Pid, kill) sends the untrappable reason kill,
which forces termination even if the target process is trapping exits — every other exit reason can be
intercepted via trap_exit, but kill always succeeds, making it the reliable
"stop this process no matter what" tool when a normal shutdown request might be ignored or delayed.
14. What is an OTP application (.app file) and how does it differ from a single module?
An OTP application is a packaged, independently startable unit of functionality — typically a
whole subsystem (a web server, a connection pool, a whole product component) — described by a
.app file that declares its name, version, modules, registered processes, and dependencies on
other applications.
{application, my_app, [{description, "My application"}, {vsn, "1.0.0"}, {modules, [my_app_sup, my_worker]}, {registered, [my_app_sup]}, {applications, [kernel, stdlib, sasl]}, {mod, {my_app, []}}]}.
Where a module is just a unit of compiled code, an application is a lifecycle-managed unit: it has a defined
start function (the mod entry, typically starting a top-level supervisor), can declare
dependencies that must start first, and can be started, stopped, and monitored as a whole via
application:start/1 and application:stop/1, rather than by manually spawning
individual processes yourself.
15. Explain the execution flow of application:start/1 and its dependency resolution?
Calling application:start(my_app) doesn't just run one function — it triggers a
dependency-aware startup sequence managed by the application controller.
flowchart TD
A[application:start my_app] --> B{Dependencies in .app already running?}
B -->|No| C[Start each missing dependency first, recursively]
C --> B
B -->|Yes| D[Call my_app:start/2 - the mod callback]
D --> E[Top-level supervisor starts via start_link]
E --> F[Supervisor starts its children per its init/1 spec]
The application controller reads the applications list from the .app file and
recursively ensures each dependency is already running, starting any that aren't, before calling the target
application's own start callback. That callback (matching the mod entry) is expected to start and
return the PID of a top-level supervisor, which then boots its own children exactly as any supervision tree
does. If a required dependency can't be started, the whole start call fails rather than leaving the application
half-initialized.
16. What is a release in OTP and how does it differ from an application?
A release bundles a specific set of applications (your own plus the OTP applications they depend on,
like kernel, stdlib, and sasl) together with a matching Erlang/OTP
runtime version into one deployable, versioned unit — typically built with rebar3 release or
similar tooling, producing a boot script and a self-contained directory tree you can ship to a server.
Where an application is one component (say, a payments service), a release is the complete, runnable system:
every application it needs, pinned to specific versions, plus the runtime itself. This distinction matters most
during upgrades — you can hot-swap an individual module inside a running application, but moving from one
full release version to another (with dependency version changes, new applications, or config changes) is
handled by release-level tooling (relup/release_handler), not simple module reloading.
17. Explain the internal working of a relup-based hot upgrade?
A relup (release upgrade) file is a generated script describing exactly how to move a running
system from one release version to another without a restart: which applications to stop/start, which modules
to load, in what order, and which processes need their internal state transformed via
code_change/3.
flowchart TD
A[release_handler:install_release] --> B[Suspend affected processes]
B --> C[Load new module code]
C --> D[Call code_change/3 on running gen_server state]
D --> E[Resume processes with upgraded code + transformed state]
E --> F{Upgrade successful?}
F -->|No| G[Roll back using matching downgrade instructions]
release_handler:install_release/1 reads the relup script and executes its instructions in
order, pausing affected gen_servers just long enough to swap in new code and let
code_change/3 reshape their existing state to match the new version's expectations. Because the
relup also contains the reverse (downgrade) instructions, a failed upgrade can be rolled back to the previous
known-good release rather than leaving the system in a broken intermediate state.
18. What is the code_change/3 callback used for in gen_server?
code_change(OldVsn, State, Extra) is the hook OTP calls during a release upgrade or downgrade
so a gen_server can transform its existing in-memory state to match whatever shape the new code version
expects, instead of losing state by simply restarting.
code_change(OldVsn, State, _Extra) when OldVsn =:= "1.0" -> %% old state was a plain tuple {Count}, new state is a map NewState = #{count => element(1, State)}, {ok, NewState}; code_change(_OldVsn, State, _Extra) -> {ok, State}.
It's only invoked as part of the relup-driven upgrade machinery, not on a normal restart, and only makes sense when a release upgrade actually changes what a process's internal state looks like — adding a new field to a record, switching representations, or renaming keys. If the state shape hasn't changed between versions, the default identity implementation (returning the state unchanged) is all you need.
19. How does the global module handle process name registration across a cluster?
The global module extends Erlang's local process registry (register/2) across an
entire distributed cluster: global:register_name/2 makes a PID discoverable by name from any
connected node, not just the one it was spawned on.
global:register_name(payment_processor, self()), %% from any node in the cluster: Pid = global:whereis_name(payment_processor).
To keep the name table consistent across nodes, global synchronizes registrations between all
connected nodes and resolves simultaneous registration conflicts (two nodes registering the same name at
nearly the same time) by calling a configurable resolver function, defaulting to keeping whichever registration
happened first and killing the later, conflicting one. This synchronization has a real cost: registering or
looking up global names is significantly slower than local register/2, since it may require
cluster-wide coordination.
20. Why can global name registration cause a network partition ("split brain") problem?
If a cluster's network splits into two isolated groups that can each still see their own members but not the other side, each half only knows about its own view of global names. A process on each side of the partition could register the same global name independently, since neither side can detect the other's conflicting registration while the network is split — you end up with two "single" registered processes simultaneously, each side believing it holds the only one.
When the partition heals and the two node groups reconnect, global's conflict resolver runs
retroactively and kills one of the two conflicting processes to restore a single consistent registration
— meaning whichever side loses that resolution had been operating, potentially for some time, under a
now-invalidated assumption that it held the sole registered process.
This is why systems relying on global uniqueness guarantees during network instability often add their own
quorum or leader-election logic on top of global, rather than trusting it alone under partition
conditions.
21. What is the pg (process groups) module used for?
pg lets multiple processes join a named group, cluster-wide, so a caller can broadcast or fan
out work to every member without tracking individual PIDs itself — unlike global, which
maps one name to exactly one process, pg maps one name to a set of processes.
pg:join(chat_room_42, self()), Members = pg:get_members(chat_room_42), [Pid ! {new_message, Msg} || Pid <- Members].
Typical uses include pub/sub-style fan-out (every subscriber process in a group gets a copy of an event) and
worker pools where any member can pick up a task. It's deliberately simpler than global: it makes
no attempt at strong consistency guarantees during partitions, favoring availability and low overhead, which
fits well for use cases like broadcast messaging where an occasional stale group view is tolerable.
22. What are Erlang maps and how do they differ from records?
A map is a dynamic key-value data structure, written #{Key => Value}, where keys can be
any term and the set of keys is decided at runtime rather than fixed at compile time. A record is
syntactic sugar over a tuple with a fixed, compile-time-known set of named fields.
M = #{name => "Ada", age => 34}, Age = maps:get(age, M), M2 = M#{age => 35}. %% functional update, returns a new map
| Record | Map |
| Fixed fields, known at compile time; compiles to a plain tuple. | Dynamic keys, can vary at runtime; a distinct built-in data type. |
| Field access requires the record definition to be in scope. | Keys/values are directly inspectable without any compile-time definition. |
| Best for fixed-shape internal data (like OTP state). | Best for flexible, JSON-like, or externally-driven data. |
23. When should you choose a map over a record for structured data?
Reach for a record when the shape of the data is known and fixed at compile time and you want the compiler to catch typos in field names — classic examples are a gen_server's internal state or an internal domain struct that never gets serialized to an external format.
Reach for a map when the set of keys can vary at runtime, when the data crosses a boundary where records don't make sense (decoded JSON from an HTTP request, dynamically configured options), or when you need to inspect keys generically without knowing the record definition — a function that logs "all fields of this state" can iterate a map's keys directly, but can't easily introspect an arbitrary record's field names without extra metadata.
A common real-world pattern is actually both: define a record for well-known, always-present fields, plus a nested map field within it for optional or dynamically-provided extras, getting compile-time safety on the core shape and flexibility where it's genuinely needed.
24. What is the difference between ets:match, ets:select, and ets:foldl?
All three read multiple rows out of an ETS table, but they differ in expressiveness and performance characteristics.
| ets:match | ets:select | ets:foldl |
Simple pattern with '_' wildcards; returns matching bound variables. |
Full match specifications: pattern + guard conditions + result shape, compiled for speed. | Walks every row via a fold function; no filtering pushed into the table lookup. |
| Easiest to write, least flexible. | Most flexible and fastest for selective queries; harder to write by hand. | Simplest mental model, but always scans the whole table. |
ets:select(Tab, [{{'$1', '$2'}, [{'>', '$2', 10}], ['$1']}]). %% equivalent to: select keys where value > 10
As a rule: use match for a quick one-off lookup, select when you need conditions
beyond simple equality and want the table engine itself to filter efficiently, and foldl when
you genuinely need to process every row (e.g. computing an aggregate).
25. How can you use match specifications to filter ETS data efficiently?
A match specification is a low-level, compiled query format — a list of
{Pattern, Guards, Result} tuples — that ets:select/2 executes directly inside
the table engine, so filtering happens without copying every row out to the calling process first.
%% Find {Key, Value} pairs where Value > 100, returning just the Key ets:select(Tab, [{{'$1', '$2'}, [{'>', '$2', 100}], ['$1']}]).
Writing match specs by hand is error-prone, so most code generates them with the
ets:fun2ms/1 parse-transform, which lets you write an ordinary-looking fun and have it converted
into the equivalent match spec at compile time:
ets:fun2ms(fun({K, V}) when V > 100 -> K end).
The performance benefit over pulling all rows and filtering in Erlang code is significant on large tables, since the match spec engine avoids constructing and copying rows that don't pass the guard at all.
26. What do the write_concurrency and read_concurrency ETS options optimize for?
By default, an ETS table uses a locking scheme tuned for a single, simple access pattern. The
write_concurrency and read_concurrency options let you tell the table engine what
kind of concurrent access to expect, so it can pick internal locking granularity accordingly.
ets:new(my_table, [set, public, {read_concurrency, true}, {write_concurrency, true}]).
- read_concurrency — optimizes for many processes reading concurrently, at a small cost to single-reader latency and to switching between heavy reads and heavy writes.
- write_concurrency — splits the table's internal locks more finely so concurrent writers to different keys don't contend with each other, at the cost of slightly higher memory overhead.
Neither option is free: enabling both gives the best concurrent throughput for a table hit hard from many directions at once, but for a table with light or single-process access, the extra locking machinery is pure overhead with no benefit.
27. How can you optimize binary pattern matching for parsing variable-length network protocols?
Erlang's bit-syntax lets you decode a protocol header directly in a pattern, including fields whose length depends on an earlier field — a common shape in real network protocols (a length-prefixed payload, for instance).
parse(<<Type:8, Len:16, Payload:Len/binary, Rest/binary>>) -> {Type, Payload, Rest}.
A few concrete performance habits matter at scale:
- Match the sub-binary, don't copy it — the
Payload:Len/binarypattern above creates a reference into the original binary rather than copying bytes, as long as the binary is large enough to be reference-counted. - Avoid rebuilding accumulated binaries with
<<Acc/binary, New/binary>>in a tight loop — each concatenation can copy the growing accumulator; prefer collecting parts in a list and callinglist_to_binary/1once at the end. - Use
Rest/binarygenerously to keep unconsumed trailing bytes as a reference rather than slicing them into a new copy prematurely.
28. Why is copying a large list between processes more expensive than copying a large binary?
A large binary (over 64 bytes) is stored off the process heap and reference-counted, so sending it in a message copies only a small pointer-sized reference — the actual byte data never moves. A list, by contrast, is a chain of individually heap-allocated cons cells with no such off-heap, reference-counted representation.
flowchart LR
subgraph Binary send
A1[Large binary off-heap] --> A2[Message copies a small reference]
end
subgraph List send
B1[List of cons cells on sender heap] --> B2[Every cell deep-copied into receiver heap]
end
When a list is sent in a message, the entire structure — every cons cell and every element — is deep-copied into the receiving process's heap, since processes share no memory and lists have no built-in sharing mechanism the way large binaries do. For data that's genuinely large and needs to move between many processes repeatedly, converting it to a binary (or storing it in ETS and passing a key/reference instead) is the standard way to sidestep this cost.
29. What is tail call optimization in Erlang and why does it matter for long-running loops?
A function call is a tail call when it's the very last operation in a clause — nothing happens with its result except returning it directly. Erlang recognizes this pattern and reuses the current stack frame instead of pushing a new one, so a tail-recursive function can loop indefinitely without growing the call stack.
%% tail-recursive: the recursive call is the last thing evaluated loop(State) -> receive Msg -> loop(handle(Msg, State)) %% tail call end. %% NOT tail-recursive: work happens AFTER the recursive call returns sum([]) -> 0; sum([H|T]) -> H + sum(T). %% addition happens after the call
This matters directly for OTP: every gen_server, supervisor, and hand-rolled process loop relies on tail
call optimization to run forever inside a receive loop without ever running out of stack space.
Without it, a long-lived Erlang process would eventually overflow its stack simply by staying alive and
handling messages.
30. How do you write a properly tail-recursive accumulator-based function?
The standard technique is to carry the running result forward as an extra argument (an accumulator), so each recursive call is the last thing that happens — there's no pending arithmetic or list-building left to do after the call returns.
%% Not tail-recursive: work (H + Rest) happens after sum/1 returns sum([]) -> 0; sum([H|T]) -> H + sum(T). %% Tail-recursive: accumulator carries the running total sum(List) -> sum(List, 0). sum([], Acc) -> Acc; sum([H|T], Acc) -> sum(T, Acc + H). %% recursive call is the last operation
A common gotcha is doing this with lists via [H|Acc] to "reverse-build" a result: it's properly
tail-recursive, but produces the list in reverse order, so you finish with a single
lists:reverse/1 call at the end rather than trying to build the list in order (which typically
isn't tail-recursive when appending to the end at each step).
31. Explain the internal working of selective receive and why message order in the queue matters?
A receive block doesn't necessarily take the first message in the mailbox — it scans the
queue in arrival order looking for the first message that matches any of its clauses, skipping over (but
leaving in place) messages that don't match, then removes only the one it matched.
flowchart LR
A[Mailbox: M1, M2, M3] --> B{Does M1 match a clause?}
B -->|No, skip| C{Does M2 match?}
C -->|Yes| D[Remove M2, bind it, continue with M1 and M3 still queued]
This is powerful — you can wait for a specific reply while ignoring unrelated traffic — but it
has a cost: the scan has to walk past every skipped message each time receive runs. If a process
accumulates a large backlog of messages that never match a narrow pattern, every subsequent
receive call gets progressively slower, since it re-scans that growing, unmatched backlog every
time. This is one of the classic causes of a process that seems to "slow down" under load without an obvious
CPU spike.
32. What is erlang:process_flag(priority,...) used for?
Every Erlang process has a scheduling priority — low, normal (the default),
high, or max — that influences how eagerly the scheduler picks it from the run
queue relative to other ready processes on the same scheduler.
process_flag(priority, high).
Higher-priority processes get scheduled ahead of lower-priority ones when both are runnable, which can help
for genuinely latency-sensitive internal system processes (some parts of the runtime itself use
max). It does not grant more CPU time overall, extra reductions per run, or preempt a currently
running lower-priority process mid-execution — it only affects the order processes are picked from the
queue when the scheduler is choosing what to run next.
33. Why should high-priority processes be used sparingly in Erlang?
Erlang's whole scheduling model assumes most processes are roughly equal citizens sharing time fairly via
reduction counting. Marking a process high or max priority breaks that assumption
locally: it can consistently jump ahead of normal-priority work on the same scheduler, and if it runs
frequently or for long stretches, it can starve normal-priority processes of scheduling time, even though
they're otherwise ready to run.
In practice, very few application-level processes actually need this — genuine latency requirements are usually better solved by architecture (dedicating a process to just that fast path, using dirty schedulers correctly for native work, or reducing message queue depth) rather than by escalating priority. Overusing elevated priorities tends to create hard-to-diagnose "why is everything else sluggish" issues, since the starved processes show no error, just quietly reduced throughput.
34. What is rpc:call/4 and how does it work across distributed nodes?
rpc:call(Node, Module, Function, Args) lets one node synchronously invoke a function on
another connected node and get the result back, wrapping the underlying message-passing machinery so it looks
like an ordinary function call.
Result = rpc:call('nodeb@host', lists, sort, [[3,1,2]]). %% Result = [1,2,3], computed on nodeb and returned to the caller
Under the hood, the local rpc server sends a request to its counterpart on the remote node,
which spawns a process to execute Module:Function(Args) there and sends the result back; the
caller blocks (with an optional timeout) waiting for that reply, similar in spirit to
gen_server:call but targeting an arbitrary function rather than a specific process's callback.
Because it executes arbitrary code on the remote node, it inherits the same trust model as the rest of
distributed Erlang — any connected, cookie-authenticated node can run arbitrary functions on any other.
35. What is the difference between rpc:call and simply sending a message to a remote PID?
Sending Pid ! Msg to a remote PID is a raw, asynchronous, fire-and-forget send — you get
no return value unless the receiving process is written to reply, and you must already know that specific PID.
rpc:call/4 is a higher-level convenience built on top of message passing that packages up
"run this function on that node and hand me back the result" as a single blocking call, without needing a
running process on the other end designed to answer you.
| Raw message send | rpc:call/4 |
| Requires a target PID that already knows how to reply. | Targets an arbitrary Module:Function on any connected node. |
| Asynchronous; caller must implement its own receive/reply protocol. | Synchronous; blocks and returns the function's result directly. |
In practice, rpc:call is reached for ad hoc cross-node operations (admin tasks, debugging,
one-off queries), while application code that needs an ongoing conversation with a remote process typically
talks to a specific gen_server via its PID or a registered name instead.
36. How does erlang:send/3 with the nosuspend option change message-sending behavior?
A plain Pid ! Msg send can, in rare cases, briefly suspend the sending process if the
underlying distribution buffer to a remote node is full — effectively creating unwanted back-pressure on
the sender. erlang:send(Pid, Msg, [nosuspend]) asks for the same send but refuses to suspend the
caller: if it would have had to block, it instead returns nosuspend immediately and the message is
not delivered.
case erlang:send(Pid, {event, Data}, [nosuspend]) of ok -> ok; nosuspend -> log_dropped_event(Data) end.
This is useful for genuinely best-effort traffic — metrics, non-critical notifications — where a sender would rather drop a message than risk stalling itself waiting on a slow or overloaded remote link. It's deliberately not the default, since silently dropping messages is the wrong tradeoff for anything the receiver actually needs to see.
37. What is Dialyzer and how does success typing differ from static typing?
Dialyzer is Erlang's static analysis tool for finding type discrepancies — but it works differently from a conventional static type checker like those in typed languages. Instead of requiring every value to be annotated and rejecting code that can't be proven correct up front, Dialyzer uses success typing: it infers the types a function could actually succeed with by analyzing the code as written, and only flags something when it can prove a call would definitely fail.
-spec add(integer(), integer()) -> integer(). add(A, B) -> A + B. bad() -> add(1, "two"). %% Dialyzer flags this: "two" can never succeed here
Because success typing is optimistic by design — it only reports what's provably wrong, never
what merely isn't proven right — it produces very few false positives, unlike stricter type systems that
might reject valid-but-hard-to-prove code. -spec annotations are optional hints that sharpen
Dialyzer's analysis, not required declarations the way type annotations are in a conventional statically typed
language.
38. Why doesn't Dialyzer catch every type error the way a traditional type checker would?
Success typing is intentionally conservative: it only reports a call site when it can prove the function can never succeed with those argument types, based on inferring what the function's body could actually produce or accept. If a function's implementation is loose enough that a call is merely suspicious rather than provably impossible, Dialyzer stays silent about it.
maybe_int(X) when is_integer(X) -> X; maybe_int(X) -> X. %% falls through for ANY other type, so nothing is provably wrong bad() -> maybe_int("oops"). %% Dialyzer says nothing here
Because the second clause accepts any value unconditionally, Dialyzer can't prove the call fails, even
though it's clearly not what the author intended. This is the direct tradeoff for success typing's low
false-positive rate: it trades completeness (catching every questionable call) for
soundness of what it does report (never crying wolf on code that actually works). Writing precise
-spec annotations and using strict guards narrows this gap, but Dialyzer will never behave like a
type checker that rejects unproven code by default.
39. How do you define and use a custom behavior with the -callback attribute?
Beyond OTP's built-in behaviors (gen_server, supervisor, and so on), you can define your own reusable
process pattern by declaring the required callbacks a module implementing your behavior must export, using the
-callback attribute.
%% my_plugin.erl - the behavior definition -module(my_plugin). -callback handle_event(Event :: term(), State :: term()) -> {ok, NewState :: term()}. %% logger_plugin.erl - a module implementing it -module(logger_plugin). -behaviour(my_plugin). -export([handle_event/2]). handle_event(Event, State) -> io:format("event: ~p~n", [Event]), {ok, State}.
The -callback declaration lets Dialyzer and the compiler verify that any module claiming
-behaviour(my_plugin) actually exports a matching handle_event/2, warning at compile
time if it's missing — giving you the same "pluggable module with an enforced contract" pattern OTP's own
behaviors use, for your own domain-specific extension points.
40. What is the difference between a behavior callback module and a plain library module?
A plain library module (like lists or string) exposes stateless functions you
call directly whenever you want — there's no framework driving it, no lifecycle, no expected shape beyond
whatever arguments and return value each function defines on its own.
A behavior callback module instead plugs into a generic, already-running process skeleton (gen_server,
gen_statem, a custom behavior, etc.) that calls your functions at specific points in its own lifecycle
— you don't call init/1 yourself, the behavior's generic engine does, at the moment it
starts a new process. The contract (which functions must exist, with what signatures) is enforced by the
-behaviour/-callback mechanism rather than being purely a documentation convention.
| Plain library module | Behavior callback module |
| You call its functions directly, whenever you choose. | A generic engine calls your functions at defined lifecycle points. |
| No enforced contract beyond documentation. | Enforced by -callback declarations, checked by the compiler/Dialyzer. |
41. Explain the internal working of exception propagation through nested try/catch blocks?
An exception raised anywhere inside a try block unwinds the call stack looking for the
nearest enclosing catch whose pattern matches the exception's class and reason — skipping
over any intervening function calls that don't themselves catch it, exactly like exception handling in most
languages.
flowchart TD
A[inner() raises error:bad_input] --> B{Caught by inner try/catch?}
B -->|No matching clause| C[Propagates up through middle/]
C --> D{Caught by middle's try/catch?}
D -->|No matching clause| E[Propagates up through outer/]
E --> F{Caught by outer's try/catch?}
F -->|Yes| G[Handled here]
If no enclosing try/catch anywhere up the call chain matches, the exception reaches the top of
the process and terminates it, which then behaves exactly like any other crash — propagating as an exit
signal to linked processes. A nested try only intercepts exceptions raised within its own body;
it has no effect on exceptions from calls made before it or after it returns, which is why the innermost
matching handler along the call chain is the one that actually catches it, not necessarily the outermost.
42. When should you use throw instead of returning an {error, Reason} tuple?
Both signal "this didn't work," but they fit different shapes of control flow. A tagged
{error, Reason} return keeps the failure visible in the function's ordinary return value, forcing
every caller to explicitly pattern-match and decide what to do — it composes naturally with Erlang's
usual "match on the result" style and is easy to trace just by reading the function's uses.
%% error tuple: caller must explicitly handle both branches case validate(Input) of {ok, Value} -> proceed(Value); {error, Reason} -> reject(Reason) end. %% throw: useful for bailing out from deep inside nested helper calls validate_all(Items) -> try [validate_one(I) || I <- Items] catch throw:{invalid, Item} -> {error, {invalid, Item}} end.
throw earns its keep specifically when you're several calls deep inside a purely internal
helper chain and want to bail out immediately to one handler at the top, without threading an
{error, Reason} check through every intermediate function in between. It should stay internal to
code you also control the corresponding catch for, rather than being part of a module's public
API contract, where a tagged tuple is the more discoverable, idiomatic choice.
43. How do you troubleshoot a memory leak caused by large binaries not being garbage collected?
Large (>64 byte) binaries live off the process heap and are reference-counted, but the counter only drops when the owning process's heap is actually garbage collected — a process that receives many large binaries but rarely triggers a GC cycle (because its own small heap of local variables never fills up) can hold references to megabytes of off-heap binary data far longer than expected.
%% inspect suspects erlang:process_info(Pid, binary). %% list of {BinId, Size, RefCount} recon:bin_leak(10). %% recon helper: top 10 processes by binary refs
Common fixes:
- Force a GC explicitly on long-lived, low-activity processes holding binaries via
erlang:garbage_collect(Pid), either periodically or after processing a large payload. - Shrink the reference's lifetime — if only a small slice of a large binary is actually needed
long-term, copy just that slice with
binary:copy/1so the full original can be released. - Check for accidental retention in a process dictionary entry or long-lived accumulator that's never cleared.
44. What is the significance of the binary reference count and off-heap binary garbage collection?
Any binary larger than 64 bytes is allocated once, off the process heap, and shared by reference rather than copied whenever it's passed around within reach of the same underlying data (for instance, sub-binary matches, or being stored in multiple variables). A reference count tracks how many live references point to that off-heap blob.
flowchart LR
A[Large binary allocated off-heap, refcount=1] --> B[Process A holds a reference]
A --> C[Sub-binary match creates another reference, refcount=2]
B --> D[Process A garbage collected, its reference dropped, refcount=1]
C --> E[Last holder GC'd, refcount=0]
E --> F[Off-heap memory freed]
The blob is only actually freed once its reference count drops to zero, which only happens as a side effect of garbage collecting every process/reference that held a pointer to it. This is precisely why off-heap binaries can outlive what you'd naively expect from looking at process code alone — the memory isn't released the moment a variable "goes out of scope" the way it might in a language with deterministic destructors; it's released only when GC actually runs and drops the last reference.
45. Why is the Erlang distribution protocol not encrypted by default, and how can you secure inter-node traffic?
The standard distribution protocol (used for node-to-node traffic once the cookie handshake succeeds) sends Erlang term data in plaintext over TCP — it was designed assuming nodes sit on a trusted, private network, not a hostile or public one, so encryption wasn't built in as the default to avoid the performance cost for every deployment that doesn't need it.
%% enabling TLS distribution erl -proto_dist inet_tls \ -ssl_dist_optfile /path/to/ssl_dist.conf \ -sname mynode
To secure inter-node traffic, Erlang supports swapping the distribution transport to
inet_tls, which wraps the same distribution protocol in TLS using certificates you configure via
an SSL distribution options file — every node in the cluster needs matching configuration to
authenticate each other and encrypt the link. In practice, many deployments instead isolate distributed Erlang
traffic entirely inside a private network or VPN rather than paying the TLS overhead, reserving
inet_tls for cases where nodes genuinely must communicate across untrusted networks.
46. Why is Mnesia's two-phase commit necessary for distributed transactions?
When a Mnesia transaction writes to tables replicated across multiple nodes, every replica must end up agreeing on the outcome — either all of them commit the change, or none of them do. Without coordination, a network hiccup could leave one node having applied the write while another hasn't, silently corrupting replica consistency.
sequenceDiagram
participant Coord as Coordinator node
participant N1 as Replica 1
participant N2 as Replica 2
Coord->>N1: prepare (can you commit?)
Coord->>N2: prepare (can you commit?)
N1-->>Coord: yes
N2-->>Coord: yes
Coord->>N1: commit
Coord->>N2: commit
In the prepare phase, the coordinating node asks every replica whether it can apply the change (data validated, locks acquired); only if every replica agrees does the coordinator send the actual commit phase, telling all of them to make it permanent. If any replica can't prepare (a conflicting write, an unreachable node), the whole transaction aborts everywhere rather than partially applying — this is what gives Mnesia its ACID-style guarantee across a cluster instead of just on a single node.
47. How do you troubleshoot slow ETS lookups on a heavily-used table?
A "fast" O(1) ETS set/bag lookup can still degrade under real load for a handful
of specific, diagnosable reasons rather than randomly — the first step is confirming which of these
actually applies before reaching for a fix.
ets:info(Tab, memory), %% table size, in words ets:info(Tab, size), %% row count ets:info(Tab, [read_concurrency, write_concurrency]).
- Lock contention — many concurrent writers without
write_concurrencyenabled serialize on the same lock; checkets:info/2for the current setting and enable it if writes are frequent and concurrent. - Oversized keys/values copied on every read — a lookup still copies the matched row into the caller; storing large blobs directly as values means every read pays that copy cost, so storing a reference (e.g. a binary handle) instead can help.
- Using ets:match/2 where ets:select/2 with a compiled match spec would filter more efficiently.
- An
ordered_setused where a plainsetwould do — ordered tables cost more per operation (tree-based) than hash-based ones when strict ordering isn't actually needed.
48. Why might increasing the number of BEAM schedulers not improve throughput linearly?
Adding scheduler threads only helps if there's enough independent, ready-to-run work to actually fill them, and if nothing else becomes the new bottleneck once CPU contention eases. Several factors commonly cap the gains well before scheduler count matches core count:
- Contended shared resources — a single ETS table or process (like a central counter or a poorly-partitioned queue) that many processes hit becomes the bottleneck regardless of how many schedulers are free to run other work.
- NIFs or dirty work saturating their own separate pools — adding regular schedulers doesn't help if the real limit is dirty CPU/IO scheduler capacity.
- I/O-bound work — if processes are mostly waiting on network or disk, more schedulers just means more idle threads, not more completed work per second.
- Diminishing returns from work-stealing overhead and cache effects — beyond the actual number of physical cores, extra scheduler threads add coordination overhead without adding real parallel capacity.
Because of this, scaling Erlang throughput usually means finding and fixing the specific contended resource first (partitioning a hot ETS table, adding dirty scheduler capacity, reducing a chatty bottleneck process) rather than just raising the scheduler count and expecting proportional gains.
