Building real-time navigation badges with Turbo Streams

Real-time user interface updates in three easy steps.

A client project I was leading needed real-time updates to certain areas of the user interface. It was a consumer application and showing badges on navigation links in a timely manner was critical to maintaining high engagement. Fortunately, vanilla Rails is more than enough for this use case.

In this article, I explain my approach and the resulting implementation, but before diving into code, let’s define the problem in detail.

Problem Statement

Imagine we’re working on a messaging application. User can send messages to and receive messages from others. Messages are marked as unread until they’re opened for the first time.

The user interface includes a navigation bar with a link to the inbox. That link must have a badge showing the number of unread messages, but the badge must be hidden if there are no unread messages. Additionally, badges must be updated in real-time with no user interaction required.

The screenshot below shows an example navigation bar with a badge on the link to the inbox showing 1 unread message. We know how the feature should work, so let’s get down to work.

A navigation bar showing "Inbox", "Drafts", and "Settings". The "Inbox" link has a badge saying there's 1 unread message.

Ingredient 1: Markup

The tagline on the Stimulus website captures its philosophy perfectly: a framework for the HTML you already have. We won’t be using Stimulus, but starting with the markup is my recommendation in most circumstances. We’ll use Tailwind CSS to show a small, red badge on a navigation link:

<!-- `relative` is used, so that the badge inside the A tag can be positioned
     relative to it. -->
<a href="/inbox" class="relative rounded border p-2 shadow-sm">
  Inbox

  <!-- The inbox badge has a unique ID, so that it can be targeted easily. The
       badge could include text for screen readers to make it read "1 unread
       message", but it's omitted here for simplicity. -->
  <div id="navbar:inbox:badge"
       class="absolute -right-1 -top-2
              inline-block
              h-5 w-5
              rounded-full
              bg-red-500
              text-center text-xs leading-5 text-white">
    1
  </div>
</a>

Ingredient 2: Helpers

That markup could be put into a partial, but I prefer helpers for such small “components”. In this case, we’ll build the framework we need and add navbar_link_to that mimics link_to. The new helper will accept the badge via arguments, but the badge itself has to be rendered outside.

module NavigationHelper
  NAVBAR_LINK_CLASS = "relative rounded border p-2 shadow-sm"

  # The top level method mimics the vanilla Rails `link_to` helper.
  def navbar_link_to(title, options, badge: nil)
    link_to(options, class: NAVBAR_LINK_CLASS) do
      concat(title)
      concat(badge) if badge
    end
  end
end

Next, we need badge_tag for rendering arbitrary badges:

module NavigationHelper
  BADGE_CLASS = "absolute -right-1 -top-2 inline-block h-5 w-5 rounded-full " \
                "bg-red-500 text-center text-xs leading-5 text-white"

  # Renders a badge tag with the specified content. The result can be passed to
  # `navbar_link_to` via the `badge:` keyword argument.
  def badge_tag(content, id: nil) = tag.div(content, class: BADGE_CLASS, id:)
end

The last step is a helper method for each badge type. We discuss the rationale for specialized badge helpers at the end of the article, but let’s focus on the code for now.

inbox_badge_tag will take an account as an argument, get the number of unread messages, and return the badge markup ensuring the badge is hidden when there are no unread messages.

module NavigationHelper
  # The badge has an ID so that it can be targeted via Turbo Streams.
  INBOX_BADGE_ID = "navbar:inbox:badge"

  # Badges are rendered using dedicated methods that contain the logic required
  # to determine the badge content.
  def inbox_badge_tag(account)
    # In this case, the number of unread messages is obtained and ...
    count = account.messages.unread.count

    # ... is shown ONLY if it's not zero.
    badge_tag(count.zero? ? nil : count, id: INBOX_BADGE_ID)
  end
end

The navigation bar can now be rendered using the helpers above. Notice badges are rendered in their final state, so there’s no room for content flashes.

<div class="p-8 flex flex-row gap-2">
  <%= navbar_link_to "Inbox", inbox_path, badge: inbox_badge_tag(current_account) %>
  <%= navbar_link_to "Drafts", drafts_path %>
  <%= navbar_link_to "Settings", settings_path %>
</div>

Ingredient 3: Broadcasts

Ingredients 1 and 2 are the static part: showing the right badge on each page load. The last ingredient makes the badge dynamic and updates it whenever an unread message is created, read, or deleted.

Assuming the messages table has a column read_at indicating when the message was read for the first time, we can implement the following broadcast on the Message model. Notice, we had to explicitly override locals, even though they’re unused, because otherwise the background update job would try to automatically fetch a message after its deletion, producing an error.

class Message < ApplicationRecord
  belongs_to :account

  # An unread message is one with `read_at` set to `nil`.
  scope :unread, -> { where(read_at: nil) }

  # Broadcast an update after every transaction.
  after_commit :broadcast_updates

  private

  def broadcast_updates
    # Replace the entire badge markup.
    broadcast_replace_later_to(
      # Broadcast the change to the account channel. Each user has a separate
      # account and a separate stream of updates.
      account,

      # Replace the inbox badge. The ID was assigned to a constant to keep the
      # code DRY.
      target: NavigationHelper::INBOX_BADGE_ID,

      # The content is passed explicitly.
      content: ApplicationController.helpers.inbox_badge_tag(account),

      # #broadcast_* automatically injects a partial and locals that reference
      # the message. We ignore both, but locals must be explicitly reset, as
      # otherwise deleting a message would make the broadcast job fail due to
      # its inability to fetch the no-longer existent message.
      locals: { message: nil }
    )
  end
end

The code above triggers an update after every update to the model. On larger models this could mean many unnecessary updates. In this case, it’s a good idea to add fine-grained update control like in the following snippet:

class Message < ApplicationRecord
  private

  def broadcast_updates
    # This is for fine-grained control when an update is sent. We don't want to
    # broadcast updates for unrelated changes, only when the read status is
    # affected.
    return if !broadcast_updates?

    # ...
  end

  # Determines whether an update should be broadcasted. There are three cases
  # to consider.
  def broadcast_updates?
    # 1. New message in an unread state.
    (previously_new_record? && read_at.nil?) ||

      # 2. Deleting an unread message.
      (previously_persisted? && read_at.nil?) ||

      # 3. Updating the read state of an existing message.
      (
        !previously_new_record? &&
          persisted? &&
          read_at.nil? != read_at_previously_was.nil?
      )
  end
end

Last but not least, a connection from the browser to the app is needed:

<div class="p-8 flex flex-row gap-2">
  <!-- Establish an Action Cable connection for Turbo Streams, so that updates
       will be sent whenever the navigation bar is visible. -->
  <%= turbo_stream_from current_account %>

  <!-- Navigation links follow -->
</div>

That’s it! Let’s talk about dedicated badge helpers before finishing the article.

Dedicated Badge Helpers

The decision to introduce dedicated badge helpers my seem strange, but it helps to avoid numerous failure modes. inbox_badge_tag can be used in both the template and the broadcast, guaranteeing the behavior is the same. To demonstrate a failure mode we’ve avoided, consider the following alternative implementation:

<% inbox_badge = current_account.messages.unread.count %>
<%= navbar_link_to "Inbox",
                   inbox_path,
                   badge: badge_tag(inbox_badge.zero? ? nil : inbox_badge) %>

Aside from ugly variable assignment in the template, the structure above would force the broadcast to repeat the code to obtain the number of unread messages and the code to hide the badge when there are no messages. The situation would get even worse if there were different badges (e.g. red and green). If the navigation bar template used a red badge, but the broadcast used a green badge by mistake then a bug would result.

When thinking about the app, it’s natural to think “there’s a button and it has a badge”. Both the button and the badge should be embodied in the code as standalone constructs, so that the way we think about the app maps closely to how the app is implemented.

Closing Thoughts

The implementation above is one of many. Possible variations include:

  1. Using partials instead of helpers.
  2. Implementing badges as Stimulus controllers and changing them by updating a value via Turbo Stream.
  3. Updating entire buttons, instead of badges.
  4. Updating the entire navigation bar, instead of individual buttons or badges.

The client project that inspired the article was a consumer app expected to scale to a vast number of users. The choice of updating individual badges via helpers was to keep the database load at a minimum. In a B2B SaaS I’d certainly consider variation 4 above.

The fact this kind of interactivity can be implemented in three relatively simple steps is why I love Ruby on Rails and recommend you consider it for your next tech venture

Enjoyed the article? Follow me on Twitter!

I regularly post about Ruby, Ruby on Rails, PostgreSQL, and Hotwire.

Leave your email to receive updates about articles.