Cloudflare Workersで本番だけ日時が9時間ズレた話

Cloudflare Workers本番環境でタイムゾーン未指定によりUTC表示になったバグを示すイメージ プログラミング

結論から書くと、私が個人開発している1on1管理Webアプリで、本番のCloudflare Workersだけセッション日時が9時間ズレていました。原因は日付整形関数のtimeZone未指定です。JSTで動く開発機では正しく見え、UTCで動く本番だけ表示が壊れる、環境依存のバグでした。

同じ症状で検索して来た方向けに、原因の構造と直し方、そしてなぜローカルのテストでは一度も気づけなかったのかを整理します。

本番のCloudflare Workersだけ、日時が9時間ズレていた

このアプリはCloudflare Workers / Pages とSupabaseで組んだ、1on1の記録を管理するWebアプリです。セッションの一覧や詳細画面には、実施日時をformatDateJa(日付のみ)とformatDateTimeJa(日時込み)という2つの関数で整形して表示しています。

このバグに気づいたのは、リリースからしばらく経って、本番のブラウザで実際に画面を開いたときでした。セッション一覧に並ぶ日時が、記録した時刻より9時間先にズレていました。深夜0時台に記録したセッションが、翌日の9時台の記録として表示されているような状態です。開発中はローカルの画面を何度も確認していましたが、その段階では一度もこのズレに気づけませんでした。全画面のセッション日時がUTC表示になっており、リリース当初から存在していた潜在バグだったことになります。

原因はformatDateJa/formatDateTimeJaのtimeZone未指定

該当の関数は、JavaScript標準のtoLocaleDateStringとtoLocaleStringを使って日時を整形していました。コードにすると次のような形です。


// 修正前
export function formatDateJa(date: Date): string {
  return date.toLocaleDateString('ja-JP', {
    year: 'numeric',
    month: '2-digit',
    day: '2-digit',
  });
}

export function formatDateTimeJa(date: Date): string {
  return date.toLocaleString('ja-JP', {
    year: 'numeric',
    month: '2-digit',
    day: '2-digit',
    hour: '2-digit',
    minute: '2-digit',
  });
}

toLocaleStringやIntl.DateTimeFormatは、timeZoneオプションを省略すると実行環境のタイムゾーンをそのまま使います。ロケールを’ja-JP’に指定していても、それは表記のルール(漢数字か算用数字か、年月日の並び順かなど)を決めるだけで、タイムゾーンの指定は別項目です。日本語の表記になっていたので一見正しく見えましたが、時刻の基準がどこの地域のものかは、timeZoneを書かない限り実行環境まかせになっていました。

ローカルでは気づけなかった理由——実行環境ごとにタイムゾーンの既定値が違う

開発機はJSTで動いています。macOSのシステム設定に合わせて、Node.jsのDateやtoLocaleStringも既定でJSTを基準に日時を組み立てます。だからローカルで画面を確認しても、timeZoneを指定していないこの関数は正しい時刻を表示していました。

一方、Cloudflare Workersの本番環境には、開発機のような「OSのタイムゾーン設定」に相当するものがありません。timeZoneを指定しないコードは、Workers側の既定タイムゾーンを基準に動きます。今回のケースでは、この既定タイムゾーンがUTCでした。同じコードが、実行される場所によって別の時刻を返していたわけです。

Node.jsのローカル実行と、Cloudflare WorkersやAWS Lambdaのようなエッジ・サーバーレス環境とでは、既定のタイムゾーンが違います。多くのサーバーレス環境はUTCを基準にしています。つまり「ローカルで正しく表示された」という事実は、タイムゾーンが正しいことを何も保証しません。保証しているのは、開発機のタイムゾーンとたまたま一致していた、というだけです。

修正はtimeZoneを明示的に固定するだけだった

直し方はシンプルで、timeZoneをAsia/Tokyoに固定することです。


// 修正後
const JST_TIMEZONE = 'Asia/Tokyo';

export function formatDateJa(date: Date): string {
  return date.toLocaleDateString('ja-JP', {
    timeZone: JST_TIMEZONE,
    year: 'numeric',
    month: '2-digit',
    day: '2-digit',
  });
}

export function formatDateTimeJa(date: Date): string {
  return date.toLocaleString('ja-JP', {
    timeZone: JST_TIMEZONE,
    year: 'numeric',
    month: '2-digit',
    day: '2-digit',
    hour: '2-digit',
    minute: '2-digit',
  });
}

変更点はtimeZoneオプションを1行足しただけです。これで実行環境がJSTだろうとUTCだろうと、表示結果は常にAsia/Tokyoを基準にした時刻になります。テスト側も、実行環境のタイムゾーンに依存しない前提に書き換えました。想定するタイムゾーンでの出力を固定的に検証するようにした上で、修正後のユニットテストは212件すべてパスしています。

もう一つの罠——datetime-local入力にも同じ構造のバグがあった

表示側を直して安心しかけたところで、入力側にも似た問題があることに気づきました。セッション作成フォームでは、HTMLのdatetime-local入力を使って日時を受け取っています。

datetime-localの値は、2026-08-01T14:30のようなタイムゾーン情報を持たない文字列です。この文字列は入力した人のローカル時刻ではありますが、それがJSTなのかUTCなのかという情報はどこにも含まれていません。どのタイムゾーンとして解釈するかは、アプリ側が決めるしかありません。

今回の実装は、この値をJSTとして解釈すべきところを、そのままUTCとして扱ってしまっていました。ブラウザのローカル時刻をそのままUTCとして送ってしまう形になっていたわけです。結果として、フォームで入力した時刻と、実際に保存される時刻がやはり9時間ズレていました。表示側とは逆方向のズレですが、原因の構造は同じです。タイムゾーンを明示しないまま、文字列とDateを行き来させていたことです。

修正は、入力値をJSTとして明示的に解釈してからDateに変換する形に直しました。


// datetime-local の値をJSTとして解釈してからDateに変換する
function parseSessionDatetimeAsJst(datetimeLocalValue: string): Date {
  return new Date(`${datetimeLocalValue}:00+09:00`);
}

+09:00のオフセットを自分で付けてからDateに渡すことで、どのタイムゾーンとして解釈するかをアプリ側の意図どおりに固定できます。表示側のtimeZone指定と、入力側のオフセット付与は、どちらも「タイムゾーンは実行環境まかせにしない」という同じ対策です。

そうは言っても、DB側の保存形式を全部UTCに統一すればこの手の問題は起きないのでは、と思う方もいるはずです。実際、Supabase側の保存はUTCのままで問題ありません。今回の本質的な問題は保存形式ではなく、値を表示するとき・入力を解釈するときに、どのタイムゾーンを基準にするかを明示しているかどうかでした。UTCで保存すること自体は正しい設計で、そこは変えていません。変えたのは、UTCの値をJSTとして見せる境界と、JSTの入力をUTCとして保存する境界の、その2箇所だけです。

同じ症状に当たったら、まず確認する3点

検索でこの記事にたどり着いた方向けに、確認する順番を残しておきます。

  • 日付を整形している関数で、toLocaleString・toLocaleDateString・Intl.DateTimeFormatにtimeZoneを渡しているか
  • datetime-localのようにタイムゾーン情報を持たない入力を、どのタイムゾーンとして解釈するかをコード側で明示しているか
  • テストが、実行環境のタイムゾーンに依存しない書き方になっているか(テストプロセス自身がJSTで動いていると、本番だけの不具合を再現できません)

この3点のうち、今回のバグは1つ目と2つ目の両方に該当していました。表示側と入力側、どちらか片方だけ直して安心してしまうと、もう一方に同じ構造のバグが残ります。

テストは全部通っていた。それでも本番だけ壊れていた

この一連のバグで印象に残っているのは、ローカルのユニットテストが一度も赤くならなかったことです。テストコードも、テストを実行するプロセスも、開発機と同じJSTの上で動いています。timeZoneを指定していないコードとテストが、同じ暗黙の前提を共有していたことになります。前提そのものが間違っていても、テストの中では矛盾が起きないので、緑のまま通り続けます。

私が気づけたのは、本番のブラウザで実際の画面を開いたときだけでした。ローカルのテストが保証していたのは「ロジックが意図通りに動くこと」であって、「意図している前提そのものが本番と一致していること」ではなかったわけです。EMとして日々チームのテストカバレッジを気にする立場ですが、今回の件で、緑の数だけを見て安心するのは危ういと改めて感じました。カバレッジやテストの本数は、実行環境の前提がずれていないかまでは教えてくれません。

まとめ

まとめると、原因はtoLocaleString系の関数がtimeZoneを省略すると実行環境依存になるという、JavaScriptの基本的な仕様でした。修正は日付整形関数にAsia/Tokyoを明示するだけで、入力側のdatetime-localも同様にJSTとして明示的に解釈する形に直しました。もし同じようにローカルでは正しいのに本番だけ日時がズレる症状に当たっている場合、まず疑うべきはtimeZoneの指定漏れです。表示用の整形関数と、入力を解釈する箇所の両方を、私と同じように確認してみてください。


私は現役EMとして、1on1やチーム運営の仕組み化について書いています。1on1管理のNotionテンプレート(無料Lite版)を公開中です → 試してみる。X: @anikuma_tech

コメント