フロントエンド

なぜタイムゾーンのバグはあなたのローカルマシンで再現しないのか

サーバーはUTCを、ラップトップはローカルタイムを送信し、文字列をスライスするとその差が隠れてしまいます。なぜ本番環境でのみ壊れるのか、そしてローカルで強制的に再現する方法。

この記事は英語の原文をAIモデルが翻訳したものです。表現が原文と異なる場合があります。 英語の原文を読む

社内ダッシュボードのタイムスタンプは 09:41 と表示されていました。イベントが実際に発生したのは 18:41 でした。9時間のずれ、これはちょうどタイムゾーンオフセット1つ分です。そして、それを生成したコードは、誰にも気づかれないまま数ヶ月前にシップされていました。誰も気づかなかった理由は興味深いものです。すべての開発者のマシンでは、同じコードが正しい時刻を表示していたのです。

これは、名前を付ける価値のある特定の障害モードです。このバグは、フォーマットロジックだけに存在するわけではありません。サーバーが時刻をシリアライズする方法と、開発者のマシンがたまたま設定されているタイムゾーンとの相互作用の中に存在するのです。この2つが一致すると、壊れた実装が正しく見えてしまいます。

一見問題ないが、実はそうではないコード

サーバーはタイムスタンプを RFC 3339 文字列として返します。フロントエンドでは MM-DD HH:mm 形式が必要です。誰かが、当然思いつくであろうことを書きました。

// Renders "08-05 09:41" from "2026-08-05T09:41:42.240399Z"
function shortTime(iso) {
  return iso ? iso.slice(5, 16).replace('T', ' ') : '-';
}

ここではタイムゾーン変換は行われません。Dateオブジェクトは一切存在しません。この関数は、文字列の5文字目から16文字目までを取得して表示します。サーバーがエンコードしたタイムゾーンが何であれ、ユーザーにはそれがそのまま表示されます。

本番環境では、コンテナはTZが未設定の状態で実行されます。これはUTCを意味します。ペイロードは"2026-08-05T09:41:42.240399Z"で、スライスによって08-05 09:41が生成されます。オフィスのすべての時計を現地時間として読むオペレーターは、実際より9時間遅れた数値を目にすることになります。

ラップトップで隠される理由

ソウルの開発者マシンで同じサーバーを実行すると、Z はシリアライズされません。Goの time.Time はロケーションを保持しており、timestamptz 型のカラムを読み取るドライバーはそれをセッションのタイムゾーンで具現化します。TZ=Asia/Seoul のマシンでは、JSONは次のようになります。

{ "startedAt": "2026-08-05T18:41:06.190270+09:00" }

その5文字目から16文字目を切り出します。08-05 18:41が得られますが、これは正しい値です。関数が何かを変換したからではなく、サーバーがすでにローカルタイムを出力しており、単純なスライスがそれを維持したからです。

これが罠のすべてです。この関数にはタイムゾーンのロジックがないため、入力が書かれたタイムゾーンをそのまま返します。ローカルでの開発では、偶然にもターゲットのタイムゾーンが提供されるため、出力は偶然に正しくなります。本番環境ではUTCが提供されるため、出力は間違ったものになります。どちらの場合もコードは同一です。

同じコード、2つの環境 1 ローカル 2 UTC 開発サーバー 本番サーバー slice() slice() 正しく見える 9時間ずれている // 同じコードでも、入力のタイムゾーンによって結果が決まる

修正を信頼する前に、本番環境の条件を強制する

表示ターゲットとゾーンが一致するマシンでは、タイムゾーンの修正を検証できません。壊れたバージョンでも通ってしまうため、そこでのチェックには意味がありません。

本番環境が実際に使用するゾーンでサーバーを起動します:

TZ=UTC PORT=8099 go run . serve

これで、ローカルAPIは本番環境が送信するものとバイト単位で同じ "2026-08-05T09:41:06.190270Z" を返します。ページを読み込みます。画面にまだ 09:41 と表示されている場合、デバッガーをアタッチし、2秒の編集ループを持つ自身のマシンで不具合を再現したことになります。

この1つの環境変数が、再現不能な本番環境のレポートを、通常のローカルのバグに変えます。それが機能するのは、バグが存在するのと同じ理由です。サーバーのシリアライズはプロセスゾーンに従い、そのプロセスゾーンはあなたが設定できるものだからです。

関連する2つの習慣を身につける価値があります。自分のゾーンでのみ実行されるスイートはゾーン処理について何も保証しないため、少なくとも1つの自動テストを、自分とは大きく異なる TZ を設定して実行してください。そして、バグレポートに「時刻が間違っている」と書かれていて再現できない場合は、画面に何が表示されたかではなく、サーバーが何を送信したかを尋ねてください。ペイロードが数秒でそれを解決します。

解決策は、最初にパースし、その後固定のゾーンでフォーマットすることです

入力が実際の Date になると、文字列内のオフセットは問題にならなくなります。new Date()Z+09:00 を同一に扱い、両方を同じ瞬間に解決します。そこから、明示的なゾーンでフォーマットします:

function kstParts(iso) {
  if (!iso) return null;
  const d = new Date(iso);
  if (isNaN(d.getTime())) return null;
  const parts = {};
  for (const { type, value } of new Intl.DateTimeFormat('en-US', {
    timeZone: 'Asia/Seoul', hourCycle: 'h23',
    year: 'numeric', month: '2-digit', day: '2-digit',
    hour: '2-digit', minute: '2-digit', second: '2-digit',
  }).formatToParts(d)) parts[type] = value;
  return parts;
}

そのブロック内での3つの決定には、擁護する価値があります。

ブラウザのタイムゾーンを使用せず、ゾーンを固定する。 素の toLocaleString() は、クライアントマシンが報告するものをそのまま使用します。各ユーザーが自身の時計を求めるコンシューマー製品では、それは正しい挙動です。しかし、社内の全員が1つの共有タイムラインを読み、ゾーン設定が間違っているラップトップが隣に座っている人とは異なる数値を気づかれないうちに生成してしまうような社内ツールにとっては、それは不適切です。

hourCycle: 'h23' を設定する。 en-US ロケールと hour12: false の組み合わせは、深夜0時を 24 時として描画するため、00:0524:05 として出力されます。これは1日に1回は正しく、1日に1回は間違っているため、目視で発見するのが最も困難な種類のバグです。サイクルを明示することで、この曖昧さが解消されます。

文字列出力ではなく formatToParts を使用する。 ロケールによる書式設定は、独自の区切り文字を挿入し、ロケールのルールに従ってフィールドを並べ替えます。名前付きのパーツを取得して自分で組み立てることで、ロケールがどのように処理しようとも、出力は安定します。

日付のみのフィールドが丸一日ずれる

明白な対象は時間を表示するフィールドです。見落とされるのは、日付のみを表示するものです:

expiresAt.slice(0, 10)   // "2026-08-05"

間違っている可能性のある表示上の時計がないため、これは無害に見えます。毎日9時間の間、1日ずれてしまいます。2026-08-06T07:00:00+09:00の有効期限は2026-08-05T22:00:00Zなので、8月6日に期限切れになるものに対して、スライスは8月5日と表示します。ライセンスページや請求画面では、それはサポートチケットの発生につながり、目に見えて間違っている時計よりも診断が困難です。

これについてコードベースを洗い出す際には、概念ではなく文字列操作を検索してください。ISO文字列をスライスすることがそのパターンであり、ターゲットが日付であろうと完全なタイムスタンプであろうと、それは同じように見えます。

ある1つのカテゴリは安全であり、触る必要がありません。それは相対時間です。Date.now() - new Date(iso)から「3時間前」を計算する関数は瞬間を比較し、瞬間にはゾーンがありません。それらはそのままにしておいてください。

変換を行うべき場所

ストレージはUTCのままにします。データベースはtimestamptzを、APIはRFC 3339を、ログはプラットフォームが出力するものを何でも保持します。変換は表示の瞬間に一度だけ行われ、それ以外の場所では行われません。

これはスタイル上の好みではありません。早期に変換するということは、すべての下流のコンシューマーが、自身が行ったものではなく見ることもできないゾーンの決定を継承することを意味します。そして、フォーマットされた文字列に一度焼き込まれてしまうと、そのゾーンは値の中では見えなくなります。エッジで変換することで、転送中は1つの表現を保ち、境界で1つのプレゼンテーションルールを保つことになります。

そのルールの実践的な形式は、共有フォーマッターの小さなセットです。散在するslice()呼び出しはスタイルの問題ではなく、次に誰かが構築する画面が同じ欠陥を持つ理由となります。すべての呼び出し箇所が名前付きヘルパーを経由するようになれば、一度ゾーンを修正すればすべてが修正され、次の開発者は手を伸ばすべき明確なものを手に入れることになります。

よくある質問

これはJavaScriptのフロントエンドにのみ影響しますか? いいえ。シリアル化されたタイムスタンプをテキストとして扱うレイヤーは、すべて同じ影響を受けます。生のフィールドを印字するテンプレート、文字列を連結するログフォーマッター、文字列結合で構築されたCSVエクスポートなどです。JavaScriptでは、文字列のスライスがフォーマッターを構築するよりも短く書けるため、特にこの問題が起こりやすくなっています。

コンテナのタイムゾーンをユーザーに合わせるだけではだめですか? 一時的には機能しますが、いずれ破綻します。そうなると、システムの正しさがデプロイマニフェスト内の環境変数に依存することになり、それを忘れたサービスは誤った出力を生成します。また、2つ目のタイムゾーンにユーザーがいるようになった瞬間に破綻します。どこでもUTCを使い、表示時に変換することで、不変条件をテスト可能なコード内に維持できます。

Intl.DateTimeFormatは、数百行あるテーブルに対して十分高速ですか? 高コストなのはフォーマッターの構築であり、その呼び出しではありません。大きなテーブルをレンダリングする場合は、セルごとに構築するのではなく、モジュールスコープで一度フォーマッターを構築して再利用してください。数十行程度であれば、その差は測定できないほどです。

影響を受ける箇所をすべて見つけたと、どうすればわかりますか? 意味ではなく、メカニズムでgrepしてください。名前がAtDateで終わるフィールドに適用されている.slice(や、タイムスタンプがフォーマッターを経由せずにDOMに到達する箇所を検索してください。その後、サーバーをTZ=UTCに切り替えて画面を確認してください。間違った値は、呼び出しの欠落よりも見つけやすいためです。