This document describes the challenge and working solution for detecting browser timezone and sending it to a Phoenix LiveView server, specifically for self-contained LiveView plugins that cannot modify the host application's JavaScript.
When building a LiveView dashboard (like a debugger), you want to display timestamps in the user's local timezone rather than UTC. This requires:
- Detecting the browser's timezone (client-side JavaScript)
- Sending that timezone to the LiveView server
- Using it to format timestamps
The challenge is that self-contained LiveView plugins cannot modify the host app's JavaScript, which rules out the standard approach of passing timezone via LiveSocket connect params.
Normally, you'd pass timezone in LiveSocket connect params:
// In host app's app.js - NOT available for plugins
let liveSocket = new LiveSocket("/live", Socket, {
params: {time_zone: Intl.DateTimeFormat().resolvedOptions().timeZone}
});A self-contained LiveView plugin must:
- Work without modifying the host app's JavaScript
- Not require hook registration in the host app
- Use only inline JavaScript in templates
Hooks require registration in the host app's JavaScript:
// Requires host app modification - NOT self-contained
let Hooks = { TimezoneHook: { mounted() { ... } } }
let liveSocket = new LiveSocket("/live", Socket, { hooks: Hooks })Result: unknown hook found for 'TimezoneHook' error
<form phx-change="set_timezone">
<input type="hidden" id="tz-input" name="timezone" value="UTC" />
</form>
<script>
input.value = detectedTimezone;
input.dispatchEvent(new Event('input', { bubbles: true }));
</script>Result: Event not received by server. The input event on hidden inputs doesn't reliably trigger phx-change.
Same as above but with 'change' event instead of 'input'.
Result: Still not received. Hidden inputs don't participate in form change detection the same way visible inputs do.
<div phx-window-customtz="set_timezone"></div>
<script>
window.dispatchEvent(new CustomEvent('customtz', {detail: {timezone: tz}}));
</script>Result: Event not received. phx-window-* bindings may not work reliably with CustomEvents or have other constraints.
<script>
// Runs immediately on page load
dispatchTimezoneEvent();
</script>Result: Event dispatched before LiveView WebSocket connected, so server never receives it.
setTimeout(() => dispatchTimezoneEvent(), 100);Result: Unreliable. 100ms may not be enough, and longer delays create visible lag.
Use a hidden button with phx-click and dynamically set phx-value-* attributes before programmatically clicking it. This works because:
phx-clickis a core LiveView binding that reliably sends eventsphx-value-*attributes are read at click time, so dynamic values workphx:page-loading-stopevent signals LiveView is connected
However, there are two critical timing issues that must be addressed:
-
Event binding timing: Even after
phx:page-loading-stopfires, LiveView's event bindings may not be fully active. ArequestAnimationFrame+setTimeoutcombination is needed. -
LiveView reconnects: When the server restarts and the browser reconnects,
phx:page-loading-stopfires again. Using{ once: true }would miss these reconnects.
1. Hidden Button (Outside phx-update="ignore")
<button id="sagents-tz-btn" phx-click="set_timezone" style="display: none;"></button>The button must be outside any phx-update="ignore" container so LiveView can handle its events.
<div phx-update="ignore" id="tz-script-container">
<script>
(function() {
// Listen for EVERY phx:page-loading-stop (initial load AND reconnects)
// Do NOT use { once: true } - it would break on reconnects
window.addEventListener('phx:page-loading-stop', function() {
// Use requestAnimationFrame + setTimeout for reliable timing
// RAF ensures we're past the current render cycle
// setTimeout adds buffer for LiveView event bindings to be fully active
requestAnimationFrame(function() {
setTimeout(function() {
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
const btn = document.getElementById('sagents-tz-btn');
if (btn) {
btn.setAttribute('phx-value-timezone', tz);
btn.click();
}
}, 100);
});
});
})();
</script>
</div>The script is inside phx-update="ignore" to prevent re-execution on LiveView re-renders (but the event listener persists and handles reconnects).
def handle_event("set_timezone", %{"timezone" => timezone}, socket) do
case validate_timezone(timezone) do
{:ok, validated_tz} ->
{:noreply, assign(socket, :user_timezone, validated_tz)}
{:error, _} ->
{:noreply, socket}
end
end
defp validate_timezone(timezone) when is_binary(timezone) do
case DateTime.shift_zone(DateTime.utc_now(), timezone) do
{:ok, _} -> {:ok, timezone}
{:error, _} -> {:error, :invalid_timezone}
end
endThe host app must have a timezone database configured:
# config/config.exs
config :elixir, :time_zone_database, Tzdata.TimeZoneDatabaseAnd the dependency:
# mix.exs
{:tzdata, "~> 1.1"}defp format_timestamp(datetime, timezone) do
case DateTime.shift_zone(datetime, timezone) do
{:ok, shifted} ->
shifted
|> DateTime.truncate(:second)
|> Calendar.strftime("%H:%M:%S %Z")
{:error, _} ->
datetime
|> DateTime.truncate(:second)
|> Calendar.strftime("%H:%M:%S UTC")
end
endThis Phoenix LiveView event fires when:
- The initial page load completes
- LiveView WebSocket is connected
- The DOM is ready for interaction
- Also fires on reconnects (e.g., after server restart)
The { once: true } option removes the event listener after it fires once. This breaks on LiveView reconnects:
- User opens page → listener fires → timezone set ✓
- Server restarts → LiveView reconnects
phx:page-loading-stopfires again- But listener was removed → timezone not set ✗
- Page shows UTC instead of local time
Solution: Don't use { once: true }. Setting the timezone multiple times is harmless (idempotent).
Even after phx:page-loading-stop fires, LiveView's event bindings may not be fully active. Testing showed that clicking the button immediately often failed silently.
- requestAnimationFrame: Ensures we're past the current browser render cycle
- setTimeout(100): Adds buffer time for LiveView to fully wire up event bindings
This combination proved reliable across initial loads and reconnects.
Without this, LiveView would re-execute the script on every re-render, adding duplicate event listeners. With phx-update="ignore":
- Script runs once on initial page load
- Event listener persists and handles all
phx:page-loading-stopevents - No duplicate listeners accumulate
Elements inside phx-update="ignore" have their events ignored by LiveView. The button must be outside so phx-click works.
To prevent:
- Invalid timezone strings from breaking
DateTime.shift_zone/2 - Potential injection attacks via malicious timezone values
For self-contained LiveView plugins that need browser-side data:
- Use a hidden button with
phx-click - Set
phx-value-*attributes dynamically before clicking - Wait for
phx:page-loading-stopto ensure LiveView is connected - Use requestAnimationFrame + setTimeout for reliable event binding timing
- Don't use
{ once: true }- allow listener to handle reconnects - Keep the button outside and script inside
phx-update="ignore" - Always validate client-provided data server-side