Elixir's pitch is that the BEAM makes failure a first-class citizen: processes crash, supervisors restart them, and the system heals in microseconds. It's true, and it's why Phoenix apps are famously stable. It is also exactly why external monitoring matters more on the BEAM, not less — because the runtime is so good at recovering that it hides failure from everyone, including you. A GenServer crash-looping every four seconds looks "up" from every angle except the requests that land mid-restart. A supervisor that finally exhausts its restart budget takes an entire subtree down at once, with no warning visible from outside. The system that never appears to fail is the system whose failures you never practice detecting.
This guide covers monitoring Phoenix and Elixir applications the way they actually misbehave: a health endpoint worth pointing a monitor at, the crash-loop blind spot, mailbox backlogs and pool exhaustion, Oban and scheduled work, and LiveView's two failure planes. As with the rest of this series, CronAlert is agentless — plain HTTP checks, content assertions, and heartbeats. Nothing to add to your mix.exs except one twenty-line controller.
Write a health check endpoint
Phoenix doesn't ship one, and the decisions come from our health check endpoints guide: verify what the app is useless without, bound every probe with a timeout, and never leak internals:
defmodule MyAppWeb.HealthController do
use MyAppWeb, :controller
def healthz(conn, _params) do
case Ecto.Adapters.SQL.query(MyApp.Repo, "SELECT 1", [], timeout: 2_000) do
{:ok, _} ->
json(conn, %{status: "ok"})
{:error, reason} ->
# Log the detail; never send it to the caller
Logger.error("healthz failed: #{inspect(reason)}")
conn
|> put_status(503)
|> json(%{status: "down"})
end
end
end Route it outside any authentication pipeline (get "/healthz", HealthController, :healthz in a pipeline without auth plugs). Extend the same shape for Redis, object storage, or a downstream API. Point your external monitor at it with a keyword assertion on "status":"ok" — a load balancer's generic 200 will never contain it. The database health endpoint guide covers deeper probes than SELECT 1.
The crash-loop blind spot
"Let it crash" is sound engineering and a genuine observability trap. A process that crashes and restarts every few seconds costs you: the requests in flight when it dies, whatever state it was holding, and — the sharp edge — progress toward the supervisor's max_restarts budget (default: 3 restarts in 5 seconds). Stay under the threshold and the loop can run indefinitely, invisible; cross it and the supervisor itself exits, escalating the crash up the tree until something big goes down all at once. From the outside, that reads as "the app was fine and then suddenly it wasn't."
External symptoms to watch for: flapping (a monitor that fails one check in ten as requests land mid-restart) and step-function latency (each restart cold-starts caches and connections). A 1-minute check interval with CronAlert's consecutive-check verification will surface flapping honestly — intermittent failures are a signal here, not noise to be smoothed away. If a monitor on a Phoenix app flaps, don't raise the threshold; go read the logs for a restart loop.
What to monitor in a Phoenix app
- The homepage or API root — with an assertion on real content, not just a 200.
- Your
/healthz— the dependency-aware signal, keyword-asserted. - Key LiveView routes — the initial render is plain HTTP and assertable; see the LiveView section below for the socket half.
- Channels/socket endpoints if you use them — our WebSocket monitoring guide covers the handshake-check pattern.
- HTTPS and the certificate — see SSL certificate monitoring.
- A heartbeat per Oban queue and scheduled job — nothing in the request path can see them.
The HTTP and SSL checks fit in CronAlert's free plan (25 monitors, 3-minute interval); Pro ($5/mo) adds 1-minute checks, keyword assertions, and heartbeats.
BEAM-specific failure modes
- GenServer mailbox backlog. One process, sequential messages: when arrivals outpace handling, the mailbox grows without bound. Memory climbs, callers of that process slow down together, and eventually the node OOMs or the process dies mid-message. The observable shape is a slow ramp — exactly what a response-time threshold on affected routes catches. Our thresholds guide covers picking numbers.
- Ecto/DBConnection pool exhaustion.
pool_size(default 10) is a hard ceiling; slow queries, long transactions, or a traffic spike exhaust it and callers queue untilDBConnection.ConnectionError. The app is alive, the scheduler is fine — everything is just waiting. Your health check's bounded query surfaces the stall. - Supervisor restart-budget exhaustion. The delayed cliff described above: fine, fine, fine, then a whole subtree gone. The flapping before the cliff is your only advance warning.
- ETS/state loss on restart. A crash-looping process that owns an ETS table or in-memory cache rebuilds it every cycle — correctness survives, latency doesn't. Step-function response times after each blip are the tell.
- Node-level failures in clusters. libcluster misconfiguration or a partitioned node means Phoenix.PubSub messages silently stop reaching half your users — LiveView updates freeze while every HTTP check passes. A keyword assertion on freshness-dependent content (a "last updated" timestamp via content-stale checks) is the external proxy.
- Deploy half-states. A release that boots but can't reach the database sits in a retry loop with the port open. A dependency-aware
/healthzrefuses to sayok; a TCP check happily passes. The recurring lesson of this series: status codes lie, and "the port is open" lies harder.
Monitoring Oban and scheduled work
Elixir apps rarely shell out to cron — scheduled work lives inside the runtime as Oban cron jobs, Quantum schedules, or hand-rolled Process.send_after loops. All invisible to HTTP checks, and all failing silently in their own ways: a paused Oban queue, jobs exhausting retries into the discard state, a Quantum schedule whose process restarted without rescheduling, a cron entry deleted in a refactor.
The fix is the same heartbeat pattern as every scheduler: ping a unique CronAlert URL only after verified success, and let silence raise the alert:
defmodule MyApp.Workers.NightlyExport do
use Oban.Worker, queue: :exports
@impl Oban.Worker
def perform(%Oban.Job{}) do
with {:ok, _count} <- MyApp.Exports.run() do
# Ping only after verified success — silence is the alarm
Req.get("https://cronalert.com/api/heartbeat/TOKEN")
:ok
end
end
end One heartbeat per critical schedule, grace period sized to the cadence. If the job fails, Oban retries and the ping stays absent — which is precisely the behavior you want: the alert fires only when retries aren't saving you. For long-running jobs, the paired start/finish pattern in our batch job guide applies unchanged. Heartbeats are on every paid CronAlert plan, from $5/mo.
LiveView: two failure planes, two checks
A LiveView page can fail on either of two planes. The HTTP render is a normal request — monitor it like any route, asserting on content only a successful mount produces. The WebSocket upgrade is where the subtle failures live: a check_origin list missing your new domain, a proxy stripping upgrade headers, a CDN buffering the socket path. When that half breaks, every page renders perfectly and nothing works — buttons dead, forms silent, presence frozen. Users experience a fully down app; your page monitors see nothing wrong.
Practical coverage: an HTTP monitor on the socket endpoint path catches configuration-level breakage (it should answer, even if only with an upgrade-required response), and end-to-end socket verification patterns are in the WebSocket guide. If you've just changed proxies, origins, or domains, test the socket half deliberately — it's the piece that breaks on infrastructure changes while every HTTP check stays green.
A concrete CronAlert setup
- Ship
/healthz— the controller above, outside auth pipelines, with a bounded Ecto probe. - HTTP monitor on it — expect 200 (3-minute interval free, 1-minute on Pro), keyword assertion on
"status":"ok"(Pro). - Monitors on key routes and LiveView pages — assertions on post-mount content.
- A heartbeat per Oban cron and scheduled loop (Pro) — ping inside
perform/1after verified success. - SSL and response-time checks — certificate expiry on the domain; thresholds on
/healthzand key routes as your mailbox-backlog and pool-exhaustion early warning. - Take flapping seriously. On the BEAM, an intermittent monitor is a crash loop until proven otherwise.
- Alert channels — email, Slack, Discord, Teams, Telegram, PagerDuty, Opsgenie, Splunk On-Call, webhooks, or PWA push.
Every plan includes the REST API (read-only on free, read-write from Pro), and CronAlert ships an MCP server so Claude Code, Cursor, or Windsurf can manage monitors conversationally. Create a free account and the first monitor takes about a minute.
Frequently asked questions
Does Phoenix have a built-in health check endpoint?
No — write a small controller with a bounded Ecto probe, route it outside auth pipelines, and return 200/503 without internals. Twenty lines, shown above.
If supervisors restart everything, why do I need external monitoring?
Supervision recovers; it doesn't report. Crash loops hide under the restart threshold for weeks, then exhaust the budget and take a whole subtree down at once. External checks see the flapping before the cliff.
Why is my Phoenix app slow even though nothing is crashing?
Usually a GenServer mailbox backlog or Ecto pool exhaustion — both build gradually. A response-time threshold on your health endpoint fires during the ramp.
How do I monitor Oban jobs and scheduled tasks?
A heartbeat per critical job: ping a unique URL as the last step of perform/1 after verified success. Paused queues, dead schedulers, and retry-exhausted jobs all collapse into the same missing ping.
How should I monitor Phoenix LiveView apps?
Both planes: HTTP monitors with content assertions on the rendered pages, plus a check on the socket endpoint — the WebSocket half breaks independently and invisibly.
Start monitoring your Phoenix app
The BEAM's genius is recovering from failure faster than anyone notices — which makes it your job to notice. A twenty-line /healthz, content assertions on key routes, heartbeats inside your Oban workers, and a policy of treating flapping as signal give you visibility the runtime deliberately declines to provide. HTTP and SSL checks start on CronAlert's free plan; assertions and heartbeats come with Pro at $5/mo. Create a free CronAlert account and point the first check at /healthz in the next five minutes.
Related reading: HTTP health check endpoints, WebSocket monitoring, background worker monitoring, and our companion guides for Django, Rails, Laravel, Symfony, FastAPI, Express/Node.js, Spring Boot, Go, and ASP.NET Core applications.