Caching Liveviews - Part 2: Publicly caching private Liveviews

Posted on Sep 5, 2024

In Part 1: The road to HTTP-caching Liveviews, we’ve succeeded in caching the initial rendering of Liveviews.

For this we had to:

  • disable CSRF token check, using a modified version of Phoenix
  • disable sending the CSRF token when establishing the websocket connection
  • configure a Plug that sets the cache-control header
  • and eventually configure PlugHTTPCache

So far we succeeded in caching Liveviews that render public content. Caching private content with plug_http_cache or a CDN makes little sense, as the goal of a shared cache is to reuse response generated for a user A to all other users.

There is an in-between use-case though: how about caching pages that are private, but just a little?

At first it sounds like nonsense. A page is either private or public. Like a cable, it can be plugged or unplugged into a computer, not plugged just a little.

Well, take the example website below:

Sample app with authenticated user

I guess you now know what I mean: the only private information, specific to the current user, is the two badges at the top-right of the screen showing the number of articles in the basket, and notifications for the user.

It’s frustrating that we cannot cache this page just because of these 2 badges. Is there a way to cache at least part of this page?

Prior art

Different strategies are used to deal with caching pages with user content. Let’s explore some of them.


Some libraries, such as Drupal’s Authcache, alter the cache key depending on user roles and cache a version of the page for each of them.

Think of an online newspaper with anonymous users and subscribed users: the first version of the page contains only 30% of the article, and the second version, for subscribed users, contains the full content. In this case we would cache the 2 pages, and return the right one depending on the user’s billing status.

This method is quite similar to what we’ve done in A/B testing and HTTP caching with plug_http_cache and may or may not use the vary header (this is not important).

It therefore suffers from the same downsides:

  • if you cache at the application level, you cannot cache the page at your CDN
  • you can configure your CDN as explained in our previous article, at the cost of increased complexity
  • storing too many variants of the same page may impact the performance of your cache
  • but more important, you cannot cache user-specific content like our badges, but only pages by class of user

Another method consists in using ESI (Edge-Side Includes) to load user-specific content. Your template is cached, usually at the edge, and ESI loads the tiny user-specific bits before returning the page.

The idea is that loading these tiny bits is quicker than loading the full page, and can possibly be performed in concurrently.

ESI loading private data

In our example, we would replace the value in the badges by:

<p class="absolute top-0 right-0 rounded-xl bg-brand text-white font-bold px-1 leading-none">
  <esi:include src="/api/basket/nb_products" />
</p>

It’s similar to loading these private parts with javascript in the browser, except it’s done at the shared cache. Therefore, the first bytes will not be sent before all includes are fetched. By the way, will not discuss loading private part with javascript, as we use Liveview to avoid the complexity of developing the backend and frontend separately in the first place.

As a consequence:

  • depending on the performance of your backend, the speed gain might not be so great
  • you need to develop additional APIs to deal with user-specific content
  • more complexity at the CDN and on the pages you serve
  • loss of flexibility: we can’t discard the badge if the count is 0

When the Time To First Byte really maters, the previous solution is not satisfactory. A neat solution has been used to speed up this specific metric and allow caching pages containing user-specific content.

It consists in caching the page up to a certain point. You read it well: the first part of the page is cached and doesn’t contain any user data. The second part is not cached, may contain user data and is sent later.

Hole punching

This is particularly interesting because if you cache the <head/> of your HTML: your browser will immediately read the first lines and start loading the external resources in it (CSS, js…).

If we take our previous sample app and using this method, we could cache the page up to the right part of the top header which contains user-specific data.

Actually, using CSS tricks (setting reverse order in a flex container for instance), we could put this top bar at the end of our HTML code and cache most of the page.

Do we really want to do that though?

Proposing a new pattern: publicly cacheable private Liveviews

In part 1, we took a look at how Liveview renders the page twice:

  • the first time to render the initial page. This is the page we can cache and we called it the static mount
  • the second time when the Websocket that enables Liveview’s interactivity is established. We called it the live mount

This is well documented in Liveview’s mount callback documentation:

For each LiveView in the root of a template, mount/3 is invoked twice: once to do the initial page load and again to establish the live socket.

The idea to cache Liveviews with private user content is simple: render private data only on the second render - the live render.

Static render Live render Final render
Static render Live render 🟰 Full render
HTTP Websocket
Public Private
Cacheable Non-cacheable

First, let’s admit that this visual description is not 100% accurate, since the live render renders the full page again, and therefore loads the same data (here the product list). This depicts the part of the page that actually changes.

Second, this approach is not suited for heavy user-centric pages. This can also pose problems when the page contains forms and CSRF tokens, although Liveviews handle forms differently (and without CSRF tokens).

Otherwise, for all the pages with little user content, such as badges, username, unread threads on a forum… I believe this approach remains sane to have mainly public-content pages cached.

Let’s try a naive implementation to this pattern in an example project called cacheable_liveviews and see how it looks on the screen. The core code consists in loading user data only on the live mount:

lib/cacheable_liveviews_web/live/main_live/index.ex

defmodule CacheableLiveviewsWeb.MainLive.Index do
  #...

  @impl true
  def mount(_params, session, socket) do
    socket =
      socket
      |> assign(:top_products, Product.top())
      |> assign(:nb_items_in_cart, nil)
      |> assign(:nb_notifications, nil)
      |> assign(:user_id, nil)
      # ...

    if not connected?(socket) do
      socket
      |> assign(:staticmount, true)
      |> static_mount()
    else
      socket
      |> assign(:staticmount, false)
      |> live_mount(session)
    end
  end

  defp static_mount(socket) do
    # Nothing to do
    {:ok, socket}
  end

  defp live_mount(socket, session) do
    # ...

    if authenticated?(session) do
      live_mount_authenticated(socket, session)
    else
      live_mount_anonymous(socket)
    end
  end

  defp live_mount_authenticated(socket, session) do
    # User is logged in
    user_id = session["user_id"]

    socket =
      socket
      |> assign(:user_id, user_id)
      |> assign(:nb_items_in_cart, User.items_in_cart(user_id) |> length())
      |> assign(:nb_notifications, User.list_notifications(user_id) |> length())

    {:ok, socket}
  end

  defp live_mount_anonymous(socket) do
    # Nothing to do, user is logged out
    {:ok, socket}
  end

  #...
end

We use Liveview’s connected? function to figure out which render we are currently performing.

We then initialize a few variables that will receive a value on the live mount if the user is authenticated, along with some other variables to figure out in the code if the render is static or live, and the user authenticated or not.

Then we can use it in our template to conditionnaly render private part of the page when the user is authenticated:

lib/cacheable_liveviews_web/components/layouts/app.html.heex

  <div :if={not @staticmount} class="absolute right-1 top-1 flex">
    <!-- ... -->
    <div>
      <.link href="/login">
        <Heroicons.user :if={@user_id} class="w-8 h-8 text-gray-500" />
        <Heroicons.arrow_right_end_on_rectangle :if={!@user_id} class="w-8 h-8 text-gray-500" />
      </.link>
      <p
        :if={is_integer(@nb_notifications) and @nb_notifications > 0}
        class="absolute top-0 right-0 rounded-xl bg-brand text-white font-bold px-1 leading-none"
      >
        <%= @nb_notifications %>
      </p>
    </div>
  </div>

First we render this part of the page only on the live mount. It’s not needed for SEO, and rendering some default icons could provoke blips when the live mount update this part with other icons (unless they’re exactly aligned pixel-wise).

Then, we render a different icon depending on whether the user is authenticated or not. In a real-world example, we’d have different links, but keep in mind this is just a demo.

Finally, we render the notification if they are some.

Let’s log in:

Full render

On localhost, everything renders instantly. Let’s simulate slower network with the browser’s tools:

Slowed down full render

It emphasizes the importance, when using this method, of correctly building your pages so as to avoid having live elements appearing later moving other parts of the page: make sure they have the sufficient place left blank in the static rendering.

Refining the implementation

The greatest danger when caching Liveviews with user content is to make a mistake and include some private content during the static render. Then it will be cached and served to other users.

You might have noticed we asynchronously load the total number of products. We simulate a heavy operation that we don’t want to slow down the rendering of the overall page.

To do this, we use the Phoenix.LiveView.assign_async/3 function that was released in LiveView 0.20. In our LiveView we added:

cacheable_liveviews/lib/cacheable_liveviews_web/live/main_live/index.ex

  @impl true
  def mount(_params, session, socket) do
    socket =
      socket
      |> [...]
      |> assign_async(:nb_total_products, &get_nb_total_products/0)

    [...]
  end

  [...]
  defp get_nb_total_products(), do: {:ok, %{nb_total_products: Product.count_products()}}

As the documentation state:

The task is only started when the socket is connected.

This is pretty much what we want to do for our private assigns, as well as checking the user is authenticated.

Let’s create a helper function similar to assign_async/3:

cacheable_liveviews/lib/cacheable_liveviews_web/live_helpers.ex

defmodule CacheableLiveviewsWeb.LiveHelpers do
  import Phoenix.LiveView, only: [connected?: 1]
  import Phoenix.Component, only: [assign: 3]

  @user_session_key "user_id"

  def assign_private(socket, session, key, fun) do
    if connected?(socket) and is_map_key(session, @user_session_key) do
      assign(socket, key, fun.(session[@user_session_key]))
    else
      assign(socket, key, nil)
    end
  end
end

Then we import it to all our Liveviews:

cacheable_liveviews/lib/cacheable_liveviews_web.ex

  def live_view do
    quote do
      use Phoenix.LiveView,
        layout: {CacheableLiveviewsWeb.Layouts, :app}

      # We add this line
      import CacheableLiveviewsWeb.LiveHelpers

      unquote(html_helpers())
    end
  end

Assigns are initialized to nil for static rendering or when the user is unauthenticated, but you might need to set another value if nil is a meaningful value in your case. We can now use this helper yo simplify this new pattern and make it safer:

cacheable_liveviews/lib/cacheable_liveviews_web/live/main_live/index.ex

defmodule CacheableLiveviewsWeb.MainLive.Index do
  # [...]

  def mount(_params, session, socket) do
    socket =
      socket
      |> assign(:top_products, Product.top())
      |> assign(:connected?, connected?(socket))
      |> assign(:authenticated?, is_map_key(session, @user_session_key))
      |> assign_private(session, :nb_items_in_cart, &get_nb_items_in_cart/1)
      |> assign_private(session, :nb_notifications, &get_nb_notifications/1)
      |> assign_async(:nb_total_products, &get_nb_total_products/0)

    if not connected?(socket) do
      static_mount(socket)
    else
      live_mount(socket)
    end
  end

  defp static_mount(socket) do
    # Nothing more to do
    {:ok, socket}
  end

  defp live_mount(socket) do
    # Set up additional live features. For instance we could subscribe to
    # price updates through a Phoenix channel

    {:ok, socket}
  end

  @impl true
  def handle_info(evt, socket) do
    # Handle live updates of the page, e.g. price updates

    {:noreply, socket}
  end

  defp get_nb_items_in_cart(user_id), do: user_id |> User.items_in_cart() |> length()
  defp get_nb_notifications(user_id), do: user_id |> User.list_notifications() |> length()
  defp get_nb_total_products(), do: {:ok, %{nb_total_products: Product.count_products()}}
end

If you stick to the rule of using the session variable only as an argument to assign_private/4, then you should be safe.

Additional notes

This method doesn’t only work with Elixir’s plug_http_cache library, but with any compliant HTTP cache. You could even use both, plug_http_cache couple with a CDN, and have 2 levels of caching.

A few last things need to be considered:

  • when using live navigation, only the first visited page is returned as HTTP. All other pages are sent over Liveview’s websocket, and therefore the HTTP cache will not been used
  • as a consequence and as explained in part 1, and especially if you want to cache pages for the sake of SEO, you might want to warm up your cache after deployment by visiting these pages, either using external scripts or using some tricks to visit (and cache) them programmatically from your app
  • speaking of deployment, you might need to invalidate your modified Liveviews after deploying new versions if you use plug_http_cache. You can use http_cache:invalidate_url/2 to that end. Since Liveview rerenders the full content on live mount, the page will eventually be displayed as expected even if the cache version differs, but the page layout will change between the static and live mounts as shown in the following capture:

Rendering stale page

  • live_session/3 accepts a :session parameter to add some values to the user session. These values are stored in the (static) HTML page. Therefore, do not use it to store user content when caching is enabled

Wrapping it up

By rendering private user-data only on live mount, we can enable caching the public part of a page containing user data using regular HTTP caching mechanisms.

This method works within Phoenix (using the plug_http_cache library) or with any shared cache between your Phoenix app and the user’s browser.

Extra care must be taken to not inadvertently leak private user-data. I believe one can mitigate this risk by implementing well-thought helpers inspired by async_assign.

The demo application used for the screenshots of this post is published on github and is called cacheable_liveviews.

Happy hacking!