What this usually means
The root cause is that LiveView's change tracking didn't detect a difference between the old and new assign values. Phoenix LiveView computes a diff by comparing the old socket assigns to the new ones using Elixir's `==` operator. If the assign is a deeply nested struct (like an Ecto schema with `__meta__` or virtual fields), or if you're modifying the assign in place instead of returning a new socket, the comparison may see them as equal. Alternatively, the update might be happening on a different process (e.g., a GenServer sending a message to the wrong PID) or the socket is not being updated via `assign/3` but via a side effect on the assigns map. Another common scenario is that the LiveView is using `@impl` callbacks but the function clause doesn't match, so the intended update never runs.
The first ten minutes — establish facts before touching code.
- 1Add `IO.inspect(socket.assigns, label: "assigns before update")` and the same after your `assign/3` call. Check that the value actually differs.
- 2Enable LiveView debug logging: in `config/dev.exs`, set `config :phoenix, :debug_errors, true` and check the WebSocket logs in browser devtools for the diff payload.
- 3Open the LiveDashboard for your LiveView and observe the socket state after the event. If the assign changed but UI didn't, it's a diff issue.
- 4Check if you're using `Phoenix.LiveView.send_update/2` or `send/2` to the same process. Verify the PID matches: `IO.inspect(self(), label: "current pid")`.
- 5Isolate the assign: replace the problematic assign with a simple integer counter that increments. If it updates, the issue is with the assign type.
The specific files, logs, configs, and dashboards that usually own this bug.
- searchThe LiveView module file where `handle_info/2` or `handle_event/3` is defined
- searchBrowser devtools Network tab -> WebSocket messages -> look for `"diff"` payload
- searchPhoenix LiveDashboard route: `/dashboard` under LiveViews section
- searchEcto schema definition for the assign's struct type (look for `@derive`, `__meta__`, or virtual fields)
- search`mix.lock` to verify Phoenix LiveView version (bug in < 0.18.0 related to map key ordering)
- searchApplication config: `config :phoenix_live_view, debug_errors: true` and `:enable_assigns_tracking`
Practical causes, not theory. These are the things you will actually find.
- warningAssign is an Ecto schema struct with `__meta__` that changes on every DB fetch, causing unnecessary re-renders OR the opposite: the struct is modified in place and retains the same reference
- warningUsing `Map.put` on the assigns map directly instead of `assign/3` or `assign_new/3`
- warning`handle_info` receiving a message but not returning a new socket (forgetting to return `{:noreply, socket}`)
- warningThe LiveView component is mounted inside a parent LiveView that does not propagate updates (e.g., `Phoenix.LiveComponent` not using `send_update` correctly)
- warningAssign is a map with atom keys that get stringified somewhere, breaking equality
- warningProcess sending an update to a stale PID (often when using `Process.send_after` with a captured PID that later changes during reconnection)
Concrete fix directions. Pick the one that matches your root cause.
- buildFor Ecto schemas: implement the `Phoenix.LiveView.ChangeTracker` protocol or use `Map.take` to compare only relevant fields: `assign(socket, :user, Map.take(user, [:id, :name, :email]))`
- buildReplace direct mutation with `assign(socket, key, new_value)` always; never do `socket.assigns |> Map.put(...)`
- buildIf using `send_update` to a component, ensure the component's `update/2` callback returns a socket: `{:ok, assign(socket, ...)}`
- buildFor timers that need to survive reconnection, use `:erlang.send_after/3` with a named process or a registry instead of a PID
- buildIf the assign is a list, consider using `Phoenix.LiveView.Utils.assign_new/3` or a dedicated Ecto schema with `@primary_key false` to avoid virtual fields
- buildUpgrade to Phoenix LiveView ≥ 0.19.0 which improved diff tracking for structs
A fix you cannot prove is a guess. Close the loop.
- verifiedAdd a timestamp assign that updates on every event: `assign(socket, :timestamp, DateTime.utc_now())` and confirm the UI shows it changing
- verifiedCheck the WebSocket message in browser devtools after the event: the diff should contain the changed key with old and new values
- verifiedRun `Phoenix.LiveViewTest.render_change(live_view, :event, %{})` in a test and assert the rendered HTML changes
- verifiedInspect the socket after the callback with `dbg(socket)` to confirm the assigns are different
- verifiedUse `:sys.get_state/1` on the LiveView process to see the internal state (only for debugging)
Things that make this bug worse or harder to find.
- warningDon't use `socket = assign(socket, :key, value)` inside a `cond` or `if` without ensuring all branches return the socket
- warningAvoid storing large structs with `__meta__` directly in assigns; use a view model or a subset
- warningDon't rely on `socket.assigns` being mutable; treat it as immutable
- warningNever use `Process.send_after(self(), :tick, 1000)` inside `mount/3` without also handling `:tick` in `handle_info/2` and cleaning up on disconnect
- warningDon't assume `send_update/2` works across LiveViews on different topics; it only works for components within the same LiveView tree
User profile not updating after save
Timeline
- 10:15Deploy new feature: user can edit profile name
- 10:35User reports: profile name doesn't update after save; page refresh shows new name
- 10:42I reproduce locally: same behavior. WebSocket shows 'diff' with empty payload
- 10:48Add IO.inspect in handle_event('save', ...) — socket.assigns.user has new name
- 10:52Check Ecto schema: User has virtual field :full_name that is not in DB
- 10:58Realize: Repo.get! returns a struct with __meta__ that includes :__meta__ state, but the diff compares old and new structs — they are different but Phoenix LiveView thinks they're equal because the struct's __struct__ key matches and other fields are ignored due to a bug in earlier versions
- 11:05Upgrade LiveView to 0.20.0, but issue persists
- 11:12Read docs: LiveView compares assigns using ==, but structs with __meta__ are equal? Actually no — they are different. I notice that I'm assigning the same struct reference from the database: `assign(socket, :user, Repo.get!(User, id))` both times. The struct is the same object because Ecto caches? No.
- 11:20I add a timestamp to the assign: it updates. So the user assign is not changing? But IO.inspect shows different values.
- 11:25Check the diff: Phoenix LiveView uses `:erlang.phash2` on the assign value. For maps/structs, it computes a hash. The old and new user structs have different :updated_at but same phash2? Actually, I find that the :full_name virtual field is not being set, so the structs are identical except for __meta__ which is not compared because __meta__ is not a field in the struct definition.
- 11:32Fix: use `Map.drop(user, [:__meta__])` before assign. UI updates immediately.
The incident started with a simple profile edit feature. Users could update their name, and the LiveView would re-render the profile section. After deploy, users reported that the name only updated after a full page refresh. I reproduced locally and confirmed: the WebSocket 'diff' message contained an empty payload, meaning LiveView detected no changes even though the socket.assigns showed the new name.
I added debug logging and saw that the user assign was indeed changing: the old `user.name` was 'Alice', the new was 'Alice Smith'. But the diff was empty. I suspected the struct comparison. I checked the Ecto schema: the User model had a virtual field `:full_name` that was not being set on the DB fetch. The structs differed only in the `updated_at` timestamp and the `__meta__` state. But LiveView's diff algorithm uses `:erlang.phash2/1` which for structs includes the struct name and fields, but not the `__meta__` because it's not a field in the struct definition.
The real culprit was that the old and new user structs were actually identical in terms of the fields that LiveView considers: the `name` field was the same because the update was not persisted? No, the database had the new name. Wait, I was calling `Repo.get!` after the update, but the Repo returned the same struct because the transaction was not committed? Actually, the update was committed. But the struct returned from Repo.get! had the new name. So why was the struct equal? I realized that I was using `assign(socket, :user, user)` where `user` was the result of `Accounts.update_user(...)`. That function returned the updated user struct. But the old assign was also a user struct from a previous fetch. Both structs had the same fields, same values, except the new one had updated `updated_at`. However, LiveView's `compute_diff` function compares assigns using `==` if the assign is a struct. And `==` for structs compares all fields, including `updated_at`. So they should be different. But they were not? I found the bug: the `Accounts.update_user` function was using `Repo.update!` and then returning the changeset, not the struct. I was assigning the changeset instead of the struct. The changeset struct is different from the user struct, but when I assigned it, the old assign was a User struct, the new was a Changeset struct. LiveView's diff saw them as different types and triggered a re-render? Actually, it triggered a re-render but the template expected a User struct and failed to render? No, the template was rendering fine after refresh. I finally tracked it: In a different handle_event, I was directly modifying the socket.assigns.user.name via a form. That was working because the template changed. But the save event was not reassigning the user correctly. The root cause was that I was using `assign(socket, :user, user)` but `user` was the result of `Accounts.update_user(...)` which returned `{:ok, user}`. I forgot to pattern match: `{:ok, user} = Accounts.update_user(...)`. So `user` was actually `{:ok, %User{...}}`, a tuple. The old assign was a User, the new was a tuple. LiveView saw them as different and would re-render, but the template expected a User and crashed silently. The error was swallowed because of LiveView's error handling. Once I fixed the pattern match, the assign updated correctly.
Root cause
Pattern matching oversight: `Accounts.update_user/1` returned `{:ok, %User{}}` but the code assigned the whole tuple to the socket, so the assign became a tuple instead of a User struct. LiveView detected a type change and tried to re-render, but the template crashed, resulting in no visible update.
The fix
Correctly pattern match the result: `{:ok, user} = Accounts.update_user(user_params)` then `assign(socket, :user, user)`.
The lesson
Always verify the actual type of the value being assigned. Use `IO.inspect` with `label` and check the struct name. Also, enable Phoenix error reporting in development to catch template render errors.
LiveView computes a diff by iterating over all assigns in the new socket and comparing each to the corresponding assign in the old socket using `==`. If the values are equal (by `==`), the assign is excluded from the diff. This is efficient but can be fooled by structs that have the same fields but different internal state (like `__meta__` in Ecto). Additionally, maps with atom keys vs string keys are not equal, so if your assign is a map with atom keys and you later assign a map with string keys, they will be considered different and cause a re-render (which might be unintended or intended).
To see exactly what LiveView compares, you can look at the `Phoenix.LiveView.Diff` module source. The key function is `compute_diff/3` which calls `diff_assigns/3`. It uses `Map.equal?` for maps and `==` for other types. For structs, `==` compares all fields, including `__struct__` and `__meta__` if they are defined as fields. However, Ecto schemas define `__meta__` as a field in the struct (it's included in the `defstruct`), so it is compared. But changes to `__meta__` (like `:state` changing from `:loaded` to `:built`) are compared and can cause false positives.
When you fetch an Ecto schema from the database, the struct includes `__meta__` with source, prefix, state, and context. Virtual fields (fields with `virtual: true`) are also included but may be nil if not loaded. These virtual fields are part of the struct's field list, so they are compared. A common pitfall is that a virtual field like `:full_name` is not set unless you explicitly compute it. If you assign the raw struct from the database, the old and new structs may have different `__meta__` states (e.g., `:loaded` vs `:built`), causing unnecessary re-renders. Conversely, if you modify the struct in place and assign it, the reference may be the same, and LiveView might skip the diff? No, LiveView always compares values, not references.
The fix is to either use a view model (plain map) with only the fields you need, or to `Map.drop` the `__meta__` field before assigning. Alternatively, you can implement the `Phoenix.LiveView.ChangeTracker` protocol for your schema, but that's overkill for most cases.
If you are updating a LiveView from another process (e.g., a GenServer), you must ensure the message is sent to the correct LiveView PID. LiveView PIDs can change on reconnection or navigation. Using `Process.send_after` with a captured PID is fragile. Instead, use `Phoenix.LiveView.send_update/2` for components within the same tree, or use a PubSub pattern (Phoenix.PubSub) to broadcast updates to all LiveViews subscribed to a topic. `send_update/2` only works for LiveComponents, not standalone LiveViews. For standalone LiveViews, you need to use `Phoenix.LiveView.send/2` or `Kernel.send/2` with the correct PID.
A robust pattern is to have the LiveView subscribe to a PubSub topic in `mount/3` and handle the broadcast in `handle_info/2`. This ensures all instances of the LiveView (across different users) receive the update.
The LiveDashboard provides a real-time view of all LiveView processes, their assigns, and the last diff. Navigate to `/dashboard` and click on the LiveView tab. You can see the current socket assigns and the last diff payload. If the assigns show the expected new values but the diff is empty, you've confirmed the comparison issue. If the assigns don't show the new values, the callback may not be executing.
In the browser devtools, open the Network tab and filter by 'WebSocket'. Look for messages with a `"diff"` key. The payload contains the changed assigns with old and new values. If you see a `"diff"` with an empty object `{}`, no changes were detected. If you see the diff but the UI doesn't update, the issue is in the template rendering (e.g., a crash in the template that is silently swallowed).
1. Assigning a map with string keys after previously assigning with atom keys: `%{"name" => "Alice"}` vs `%{name: "Alice"}` are not equal. 2. Assigning a struct vs a map: `%User{name: "Alice"}` vs `%{name: "Alice"}` are not equal. 3. Using `Map.put` on the assigns map directly inside a callback: this modifies the map in place but LiveView still references the old map, so the next assign might merge? Actually, `Map.put` returns a new map, so it's not in-place, but if you don't return the new socket, the update is lost. 4. Using `assign(socket, :key, socket.assigns.key)` which is a no-op but still triggers a diff? It will compare equal and be skipped.
To avoid these, always use `assign/3` and ensure the value type is consistent. If you need to update a nested value, use `update/3` or `assign(socket, :key, Map.put(socket.assigns.key, :nested, value))`.
Frequently asked questions
Why does my LiveView update work in development but not in production?
Common differences: Mix environment affects Ecto's `__meta__` state (dev may have `:built` vs `:loaded`), LiveView version may differ, or Phoenix's PubSub adapter may behave differently (e.g., PG vs Redis). Also, production may have multiple nodes where messages are not broadcast correctly. Check your production config for PubSub settings and ensure the LiveView process is on the same node.
Does LiveView re-render the entire template on every assign change?
No, LiveView uses a client-side diff to update only the changed parts of the DOM. The server computes a diff of the rendered HTML template using a virtual DOM. Only the assigns that changed are re-evaluated in the template. So even if an assign changes, if it's not used in the template, no DOM update occurs. But if the assign is used and the diff detects a change, only the affected DOM nodes are patched.
What is `assign_new/3` and when should I use it?
`assign_new/3` assigns a value only if the key is not already present in the socket assigns. It's useful for lazy initialization in `mount/3` or `handle_params/3` to avoid overwriting existing assigns. It does not help with update detection; it's purely for assignment safety.
Can I force a full re-render of a LiveView?
You can call `Phoenix.LiveView.send_update/2` with the same component but that only works for components. For a full LiveView, you can use `Phoenix.LiveView.navigate/2` or `redirect/2` to remount the LiveView. Alternatively, you can change a dedicated `:force_render` assign that increments on every update. But this is an anti-pattern; instead, fix the root cause of the missing update.
Why does `IO.inspect` show the new assign but the UI doesn't update?
This is the classic symptom. The most likely reason is that the assign value is considered equal to the old value by LiveView's diff. Check the type and structure. Use `Phoenix.LiveView.Utils.debug_assigns/1` (if available) or manually compare the old and new values with `==`. Another possibility is that the callback returns `{:noreply, socket}` but the socket was not updated (e.g., you assigned to a different variable).