LiveView Already Knows When Your Server Crashed

This week, I reviewed a pull request that added error flash handling to a Phoenix app. The JavaScript implementation seemed straightforward: listening for phx:page-loading-start, checking the socket connection, showing a popover for server crashes, and hiding it during navigation. It included two helper functions, an event listener, and a manual isConnected() check to differentiate between server errors and network issues. Then I opened the LiveView documentation and realized we were reinventing functionality that already exists in the framework.

What We Initially Built

Here's the code we thought was complete:

window.addEventListener("phx:page-loading-start", (info) => {
  topbar.show(300)

  if (info.detail.kind === "error" && liveSocket.socket.isConnected()) {
    showErrorFlash()
  } else {
    clearErrorFlash()
  }
})

function showErrorFlash() {
  const el = document.getElementById("client-error-flash")
  if (el && !el.matches(":popover-open")) {
    el.showPopover()
  }
}

function clearErrorFlash() {
  const el = document.getElementById("client-error-flash")
  if (el?.matches(":popover-open")) {
    el.hidePopover()
  }
}

The key logic here is the isConnected() check. The phx:page-loading-start event triggers with kind: "error" for both server crashes and network disconnects. To show the error flash only for server crashes, we checked if the socket was connected at the time of the error, an indicator that the server process failed rather than the network. It worked, and initially, it made sense. But as I dug deeper into the docs, I realized LiveView already accounts for this distinction.

LiveView's Built-in Classes

LiveView uses CSS classes on the root container to represent connection states. The data-phx-main element gets the following classes:

  • phx-connected when the WebSocket is active.
  • phx-disconnected when the network connection drops.
  • phx-error when the server process crashes.

That last class, phx-error, was exactly what we needed. LiveView distinguishes between server crashes and network disconnects, applying phx-error only to server issues. The manual isConnected() check and event logic we wrote were redundant.

Observing Classes Instead of Events

If we were just toggling a div CSS alone could handle this: .phx-error #client-error-flash { display: block }. But since we're using the Popover API with popover="manual", CSS can't do it alone. The popover needs to be in the top layer to properly display above <dialog> modals in our app. We simplified the JavaScript by replacing the event listener and helper functions with a MutationObserver to watch for class changes on the LiveView container:

const main = document.querySelector("[data-phx-main]")
if (main) {
  new MutationObserver(() => {
    const flash = document.getElementById("client-error-flash")
    if (!flash) return

    if (main.classList.contains("phx-error")) {
      if (!flash.matches(":popover-open")) flash.showPopover()
    } else {
      if (flash.matches(":popover-open")) flash.hidePopover()
    }
  }).observe(main, { attributes: true, attributeFilter: ["class"] })
}

The observer reacts to LiveView's state transitions. When a server crash occurs, phx-error is added, triggering the popover to open. When LiveView reconnects, phx-error is replaced with phx-connected, closing the popover. For network disconnects, phx-disconnected is added instead, and the observer does nothing, exactly the behavior we wanted, but with much less code.

Timing Considerations

One issue we ran into: the observer must be set up after liveSocket.connect(). Initially, we placed it at the top of the script, but querySelector returned null because the [data-phx-main] element wasn't ready. Moving the setup after liveSocket.connect() resolved this:

liveSocket.connect()

const main = document.querySelector("[data-phx-main]")
if (main) {
  new MutationObserver(() => {
    // ...
  }).observe(main, { attributes: true, attributeFilter: ["class"] })
}

Alternatively, a phx-hook guarantees the DOM is ready, but for a simple observer like this, the standalone approach is cleaner.

The Bigger Takeaway

This reinforced a lesson I've learned before: check what LiveView already provides before writing custom JavaScript. Features like phx:page-loading-start events and navigation classes are well-documented, but it's easy to default to event-driven logic because that's what most JavaScript frameworks emphasize. LiveView often does more than you expect. Sometimes, the best solution is to rely on what's already built in.

Further reading: