Elixir GenServer as a Single-Writer Gate for External Resources
Use a supervised Elixir GenServer to serialize writes to a mutable external resource, with timeouts, input validation, and operational checks.
16 Oct 2025, 02:03 UTC

Your application receives events from many client processes and must write them to an external resource—an API endpoint, a database table, or a file—that cannot safely accept concurrent writes. If several Elixir processes call that resource at once, you can get duplicate records, corrupted state, or race-condition errors that are hard to reproduce.
The useful takeaway is to put one supervised GenServer between your clients and the external resource. That GenServer becomes the single writer: every client sends a command, the GenServer processes commands one at a time, and the supervisor restarts the gate if it crashes. This pattern works for low-to-moderate write volume, but it is not a scaling solution. A single process serializes all work and becomes unavailable while it restarts.
Requirements
- Writes to the external resource must be serialized.
- Callers need either fire-and-forget submission or a reply with a timeout.
- Invalid input must never crash the gate.
- Credentials must not live in long-lived process state.
- You need to detect queue buildup and excessive restarts before clients are affected.
Smallest suitable design
Start with a single locally registered GenServer under a :one_for_one supervisor. Local registration means any process in the same node can address it by name.
defmodule ExternalWrite.Gate do
use GenServer
def start_link(opts) do
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
end
def submit_async(payload) do
GenServer.cast(__MODULE__, {:submit, payload})
end
def submit(payload, timeout) do
GenServer.call(__MODULE__, {:submit, payload}, timeout)
end
@impl true
def init(opts) do
endpoint = Keyword.fetch!(opts, :endpoint)
{:ok, %{endpoint: endpoint}}
end
@impl true
def handle_call({:submit, payload}, _from, state) do
with {:ok, clean} <- validate_payload(payload),
{:ok, reference} <- write_to_external(state, clean) do
{:reply, {:ok, reference}, state}
else
{:error, :bad_payload} ->
{:reply, {:error, :bad_payload}, state}
{:error, _upstream} ->
{:reply, {:error, :upstream_failure}, state}
end
end
@impl true
def handle_cast({:submit, payload}, state) do
case validate_payload(payload) do
{:ok, clean} -> write_to_external(state, clean)
{:error, _} -> :ok
end
{:noreply, state}
end
defp validate_payload(payload) do
if is_map(payload) and Map.has_key?(payload, :id) do
{:ok, payload}
else
{:error, :bad_payload}
end
end
defp write_to_external(state, payload) do
# Replace with your HTTP client, database driver, or file writer.
# Never perform blocking CPU or NIF work here; it stalls the scheduler.
ExternalAdapter.write(state.endpoint, payload)
end
end
Supervise it with :one_for_one so a crash in the gate does not restart unrelated workers.
children = [
{ExternalWrite.Gate, endpoint: System.get_env("EXTERNAL_WRITE_ENDPOINT")}
]
Supervisor.start_link(children, strategy: :one_for_one, name: ExternalWrite.Supervisor)
Where to run: compile and start in your application's supervision tree, typically in application.ex. No special OS permissions are required beyond those already needed to reach the external endpoint. Replace EXTERNAL_WRITE_ENDPOINT with your actual service URL or connection string, preferably from an environment variable.
Expected checks: after starting, GenServer.whereis(ExternalWrite.Gate) should return a PID. Sending ExternalWrite.Gate.submit(%{id: 1}, 5000) should return {:ok, reference} or {:error, :bad_payload} within the timeout.
Trust and data boundaries
Treat the GenServer as a trust boundary between callers and the external resource.
- Validate and sanitize every payload before it reaches the external adapter. Reject malformed input with a generic error atom.
- Map upstream errors to client-safe values. Do not return stack traces, connection strings, or raw HTTP error bodies to untrusted callers.
- Fetch credentials from environment variables or a secrets store at startup or on demand. Do not store long-lived tokens in GenServer state.
- Keep state small and pure. A map with configuration is fine; a growing list of in-flight requests is not.
Operational checks
Monitor the gate process, not just the external resource.
Process.info(pid, :message_queue_len)— alert if the queue length stays above your threshold, for example 100 messages. A growing queue means clients are producing writes faster than the gate can forward them.Process.info(pid, :memory)and reductions — sudden growth can indicate a runaway caller or a leaking state structure.:supervisor.count_children(ExternalWrite.Supervisor)— watchrestarts. A high restart rate means the gate is crashing repeatedly, often because of bad input or a flaky upstream.- Measure upstream latency with
:timer.tc/1around the external call. If latency approaches yourGenServer.calltimeout, tune the timeout or add a circuit breaker.
Where to run: IEx attached to your node (iex -S mix) or a remote observability shell. Required permissions: none beyond connecting to the running node. Risk: calling Process.info on a dead PID returns nil; handle that in your monitoring script.
Failure modes and mitigations
| Failure mode | What happens | Mitigation |
|---|---|---|
| Repeated invalid input | GenServer crashes and restarts in a loop | Validate before calling the external adapter; keep restart intensity within :max_restarts/:max_seconds |
| External resource downtime | GenServer.call blocks until timeout; callers pile up | Use timeouts, circuit breakers, and backpressure; consider shedding load |
| Cast overload | GenServer.cast has no reply, so queue buildup is invisible to callers | Monitor queue length; switch to call for critical writes or add admission control |
| Blocking work in a callback | Stalls the scheduler and delays every other process on that core | Keep handle_call/handle_cast short; offload heavy encoding or compression to a separate task pool |
Conditions that would change the design
Reconsider the single-gate design when any of the following are true:
- You need to write to the resource from more than one BEAM node. A local registered name does not span nodes.
- Throughput exceeds what one process can serialize. A single GenServer handles one message at a time on one scheduler.
- You need strict ordering per customer or per entity, not global ordering. Shard by key into a
DynamicSupervisorof per-key GenServers. - You need multi-region writes or durable replay. Replace the in-process gate with an ordered external log and consume it with a single writer per partition.
Limitations and how to verify
This design serializes all writes through one BEAM process. That makes it simple and safe, but it also means the gate is a throughput ceiling and a brief point of unavailability during restarts. It does not provide distributed locking, and it does not survive node failure on its own.
Practical verification before production use:
- Confirm the toolchain:
elixir --versionanderl -versionin your terminal. Read local docs withh GenServerandh Supervisorin IEx. - Start the gate under its supervisor, then run
Process.exit(pid, :kill)from IEx. Confirm the supervisor restarts it and that callers receive timeouts rather than crashes during the gap. - Send an invalid payload and confirm the gate returns
{:error, :bad_payload}without restarting. - Measure
Process.info(pid, :message_queue_len)under a burst of casts and confirm your alert threshold fires at the chosen value. - Review current Elixir/OTP release notes for changes to
GenServerorSupervisorsemantics before adopting in production.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.