Automatic Timezones in Rails

This is basically how Basecamp handles timezones, with a small change. Check out basecamp/fizzy for their implementation.

Server-side Rails apps default to UTC. That’s fine until you need to show a user dates in their timezone. “You read this chapter on March 27” shouldn’t say March 28 because they’re in America/New_York.

Here’s a simple pattern that detects the browser’s timezone, syncs it to the server, and makes Time.current just work everywhere.

How it works

  1. A Stimulus controller sets a cookie with the browser’s IANA timezone on every page load
  2. A Rails around_action wraps every request in Time.use_zone using that cookie
  3. A hidden auto-submitting form persists the timezone to the user record when it changes

There is nothing for the user to configure, it just works automatically.

Step 1: Migration

Add a timezone_name column to your users table:

class AddTimezoneNameToUsers < ActiveRecord::Migration[8.1]
  def change
    add_column :users, :timezone_name, :string, default: "UTC", null: false
  end
end

Step 2: User model

Add a helper that returns an ActiveSupport::TimeZone object, falling back to UTC for invalid values:

class User < ApplicationRecord
  def timezone
    ActiveSupport::TimeZone[timezone_name] || ActiveSupport::TimeZone["UTC"]
  end
end

Step 3: Stimulus controller

Detect the browser’s timezone via the Intl API and set it as a cookie on every page load:

// app/javascript/controllers/timezone_cookie_controller.js
import { Controller } from "@hotwired/stimulus";

export default class extends Controller {
  connect() {
    this.#setTimezoneCookie();
  }

  #setTimezoneCookie() {
    const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
    document.cookie = `timezone=${encodeURIComponent(timezone)}; path=/`;
  }
}

Attach it to the <body> so it runs on every page:

<body data-controller="timezone-cookie">

Intl.DateTimeFormat().resolvedOptions().timeZone returns IANA identifiers like America/New_York or Asia/Tokyo. These map directly to ActiveSupport::TimeZone keys.

Step 4: Controller concern

Wrap every request in the user’s timezone so Time.current and Date.current return the right values for their timezone. Timestamps are still stored in UTC in the database, but this ensures you get the correct date when calling things like Time.current.to_date:

# app/controllers/concerns/current_timezone.rb
module CurrentTimezone
  extend ActiveSupport::Concern

  included do
    around_action :set_current_timezone
    helper_method :timezone_from_cookie
  end

  private

  def set_current_timezone(&)
    Time.use_zone(timezone_from_cookie, &)
  end

  def timezone_from_cookie
    @timezone_from_cookie ||= begin
      timezone = cookies[:timezone]
      ActiveSupport::TimeZone[timezone] if timezone.present?
    end
  end
end

Include it in your ApplicationController:

class ApplicationController < ActionController::Base
  include CurrentTimezone
end

Time.use_zone sets the timezone for the duration of the block. If the cookie is missing or invalid, it passes nil, which just falls back to the app default (UTC).

Step 5: Auto-sync to the user record

When the browser timezone differs from what’s saved on the user, auto-submit a hidden form to persist it:

<%# app/views/layouts/shared/_time_zone.html.erb %>
<% if timezone_from_cookie.present? && timezone_from_cookie != Current.user.timezone %>
  <%= form_with url: user_timezone_path, method: :put, data: { controller: "auto-submit" } do |form| %>
    <%= form.hidden_field :timezone_name, value: timezone_from_cookie.name %>
  <% end %>
<% end %>

The auto-submit Stimulus controller just submits the form as soon as it connects. Since the form only renders when the timezone has changed, it only fires when needed:

// app/javascript/controllers/auto_submit_controller.js
import { Controller } from "@hotwired/stimulus";

export default class extends Controller {
  connect() {
    this.element.requestSubmit();
  }
}

Render it in your layout for authenticated users:

<%# app/views/layouts/application.html.erb %>
<%= render "layouts/shared/time_zone" if Current.user %>

The controller that handles the update:

# app/controllers/user/timezones_controller.rb
class User::TimezonesController < ApplicationController
  def update
    Current.user.update!(timezone_name: timezone_param)
    head :ok
  end

  private

  def timezone_param
    params[:timezone_name]
  end
end

With the route:

namespace :user do
  resource :timezone, only: :update
end

Why this works well

  • No UI needed. The browser sets the timezone every time so if a user travels it will just work.
  • Time.current just works.Timestamps are still stored in UTC, but Time.current and Date.current return the right values for the user’s timezone. No .in_time_zone calls scattered everywhere.
  • Persisting to the user record means you have their timezone available outside of requests, like if you need to schedule something at a specific time in their timezone.
  • The auto-submit formfires once when the timezone changes, then the condition timezone_from_cookie != Current.user.timezone stops matching, so it doesn’t fire again.