Phoenix Interview Questions and Answers
Last updated:
Check out 40 of the most common Phoenix interview questions, then take an AI-powered practice interview
Q1What is Phoenix and how does it differ from Rails or Django?
BasicFundamentals
Answer
Phoenix is a web framework written in Elixir, designed for high-concurrency, low-latency applications. It looks superficially like Rails, same MVC layout, similar generators, comparable router DSL, but the runtime story is completely different. Phoenix sits on top of the BEAM (Erlang VM), which gives it preemptive scheduling, lightweight processes (a single node handles 2M+ idle connections), and supervision trees.
A Rails request handler that blocks on a slow DB call ties up a thread; a Phoenix handler runs in its own process and the scheduler simply picks another. The other big differentiator is real-time: Channels (WebSockets) and LiveView (server-rendered reactive UIs) are first-class, not bolted on. The trade-off is the language, Elixir is functional, immutable, and message-passing, which is unfamiliar if you come from Ruby or Python.
Mechanically, the BEAM preempts each process after roughly 2,000 reductions, so one slow request cannot starve the others, and every process owns a private heap that is collected independently, so there is no stop-the-world GC pause for the whole node. That is why Phoenix p99 latency stays flat as concurrency rises while thread-pool frameworks degrade sharply past their worker count. It also collapses infrastructure: the Rails equivalent of a Phoenix app usually means Sidekiq plus Redis plus ActionCable, whereas Oban, Channels, and Phoenix.PubSub all run inside the same release with no extra moving parts.
A senior interviewer almost always follows up with 'so what can still block the BEAM?'. The honest answers are NIFs and port drivers that overrun their scheduler slice, and funnelling every request through one overloaded GenServer, which turns a concurrent system back into a serial one.
Key Points
- Runs on the BEAM, preemptive scheduling, millions of processes per node
- Real-time (Channels, LiveView) is first-class, not an afterthought
- MVC structure feels familiar to Rails devs; functional language doesn't
- Fault tolerance via OTP supervision trees inherited from Erlang
Q2What does a Phoenix 1.7 route look like with verified routes (`~p`)?
BasicRouting
Answer
Phoenix 1.7 introduced verified routes, the `~p` sigil, which checks at compile time that every URL you generate in your templates and controllers actually exists in the router. If you misspell a route or delete it, the compiler tells you immediately instead of failing at runtime. The router itself uses a pipeline-and-scope DSL: pipelines apply middleware (Plugs) to groups of routes, and scopes group routes under a common path/module prefix.
Mechanically, `use MyAppWeb, :verified_routes` expands to `use Phoenix.VerifiedRoutes, endpoint: MyAppWeb.Endpoint, router: MyAppWeb.Router, statics: ~w(assets fonts images favicon.ico robots.txt)`, and an unmatched path emits a compile warning like `no route path for MyAppWeb.Router matches "/postz/1"`. Because `~p` is a sigil expanded at compile time, the path must be a literal with interpolated segments: you cannot hand it a runtime string that holds the whole path. Query strings interpolate as a keyword list, `~p"/posts?#{[page: 2, q: term]}"`, and Phoenix URL-encodes the values for you. `url(~p"/posts/#{post}")` gives the absolute URL you need inside emails, and `~p"/images/logo.png"` is checked against the `:statics` prefixes, so a renamed asset breaks the build instead of shipping a silent 404.
Verified routes replaced the older `Routes.post_path(conn, :show, post)` helpers, which still work in upgraded apps but are no longer generated. Follow-ups worth preparing: `mix phx.routes` prints the resolved table, pipeline order matters (`:fetch_session` has to run before `:protect_from_forgery`), and `forward "/admin", SomePlug` sits outside verification because the target router is a separate plug.
# lib/my_app_web/router.ex
defmodule MyAppWeb.Router do
use MyAppWeb, :router
pipeline :browser do
plug :accepts, ["html"]
plug :fetch_session
plug :protect_from_forgery
end
scope "/", MyAppWeb do
pipe_through :browser
get "/", PageController, :home
resources "/posts", PostController
end
end
# In a template, checked at compile time:
<.link href={~p"/posts/#{post.id}"}>View post</.link>
Key Points
- `~p` sigil = compile-time-checked URLs
- Pipelines apply Plugs in order
- Scopes group routes by path + module
Q3What is a 'context' in Phoenix and why does it exist?
BasicArchitecture
Answer
A context is a Phoenix-recommended pattern for grouping related business logic into a single module that becomes the public API for that domain. Instead of controllers calling Ecto schemas directly, they call functions like `Accounts.create_user/1` or `Billing.charge_subscription/2`. The point is to keep the web layer (controllers, LiveViews) thin and decoupled from the data layer.
Generated by `mix phx.gen.context Accounts User users email:string`, a context bundles the schema, the changeset, and CRUD functions in one place. Critically, contexts let two teams share the same database without one stomping on the other, `Accounts.User` and `Billing.User` can be different views of the same underlying table. Physically the split is just directories: `lib/my_app/accounts.ex` holds the public functions, `lib/my_app/accounts/user.ex` holds the schema, and nothing under `lib/my_app_web/` should call `Repo` or build an `Ecto.Query`.
The convention that carries the most weight in code review is the return shape. Context functions return `{:ok, struct}` or `{:error, changeset}` so callers can chain them with `with`, while the bang variants (`get_user!/1`) raise `Ecto.NoResultsError`, which Phoenix renders as a 404 through `Plug.Exception`. The usual failure mode is one god context: a 2,000-line `Accounts` module that has absorbed billing, notifications, and audit logging.
Split when the module has more than one reason to change, and note that the generator already nudges you by printing a warning when you generate into an existing context. Cross-context references should travel by id rather than by `has_many` associations that reach across a boundary, because a preload across contexts quietly rebuilds the coupling you were trying to remove. Teams that want the rule enforced rather than agreed add the `boundary` Hex package or a Credo check that fails the build if `MyAppWeb` mentions `Repo`. To be clear on a common misread: contexts are compile-time module boundaries, not microservices, and they all run in the same BEAM node.
# lib/my_app/accounts.ex, the public API for the domain
defmodule MyApp.Accounts do
import Ecto.Query
alias MyApp.Repo
alias MyApp.Accounts.User
def get_user!(id), do: Repo.get!(User, id)
def list_active_users do
User |> where([u], u.status == "active") |> Repo.all()
end
def create_user(attrs) do
%User{} |> User.changeset(attrs) |> Repo.insert()
end
end
# lib/my_app_web/controllers/user_controller.ex, no Repo in sight
def create(conn, %{"user" => params}) do
case Accounts.create_user(params) do
{:ok, user} -> redirect(conn, to: ~p"/users/#{user}")
{:error, changeset} -> render(conn, :new, changeset: changeset)
end
end
Key Points
- Public API for a business domain
- Controllers / LiveViews never touch Ecto directly
- Generated via `mix phx.gen.context`
- Lets multiple bounded contexts share the same DB safely
Q4What is Ecto and how do schema, changeset, and Repo fit together?
BasicDatabase
Answer
Ecto is Elixir's database wrapper, closer to a query builder than a traditional ORM. Three pieces: (1) **Schema** maps a database table to an Elixir struct with typed fields. (2) **Changeset** is a struct that wraps changes about to be applied, it tracks the original data, the new params, validation errors, and required fields. Every insert / update goes through a changeset. (3) **Repo** is the module that actually talks to the database, `Repo.insert/1`, `Repo.update/1`, `Repo.get/2`, `Repo.all/1`.
Keeping these separate is intentional: a schema with no changeset never reaches the DB, so you can never accidentally write invalid data. Two details separate people who have shipped Ecto from people who have only read about it. First, `validate_*` runs in Elixir before any SQL leaves the process, whereas `unique_constraint/3` and `foreign_key_constraint/3` validate nothing at all: they only teach the changeset how to translate a Postgres error (23505 unique_violation, 23503 foreign_key_violation) back into a field error.
Without the matching database index the constraint never fires, and without the changeset declaration the same collision raises `Ecto.ConstraintError` instead of returning a friendly form error. Second, `cast/3` whitelists by key, so any field you forget to list is silently dropped, which is the real cause of most 'my update did nothing' bugs. Around those, `Repo` is backed by a DBConnection pool tuned with `pool_size`, `queue_target`, and `queue_interval`, and exhausting it shows up as `DBConnection.ConnectionError: connection not available and request was dropped from queue after 1000ms` rather than as a slow query.
Bang variants raise instead of returning tuples, so `Repo.get!/2` raises `Ecto.NoResultsError` which Phoenix maps to a 404. Upserts go through `Repo.insert(changeset, on_conflict: {:replace, [:name]}, conflict_target: :email)`, and search forms with no table behind them use a schemaless changeset built from `{%{}, %{query: :string}}`.
defmodule MyApp.Accounts.User do
use Ecto.Schema
import Ecto.Changeset
schema "users" do
field :email, :string
field :age, :integer
timestamps()
end
def changeset(user, attrs) do
user
|> cast(attrs, [:email, :age])
|> validate_required([:email])
|> validate_format(:email, ~r/@/)
|> unique_constraint(:email)
end
end
# Usage:
%User{} |> User.changeset(%{email: "a@b.c", age: 30}) |> Repo.insert()
Q5What is a Plug and how does it work?
BasicPlug
Answer
Plug is the Elixir equivalent of Rack (Ruby) or WSGI/ASGI (Python), a specification for composable HTTP middleware. A Plug is either a function `plug(conn, opts) -> conn` or a module with `init/1` and `call/2`. The router, controllers, and even individual actions are all built as Plugs.
The Conn (`%Plug.Conn{}`) struct flows through every Plug and accumulates state, headers, params, assigns, response body. Each Plug returns a possibly-modified Conn, which becomes input to the next Plug. If a Plug calls `halt(conn)`, the chain stops (used for auth failures, redirects).
The details interviewers actually probe: `init/1` is evaluated at compile time in production builds, so its options must be compile-time constants, and reading `System.get_env/1` there bakes the build machine's value into the release. Phoenix sets `:init_mode` to `:runtime` in dev so that recompiles pick changes up. `halt/1` only flips `conn.halted` to true; the function you are in keeps executing to its last line, and it is `Plug.Builder` that checks the flag before calling the next plug, so you must return the halted conn immediately rather than assuming halt behaves like `return`. A conn's response state moves from `:unset` to `:set` to `:sent`, and sending twice raises `Plug.Conn.AlreadySentError`, the classic symptom of a controller action that both redirects and renders.
Your `endpoint.ex` is itself a plug pipeline, and reading it top to bottom gives you the true request path: `Plug.Static`, `Plug.RequestId`, `Plug.Telemetry`, `Plug.Parsers`, `Plug.MethodOverride`, `Plug.Head`, `Plug.Session`, then the Router. Order is load-bearing, which is why a plug that reads `conn.body_params` has to sit after `Plug.Parsers`. Inside a controller you can scope a plug to specific actions with `plug :require_admin when action in [:edit, :delete]`, and `register_before_send/2` is the hook for response timing or headers computed after the body exists.
defmodule MyAppWeb.Plugs.RequireAuth do
import Plug.Conn
import Phoenix.Controller
def init(opts), do: opts
def call(conn, _opts) do
case get_session(conn, :user_id) do
nil -> conn |> redirect(to: "/login") |> halt()
_id -> conn
end
end
end
# In the router:
pipeline :authenticated do
plug MyAppWeb.Plugs.RequireAuth
end
Q6How do you query data with Ecto?
BasicDatabase
Answer
Two styles. (1) **Keyword syntax**, concise, declarative, recommended for most queries. (2) **Composable pipeline** with `from`, `where`, `order_by`, `select`, useful when you build queries from runtime conditions. Either compiles to parameterized SQL with placeholders, so you're protected from injection automatically. Use `Repo.all/1` for lists, `Repo.one/1` for a single result, `Repo.get/2` for a primary-key lookup, `Repo.get_by/2` for a lookup on any column, and `Repo.exists?/1` when you only need a boolean and want the SQL to be `SELECT 1 ...
LIMIT 1`. Beyond the basics, `select:` controls the shape you get back, so `select: %{id: u.id, email: u.email}` returns plain maps and skips struct building on large result sets, and `Repo.aggregate(Post, :count)` beats loading rows to call `length/1`. Anything SQL can do but Ecto has no DSL for goes through `fragment/1`, for example `where: fragment("? @> ?", p.tags, ^["elixir"])` for a Postgres array containment check.
For queries assembled from many optional filters, `Ecto.Query.dynamic/2` composes conditions into one `where` instead of a chain of pipes, and named bindings (`from p in Post, as: :post`) let a helper add `where([post: p], ...)` without knowing the join position. Two gotchas that bite in review: `where: u.email == nil` compiles to `= NULL` in SQL and never matches, so you need `is_nil(u.email)`; and `Repo.one/1` raises `Ecto.MultipleResultsError` if the query returns two rows, which is a real production crash when a supposedly unique column is not actually indexed. For exports, wrap `Repo.stream/2` in `Repo.transaction/1` so Postgres uses a cursor instead of loading the table into memory.
import Ecto.Query
# Keyword:
query = from u in User,
where: u.age >= 18 and not is_nil(u.email),
order_by: [desc: u.inserted_at],
limit: 10
adults = Repo.all(query)
# Composable:
def list_users(filters) do
User
|> maybe_filter_age(filters[:min_age])
|> order_by([u], desc: u.inserted_at)
|> Repo.all()
end
defp maybe_filter_age(q, nil), do: q
defp maybe_filter_age(q, age), do: where(q, [u], u.age >= ^age)
Key Points
- `^` interpolates Elixir values safely into SQL
- Repo.all/Repo.one/Repo.get cover most reads
Q7What is a controller in Phoenix and how does it return responses?
BasicControllers
Answer
A controller is a module whose functions (`action/2`) take a Conn plus params and return a Conn. Phoenix's `Phoenix.Controller` provides helpers like `render/3`, `redirect/2`, `json/2`, `put_status/2`, `put_flash/3`. For JSON APIs, you typically render via a separate `JSON` module that defines `render("index.json", %{posts: posts})`.
For HTML, you render HEEx templates via a `HTML` module. The split keeps view logic out of controllers. Phoenix 1.7 removed the old View layer: instead of `PostView`, the generators emit `MyAppWeb.PostHTML` and `MyAppWeb.PostJSON` modules that call `embed_templates "post_html/*"`, so `render(conn, :index, posts: posts)` resolves the atom `:index` against the format negotiated by `plug :accepts, ["html", "json"]`.
The hard rule is that an action must return a conn that has actually sent a response. Falling off the end of a function without rendering raises `** (Plug.Conn.NotSentError) a response was neither set nor sent from the connection`, and returning something that is not a conn raises `expected action/2 to return a Plug.Conn`. Useful escape hatches: `send_resp(conn, :no_content, "")` for a bare 204, `put_resp_header/3` for custom headers, `put_status(conn, :unprocessable_entity)` before `render` for a 422, and `put_layout(conn, html: false)` when you need a bare fragment for an HTMX-style partial.
You can also override `action/2` in a controller to inject an extra argument into every action, which is the idiomatic way to pass `conn.assigns.current_user` without repeating it in each function head. Interviewers often ask where authorization belongs: the answer is a plug scoped with `plug :require_owner when action in [:edit, :update, :delete]`, not an `if` at the top of each action.
defmodule MyAppWeb.PostController do
use MyAppWeb, :controller
alias MyApp.Blog
def index(conn, _params) do
posts = Blog.list_posts()
render(conn, :index, posts: posts)
end
def create(conn, %{"post" => params}) do
case Blog.create_post(params) do
{:ok, post} ->
conn |> put_flash(:info, "Created") |> redirect(to: ~p"/posts/#{post.id}")
{:error, changeset} ->
render(conn, :new, changeset: changeset)
end
end
end
Q8What is HEEx and how does it differ from EEx?
BasicTemplating
Answer
HEEx (HTML+EEx) is Phoenix's HTML template engine, introduced in 1.6 and now the default. It validates HTML at compile time, unclosed tags, mismatched attributes, and bad interpolation become compile errors. EEx (Embedded Elixir) is the older, plain-text template engine still used for non-HTML output (emails, text files).
HEEx also enables function components: reusable HTML snippets declared like Elixir functions, callable as `<.button>Save</.button>`. This is the foundation for LiveView's UI composition. Concretely, an unbalanced tag fails the build with something like `expected closing tag for <div> defined at line 4`, which is a class of bug EEx would have shipped to production.
Every interpolated value is escaped through the `Phoenix.HTML.Safe` protocol, so user content cannot inject markup unless you deliberately wrap it in `raw/1`. Attributes get real semantics too: `disabled={@disabled}` omits the attribute entirely when the value is false or nil rather than rendering `disabled="false"`, and `attr :rest, :global` plus `{@rest}` lets a component forward arbitrary HTML attributes. Declaring `attr :label, :string, required: true` and `slot :inner_block` gives you compile-time warnings when a caller forgets an attribute, which is the closest thing Elixir has to typed props.
Newer syntax matters in interviews: alongside `<%= @post.title %>`, HEEx now supports `{@post.title}` in body and attribute positions, and the special attributes `:if={@admin?}` and `:for={item <- @items}` replace the old block forms. The real payoff is structural: the compiler splits each template into a static list and a dynamic list, so LiveView can send only the changed dynamics over the WebSocket instead of re-serialising HTML. That is also why building HTML with string concatenation inside a component silently destroys change tracking.
# A HEEx template:
<div class="post">
<h1><%= @post.title %></h1>
<.button phx-click="like" id={"like-#{@post.id}"}>Like</.button>
<%= for comment <- @post.comments do %>
<p><%= comment.body %></p>
<% end %>
</div>
# Function component:
def button(assigns) do
~H"""
<button class="btn" {@rest}><%= render_slot(@inner_block) %></button>
"""
end
Q9How do you handle params and validate them in a controller?
BasicValidation
Answer
Controllers don't validate params themselves, that's the context/changeset's job. The controller pattern-matches on params, hands them to a context function like `Blog.create_post(params)`, and branches on the `{:ok, ...} | {:error, changeset}` result. The changeset returned on error contains all field-level error messages, which the template renders next to each input.
This separation means validation rules live in one place (the schema's `changeset/2`) and are reused across HTML and JSON endpoints. For JSON APIs, you also typically use `action_fallback` (set once at the top of the controller) so any `{:error, changeset}` automatically renders as a 422 response with the error map, keeping your action functions to a single happy-path line each. Before the changeset ever runs, the function head does the first filter: writing `def create(conn, %{"post" => post_params})` means a request without a `post` key never enters the body, and Phoenix converts that no-match into `Phoenix.ActionClauseError`, which `Plug.Exception` renders as 400 Bad Request rather than a 500.
Remember params keys are always strings, never atoms, because turning arbitrary user input into atoms would leak the atom table. Two more layers sit underneath: `Plug.Parsers` enforces `length: 8_000_000` by default and raises `Plug.Parsers.RequestTooLargeError` (413) past it, and an unknown content type raises `Plug.Parsers.UnsupportedMediaTypeError` (415). For live form validation you want the validation results without a write, which is `changeset |> Ecto.Changeset.apply_action(:validate)`; this returns `{:error, changeset}` with `action: :validate` set so HEEx renders the field errors, and it never touches the database.
On the JSON side, `Ecto.Changeset.traverse_errors/2` turns the error keyword list into a `%{email: ["has already been taken"]}` map that your FallbackController can render. The senior follow-up is usually about `phx-change` payloads: LiveView sends a `_target` key naming the field that changed, which is how you validate one field at a time instead of flashing errors on an untouched form.
defmodule MyAppWeb.PostController do
use MyAppWeb, :controller
action_fallback MyAppWeb.FallbackController
def create(conn, %{"post" => params}) do
with {:ok, post} <- Blog.create_post(params) do
conn |> put_status(:created) |> render(:show, post: post)
end
end
end
Q10What is `mix` and how do you start the Phoenix dev server?
BasicTooling
Answer
Mix is Elixir's build tool, equivalent of Cargo, Maven, or npm scripts. Phoenix ships dozens of Mix tasks. The ones you'll run daily: `mix phx.new my_app` (new project), `mix phx.gen.html` / `phx.gen.json` / `phx.gen.live` (generators for HTML, JSON API, and LiveView resources), `mix phx.gen.auth` (full auth scaffolding with verification, password reset, sessions), `mix ecto.create` / `ecto.migrate` / `ecto.rollback`, `mix test`, `mix deps.get`.
To start the server, `mix phx.server` compiles and listens on port 4000. The pro move is `iex -S mix phx.server`, which attaches an interactive Elixir REPL, you can call `Repo.all(User)` directly, inspect running GenServers with `:observer.start()`, hot-reload code with `recompile()`, and connect to a running production node over SSH for live debugging. There's no Phoenix equivalent of `rails console` because IEx is the console.
A few flags come up in interviews: `mix phx.new my_app --binary-id --database postgres --no-mailer` decides UUID primary keys and which adapters get wired in at generation time, and those choices are painful to reverse later. `mix.exs` defines aliases, which is why `mix ecto.setup` (create, migrate, seed) and `mix ecto.reset` exist even though Ecto ships no such tasks. `mix format` reads `.formatter.exs`, and the `import_deps: [:ecto, :phoenix]` line there is what teaches the formatter to leave `plug :accepts, ["html"]` and `from p in Post` alone. In CI you want `mix compile --warnings-as-errors`, because Phoenix surfaces unused assigns, undefined function components, and unmatched routes as warnings rather than errors. Production is different again: releases built with `MIX_ENV=prod mix release` do not ship Mix at all, so `mix ecto.migrate` is unavailable and you call a `MyApp.Release.migrate/0` module instead, and static assets must be fingerprinted with `mix assets.deploy` (which runs esbuild, tailwind, and `mix phx.digest`) or `Plug.Static` will serve unversioned files with no cache headers.
# Day-to-day
mix deps.get
mix ecto.setup # alias: create + migrate + seed
iex -S mix phx.server # server with an attached REPL
mix phx.routes # print the resolved routing table
mix test --failed --max-failures 1
# Generators
mix phx.gen.live Blog Post posts title:string body:text
mix phx.gen.json Blog Comment comments body:text post_id:references:posts
mix phx.gen.auth Accounts User users
# Release build (no Mix at runtime)
MIX_ENV=prod mix assets.deploy
MIX_ENV=prod mix release
_build/prod/rel/my_app/bin/my_app eval "MyApp.Release.migrate()"
_build/prod/rel/my_app/bin/my_app remote # attach IEx to the running node
Key Points
- `iex -S mix phx.server` = REPL attached to running server
- Code reload via `recompile()` without restart
- `:observer.start()` for live GUI of processes / memory
- Generators emit code you own, edit freely, unlike opaque scaffolds
Q11What is `assigns` and `@conn` in a Phoenix template?
BasicTemplating
Answer
When a controller calls `render(conn, :index, posts: posts)`, the keyword list (`posts: posts`) becomes the template's `assigns` map. Inside the template, you access them with `@posts`, which is shorthand for `assigns.posts`. The Conn itself is available as `@conn` for things like reading the current URL or session data.
In LiveView, the same convention applies: `socket.assigns.posts` in the LiveView module, `@posts` in the template. Phoenix tracks which assigns change so re-renders can be diffed efficiently, at compile time, the HEEx engine walks the template and figures out which `@var` appears in which static chunk, so on re-render it only re-evaluates the chunks whose dependencies changed. This is what makes LiveView's WebSocket payloads so small in practice.
The API around it is small but has sharp edges. `assign/3` overwrites, `assign_new/3` only computes the value if the key is missing, which is exactly how `fetch_current_user` avoids querying the users table twice when a plug already loaded it and the LiveView mount runs again over the WebSocket. `@conn` exists only in the controller-rendered world; inside a LiveView template it is `@socket`, and reaching for `@conn` there is a common early mistake because the request that started the process is long gone. Change tracking is the part people get wrong in production. HEEx rewrites `@posts` into a tracked read of `assigns.posts`, so touching `assigns` directly (for example `Map.get(assigns, :posts)` or passing the whole `assigns` map into a helper function) opts that chunk out of tracking and forces it to re-render on every event.
The same happens if you compute a derived value inside the template rather than assigning it in `handle_event/3`. For large lists that only ever grow, `temporary_assigns: [messages: []]` resets the assign to its default after each render so the process does not hold the history, and `stream/4` is the modern replacement that keeps the data on the client entirely.
# Controller sets assigns, template reads them with @
def show(conn, %{"id" => id}) do
render(conn, :show, post: Blog.get_post!(id), page_title: "Post")
end
# post_html/show.html.heex
<h1>{@post.title}</h1>
<p>Current path: {@conn.request_path}</p>
# LiveView: same @ syntax, but socket assigns and no @conn
def mount(_params, _session, socket) do
socket =
socket
|> assign(:filter, "all")
|> assign_new(:current_user, fn -> Accounts.get_user!(user_id) end)
{:ok, socket, temporary_assigns: [events: []]}
end
Q12How do you write Ecto migrations and what is `Ecto.Migration`?
BasicDatabase
Answer
Migrations are versioned changes to your database schema, stored under `priv/repo/migrations/` as timestamped Elixir files. Generate one with `mix ecto.gen.migration add_email_to_users`. Each migration module uses `Ecto.Migration` and defines a `change/0` function with helpers like `create table(:users)`, `add :email, :string`, `create unique_index(...)`.
Ecto handles up/down for most operations automatically, if you write `add`, it knows to `remove` on rollback. Run with `mix ecto.migrate`, undo with `mix ecto.rollback`. Migrations run inside a transaction by default; if you need a non-transactional operation (e.g. creating an index concurrently in Postgres), set `@disable_ddl_transaction true` at the top of the module, and pair it with `@migration_lock nil` so Ecto does not take its advisory lock around a statement that cannot run in a transaction. `mix ecto.migrations` prints the up/down status of every file, which is the first thing to check when a deploy 'succeeded' but the column is missing.
Two rules save real incidents. First, never reference a schema module such as `MyApp.Accounts.User` inside a migration: the migration is frozen in time, the schema is not, and a field renamed six months later breaks a fresh `mix ecto.migrate` on a new developer's machine. Query with `execute/1` or `Ecto.Adapters.SQL.query!/3` instead, and call `flush()` when a data migration needs the preceding DDL to be applied first.
Second, anything Ecto cannot reverse needs the two-function form `def up` and `def down`, or a rollback will raise `Ecto.MigrationError: cannot reverse migration`. `alter table(:users) do modify :role, :string, from: :integer end` is reversible precisely because you supplied the `from:`. Ecto tracks applied versions in the `schema_migrations` table, `mix ecto.migrate --step 1` moves one at a time, and in a release you run `MyApp.Release.migrate/0` through `bin/my_app eval` because Mix is not shipped with the release.
defmodule MyApp.Repo.Migrations.CreateUsers do
use Ecto.Migration
def change do
create table(:users) do
add :email, :string, null: false
add :hashed_password, :string
timestamps()
end
create unique_index(:users, [:email])
end
end
Q13Which HTTP server does a new Phoenix app run on, and what does the Endpoint actually do?
BasicEndpoint
Answer
Since Phoenix 1.7.11 new projects boot on Bandit rather than Cowboy, configured as `adapter: Bandit.PhoenixAdapter` in the endpoint config. Bandit is written in Elixir on top of Thousand Island, gives clearer stacktraces when a plug raises, and generally benchmarks faster on keep-alive HTTP/1.1 traffic. Cowboy is still fully supported through `plug_cowboy`, and because both implement the `WebSock` behaviour behind `WebSockAdapter`, Channels and LiveView work identically on either, which is why swapping servers is a two-line change rather than a migration.
The Endpoint itself is two things at once. It is a Plug pipeline, so `endpoint.ex` read top to bottom is the literal list of everything that touches a request before your router sees it: `Plug.Static` (with `only: MyAppWeb.static_paths()` and `gzip: true`), the code reloader and `Phoenix.LiveReloader` in dev, `Plug.RequestId`, `Plug.Telemetry`, `Plug.Parsers`, `Plug.Session`, then the Router. It is also a supervisor and a module of runtime helpers: it starts the web server and the socket transports declared with `socket "/live", Phoenix.LiveView.Socket`, and it exposes `MyAppWeb.Endpoint.url/0`, `config/2`, and `broadcast/3`. Two configuration details cause real incidents: `server: true` must be set (usually via `PHX_SERVER=true` in `runtime.exs`) or a release boots happily and listens on nothing, and `check_origin` must list your production host or WebSocket handshakes fail with a 403 that never appears in development.
# lib/my_app_web/endpoint.ex
defmodule MyAppWeb.Endpoint do
use Phoenix.Endpoint, otp_app: :my_app
@session_options [
store: :cookie,
key: "_my_app_key",
signing_salt: "aBcD1234",
same_site: "Lax"
]
socket "/live", Phoenix.LiveView.Socket,
websocket: [connect_info: [session: @session_options]]
plug Plug.Static, at: "/", from: :my_app, gzip: true,
only: MyAppWeb.static_paths()
plug Plug.RequestId
plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint]
plug Plug.Parsers, parsers: [:urlencoded, :multipart, :json],
json_decoder: Phoenix.json_library()
plug Plug.Session, @session_options
plug MyAppWeb.Router
end
# config/config.exs
config :my_app, MyAppWeb.Endpoint, adapter: Bandit.PhoenixAdapter
Key Points
- Bandit is the default adapter in new apps; Cowboy still works via `plug_cowboy`
- Endpoint = plug pipeline + supervisor + `socket/3` transport declarations
- `server: true` (PHX_SERVER) is required in releases or nothing listens
- `check_origin` misconfiguration shows up only in production, as WebSocket 403s
Q14How is a Phoenix application started and supervised at boot?
BasicOTP
Answer
`mix.exs` declares `mod: {MyApp.Application, []}`, so the BEAM calls `MyApp.Application.start/2`, which returns `Supervisor.start_link(children, strategy: :one_for_one, name: MyApp.Supervisor)`. Everything long-lived in your app is in that children list, and the order is deliberate: `MyAppWeb.Telemetry` first so metrics exist before anything can emit them, then `MyApp.Repo`, `{DNSCluster, query: ...}`, `{Phoenix.PubSub, name: MyApp.PubSub}`, an HTTP client pool such as `{Finch, name: MyApp.Finch}`, and `MyAppWeb.Endpoint` last, because the endpoint is what starts accepting traffic and you do not want requests arriving before the database pool exists. Adding your own worker means adding `{MyApp.RateLimiter, []}` to the same list; the tuple form calls that module's `child_spec/1`.
The behaviour under failure is the part interviewers actually test. With `:one_for_one`, a crashed child is restarted alone; `:one_for_all` restarts every sibling, which is what you want when children share state; `:rest_for_one` restarts the crashed child and everything started after it, which suits a pipeline where later stages depend on earlier ones. Restart intensity is the trap: the default of 3 restarts in 5 seconds means a GenServer that crash-loops on a bad environment variable takes the supervisor down with it, and because that supervisor is the application root, the whole node exits. A pod that boots and dies every 20 seconds in Kubernetes is almost always this, not the framework, and the fix is either a `:transient` restart value or not crashing in `init/1`.
# lib/my_app/application.ex
defmodule MyApp.Application do
use Application
@impl true
def start(_type, _args) do
children = [
MyAppWeb.Telemetry,
MyApp.Repo,
{DNSCluster, query: Application.get_env(:my_app, :dns_cluster_query) || :ignore},
{Phoenix.PubSub, name: MyApp.PubSub},
{Finch, name: MyApp.Finch},
{Task.Supervisor, name: MyApp.TaskSupervisor},
MyApp.RateLimiter,
MyAppWeb.Endpoint
]
Supervisor.start_link(children,
strategy: :one_for_one,
name: MyApp.Supervisor,
max_restarts: 3,
max_seconds: 5
)
end
@impl true
def config_change(changed, _new, removed) do
MyAppWeb.Endpoint.config_change(changed, removed)
:ok
end
end
Key Points
- Endpoint goes last so traffic only arrives after Repo and PubSub are up
- `:one_for_one` vs `:one_for_all` vs `:rest_for_one` encode child dependencies
- 3 restarts in 5 seconds kills the root supervisor and the whole node
- Fire-and-forget work belongs under a `Task.Supervisor`, not a bare `spawn`
Q15What are Phoenix Channels and how do they work?
IntermediateChannels
Answer
Channels are Phoenix's abstraction over WebSockets (with longpoll fallback) for soft real-time communication. The model is publish/subscribe over named topics like `"room:lobby"` or `"user:42"`. Clients connect via a Socket (one TCP connection per browser), join one or more topics, and exchange messages.
Each joined topic runs as its own Elixir process, if one channel crashes, others on the same socket survive. This is how Discord scales to billions of messages: a single node easily handles 200k+ concurrent connections because each is just a tiny process, not an OS thread. The transport is declared in `endpoint.ex` with `socket "/socket", MyAppWeb.UserSocket, websocket: [connect_info: [:peer_data, session: @session_options], timeout: 45_000], longpoll: false`, and the JS client sends a heartbeat every 30 seconds so the server can reap sockets whose TCP connection died without a FIN.
Inside a channel the return shape of `handle_in/3` decides what the client sees: `{:noreply, socket}` sends nothing back, while `{:reply, {:ok, %{id: id}}, socket}` is what makes `channel.push("new_msg", body).receive("ok", fn)` fire, which is how you get request/response semantics over a push-based protocol. `push/3` targets the one connected client, `broadcast!/3` hits every subscriber on the topic including the sender, and `broadcast_from!/4` excludes the sender, which is the usual choice when the sender already rendered its own message optimistically. `intercept ["new_msg"]` plus `handle_out/3` lets you tailor the payload per subscriber (for example hiding a blocked user's message), but it moves work from one broadcast into N channel processes, so it is a known scaling trap on hot topics. Failure behaviour is the other half of the answer: if a channel process crashes, only that topic dies, the JS client receives `phx_error` and rejoins with exponential backoff, and because the rejoin calls `join/3` again, your join has to be idempotent and cheap.
# lib/my_app_web/channels/room_channel.ex
defmodule MyAppWeb.RoomChannel do
use MyAppWeb, :channel
def join("room:" <> room_id, _params, socket) do
if authorized?(socket, room_id) do
{:ok, assign(socket, :room_id, room_id)}
else
{:error, %{reason: "unauthorized"}}
end
end
def handle_in("new_msg", %{"body" => body}, socket) do
broadcast!(socket, "new_msg", %{body: body, user_id: socket.assigns.user_id})
{:noreply, socket}
end
end
# In the JS client:
let channel = socket.channel("room:lobby", {})
channel.join().receive("ok", resp => console.log("joined"))
channel.on("new_msg", payload => render(payload))
Key Points
- One Elixir process per joined topic
- Pub/sub via `broadcast!/3`
- Built-in longpoll fallback for old browsers
Q16How do you authenticate WebSocket / Channel connections?
IntermediateChannels
Answer
Two places to verify: the socket connect (one-time, when the WebSocket opens) and the channel join (per-topic). At connect, the client sends a token (typically a Phoenix.Token signed during page load), which `connect/3` decodes to extract the user id and assign it to the socket. At join, you re-check that the user is allowed in that specific topic, important because the same socket may join `user:42` and `admin:audit-log`, and only the second needs an admin role check.
The token is minted server side during page render with `Phoenix.Token.sign(MyAppWeb.Endpoint, "user socket", user.id)` and dropped into a meta tag, and `max_age:` is enforced at verify time, so a stale tab reconnecting after a day gets `{:error, :expired}` and the JS client should reload rather than retry forever. Cookies do travel with the WebSocket handshake, so you can instead pass `connect_info: [session: @session_options]` in the endpoint's socket declaration and read the Plug session inside `connect/3`; the reason many teams still prefer a token is that it is explicit about scope and cannot be replayed cross-origin. Related config: `check_origin: ["https://app.example.com"]` on the endpoint, which is what produces the production-only failure `Could not check origin for Phoenix.Socket transport` and a 403 handshake after someone puts the app behind a new domain.
The subtle production issue is revocation. Authentication happens once at connect, so a user you ban or log out keeps their socket until something closes it, which is exactly what the `id/1` callback plus `Endpoint.broadcast("users_socket:42", "disconnect", %{})` exists to solve. LiveView has the same mechanism under a different name, `live_socket_id` in the session. Finally, returning `{:error, %{reason: "unauthorized"}}` from `join/3` reaches the client as `.receive("error", ...)`, and returning `:error` from `connect/3` fails the handshake outright.
# lib/my_app_web/channels/user_socket.ex
defmodule MyAppWeb.UserSocket do
use Phoenix.Socket
channel "room:*", MyAppWeb.RoomChannel
def connect(%{"token" => token}, socket, _info) do
case Phoenix.Token.verify(socket, "user socket", token, max_age: 86400) do
{:ok, user_id} -> {:ok, assign(socket, :user_id, user_id)}
{:error, _} -> :error
end
end
def connect(_, _, _), do: :error
def id(socket), do: "users_socket:#{socket.assigns.user_id}"
end
Q17What is Phoenix.PubSub and when do you use it directly?
IntermediatePubSub
Answer
PubSub is the publish/subscribe layer underneath Channels and LiveView. Channels use it to broadcast messages to all subscribers of a topic. LiveView uses it to push assigns to mounted views.
You'll use it directly when one part of your app needs to notify another that something happened, a payment processed in the Billing context can broadcast `{:payment_succeeded, payment_id}` and the Notifications context subscribes to react. Defaults to PG2 (in-cluster Erlang process groups); switch to Redis adapter when you can't form a BEAM cluster across regions. It is started once in your application supervision tree as `{Phoenix.PubSub, name: MyApp.PubSub}`, and subscriptions are per process: whichever process calls `subscribe/2` is the one whose mailbox receives the message, and the subscription is cleaned up automatically when that process dies, which is why LiveViews never need to unsubscribe.
The delivery semantics are the part interviewers care about. PubSub is fire and forget with no persistence, no acknowledgement, and no replay, so a subscriber that is restarting when the broadcast happens simply misses it. That makes it correct for 'refresh the screen' and wrong for 'charge the card': anything that must survive a crash belongs in Oban or a database row, with PubSub used only to nudge listeners that the row exists.
Ordering holds between one sender and one receiver, not globally across senders. Cost matters at scale too, because the PG2 adapter copies the message once per subscribing process per node, so broadcasting a 200KB struct to 5,000 subscribers really does copy 200KB 5,000 times. Broadcast ids and let each subscriber fetch what it needs. Two API details worth naming: `broadcast_from/4` skips the sending process, and `local_broadcast/3` stays on the current node when you know the subscribers cannot be remote.
# Publisher (anywhere in your app):
Phoenix.PubSub.broadcast(MyApp.PubSub, "orders", {:order_placed, order})
# Subscriber (e.g. in a GenServer init):
Phoenix.PubSub.subscribe(MyApp.PubSub, "orders")
# Receive the message:
def handle_info({:order_placed, order}, state) do
send_email(order)
{:noreply, state}
end
Q18What is LiveView and what is its lifecycle?
IntermediateLiveView
Answer
LiveView is a Phoenix library for building reactive, real-time UIs in pure Elixir, no client JS framework needed. A request to a LiveView route triggers two render passes: first an HTTP `mount/3` + `render/1` that returns a fully-rendered HTML page (great for SEO and first paint), then a WebSocket upgrade where the same `mount/3` runs again, this time stateful, holding a `socket` with assigns in process memory. Subsequent user events (`phx-click`, `phx-change`, `phx-submit`) call `handle_event/3`, you update `socket.assigns`, and LiveView sends back only the diffed HTML over the wire.
Typical payload after the first render is a few hundred bytes per event. The full callback order is `mount/3`, then `handle_params/3` (which also runs on every `push_patch` and live navigation), then `render/1`; after that the process sits idle until `handle_event/3`, `handle_info/2`, or `handle_async/3` wakes it and triggers another render. Branch on `connected?(socket)` when something should happen only once: subscribing to PubSub, starting a timer, or issuing an expensive query belongs behind that guard, otherwise it runs twice per page load.
The double mount is also why side effects in `mount/3` are a bug rather than a style issue, since sending a welcome email there sends two. Because state lives in a process, everything must be rebuildable from params and session: if the WebSocket drops (a laptop lid, a load balancer idle timeout, a deploy), the client reconnects, the old process is gone, and `mount/3` runs from scratch. Session data reaches mount as the second argument and is signed, and under `live_session` only the keys you list in `session:` are passed. `terminate/2` is not guaranteed to run, so never treat it as a place to flush state. The router supplies `socket.assigns.live_action` from the `live "/posts/:id/edit", PostLive.Show, :edit` form, which is how one LiveView renders both a show page and an edit modal.
defmodule MyAppWeb.CounterLive do
use MyAppWeb, :live_view
def mount(_params, _session, socket) do
{:ok, assign(socket, :count, 0)}
end
def handle_event("inc", _, socket) do
{:noreply, update(socket, :count, &(&1 + 1))}
end
def render(assigns) do
~H"""
<p>Count: <%= @count %></p>
<button phx-click="inc">+</button>
"""
end
end
Key Points
- Mount runs twice: HTTP first (SEO), then WebSocket (stateful)
- State lives in process memory tied to the WebSocket
- Only diffs travel over the wire, not full HTML
Q19How do you avoid N+1 queries in Ecto?
IntermediateDatabase
Answer
The classic Ecto trap: you fetch a list of posts, then in the template do `<%= post.author.name %>`, Ecto raises `Ecto.Association.NotLoaded` unless you preloaded the association. The fix is `Repo.preload/2` or `preload:` in the query. `Repo.preload` fires one extra query per association (still N+1-safe, it's a single `WHERE id IN (...)` lookup, not one per row). For multiple joins, prefer `join: ... preload:` to keep it to one SQL statement.
The mistake to watch for: calling `Repo.preload` inside a loop, which defeats the batching. Choosing between the two strategies is the part that separates a junior answer from a senior one. A join-preload is right for `belongs_to`, where each parent has exactly one child, but for `has_many` it produces a cartesian result: a post with 50 comments arrives as 50 duplicated post rows on the wire, which is slower than the extra round trip you were trying to avoid.
Separate preloads also parallelise, since Ecto runs them in their own tasks by default. Nested preloads compose with `Repo.preload(posts, comments: :author)`, and you can preload with a custom query using `preload: [comments: ^from(c in Comment, order_by: [desc: c.inserted_at])]`. The trap there is `limit`, which applies to the entire preload query rather than per parent, so 'latest three comments per post' needs a lateral join or a window function, not `limit: 3`.
Detection is straightforward once you know where to look: an unloaded association inspects as `#Ecto.Association.NotLoaded<:author>`, and the `[:my_app, :repo, :query]` telemetry event gives you a per-request query count you can log or assert on in tests. In LiveView the N+1 usually hides in a component that preloads per row, so check the render path, not just the context function.
# BAD, N+1 if you access .author later:
posts = Repo.all(Post)
# GOOD, one extra query, regardless of number of posts:
posts = Repo.all(Post) |> Repo.preload(:author)
# BEST, single query with join:
from(p in Post, join: a in assoc(p, :author), preload: [author: a])
|> Repo.all()
Q20What is Ecto.Multi and when should you reach for it?
IntermediateDatabase
Answer
`Ecto.Multi` composes multiple database operations into a single transaction with structured error handling. Without Multi, you'd write nested case statements or pile everything into a `Repo.transaction(fn -> ... end)` block where any raise rolls back. Multi gives you a pipeline: each step has a name; on success, later steps can read earlier results; on failure, you get `{:error, failed_step, failed_value, changes_so_far}`, so you know exactly which step blew up.
Critical for any flow that touches two or more tables, orders + line items, user signup + verification email row, etc. Mechanically, `Multi.run/3` must return `{:ok, value}` or `{:error, value}`, and returning anything else raises; step names must be unique or building the Multi fails immediately; and `Multi.insert_all/4`, `Multi.update_all/4`, and `Multi.delete_all/3` exist so bulk operations join the same transaction instead of running outside it. The mistake that reaches production is putting side effects inside a step. If `Multi.run(:notify, ...)` sends an email or calls Razorpay and a later step fails, Postgres rolls back the rows but the email is already gone and the charge already happened.
Side effects belong after `Repo.transaction/1` returns `{:ok, changes}`, or in an Oban job inserted inside the transaction so it only becomes visible if the commit succeeds. The second production concern is duration: the whole Multi runs on one checked-out pooled connection inside one Postgres transaction, so an external HTTP call in the middle holds both a connection and row locks, and under load that turns into `DBConnection.ConnectionError` for everyone else. Keep the transaction short, set `Repo.transaction(multi, timeout: 15_000)` deliberately, and know that concurrent writers on the same rows can surface as a Postgres 40001 serialization failure that your caller has to retry. `Multi.append/2` lets two contexts each contribute steps to one transaction without knowing about each other.
alias Ecto.Multi
def register_user(attrs) do
Multi.new()
|> Multi.insert(:user, User.changeset(%User{}, attrs))
|> Multi.insert(:profile, fn %{user: user} ->
Profile.changeset(%Profile{user_id: user.id}, attrs)
end)
|> Multi.run(:audit, fn _repo, %{user: user} ->
Audit.record("user.created", user.id)
end)
|> Repo.transaction()
end
# Returns {:ok, %{user: u, profile: p, audit: a}} or {:error, step, val, changes}
Q21How do you handle file uploads in LiveView?
IntermediateLiveView
Answer
LiveView has first-class uploads via `allow_upload/3` in `mount` and the `<.live_file_input>` component. The browser sends chunks over the existing WebSocket, so you don't need a separate upload endpoint. In `handle_event("save", ...)` you call `consume_uploaded_entries/3` to move temp files to their final location, local disk, S3, or wherever.
Built-in progress events (`@uploads.avatar.progress`) make progress bars trivial. For S3 uploads at scale, swap to `:external` mode where LiveView signs a presigned URL and the browser uploads direct to S3. The options worth knowing by name are `accept:` (extensions or MIME types, or `:any`), `max_entries:`, `max_file_size:` (8MB default, and it is enforced on the server, not just in the browser), `chunk_size:`, `auto_upload: true` to start transferring as soon as a file is selected, and `progress:` to run a callback per chunk.
Validation failures do not raise: they land in `upload_errors(@uploads.avatar, entry)` as atoms like `:too_large`, `:not_accepted`, and `:too_many_files`, and you are expected to map those to human strings yourself. `<div phx-drop-target={@uploads.avatar.ref}>` gives drag and drop for free, and cancelling needs `phx-click="cancel" phx-value-ref={entry.ref}` wired to `cancel_upload/3`. Two failure modes come up in real systems. First, entries are consumable exactly once, and `consume_uploaded_entries/3` deletes the temp file afterwards, so a second call returns an empty list and your 'save again' button silently uploads nothing.
Second, the temp file is owned by the LiveView process, so if the user closes the tab while your handler is streaming a 200MB file to S3, the process dies and the partial upload is orphaned. That is the main argument for `:external` mode, where the bytes never touch your server; it needs a bucket CORS policy that allows PUT from your origin plus a JS uploader registered under `uploaders:` on the LiveSocket.
def mount(_params, _session, socket) do
{:ok, allow_upload(socket, :avatar, accept: ~w(.png .jpg), max_entries: 1, max_file_size: 5_000_000)}
end
def handle_event("save", _params, socket) do
paths = consume_uploaded_entries(socket, :avatar, fn %{path: tmp}, _entry ->
dest = Path.join("priv/static/uploads", Path.basename(tmp))
File.cp!(tmp, dest)
{:ok, "/uploads/#{Path.basename(dest)}"}
end)
{:noreply, assign(socket, :avatar_url, hd(paths))}
end
Q22What are LiveView Hooks and when do you need them?
IntermediateLiveView
Answer
Hooks are the escape hatch when LiveView's server-driven model isn't enough, you need actual JavaScript on the client. Examples: integrating a charting library (Chart.js, ApexCharts), an editor (CodeMirror, TipTap), drag-and-drop, copying to clipboard, or any DOM API LiveView can't synthesize over the wire. You declare a hook with a `phx-hook="MyChart"` attribute, and register it in your JS app.
The hook receives lifecycle callbacks (`mounted`, `updated`, `destroyed`) and can push events back to the server with `this.pushEvent("name", payload)`. The full callback set is `mounted`, `beforeUpdate`, `updated`, `destroyed`, `disconnected`, and `reconnected`, and the last two matter more than people expect: when the WebSocket drops and comes back, LiveView re-patches the DOM, so a chart or editor that held client-only state needs to rebuild or resync there. Communication runs both ways. `this.pushEvent(name, payload, reply => ...)` sends to the LiveView, `this.pushEventTo(this.el, ...)` or `pushEventTo("#comp-id", ...)` targets a specific LiveComponent, and on the server `push_event(socket, "highlight", %{id: id})` is received by `this.handleEvent("highlight", cb)` in any mounted hook.
Requirements that trip people up: the element carrying `phx-hook` must have a unique `id` or LiveView raises at runtime, and any element whose DOM a JS library mutates needs `phx-update="ignore"` or the morphdom patch will delete the library's nodes on the next render. Always tear down in `destroyed()`, since a long-lived LiveView that mounts and unmounts a hook repeatedly will otherwise leak listeners and timers in the browser. Recent LiveView versions also support colocated hooks, where the JavaScript is declared next to the component that uses it instead of in a central `Hooks` object in `app.js`, which keeps a component and its client code in one file.
// assets/js/app.js
let Hooks = {
CodeMirrorHook: {
mounted() {
this.editor = CodeMirror(this.el, { value: this.el.dataset.value })
this.editor.on("change", () => {
this.pushEvent("code_changed", { value: this.editor.getValue() })
})
},
destroyed() { this.editor = null }
}
}
let liveSocket = new LiveSocket("/live", Socket, { hooks: Hooks })
# Template:
<div id="editor" phx-hook="CodeMirrorHook" data-value={@code} phx-update="ignore" />
Q23What is Phoenix Presence and how does it work?
IntermediateChannels
Answer
Presence tracks who is connected in real time, 'show me everyone in this room', 'is user 42 online'. It's built into Phoenix and uses a CRDT (conflict-free replicated data type) to stay consistent across multiple nodes without coordination. Each node maintains its own copy; when nodes gossip, they merge deterministically.
You call `Presence.track/3` in a channel's `join`, and `Presence.list/1` to get the current map of connected users with metadata (last seen, status, etc.). When users join or leave, all subscribers get a `presence_diff` event with the delta whose payload is shaped `%{joins: %{}, leaves: %{}}`. Setup detail people forget: the Presence module is `use Phoenix.Presence, otp_app: :my_app, pubsub_server: MyApp.PubSub` and must be added to the application supervision tree, and in LiveView you call the process form `Presence.track(self(), topic, user_id, %{online_at: ...})` rather than the socket form.
The data model is a map of key to a list of metas, not a single entry per user, because one person with three tabs and a phone produces four metas under the same key. That is what lets you show 'online' correctly until the last device disconnects, and it is why counting `map_size(Presence.list(topic))` gives you users while summing the metas gives you connections. The `fetch/2` callback is the important optimisation: it runs once per diff on the server, so you enrich presences with database data there instead of doing a query per subscriber. Two operational realities: presence state is in memory only, so a rolling deploy makes every entry on the restarted node disappear until clients rejoin, and `list/1` on a topic with tens of thousands of members builds a very large map on every diff, so for big rooms track an aggregate counter and reserve Presence for small collaborative spaces.
def join("room:" <> _, _params, socket) do
send(self(), :after_join)
{:ok, socket}
end
def handle_info(:after_join, socket) do
push(socket, "presence_state", MyAppWeb.Presence.list(socket))
{:ok, _} = MyAppWeb.Presence.track(socket, socket.assigns.user_id, %{
online_at: System.system_time(:second),
typing: false
})
{:noreply, socket}
end
Q24How do you test a LiveView?
IntermediateTesting
Answer
Use `Phoenix.LiveViewTest`. The two key helpers are `live/2` (mounts the LiveView and returns a `view`) and `render_click/3`, `render_submit/3`, `render_change/3` for simulating events. Assertions are usually against the rendered HTML or against `socket.assigns` via `:sys.get_state/1`.
Because LiveView is server-side, you don't need a browser, tests run in milliseconds. For things hooks do, you typically extract the server-side logic to test it directly and rely on a small Wallaby or Cypress smoke test for the JS integration (Playwright is the more common choice for that browser layer in 2026). The helpers worth naming: `element(view, "#save", "Save")` scopes an action to a selector, `has_element?/3` is the assertion form, and `form(view, "#user-form", user: %{email: "a@b.c"})` is better than `render_submit` with a raw map because `form/3` checks that the fields actually exist in the rendered DOM, so renaming an input breaks the test instead of silently passing.
Navigation and side effects get their own assertions: `assert_patch(view, "/orders?status=paid")`, `assert_redirect(view, "/login")`, `assert_push_event(view, "scroll_to", %{id: _})`, and `render_async(view)` to await `assign_async` results. Authorization tests read nicely because an unauthenticated `live/2` returns `{:error, {:redirect, %{to: "/users/log_in"}}}` rather than raising. For PubSub-driven updates, just `send(view.pid, {:new_comment, comment})` and assert on the re-render, which avoids sleeping on real broadcasts.
Tests run with `async: true` under the Ecto SQL sandbox, and the debugging trick nobody mentions is `open_browser(view)`, which writes the current DOM to a temp file and opens it so you can see what the assertion was matching against. Anything living inside a `phx-hook` is invisible to these tests by design, so keep hooks thin and push the decisions to the server.
test "increments counter", %{conn: conn} do
{:ok, view, html} = live(conn, ~p"/counter")
assert html =~ "Count: 0"
assert view |> element("button", "+") |> render_click() =~ "Count: 1"
assert view |> element("button", "+") |> render_click() =~ "Count: 2"
end
Q25What is `mix phx.gen.auth` and what does it scaffold?
IntermediateAuthentication
Answer
`mix phx.gen.auth Accounts User users` scaffolds a complete authentication system: user schema with `hashed_password` (using bcrypt or pbkdf2), registration, login, email confirmation, password reset, session management, and remember-me cookies. Generates ~15 files in your context and web layer, all editable. It's deliberately not a library; you own the code.
In 1.8 with the new scope-based auth, the generator emits a `MyAppWeb.UserAuth` module with helpers like `require_authenticated_user`, `redirect_if_user_is_authenticated`, and a `current_scope` assign that propagates the current user through controllers and LiveViews. Concretely you get `lib/my_app/accounts.ex`, `accounts/user.ex`, `accounts/user_token.ex`, `accounts/user_notifier.ex`, `lib/my_app_web/user_auth.ex`, the session and registration controllers or LiveViews, plus the matching tests, and the generator refuses to run on a dirty git tree so you can always diff what it wrote. The security design is what makes it interview-worthy.
Tokens are random 32-byte values sent to the user, but only their SHA-256 hash is stored in `users_tokens`, so a database leak does not hand an attacker live sessions. Session tokens, remember-me cookies (60 days, signed and http-only), confirmation tokens, and reset tokens are separate contexts with separate validity windows, so a password-reset link cannot be replayed as a session. `UserAuth.log_in_user/3` renews the session id to defeat session fixation, and the login path calls `Bcrypt.no_user_verify()` when the email does not exist so response timing does not reveal which accounts are real. You choose the hashing library at generation time with `--hashing-lib bcrypt|pbkdf2|argon2`. For LiveViews the generator wires `live_session :require_authenticated_user, on_mount: [{MyAppWeb.UserAuth, :ensure_authenticated}]`, which is the correct place to enforce auth, because a plug in the router only guards the initial HTTP request and not the subsequent WebSocket mount.
# router.ex, as generated
scope "/", MyAppWeb do
pipe_through [:browser, :require_authenticated_user]
live_session :require_authenticated_user,
on_mount: [{MyAppWeb.UserAuth, :ensure_authenticated}] do
live "/users/settings", UserSettingsLive, :edit
end
end
# lib/my_app_web/user_auth.ex, the mount hook
def on_mount(:ensure_authenticated, _params, session, socket) do
socket = mount_current_user(socket, session)
if socket.assigns.current_user do
{:cont, socket}
else
{:halt, Phoenix.LiveView.redirect(socket, to: ~p"/users/log_in")}
end
end
Q26How do you broadcast a message from a controller to all LiveViews?
IntermediateLiveView
Answer
Broadcast over PubSub from the controller (or any context function), and have the LiveView subscribe in `mount/3`. When the message arrives, `handle_info/2` updates assigns and LiveView re-renders. This is the canonical pattern for 'something happened in another part of the app, refresh the screen': new comment posted from API → all LiveViews showing the post update; admin marks an order as shipped → customer's order page updates without refresh.
The same pattern works backwards too, a LiveView can broadcast to channels (mobile clients) and to other LiveViews simultaneously, because PubSub doesn't care who's listening. Three things go wrong in practice. First, broadcasting inside a `Repo.transaction/1` is a race: subscribers can receive the message and query the row before the transaction commits, so the LiveView refreshes and finds nothing.
Broadcast after `Repo.transaction/1` returns `{:ok, _}`, or insert an Oban job in the same transaction and broadcast from the job. Second, if the LiveView defines `handle_info/2` for some messages but no clause matches the one that arrives, the process dies with a `FunctionClauseError`, the client reconnects, and the user sees a flash of a fully remounted page; add a catch-all clause that returns `{:noreply, socket}` when you subscribe to a topic that carries several message shapes. Third, broadcasting full structs couples the publisher to every subscriber's data needs and copies the payload once per subscriber.
Sending `{:new_comment, comment.id}` and letting each LiveView load what it renders is usually cheaper and always easier to change. Topic naming is the design decision that decides your fan-out cost: `"post:#{id}"` wakes only the viewers of that post, while `"comments"` wakes every LiveView in the cluster. If the originating LiveView already applied the change optimistically, use `Phoenix.PubSub.broadcast_from(MyApp.PubSub, self(), topic, msg)` so it does not process its own update twice.
# In a controller:
def create(conn, %{"comment" => params}) do
{:ok, comment} = Blog.create_comment(params)
Phoenix.PubSub.broadcast(MyApp.PubSub, "post:#{comment.post_id}", {:new_comment, comment})
json(conn, comment)
end
# In the LiveView:
def mount(%{"id" => post_id}, _session, socket) do
if connected?(socket), do: Phoenix.PubSub.subscribe(MyApp.PubSub, "post:#{post_id}")
{:ok, assign(socket, :comments, Blog.list_comments(post_id))}
end
def handle_info({:new_comment, comment}, socket) do
{:noreply, update(socket, :comments, &[comment | &1])}
end
Key Points
- Subscribe inside `if connected?(socket)` so it only runs on the WebSocket mount
- Match the topic string exactly between publisher and subscriber
Q27What's the difference between `push_patch`, `push_navigate`, and JS commands in LiveView?
IntermediateLiveView
Answer
Three navigation primitives that handle progressively heavier transitions. **`push_patch/2`** updates the URL and triggers `handle_params/3` in the SAME LiveView process, used for tab changes, filter updates, pagination where you want to keep mounted state. No re-mount, no flash of unstyled content. **`push_navigate/2`** unmounts the current LiveView and mounts a new one over the same WebSocket, used when you move between distinct pages (post list → post detail). Faster than a full HTTP redirect because the socket stays open. **`Phoenix.LiveView.JS` commands** are client-side DSL for operations that don't need a server round-trip at all: show/hide modals, toggle classes, focus inputs, transition animations. `JS.push("event")` can also fire a server event with optional client-side optimistic updates.
Rule of thumb: if you can express the change as a CSS toggle, use `JS` commands; if the URL changes within one logical page, `push_patch`; if you cross pages, `push_navigate`. In templates the same three appear as `<.link patch={~p"/orders?page=2"}>`, `<.link navigate={~p"/orders/#{id}"}>`, and plain `<.link href={...}>` for a full HTTP request. The constraint people get wrong in interviews is that `push_navigate` only keeps the socket alive when the destination lives in the same `live_session` block; cross a `live_session` boundary and LiveView deliberately forces a full page reload so the new session and auth hooks are re-established from scratch.
Also remember `handle_params/3` fires on the initial mount as well as on every patch, so it must be safe to run twice, and the browser back button replays patches through the same callback, which is why filter state belongs in the URL rather than only in assigns. On the client side, `JS` commands compose with a pipe: `JS.push("save", target: @myself, loading: "#form")` fires a server event while applying a loading class, `JS.transition({"ease-out duration-200", "opacity-0", "opacity-100"}, time: 200)` needs both the class list and a matching `time:` or the element is removed before the animation ends, and `JS.dispatch("app:copy", to: "#token")` hands off to a plain DOM event listener. For the common case of a submit button that must not be double-clicked, `phx-disable-with="Saving..."` already does it with no JS command at all.
# push_patch, same LiveView, new params:
def handle_event("filter", %{"status" => status}, socket) do
{:noreply, push_patch(socket, to: ~p"/orders?status=#{status}")}
end
def handle_params(params, _uri, socket) do
{:noreply, assign(socket, :orders, Orders.list(params))}
end
# push_navigate, different LiveView:
{:noreply, push_navigate(socket, to: ~p"/orders/#{order.id}")}
# JS commands in a template, no server round-trip:
<button phx-click={JS.toggle(to: "#menu") |> JS.add_class("open", to: "#menu")}>
Toggle
</button>
Key Points
- `push_patch` → same LiveView, URL change, `handle_params` fires
- `push_navigate` → unmount + remount, same socket
- `JS` commands → pure client-side, no server round-trip
Q28How do you run background jobs in a Phoenix app with Oban?
IntermediateBackground Jobs
Answer
Oban is the default choice because it stores jobs in Postgres rather than Redis, and that single decision drives most of the answer. Because the `oban_jobs` insert is just another database write, you can enqueue inside `Ecto.Multi`, so the job becomes visible only if the surrounding transaction commits: no more emails sent for an order that rolled back. A worker is `use Oban.Worker, queue: :mailers, max_attempts: 5, unique: [period: 60, fields: [:worker, :args]]` with a `perform/1` that returns `:ok`, `{:ok, value}`, `{:error, reason}` to retry with exponential backoff, `{:snooze, 30}` to requeue without burning an attempt, or `{:cancel, reason}` for a permanent failure that should stop retrying.
Args are serialised to JSONB, so structs and atoms do not survive the round trip and keys come back as strings, which is the most common first bug. Concurrency is per node: `queues: [default: 10, mailers: 5]` on four nodes means forty concurrent default jobs, not ten. Two plugins are effectively mandatory in production. `Oban.Plugins.Pruner` deletes completed jobs, and without it the table grows until the index bloat starts slowing down every enqueue. `Oban.Plugins.Lifeline` rescues jobs stuck in the `executing` state after a node is SIGKILLed mid-run, which otherwise sit there forever. For tests set `testing: :manual` and use `assert_enqueued(worker: MyWorker, args: %{id: 1})` plus `Oban.drain_queue(queue: :mailers)`, so nothing runs unless a test asks for it.
defmodule MyApp.Workers.SendInvoice do
use Oban.Worker, queue: :mailers, max_attempts: 5,
unique: [period: 300, fields: [:worker, :args]]
@impl Oban.Worker
def perform(%Oban.Job{args: %{"invoice_id" => id}, attempt: attempt}) do
case Billing.deliver_invoice(id) do
:ok -> :ok
{:error, :rate_limited} -> {:snooze, 60 * attempt}
{:error, :invoice_deleted} -> {:cancel, "invoice gone"}
{:error, reason} -> {:error, reason}
end
end
end
# Enqueue inside the transaction that created the row
Ecto.Multi.new()
|> Ecto.Multi.insert(:invoice, changeset)
|> Oban.insert(:email, fn %{invoice: inv} ->
MyApp.Workers.SendInvoice.new(%{invoice_id: inv.id})
end)
|> Repo.transaction()
Key Points
- Postgres-backed, so `Oban.insert/2` inside `Ecto.Multi` is transactional
- `perform/1` return value drives retry, snooze, or permanent cancel
- Queue concurrency is per node, so multiply by your node count
- Pruner and Lifeline plugins are what keep the jobs table healthy
Q29What is a LiveComponent and when should you use one instead of a function component?
IntermediateLiveView
Answer
A function component is a pure function from assigns to HEEx: no state, no events of its own, called as `<.button>Save</.button>`. A LiveComponent (`use Phoenix.LiveComponent`) is stateful. It keeps its own assigns between renders, defines its own `handle_event/3`, and is rendered with `<.live_component module={MyAppWeb.CartComponent} id="cart" />` where the `id` is mandatory because it is the identity that lets LiveView keep state for it.
The detail that matters for performance is that a LiveComponent does not get its own process: it runs inside the parent LiveView's process, so a slow `handle_event` in a component still blocks every other event for that user. If you wanted isolation you would need a nested LiveView, not a component. Events default to the parent, so a click inside a LiveComponent needs `phx-click="add" phx-target={@myself}` or the parent will receive it and you will spend twenty minutes wondering why.
From outside, `send_update(MyAppWeb.CartComponent, id: "cart", count: 3)` pushes new assigns in and triggers `update/2`. `update/2` runs on every parent render, so it is the right place to derive state, but if you render fifty of these in a list and each one queries the database in `update/2` you have built an N+1 in the UI layer: `update_many/1` exists exactly for that, receiving all assigns at once so you can load the data in a single query. Default to function components; reach for LiveComponents only when a piece of UI genuinely owns state.
defmodule MyAppWeb.CartComponent do
use MyAppWeb, :live_component
@impl true
def update(assigns, socket) do
{:ok, socket |> assign(assigns) |> assign_new(:qty, fn -> 1 end)}
end
@impl true
def handle_event("inc", _params, socket) do
{:noreply, update(socket, :qty, &(&1 + 1))}
end
@impl true
def render(assigns) do
~H"""
<div>
<span>{@qty}</span>
<button phx-click="inc" phx-target={@myself}>+</button>
</div>
"""
end
end
# Parent template
<.live_component module={MyAppWeb.CartComponent} id={"cart-#{@item.id}"} item={@item} />
# Push state in from the parent LiveView
send_update(MyAppWeb.CartComponent, id: "cart-7", qty: 5)
Key Points
- `id` is what makes a LiveComponent stateful; function components have none
- Runs in the parent's process, so it is not an isolation boundary
- `phx-target={@myself}` or the event goes to the parent LiveView
- `update_many/1` prevents an N+1 when rendering a list of components
Q30How does the Ecto SQL sandbox make tests concurrent, and when do you need shared mode?
IntermediateTesting
Answer
`Ecto.Adapters.SQL.Sandbox` gives each test its own checked-out database connection and wraps the test in a transaction that is rolled back at the end. Nothing a test writes is ever visible to another test and nothing needs cleaning up, which is what makes `use MyApp.DataCase, async: true` safe even though every test hits the same Postgres database. The plumbing lives in `DataCase`/`ConnCase`, where `setup` calls `Ecto.Adapters.SQL.Sandbox.start_owner!(MyApp.Repo, shared: not tags[:async])` and registers `on_exit(fn -> Sandbox.stop_owner(pid) end)`.
Ownership is the concept everything else follows from: the connection belongs to the test process, so any other process that calls `Repo` raises `DBConnection.OwnershipError: cannot find ownership process for #PID<0.512.0>`. That is why a `Task.async` inside the code under test, a GenServer started in the application supervision tree, or an Oban job executed inline all blow up in tests but work in production. There are two fixes and they are not equal. `Sandbox.allow(MyApp.Repo, self(), other_pid)` grants one known process access and keeps the test async.
Shared mode, `Sandbox.mode(MyApp.Repo, {:shared, self()})`, lets every process use the test's connection but forces `async: false` for that test, because a second concurrent test would now see the same connection. Browser tests through Wallaby or Playwright run against a real server process, so they need shared mode or the `Phoenix.Ecto.SQL.Sandbox` plug with a metadata header. One limitation worth knowing: anything that depends on a real commit, such as Postgres `LISTEN`/`NOTIFY`, never fires inside a rolled-back transaction.
# test/support/data_case.ex
setup tags do
pid = Ecto.Adapters.SQL.Sandbox.start_owner!(MyApp.Repo, shared: not tags[:async])
on_exit(fn -> Ecto.Adapters.SQL.Sandbox.stop_owner(pid) end)
:ok
end
# Async test with a spawned process: allow it explicitly
test "background task writes an audit row" do
parent = self()
task =
Task.async(fn ->
Ecto.Adapters.SQL.Sandbox.allow(MyApp.Repo, parent, self())
Audit.record("user.created", 1)
end)
assert {:ok, _} = Task.await(task)
end
# Wallaby / Playwright style test: shared mode, not async
@tag async: false
test "checkout flow", %{session: session} do
Ecto.Adapters.SQL.Sandbox.mode(MyApp.Repo, {:shared, self()})
end
Key Points
- Each test owns a connection inside a rolled-back transaction
- `DBConnection.OwnershipError` means another process touched the Repo
- `Sandbox.allow/3` keeps the test async; shared mode does not
- LISTEN/NOTIFY and anything needing a real commit will not fire
Q31How do `assign_async/3` and `start_async/3` keep a LiveView responsive?
IntermediateLiveView
Answer
Before LiveView 0.20 the usual pattern for a slow query in `mount/3` was to render an empty page, `send(self(), :load)`, and handle it in `handle_info/2`. `assign_async/3` replaces that. It runs your function in a supervised Task and immediately assigns a `%Phoenix.LiveView.AsyncResult{loading: true}`, so the page paints now and the section fills in when the task returns. The template consumes it with `<.async_result :let={orders} assign={@orders}>` plus `:loading` and `:failed` slots, which means the loading and error states are part of the markup instead of three booleans in assigns.
Because the function runs in a different process it cannot touch `socket`, so anything it needs must be captured in the closure, and it has to return `{:ok, %{orders: list}}` where the key matches the assign name. A raised exception does not kill the LiveView; it arrives as `{:exit, reason}` in the `:failed` slot, which is a real improvement over a crashed process and a full remount. Use `start_async/3` with `handle_async/3` when you want to decide what happens with the result rather than just store it, for example running a save and then pushing a navigation.
Calling `assign_async` again with the same key cancels the in-flight task, and `cancel_async/2` does it explicitly. Two gotchas: without a `connected?(socket)` guard the work also runs during the disconnected HTTP render, so you pay for it twice, and each in-flight task checks out its own database connection, so a dashboard with eight async assigns consumes eight pool slots at once.
def mount(_params, _session, socket) do
socket =
if connected?(socket) do
user_id = socket.assigns.current_user.id
socket
|> assign_async(:orders, fn -> {:ok, %{orders: Orders.recent(user_id)}} end)
|> assign_async(:revenue, fn -> {:ok, %{revenue: Reports.revenue(user_id)}} end)
else
socket
end
{:ok, socket}
end
# start_async when you want to act on the result yourself
def handle_event("export", _, socket) do
{:noreply, start_async(socket, :export, fn -> Reports.build_csv() end)}
end
def handle_async(:export, {:ok, url}, socket) do
{:noreply, push_event(socket, "download", %{url: url})}
end
def handle_async(:export, {:exit, reason}, socket) do
{:noreply, put_flash(socket, :error, "Export failed: #{inspect(reason)}")}
end
Key Points
- `assign_async` returns an `AsyncResult`; render it with `<.async_result>`
- The task runs in another process, so capture values, never touch `socket`
- Crashes surface as `{:exit, reason}` instead of remounting the LiveView
- Guard with `connected?/1` or the work runs on the dead render too
Q32What's new in Phoenix 1.8 and how is scope-based auth different?
AdvancedPhoenix 1.8
Answer
Phoenix 1.8 (2024) reorganized the framework around a few themes. (1) **Scope-based auth** replaces the `current_user` assign with a richer `current_scope` struct, generated by `mix phx.gen.auth`. The scope carries the authenticated user plus arbitrary context (tenant, organization, role), this is the official answer for multi-tenancy. (2) **'Coreless' generator output**, generators no longer assume CoreComponents, so you can swap in DaisyUI, headlessUI-style components, or build your own. The framework gets out of your way. (3) **Magic links** are first-class in auth scaffolding, alongside passwords. (4) **DaisyUI** is the default Tailwind component library in new projects.
The upgrade from 1.7 is mostly mechanical; the big mental shift is moving from `assigns.current_user` to `assigns.current_scope.user` everywhere, but the generator handles the transition. Two more changes show up in day-to-day work. Layouts became function components: instead of a separate `root.html.heex` plus `app.html.heex` pair rendered implicitly, templates wrap their content in `<Layouts.app flash={@flash} current_scope={@current_scope}>`, which means the layout takes explicit attributes and you can have several without router gymnastics.
And scopes are declared in config, not just in the auth module, so `mix phx.gen.live Blog Post posts --scope user` emits context functions whose queries already filter by `scope.user.id` and whose `create` stamps the owner id. That is the difference that matters: multi-tenancy stops being a thing you remember to add to each query and becomes the shape the generated code already has. The auth generator also gained magic links and a sudo-mode re-authentication window, so changing an email or password requires a recent login rather than just a valid session.
On the assets side new projects ship Tailwind v4 with CSS-first configuration and daisyUI, so there is no `tailwind.config.js` to edit. The realistic upgrade risk is not the framework API, it is that every custom query in a multi-tenant app has to be audited against the new scope, because a generator cannot fix code you wrote before it existed.
# lib/my_app/accounts/scope.ex
defmodule MyApp.Accounts.Scope do
alias MyApp.Accounts.User
defstruct user: nil, org_id: nil
def for_user(%User{} = user),
do: %__MODULE__{user: user, org_id: user.org_id}
def for_user(nil), do: nil
end
# Scoped context function, ownership is not optional
def list_posts(%Scope{} = scope) do
Post
|> where([p], p.org_id == ^scope.org_id)
|> Repo.all()
end
# Template: layouts are function components in 1.8
<Layouts.app flash={@flash} current_scope={@current_scope}>
<.header>Posts</.header>
<.table rows={@posts}>...</.table>
</Layouts.app>
Key Points
- `current_scope` struct replaces `current_user` (richer multi-tenant story)
- Generators are 'coreless', no opinions on UI component library
- Magic-link auth out of the box, alongside passwords
- DaisyUI is the new default for Tailwind components
Q33How do you cluster Phoenix nodes and what are the gotchas?
AdvancedDistribution
Answer
Use `libcluster` to form a BEAM cluster automatically. Strategies range from `Cluster.Strategy.Epmd` (manual list of hostnames, fine for 2-3 nodes), `Cluster.Strategy.Gossip` (UDP multicast, easy LAN), to `Cluster.Strategy.Kubernetes.DNS` (production go-to, uses headless services to discover pods). Once nodes are connected, PubSub broadcasts span the cluster automatically; a chat message from a user on Node A reaches subscribers on Node B with no extra config.
Gotchas: (1) The Erlang cookie must match across all nodes, if a single node has a different cookie, it silently fails to join. (2) Network partitions are a real failure mode, Presence's CRDT handles them, but custom GenServers might not. (3) Mnesia replication across regions is slow; for multi-region, use Redis PubSub adapter and accept eventual consistency. (4) Distributed Erlang traffic isn't encrypted by default, use TLS distribution if your nodes cross a public network. (5) Distributed Erlang needs more than one open port: EPMD on 4369 plus the range you pin with `-kernel inet_dist_listen_min 9100 inet_dist_listen_max 9155` in `vm.args`, and forgetting the range is the usual reason nodes see each other in DNS but never connect inside a security group. In a release you also have to set `RELEASE_DISTRIBUTION=name`, `RELEASE_NODE=my_app@10.0.1.7`, and `RELEASE_COOKIE`, otherwise the node boots with a random short name and silently forms a cluster of one. Verify with `Node.list()` from a remote IEx, and watch membership changes with `:net_kernel.monitor_nodes(true)`.
The architectural gotcha beyond config is process registration: `Registry` is node-local, so a `via` tuple that works on one node happily starts a second copy of your 'singleton' on another. Cluster-wide you need `:global`, `Horde.Registry`, or `:syn`, and you need an explicit answer for what happens when a healed netsplit leaves two processes claiming the same name. Finally, distributed Erlang is a full mesh, so N nodes maintain N squared connections and heartbeats; that is fine at ten nodes and a real design constraint in the hundreds, at which point you partition into smaller clusters and bridge them with Redis PubSub or a message broker.
# config/runtime.exs
config :libcluster,
topologies: [
k8s: [
strategy: Cluster.Strategy.Kubernetes.DNS,
config: [
service: "my-app-headless",
application_name: "my_app",
polling_interval: 5_000
]
]
]
Q34How would you architect a Phoenix system for 100k+ concurrent WebSocket connections?
AdvancedScaling
Answer
Discord-shape problem. The BEAM handles 2M+ idle TCP connections per node in theory, but the realistic single-node limit is bounded by your OS (file descriptor limits, ephemeral port range), kernel networking buffers, and memory per connection. Step one: raise `ulimit -n` to 1M+, configure `:gen_tcp` buffer sizes via the Endpoint config, and bump `:erlang.system_flag(:schedulers_online, ...)` to match physical cores.
Step two: front the nodes with a TCP-aware load balancer (HAProxy, AWS NLB, Cloudflare Spectrum) that sticky-routes by client IP so reconnects land on the same node when possible. Step three: scale horizontally with libcluster + PubSub for cross-node broadcasts. Step four: shrink per-connection state, a process holding 10KB of state at 200k connections is 2GB just for assigns.
Use ETS as a shared store for hot data. Step five: monitor with `:observer_cli` and `:recon` in production; the BEAM's introspection is your superpower. Discord's 2017 post on scaling to 5M concurrent users on Elixir is the canonical reference, they hit limits in PubSub fan-out, not connection count, and rewrote `Manifold` to fix it.
Two BEAM limits bite before memory does: the default process limit is 262,144 (`+P`) and each connection costs at least a transport process plus one channel process, so 100k users with two topics each already exceeds it, and the port limit (`+Q`, default 65,536) caps sockets independently of processes. Both are set in `vm.args` and both fail in ways that look like random connection refusals rather than clear errors. On the network side, `net.core.somaxconn` and `net.ipv4.ip_local_port_range` need raising on the load balancer side, and the balancer's idle timeout must be longer than the client heartbeat interval: an AWS ALB at its 60-second default will silently cut idle WebSockets, which users experience as a reconnect every minute.
The failure mode that actually takes systems down is the thundering herd. When a node dies or you deploy, 100k clients reconnect at once, every one of them running `mount/3` and its queries; the fix is jittered backoff in the client's `reconnectAfterMs`, plus a rolling deploy that drains connections gradually rather than replacing the fleet at once.
# rel/vm.args.eex, raise the BEAM ceilings before you need them
+P 2000000 # max processes (default 262144)
+Q 1000000 # max ports/sockets (default 65536)
+K true # kernel poll
+sbwt none # do not busy-wait schedulers on small instances
# config/runtime.exs
config :my_app, MyAppWeb.Endpoint,
http: [
ip: {0, 0, 0, 0, 0, 0, 0, 0},
port: String.to_integer(System.get_env("PORT") || "4000"),
thousand_island_options: [num_acceptors: 200]
],
check_origin: ["https://" <> System.fetch_env!("PHX_HOST")]
# Find the memory hogs on a live node
# :recon.proc_count(:memory, 10)
# :recon.proc_count(:message_queue_len, 10)
Key Points
- OS limits matter, ulimit, file descriptors, ephemeral ports
- Per-process state is the dominant memory cost at 100k+
- Cross-node PubSub fan-out is the next bottleneck after connection count
- Sticky load balancing helps reconnects
Q35What are the most common LiveView performance pitfalls in production?
AdvancedLiveView Performance
Answer
(1) **Bloated assigns.** Every assigned struct lives in the LiveView process and is diffed on every update. Stashing an entire 1000-row dataset in assigns means 1000 rows of DOM diffing on each event. Fix: paginate, use streams (`stream/4` introduced in LiveView 0.18) which keep data only on the client, or reach for `temporary_assigns`. (2) **Long-running `handle_event` callbacks.** Each LiveView is a single process, a slow handler blocks all other events for that user.
Push expensive work to a `Task.async/1` and listen via `handle_info`. (3) **Unbounded `push_event`** to the client (e.g. on every typing keystroke). Debounce on the client with `phx-debounce` or reduce the event frequency. (4) **Subscribing to too-broad PubSub topics.** Subscribing to `"orders"` when you only care about one customer's orders means every order across the platform wakes up your process. Use per-resource topics like `"orders:#{user_id}"`. (5) **Not using `phx-update="stream"`** for long lists, the server otherwise sends the full list diff each update. (6) **Blocking `mount/3`.** Three sequential queries in mount delay first paint and are then repeated on the WebSocket mount; `assign_async(socket, :stats, fn -> {:ok, %{stats: Reports.slow_query()}} end)` renders the page immediately and fills the section in via `<.async_result>` when the task finishes. (7) **Change-tracking leaks.** Passing an entire struct or an anonymous function into a function component, or computing a value inside the template instead of assigning it, forces that chunk to re-render on every event even when nothing about it changed.
Before optimising anything, measure: the `[:phoenix, :live_view, :handle_event, :stop]` telemetry event gives per-event durations, LiveDashboard shows them live, and on a running node `Process.info(view_pid, [:memory, :message_queue_len])` tells you whether a socket is fat or merely busy. A message queue that keeps growing means the process cannot keep up with its inbox, which is almost always an over-broad PubSub subscription rather than slow rendering. The number to carry into the interview is the product: per-socket memory times concurrent users is your real memory budget, so trimming 50KB of assigns is worth 500MB at 10,000 users.
# Use streams for unbounded lists:
def mount(_, _, socket) do
{:ok, stream(socket, :messages, Chat.recent_messages())}
end
def handle_info({:new_message, msg}, socket) do
{:noreply, stream_insert(socket, :messages, msg, at: 0, limit: 100)}
end
# Template:
<div id="messages" phx-update="stream">
<div :for={{dom_id, msg} <- @streams.messages} id={dom_id}><%= msg.body %></div>
</div>
Q36How do you deploy a Phoenix app to Fly.io or Gigalixir, and what changes for a clustered setup?
AdvancedDeployment
Answer
**Fly.io** is the most popular Phoenix host in 2026, it has first-class clustering via DNS and treats each Fly machine as a BEAM node. Workflow: `fly launch` generates a Dockerfile + `fly.toml`. The release runs Elixir's `mix release` (compile to a self-contained tarball).
For clustering, you add `libcluster` with the `Cluster.Strategy.DNSPoll` strategy pointed at Fly's internal `<app>.internal` DNS. Set `RELEASE_NODE=name@$FLY_PRIVATE_IP` and `RELEASE_COOKIE` from a Fly secret. Multi-region: just `fly scale count 3 --region bom,sin,fra` and the cluster forms automatically. **Gigalixir** is the Heroku-style PaaS specifically for Elixir, pre-baked clustering, zero-downtime hot upgrades via OTP releases (rare in practice; most teams blue-green instead). **Production gotchas:** (1) `config/runtime.exs` runs at boot, not compile, so put secrets there, `config/prod.exs` is baked into the release. (2) Set the Phoenix endpoint's `server: true` and `secret_key_base` from env. (3) Ecto's `pool_size` needs tuning per machine, total pool across the cluster must stay under your Postgres `max_connections`.
PgBouncer is mandatory above 5-10 nodes. (4) For Indian latency, deploy at least one machine in `bom` (Mumbai); Glific and Razorpay-adjacent teams typically run `bom` + `sin` for redundancy. (5) New Phoenix apps already ship a `{DNSCluster, query: Application.get_env(:my_app, :dns_cluster_query) || :ignore}` child in `application.ex`, so on Fly you can often set `DNS_CLUSTER_QUERY=my-app.internal` and skip libcluster entirely. (6) The environment variables that must exist at boot are `SECRET_KEY_BASE` (generate with `mix phx.gen.secret`), `DATABASE_URL`, `PHX_HOST`, and `PORT`, and `check_origin` should be derived from `PHX_HOST` rather than left at the default or you get WebSocket 403s only in production. (7) Fly's private network is IPv6-only, so the endpoint needs `ip: {0, 0, 0, 0, 0, 0, 0, 0}` and Postgres over the internal network needs `socket_options: [:inet6]`; a missing `:inet6` is the single most common 'works locally, times out on deploy' report. Health checks should hit a route that verifies the Repo, not just the router, otherwise a node with a dead database pool stays in rotation.
# config/runtime.exs, evaluated at boot inside the release
if System.get_env("PHX_SERVER") do
config :my_app, MyAppWeb.Endpoint, server: true
end
if config_env() == :prod do
config :my_app, MyApp.Repo,
url: System.fetch_env!("DATABASE_URL"),
pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10"),
socket_options: [:inet6]
host = System.fetch_env!("PHX_HOST")
config :my_app, MyAppWeb.Endpoint,
url: [host: host, port: 443, scheme: "https"],
http: [ip: {0, 0, 0, 0, 0, 0, 0, 0}, port: 8080],
secret_key_base: System.fetch_env!("SECRET_KEY_BASE"),
check_origin: ["https://" <> host]
config :my_app, :dns_cluster_query, System.get_env("DNS_CLUSTER_QUERY")
end
Key Points
- Fly.io = de facto Phoenix host; auto-clustering via internal DNS
- `mix release` produces a standalone artifact; `config/runtime.exs` for secrets
- PgBouncer mandatory above ~10 nodes (Ecto pool × nodes ≤ Postgres max_connections)
- Multi-region: drop a machine in `bom` for India latency
Q37How do you instrument a Phoenix app with Telemetry, and what should you actually alert on?
AdvancedObservability
Answer
Phoenix, Ecto, LiveView, and Oban all emit `:telemetry` events, so instrumentation is a matter of attaching to events that already exist rather than adding timers by hand. The ones that pay rent: `[:phoenix, :endpoint, :stop]` for total request duration, `[:phoenix, :router_dispatch, :stop]` for per-route latency, `[:my_app, :repo, :query]` whose measurements include `total_time`, `query_time`, `decode_time`, and crucially `queue_time`, `[:phoenix, :live_view, :mount, :stop]` and `[:phoenix, :live_view, :handle_event, :stop]`, `[:phoenix, :channel_joined]`, and `[:oban, :job, :stop]`. `MyAppWeb.Telemetry` turns these into `Telemetry.Metrics` definitions (`summary`, `counter`, `distribution`, `last_value`) and hands them to a reporter: `ConsoleReporter` in dev, `TelemetryMetricsPrometheus` or the OpenTelemetry pair `opentelemetry_phoenix` and `opentelemetry_ecto` in production. `:telemetry_poller` adds VM gauges on a timer. On what to alert: request p99 alone tells you something is wrong but never what. `queue_time` on repo queries is the single most diagnostic database signal, because a rising queue_time with flat query_time means the connection pool is exhausted and not that Postgres is slow. `vm.total_run_queue_lengths.cpu` is the BEAM's own saturation metric and is far more meaningful than host CPU, which looks busy whenever schedulers spin.
Add Oban queue depth and retry counts, LiveView socket count, and channel join failures. One implementation note that shows experience: attach named module functions with `:telemetry.attach/4`, never inline anonymous functions, because those cannot be optimised and Elixir logs a performance warning at attach time.
defmodule MyAppWeb.Telemetry do
use Supervisor
import Telemetry.Metrics
def metrics do
[
summary("phoenix.endpoint.stop.duration", unit: {:native, :millisecond}),
summary("phoenix.router_dispatch.stop.duration",
tags: [:route], unit: {:native, :millisecond}),
summary("my_app.repo.query.queue_time", unit: {:native, :millisecond}),
summary("my_app.repo.query.query_time", unit: {:native, :millisecond}),
summary("phoenix.live_view.handle_event.stop.duration",
tags: [:event], unit: {:native, :millisecond}),
counter("oban.job.stop.duration", tags: [:queue, :state]),
last_value("vm.total_run_queue_lengths.cpu"),
last_value("vm.memory.total", unit: {:byte, :megabyte})
]
end
end
# Custom instrumentation for your own domain
:telemetry.execute([:my_app, :payment, :captured], %{amount: 4999}, %{gateway: :razorpay})
:telemetry.attach("payment-log", [:my_app, :payment, :captured], &MyApp.Metrics.handle/4, nil)
Key Points
- Repo `queue_time` rising with flat `query_time` means pool exhaustion
- `vm.total_run_queue_lengths.cpu` beats host CPU as a saturation signal
- Reporters are swappable: Console in dev, Prometheus or OTel in prod
- Attach named functions, not inline anonymous ones, to `:telemetry.attach/4`
Q38A production Phoenix node is pinned at 100% CPU and requests are timing out. How do you debug it live?
AdvancedDebugging
Answer
The advantage over most stacks is that you can open a shell inside the running system: `bin/my_app remote` from a release, or `iex --sname debug --remsh my_app@10.0.1.7 --cookie $RELEASE_COOKIE` from another machine on the same network. Take the node out of the load balancer first if you can, then diagnose, because tracing a saturated node makes it worse. Start with `:recon`, which is written for exactly this and is safe on production. `:recon.proc_count(:reductions, 10)` names the processes actually burning CPU. `:recon.proc_count(:message_queue_len, 10)` finds the mailbox that is backing up, which is the signature of a single GenServer that every request funnels through: its queue grows without bound while the rest of the node idles. `:recon.proc_count(:memory, 10)` catches a process hoarding state, and `:recon.bin_leak(10)` catches the BEAM-specific case where refc binaries are held by long-lived processes and memory climbs with no obvious owner.
Then `Process.info(pid, [:current_stacktrace, :message_queue_len, :status])` on whatever the top offender is. Cross-check the database with `queue_time` telemetry or a direct `SELECT count(*), state FROM pg_stat_activity GROUP BY state`, because 'CPU is pinned' is often really 'every request is spinning while the pool is empty'. Common culprits in Phoenix specifically: an unbounded `Enum` over a large list inside `handle_event`, a Logger backend that cannot drain (the BEAM then applies back pressure to every logging process), an ETS table that only grows, and a LiveView subscribed to a firehose topic. If you need call-level detail, use `:recon_trace.calls({Mod, :fun, :_}, 10)` which is rate limited; plain `:dbg` on a busy node can take it down.
# Attach to the running release
$ bin/my_app remote
iex> :recon.proc_count(:message_queue_len, 5)
[{#PID<0.812.0>, 48213, [MyApp.EventBus, {:current_function, ...}]}, ...]
iex> Process.info(pid(0, 812, 0), [:current_stacktrace, :message_queue_len])
iex> :recon.proc_count(:reductions, 5)
iex> :recon.bin_leak(5) # refc binary holders, forces a GC
iex> :erlang.statistics(:run_queue)
iex> :erlang.system_info(:process_count)
# Is the DB pool the real bottleneck?
iex> Ecto.Adapters.SQL.query!(MyApp.Repo,
...> "SELECT state, count(*) FROM pg_stat_activity GROUP BY state", [])
# Rate-limited tracing: 10 calls then it stops itself
iex> :recon_trace.calls({MyApp.Search, :query, :_}, 10)
Key Points
- `bin/my_app remote` gives you a live shell into the running node
- `:recon.proc_count/2` on reductions, memory, and message_queue_len first
- A growing mailbox means a serialising GenServer, not a slow renderer
- Use `:recon_trace` (rate limited), never bare `:dbg`, on a loaded node
Q39How do you run a zero-downtime Ecto migration on a large Postgres table?
AdvancedDatabase
Answer
Ecto is the easy half; Postgres locking is where the outage comes from. Adding a nullable column, or a column with a default on Postgres 11 and later, is safe and does not rewrite the table. Adding `NOT NULL` to an existing column is not: it takes an ACCESS EXCLUSIVE lock and scans every row, so do it in two migrations, first `ALTER TABLE ...
ADD CONSTRAINT ... CHECK (col IS NOT NULL) NOT VALID`, then `VALIDATE CONSTRAINT` in a later deploy, which takes only a SHARE UPDATE EXCLUSIVE lock. Indexes need `create index(:orders, [:user_id], concurrently: true)` together with `@disable_ddl_transaction true` and `@migration_lock nil`; note that a failed concurrent build leaves an INVALID index behind that you must drop before retrying.
The detail that separates people who have caused an outage from people who have not is `lock_timeout`. An ALTER waiting behind one long-running transaction queues every subsequent query behind its lock request, so a migration that should take 5 milliseconds stalls the entire application. Set `execute "SET lock_timeout TO '5s'"` at the top so the migration fails fast and you retry instead of taking the site down.
Column removal is a two-deploy dance in the other direction, because Ecto's `select` lists every schema field: drop the field from the schema and ship that first, otherwise old pods query a column that no longer exists. Renames are add, backfill, dual-write, switch reads, drop. Backfills go through batched `Repo.update_all` over id ranges with a sleep between batches, never one statement over ten million rows.
defmodule MyApp.Repo.Migrations.AddIndexAndNotNull do
use Ecto.Migration
@disable_ddl_transaction true
@migration_lock nil
def up do
execute "SET lock_timeout TO '5s'"
create index(:orders, [:user_id], concurrently: true)
execute """
ALTER TABLE orders
ADD CONSTRAINT orders_status_not_null
CHECK (status IS NOT NULL) NOT VALID
"""
end
def down do
execute "ALTER TABLE orders DROP CONSTRAINT orders_status_not_null"
drop index(:orders, [:user_id])
end
end
# Batched backfill, run as an Oban job rather than in the migration
def backfill(last_id \\ 0) do
{count, ids} =
from(o in "orders", where: o.id > ^last_id and is_nil(o.status),
order_by: o.id, limit: 5_000, select: o.id)
|> Repo.all()
|> then(fn ids -> {length(ids), ids} end)
if count > 0 do
Repo.update_all(from(o in "orders", where: o.id in ^ids), set: [status: "legacy"])
Process.sleep(200)
backfill(List.last(ids))
end
end
Key Points
- `SET lock_timeout` first, or one blocked ALTER queues every other query
- NOT NULL in two steps: CHECK ... NOT VALID, then VALIDATE CONSTRAINT
- `concurrently: true` needs `@disable_ddl_transaction` and `@migration_lock nil`
- Remove the field from the schema one deploy before dropping the column
Q40How do you enforce authentication boundaries across LiveViews, and why is a router plug not enough?
AdvancedSecurity
Answer
A pipeline plug such as `plug :require_authenticated_user` runs during the initial HTTP request only. The stateful LiveView then mounts over a completely separate WebSocket connection to `/live`, which carries the signed session but never re-runs your router pipeline, so anything that plug assigned is gone. The correct boundary is `live_session :authenticated, on_mount: [{MyAppWeb.UserAuth, :ensure_authenticated}]` in the router.
An `on_mount` hook runs before `mount/3` and returns `{:cont, socket}` or `{:halt, redirect(socket, to: ~p"/users/log_in")}`, and it runs again on every live navigation within that `live_session`. This is also why LiveView forces a full page reload when `push_navigate` crosses from one `live_session` to another: it refuses to carry a socket into a block whose hooks have not run. Keep the plug as well, so an unauthenticated user never gets the dead render either.
Three further points a senior interviewer expects. Session data reaching `mount/3` is limited to the keys listed under `session:` and it is signed rather than encrypted, so it authenticates content but does not hide it: never put anything secret there. Authentication is not authorization, and `handle_event` params come straight from the client, so `Orders.get_order!(scope, id)` must scope by owner inside the context rather than trusting a hidden field.
And revocation needs a mechanism: setting `live_socket_id` in the session lets you call `MyAppWeb.Endpoint.broadcast("users_sockets:42", "disconnect", %{})` on logout or password change and actually terminate the live connections. For per-event auditing or checks, `attach_hook(socket, :audit, :handle_event, fn ... end)` adds a hook without touching each callback.
# router.ex
live_session :authenticated,
on_mount: [{MyAppWeb.UserAuth, :ensure_authenticated}],
session: %{"locale" => "en"} do
live "/orders", OrderLive.Index, :index
live "/orders/:id", OrderLive.Show, :show
end
# lib/my_app_web/user_auth.ex
def on_mount(:ensure_authenticated, _params, %{"user_token" => token}, socket) do
case Accounts.get_user_by_session_token(token) do
nil ->
{:halt, Phoenix.LiveView.redirect(socket, to: ~p"/users/log_in")}
user ->
socket = Phoenix.Component.assign(socket, :current_user, user)
{:cont, Phoenix.LiveView.attach_hook(socket, :audit, :handle_event, &audit/3)}
end
end
defp audit(event, _params, socket) do
Audit.record(event, socket.assigns.current_user.id)
{:cont, socket}
end
# Authorization still lives in the context, never in the event handler
def get_order!(%Scope{user: user}, id),
do: Repo.get_by!(Order, id: id, user_id: user.id)
Key Points
- Router plugs guard the HTTP render only; the WebSocket mount needs `on_mount`
- Crossing a `live_session` boundary forces a full reload by design
- Session passed to mount is signed, not encrypted: no secrets in it
- `live_socket_id` broadcasts are how you actually kill live sessions on logout
Frequently Asked Questions
Should I learn Elixir before Phoenix?
Yes, but only the basics, pattern matching, pipes (`|>`), the `case`/`with` flow control, and how modules/functions work. Two days of `Exercism` Elixir track is enough to start a Phoenix tutorial. OTP concepts (GenServers, supervisors) you can pick up as you encounter them; you don't need them for typical CRUD work, but they're indispensable once you write Channels, LiveView, or background jobs.
How much does a Phoenix / Elixir developer earn in India?
₹10-28 LPA in 2026, skewing higher than equivalent Python or Node.js roles because the talent pool is smaller. Companies hiring in India: Glific (nonprofit messaging), parts of Razorpay's realtime ops, ShareChat (some services), and a long tail of remote-friendly companies (Discord, Brex, Pinterest, Lemonade) that hire from India. Specialised areas (high-concurrency systems, fintech, edtech with real-time features) pay at the upper end.
Is LiveView ready for production in 2026?
Yes, LiveView has been production-grade since around the 0.18 release (2022) and 1.0 shipped in 2024. Major deployments at Brex, Cars.commerce, Heroku Dashboard, and the Fly.io dashboard itself all run on LiveView. It's the right default for internal admin tools, dashboards, and CRUD apps; for marketing pages and content-heavy sites where SEO and CDN cache are critical, you can still pre-render to static HTML via `Phoenix.HTML` and skip LiveView entirely.
How does Phoenix compare to Node.js for real-time apps?
Phoenix wins on connection scale (BEAM scheduler vs Node's single event loop), fault tolerance (supervisors restart crashed processes; a Node process crashing usually means everything dies), and observability (`:observer.start()`, `:recon`, distributed tracing built in). Node wins on ecosystem size and how fast you can spin up the first version. For chat, notifications, multiplayer, or any system where 'how many concurrent connections' is the dominant question, Phoenix is the technically superior choice, that's why Discord uses it.
Do I need to know OTP to be productive in Phoenix?
For basic CRUD and LiveView, no, you can ship a usable app understanding only the controller / context / changeset / Repo flow. For Channels, background jobs (Oban), or anything that holds long-running state, yes, at least GenServer and supervisor basics. The good news: OTP concepts are small in number (maybe 5 building blocks) and the official Elixir docs explain them well. Most Elixir developers in India learn OTP on the job once they hit a real concurrency problem.
What's the difference between Phoenix Channels and LiveView?
Channels are a low-level pub/sub protocol over WebSockets, you control the wire format, you write the JS client, you build the UI yourself. Use Channels when you have a separate frontend (React, mobile app) that needs server push. LiveView is a higher-level abstraction built on top of Channels: it manages the WebSocket, the wire format, and the UI for you, and you write only Elixir. Use LiveView when you want a reactive web UI without a separate frontend codebase. Many production apps use both, LiveView for the dashboard, Channels for the mobile app talking to the same backend.
How long does it take to prepare for a Phoenix interview?
If you already write Elixir at work, two focused weeks is realistic: one week revising Ecto (changesets, constraints, `Ecto.Multi`, preload strategies, migrations) and the LiveView and Channel lifecycles, one week building something small that has a background queue and a test suite. Coming from Rails, Node, or Django with no BEAM exposure, plan 8 to 12 weeks: roughly 3 weeks on Elixir itself (pattern matching, `with`, GenServer, supervisors), 4 weeks building a real app with `mix phx.gen.auth`, LiveView forms, and Oban, and 2 weeks on production topics such as `mix release`, `config/runtime.exs`, Telemetry, and clustering. Almost every Phoenix role in India includes a take-home or a pairing round, so one deployed project with a LiveView dashboard and passing `mix test` is worth more than a month of reading documentation.
Can a fresher get a Phoenix job in India, or is it experienced-only?
Most Phoenix listings in India ask for a few years of backend experience, but that requirement is usually about backend judgment in general rather than Elixir specifically, because very few candidates anywhere have production Elixir. Freshers do get hired, and the route that works is visible proof: a merged contribution to a Hex package, a deployed LiveView app, and being able to explain why a supervision tree restarts what it restarts rather than reciting the strategies. Fresher offers usually start closer to the ₹6-10 LPA band, while engineers with 3-6 years and real BEAM production experience sit at the ₹18-28 LPA end because the supply is thin. The most common path in practice is sideways: get hired for Node.js, Python, or Go at a company that already runs an Elixir service, then move onto that team internally.
Is Phoenix worth learning in 2026 given how small the job market is?
The number of Phoenix openings in India is small next to Java, Node, or Python, and pretending otherwise helps nobody. What is different is the ratio: the pool of engineers who can actually debug a saturated BEAM node or design a Channel topology is tiny, which is why the pay band sits above equivalent Node roles and why remote hiring from India is common. It is worth learning if you want to work on real-time systems (chat, presence, live dashboards, logistics and trading feeds), if you are targeting remote roles at companies like Discord, Brex, or Lemonade, or if you want a second language that sharpens how you argue about concurrency in any stack. It is the wrong bet as your only skill if you need a job in the next quarter in a city with no Elixir employers. The sensible hedge is to keep your primary stack and learn Phoenix well enough to ship LiveView, so it becomes the unusual line on your profile rather than your entire profile.
Introduction
Phoenix is the dominant web framework in the Elixir ecosystem and the reason a lot of teams pick the BEAM in the first place. Built on top of OTP (Open Telecom Platform), it inherits decades of telecom-grade primitives, supervisors, lightweight processes, fault tolerance, distribution, and packages them for the web. Discord routes billions of messages per day through Phoenix Channels, Pinterest uses it for real-time notification fan-out, and in India, Glific (a WhatsApp messaging platform for nonprofits) and parts of Razorpay's realtime ops stack lean on the same patterns.
Phoenix 1.7 (2023) and 1.8 (2024) reshaped the framework around verified routes (`~p`), function components, and 'coreless' scope-based auth. LiveView, which started as an experiment in 2019, is now production-grade and frequently the main reason teams adopt Phoenix at all: server-rendered reactive UIs without writing a SPA.
If you are interviewing for a Phoenix role in India in 2026, expect questions on Ecto (the database wrapper), Channels and PubSub (WebSocket abstraction), LiveView lifecycle, OTP concepts (GenServers, supervisors), and production concerns like clustering and connection limits. This guide covers the 40 most-asked Phoenix questions, grouped by difficulty, with code where it pays off. Beyond the framework basics it works through the topics that separate a Phoenix hire from a Phoenix tutorial reader: Oban background jobs, LiveComponents and `assign_async`, the Ecto SQL sandbox and `Phoenix.LiveViewTest`, Telemetry instrumentation, debugging a saturated node with `:recon`, zero-downtime Postgres migrations, and `live_session` auth boundaries.
Ready to practice Phoenix interviews?
Don't just read, practice these Phoenix questions live with an AI interviewer that asks follow-ups and scores your answers.