一日の記録を準備しています

少しだけお待ちください。

一覧へ戻る

接続済みは同期済みではなかった — 無料枠・旧型iPhone・Realtimeの取りこぼしに向き合った二日間

無料枠の転送量上限、旧型iPhoneで止まる画面、Realtimeイベントの取りこぼしを経験し、保存・通知・復旧を分離して、接続後にも状態を合わせ直す仕組みを作った二日間を記録しました。

本のように読む
標準
黒い画面の旧型スマートフォンのそばで、切れた青い糸を白い復旧経路がつなぎ、作業台の周囲に小さな光のノードが結ばれている様子

テーマ

ハルスペース・ラボ

接続済みは同期済みではなかった — 無料枠・旧型iPhone・Realtimeの取りこぼしに向き合った二日間

無料枠の転送量上限、旧型iPhoneで止まる画面、Realtimeイベントの取りこぼしを経験し、保存・通知・復旧を分離して、接続後にも状態を合わせ直す仕組みを作った二日間を記録しました。

要約

要約

  1. 当時の無料プランの転送量上限をきっかけに、PostgreSQL、Blobストレージ、Realtimeの役割を分け、繰り返される全体取得を減らしました。
  2. 購読成功を同期完了とは見なさず、明示的な認証、購読直後の再同期、軽量なバージョン確認を重ねて、取りこぼしから復旧できるよう設計しました。
  3. 公式サポート範囲外の旧型iPhoneには完全互換を約束せず、アニメーションを減らして静的な復旧経路を残しました。
12ページ
黒い画面の旧型スマートフォンのそばで、切れた青い糸を白い復旧経路がつなぎ、作業台の周囲に小さな光のノードが結ばれている様子

Summary

ひと目でわかる要約

  • 当時の無料プランの転送量上限をきっかけに、PostgreSQL、Blobストレージ、Realtimeの役割を分け、繰り返される全体取得を減らしました。
  • 購読成功を同期完了とは見なさず、明示的な認証、購読直後の再同期、軽量なバージョン確認を重ねて、取りこぼしから復旧できるよう設計しました。
  • 公式サポート範囲外の旧型iPhoneには完全互換を約束せず、アニメーションを減らして静的な復旧経路を残しました。

この記事は、2026年7月13–14日のGit記録と当時の設計文書をもとに書いた振り返りだ。ここで触れる無料プランの上限は、当時の障害を理解するための歴史的な背景であり、現在のサービス料金や提供量を案内するものではない。

画面には接続済みと表示されていた。しかし、相手が置いた石は私の画面に現れなかった。

再読み込みすると、ようやく変化した状態が見えた。接続そのものが切れていたなら、かえって原因を探しやすかったかもしれない。問題は、ブラウザーもリアルタイムチャネルも「正常」に見えるのに、二人がそれぞれ違う場面を見ていたことだった。

その二日間、Haru Spaceでは互いに無関係に見える問題が同時に起きた。当時使っていたデータベースの無料プランでネットワーク転送量の上限に達し、旧型iPhoneでは最初の画面がいつまでも読み込み中のままになり、リアルタイムゲームでは購読に成功した後でもイベントが抜ける瞬間があった。

初めは三つの障害だと思った。振り返れば、三つとも同じ問いを投げかけていた。

接続に成功したという事実だけで、ユーザーが最新の状態を見ていると言えるのか。

答えはノーだった。そこで私たちは、より速く接続することよりも、接続が食い違ったときにもう一度合わせ直せる仕組みを作り始めた。

無料枠がシステムの解剖図を見せてくれた

当時の記録には、無料プランのネットワーク転送量上限を超えた障害が残っている。これは現在の料金体系を説明する出来事ではない。その時点でサービスが何をどれほど頻繁に運んでいたのかを、初めてきちんと調べるきっかけになった出来事だった。

最初に疑ったのは、チャットの定期取得とメディアデータの組み合わせだった。画面は新しいメッセージがあるか確かめるため短い間隔でサーバーに問い合わせ、過去にデータベースへ直接保存された大きなサムネイル文字列まで一覧の応答に混ざる可能性があった。ユーザーは新しいメッセージを一つ待っていただけなのに、システムは以前にも見た重いデータまで繰り返し運んでいたことになる。

初めは取得間隔を延ばせば済むように見えた。実際、間隔の調整も必要だった。しかし、それだけでは同じ問題が別の機能で再発し得る。「あまりに頻繁に取得した」ことよりも、「異なる責任を一つの経路に入れた」ことのほうが原因に近かった。

私たちは、保存と伝達の役割を改めて分けた。

  • PostgreSQLは、メッセージ、メンバーシップ、ゲーム状態のように、もう一度確認できなければならない原本を担う。
  • Blobストレージは画像や動画のような大きなファイルを担い、データベースには保存場所と必要なメタデータだけを残す。
  • Realtimeは原本の代わりにはならず、「何かが変わった」という小さな通知を素早く届ける。
  • APIは、通知を受け取ったときや接続が復旧したとき、権限を確認して最新の原本を読み直す経路になる。

この分離は、技術の名前をきれいに整理しただけではない。コストと復旧方法を同時に決める方針だった。大きなファイルをデータベースの応答から外せば繰り返しの転送が減り、イベントを永続データのように信じなければ、一度取りこぼしても原本から合わせ直せる。

だからといって、この対策で転送量の問題が永久に消えたとは言えない。ユーザーが増え、機能が変われば、ボトルネックも移る。私たちが得たのは「解決済み」という印ではなく、どの保存先がどのコストを生み、何を観察すべきか説明できる解剖図だった。

ポーリングを減らしても、Realtimeを絶対視しなかった

定期的に状態全体を取得する方法は単純だ。イベントを取りこぼしても、次の取得で状態が合う。その代わり、何も変わっていなくても読み続け、ユーザーが増えれば同じコストが繰り返される。Realtimeは変化があったときだけ反応できるため、はるかに効率よく見えた。

そこで、チャットとゲームの更新をリアルタイムイベント中心へ移し始めた。新しいメッセージやゲーム操作が保存されると小さなイベントを送り、受信側は必要な状態だけを取り直す方式だった。続けて届いたイベントは短い間まとめ、重複取得を減らした。画面が見えていない間は補助取得を止め、再び表示されたときに一度合わせ直した。

ここまでは典型的な最適化に見えた。問題は、Realtimeにも隙間があるという事実を、真剣に受け止めるのが遅すぎたことだった。

初期データを読んだ直後から購読の準備が整うまでの間に、状態が変わることがある。モバイルネットワークがWi-Fiとセルラーの間を移る最中に、チャネルが一時的に揺らぐこともある。認証トークンが更新される境界では、イベントが遅れたり抜けたりし得る。ブラウザーがバックグラウンドから戻る瞬間には、接続表示と実際の状態が食い違うことがある。

このとき購読成功のコールバックだけを見て「同期完了」と判断すると、静かなエラーが生まれる。接続表示には問題がなく、エラーログもないのに、ユーザーは異なるゲーム盤やチャット一覧を見ることになる。

購読済みとは、これからの通知を受け取る準備ができたという意味であり、それまでの状態が同じだという証明ではなかった。

購読より先に認証を明示した

リアルタイムチャネルは、速いという理由だけで権限の境界を越えてはならなかった。Haru Spaceでは、ログインしているだけでは不十分だった。ユーザーが現在選んでいるグループと部屋のメンバーなのか、そのゲームの実際の参加者なのかまで、サーバーが確認する必要があった。

初期実装を点検しながら、認証情報をクライアントへ明示的に適用してからチャネルを購読する順序に固定した。チャットではログインセッションと部屋のメンバーシップを、ゲームではグループへの所属と実際の参加有無をそれぞれ確認した。確認後、その部屋またはゲームだけに使える短寿命の認証情報を発行した。ブラウザーはその認証を先に適用し、その後で必要なチャネルを一つだけ開く。部屋やゲームを離れたら、以前のチャネルは解除する。

この順序が重要だった理由は二つある。

一つ目は、公開可能な接続情報と、サーバーだけが持つべき秘密を分離できたこと。二つ目は、「チャネル名を知っている」と「そのデータを受け取る権限がある」を同じ意味として扱わなくなったことだ。

当時の設計には、データベースポリシーとメンバーシップ検証を組み合わせる方向も記録した。しかし、二日間ですべての行レベル権限が完成したと誇張するつもりはない。権限は一度オンにすれば終わるスイッチではなく、テーブル、読み取り経路、運用点検に合わせて成熟させ続ける領域だった。そのとき明確になったのは、少なくともブラウザーの購読成功を権限検証の代わりにしてはいけないということだった。

イベントを取りこぼしても追いつく三層の復旧

リアルタイム同期の目標を「すべてのイベントを必ず一度で受け取る」から「取りこぼしても最新の状態へ戻る」に変えると、設計も変わった。

第一の層は、明示的な初期取得だった。画面に入ると、まずサーバーから現在の状態を読む。Realtimeは、その後の変更を知らせる役割だけを担う。

第二の層は、購読直後の再同期だった。初期取得とチャネル準備の間に生じる隙間を埋めるため、購読が成立した直後に状態をもう一度合わせる。「最初に読んだから終わり」ではなく、聞き始めた時点でもう一度確認する。

第三の層は、段階的に遅くなるバージョン確認だった。ゲーム状態全体を読み続ける代わりに、小さな整数のバージョンだけを確認した。接続直後のように食い違う可能性が高い短い区間では頻繁に確認し、時間がたって安定すれば間隔を延ばした。バージョンが進んだときだけ、状態全体を再取得した。

この仕組みでは、Realtimeが正常なら変更が即時に反映される。イベントが一時的に抜ければ、軽量なバージョン確認が差を見つけて原本を読み直す。ネットワークが復旧したときや画面が再び表示されたときにも、一度再同期する。

重要なのは、補助取得をなくさず、小さくしたことだった。ゲーム状態全体やメッセージを短い間隔で繰り返し読むのではなく、バージョン一つで「自分は遅れているか」だけを尋ねる。速い経路はRealtimeに任せ、正確さは保存された原本と復旧経路が担う組み合わせだった。

この方法も、イベントの取りこぼし自体をなくすものではない。私たちは取りこぼしの可能性を認め、検出までの時間と復旧コストに上限を設けた。当時の運用文書に残した確認手順も、「取りこぼしは絶対にない」と証明するものではない。ネットワークを一度切って戻したとき、定めた時間内に同じバージョンへ合流できるかを確かめる方法だった。

旧型iPhoneには完全互換よりも戻るための扉を残した

同じ時期、ある旧型iPhoneでは最初の画面が読み込み状態から抜け出せなかった。Chromeで接続しても問題が起きたため、初めはブラウザーの種類を疑った。しかし、iPhoneのChromeも同じシステムWebKit上で動く。デスクトップ版Chromeのバージョンだけを見て互換性を判断することはできなかった。

そのOSは、当時使っていたフレームワークの公式最小サポート範囲を下回っていた。選択肢は二つあった。サポートすると言って、あらゆる現代的なランタイム動作を古い環境に合わせ直すか、サポート境界を正直に示しながら、ユーザーが黒い画面や無限の読み込みに閉じ込められないようベストエフォートの復旧を提供するかだ。

私たちは後者を選んだ。

  • ブラウザーのビルド対象を、実用的な範囲でできるだけ広げた。
  • 古い環境では、ペットや宇宙背景のような重いアニメーションを減らすか停止した。
  • ログインのように重要な移動は、クライアント側の遷移だけに頼らず、ページ全体の読み込みへ戻れるようにした。
  • 初期クライアントの実行に失敗しても、一定時間がたつと再読み込み操作とログインリンクが静的な画面上に現れるようにした。
  • 初期取得にはタイムアウトを設け、無限に読み込み続ける代わりに、ユーザーが再試行できる状態を見せた。

四つ目は特に重要だった。Reactが正常に立ち上がった後でしか復旧ボタンが現れない仕組みなら、Reactの実行に失敗したユーザーには復旧ボタンも表示されない。だから、最低限の復旧経路は華やかなアプリ画面の外側に残す必要があった。

これらの対策は、旧型iPhoneのすべての機能を公式サポートするという約束ではない。Web Pushのように、より新しいOSバージョンを必要とする機能もあり、フレームワークランタイム自体の限界をすべて巻き戻すことはできなかった。私たちが約束できたのは「完璧に動く」ではなく、「動かないときに、何の説明もなく閉じ込めたままにしない」ことだった。

考え違いをしていた三つのこと

この二日間を振り返ると、実装より先に改めるべきだった考えが見えてくる。

1. 「無料」を「コストがない」という意味で受け取っていた

無料プランにも、ネットワーク、保存容量、接続数、呼び出し回数の境界がある。料金表だけを見てコストを計算すれば、アプリケーションが同じデータを何度運ぶのかを見落とす。無料枠は開発を始めやすくする条件であって、構造を考えなくてよいという免許ではなかった。

2. 接続表示をプロダクトの真実だと受け取っていた

ソケットが開き、チャネルの購読に成功したという事実は、伝送経路の状態だ。ユーザーが見ているデータの新しさは別の問題になる。同期しているかどうかは、原本のバージョンと最後に確認したバージョンを比較して初めて説明できた。

3. 互換性を一行のサポート有無だけで見ていた

古い端末を完全にはサポートできなくても、できることは残っている。重い機能を止め、待つ時間に終わりを設け、静的な復旧リンクを用意できる。「公式サポート外」と「何もしない」の間には、広い設計の余地があった。

リアルタイム機能を作るときに残したチェックリスト

今、似た機能をもう一度作るなら、次の問いから書き出すだろう。

原本はどこにあるか

イベントストリームや画面の状態を原本にはしない。すべての接続が切れても、もう一度読んで同じ状態を作れる保存先を定める。

イベントにはどこまで小さな情報だけを入れられるか

変更全体を繰り返し送るのではなく、どの対象のバージョンが変わったかを知らせる。機微な原本は、権限を再確認する読み取り経路から取得する。

初期取得と購読準備の間にある隙間をどう埋めるか

購読直後に再同期するか、カーソルを基準に取りこぼした分を読む。その順序を文書とテストに明記しておく。

認証は購読より前に適用されるか

ログイン、グループメンバーシップ、部屋へのアクセス、ゲームへの参加権限を区別する。チャネル名を隠すことでアクセス制御の代わりにしない。

復旧確認は十分に軽いか

状態全体を読み続けず、バージョンや最終変更時刻のような小さな値を確認する。接続直後は短い間隔で、安定した後はまれに確認し、コストと復旧時間を一緒に調整する。

画面が再び表示されたとき、またはネットワークが戻ったときに何をするか

ブラウザーの表示状態の変化と接続復旧を、再同期の通知として使う。単に購読状態のアイコンだけを描き直さない。

クライアントコードが起動しなくても出口はあるか

サポート境界外のブラウザーには、機能保証とベストエフォートの復旧可能性を分けて案内する。重要な復旧リンクは、アプリのランタイムが失敗しても見えるようにする。

当時の上限と現在の上限を区別したか

料金プランと無料提供量は変わり得る。振り返りには当時の判断根拠を残し、現在の設計判断をするときは、公式情報を改めて確認する。

同期の問題は、最後には権限の問題へつながった

二日間が終わるころ、変更を伝える経路は定期取得よりも即時に反応するようになり、取りこぼしを見つけて状態を合わせ直す道もできた。だからといって、あらゆるネットワークとすべての旧型端末で問題が消えたとは結論づけなかった。その代わり、どの状態を原本とし、接続が揺れたときにどの順番で復旧し、十分にサポートできない環境に何を見せるのかを説明できるようになった。

そして、次の問いが残った。

誰がその原本を読めるのか。誰が変更の通知を受け取れるのか。そして、誰が実際に状態を変えられるのか。

リアルタイム機能を加える前は、接続速度とコストが最も大きな問題に見えた。接続が安定するほど、権限の層がより鮮明になった。プラットフォームを運用する権限、グループに所属しているという事実、特定の操作を承認する権限は、互いに同じではなかった。

次の記録では、機能を増やす前になぜそれらの権限を分けたのか、グループメンバーシップと人による承認にどのような境界を設けたのかを続けたい。

続けて読む

前の記事 · 次の記事

前の記事1枚の写真がコスト方針に、1つのチャットルームがセキュリティ方針になった理由次の記事 ボタンより先に境界を作った — プラットフォーム権限・グループメンバーシップ・人による承認