Live Countdowns Without JavaScript: Turbo Streams + relative_time_in_words

Most countdown timers require JavaScript libraries. But with Rails 8’s new relative_time_in_words helper and Turbo Streams, you can build live-updating countdowns entirely server-side. No npm packages, no client-side date math, and it gracefully degrades when JavaScript is disabled.

This is a bit of a contrived example—a fun exploration of what’s possible with pure server-side rendering. In production, you’d likely pair client-side JavaScript countdowns with server-side validation, which uses fewer server resources while maintaining accuracy where it matters (the actual deadline enforcement). But let’s see how far we can push the server-side approach.

The New Helper

Rails 8 adds relative_time_in_words, which handles both past and future times:

relative_time_in_words(3.hours.from_now)
# => "in about 3 hours"

relative_time_in_words(2.days.ago)
# => "2 days ago"

This is different from time_ago_in_words, which only handles the past. Having a single helper for both directions makes countdown UIs much simpler.

Building a Live Auction Timer

Let’s build an auction countdown that updates every minute without writing any JavaScript.

The Model

class Auction < ApplicationRecord
  def ended?
    ends_at <= Time.current
  end

  def time_remaining
    ends_at - Time.current
  end
end

The View

<%= turbo_stream_from "auction_#{@auction.id}" %>

<div class="auction-timer">
  <span id="countdown_<%= @auction.id %>"
        class="<%= urgency_class(@auction.ends_at) %>">
    <%= relative_time_in_words(@auction.ends_at) %>
  </span>
</div>

Urgency-Based Styling

Add a helper that returns CSS classes based on time remaining:

# app/helpers/countdowns_helper.rb
module CountdownsHelper
  def urgency_class(deadline)
    return "ended" if deadline <= Time.current

    remaining = deadline - Time.current
    case remaining
    when 0..1.hour then "critical"
    when 1.hour..1.day then "warning"
    else "normal"
    end
  end
end
.critical { color: #dc2626; font-weight: bold; }
.warning { color: #d97706; }
.normal { color: #059669; }
.ended { color: #6b7280; }

The Broadcast Job

Here’s where Turbo Streams comes in. A recurring job broadcasts countdown updates:

# app/jobs/countdown_broadcast_job.rb
class CountdownBroadcastJob < ApplicationJob
  def perform(auction)
    return if auction.ended?

    Turbo::StreamsChannel.broadcast_update_to(
      "auction_#{auction.id}",
      target: "countdown_#{auction.id}",
      html: render_countdown(auction)
    )

    # Schedule next update
    self.class.set(wait: update_interval(auction)).perform_later(auction)
  end

  private

  def render_countdown(auction)
    ApplicationController.render(
      partial: "auctions/countdown",
      locals: { auction: auction }
    )
  end

  def update_interval(auction)
    remaining = auction.time_remaining
    case remaining
    when 0..5.minutes then 10.seconds
    when 5.minutes..1.hour then 1.minute
    else 5.minutes
    end
  end
end

The job schedules itself with dynamic intervals—updating every 10 seconds when the deadline is close, but only every 5 minutes when it’s far away. This keeps your job queue manageable.

The Partial

<%# app/views/auctions/_countdown.html.erb %>
<span class="<%= urgency_class(auction.ends_at) %>">
  <% if auction.ended? %>
    Ended
  <% else %>
    <%= relative_time_in_words(auction.ends_at) %>
  <% end %>
</span>

Starting the Countdown

Kick off the broadcast job when the auction page is viewed:

class AuctionsController < ApplicationController
  def show
    @auction = Auction.find(params[:id])
    CountdownBroadcastJob.perform_later(@auction) unless @auction.ended?
  end
end

Other Use Cases

This pattern works for any time-sensitive display:

Flash Sales

<div id="sale_timer">
  Sale ends <%= relative_time_in_words(@sale.ends_at) %>
</div>

Support Ticket SLAs

def sla_status(ticket)
  deadline = ticket.created_at + ticket.sla_hours.hours
  if deadline.future?
    "Response due #{relative_time_in_words(deadline)}"
  else
    "SLA breached #{relative_time_in_words(deadline)}"
  end
end

Event Schedules

<% if @event.starts_at.future? %>
  Starts <%= relative_time_in_words(@event.starts_at) %>
<% else %>
  Started <%= relative_time_in_words(@event.starts_at) %>
<% end %>

Things to Watch Out For

Job Queue Overhead

Each active countdown spawns recurring jobs. For high-traffic pages, consider:

  • Batching updates for multiple auctions
  • Using Action Cable for truly real-time updates
  • Capping the number of concurrent countdown jobs

Time Zones

relative_time_in_words uses Time.current, which respects Time.zone. Make sure your app’s time zone handling is consistent.

Graceful Degradation

The countdown works without JavaScript—users just see a static time that was accurate when the page loaded. Turbo Streams adds the live updates as an enhancement.

I18n

The helper uses Rails’ built-in translations. Customize them in your locale files:

en:
  datetime:
    relative:
      past: "%{time} ago"
      future: "in %{time}"

Wrapping Up

This approach gives you live countdowns with minimal complexity. The server is the source of truth for time, eliminating client-side clock skew issues. And because it’s just HTML being streamed, it works everywhere Turbo works.

For truly real-time precision (second-by-second), you’d still want JavaScript. But for most use cases—auctions, sales, SLAs, events—minute-level updates are sufficient, and this server-side approach is simpler to build and maintain.

See PR #55405 for the relative_time_in_words implementation.