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.