NotionやGoogle Docsのようにページ閲覧状況を管理したいときに考えること
株式会社MyVisionでエンジニアをしている吉田です!
まずはじめに、今回の想定読者とこの記事でわかることを簡単に...
想定読者・読者の課題
- Webアプリでリアルタイム機能(閲覧中・編集中など)を実装しようとしているエンジニア
- GraphQL SubscriptionやSocket.ioなどの通信レイヤはできたけど、離脱検知やゴーストユーザー問題で困っている人
- 「ブラウザを閉じても閲覧中が消えない」「通信切断を検知できない」などの課題を感じている人
この記事で分かること
- リアルタイム閲覧状態を正確に維持・削除するための設計指針(死活管理の考え方)
- 通信技術に依存せず、どんな環境でも安定した閲覧管理を行うための3つの設計軸(Stop / Heartbeat / Timeout)
- ゴーストユーザーを残さずに、リアルタイムUXを成立させるための実践的アプローチ
説明しないこと
当記事では、特定のライブラリやフレームワークに依存したコードの解説等は行いません。(この記事は概念とアーキテクチャにフォーカスします)
やりたかったこと
Webアプリケーションの開発、運用を行う中で複数のユーザーが同じリページやコンテンツを同時に閲覧している状況を把握したいというニーズがあります。
Notion docsの閲覧中、みたいな↓

社内の業務システムを作っていると
「同じページを今、誰が見ているのか」を知りたい場面って意外と多いです。
弊社のプロダクトについても一部、リアルタイム閲覧に対する機運が高まってきましたので張り切って実装に取り掛かりました。
直面した問題
弊社では、GraphQL Subscriptionを用いてリアルタイム通信を実現しているのですが
それ自体がよくできているので比較的簡単に実装ができます。
使い勝手自体がとても良いので、「編集→他のユーザーに反映」といったような素朴なリアルタイム通信はほとんど困ることがないです
しかし、実際に現状のWeb技術では愚直に実装しようとすると以下のような課題に直面しました
- ブラウザを閉じても「閲覧中」と残る
- ネットワークが切れても離脱が検知されない
などなど...
結果、ゴーストユーザーが大量に残る、、、
これらの課題を踏まえてリアルタイムの肝は死活管理であることを痛感しました。
死活管理をちゃんとやるには
課題に対してチームで検討を重ね、最終的には以下の三つの軸に落ち着きました
| 層 | 役割 | 目的 |
|---|---|---|
| 明示的な離脱(Stop) | 「もう見てません」を送る | 正常な離脱を即時に反映 |
| 生存通知(Heartbeat) | 「まだ見てます」を定期送信 | 閲覧継続を確認 |
| 自動タイムアウト(TTL + 検証) | 何も来なくなったら削除 | 異常離脱を検知 |
明示的離脱(Stop):正常系の即時反映
- ページを閉じたり遷移する際に「閲覧終了」を通知
正常経路での離脱は、これが最も正確かと思います。
ReactのuseEffectでアンマウント時に「stop」を飛ばす。
普通に動いている時はこれで十分です。
しかし、ブラウザが閉じられた瞬間やタブ強制終了時ではイベントが発火しないため
あくまでも「うまくいくとき用」でしかありません。
離脱APIについては冪等に設計(重複送信を許容)する必要があります。
リアルタイム閲覧のような「ページ離脱」などの処理はブラウザのイベント重複や通信遅延で同じAPIが2回以上呼ばれることがよくあります。
このとき非冪等な設計だと、
- 二重削除でエラーになる
- 状態が想定外に変化する(他人の閲覧が消えるなど)
といった不整合が起きます。
冪等にしておけば、
「同じ離脱通知が何度来ても結果は同じ(安全に無視できる)」ようになります。
Heartbeat: まだ生きているよを伝える
ページを開いている間、フロントから10秒おきに「まだ見ている」と通知します。
このとき、TTLは30秒に設定することで言い換えれば「通知が3回止まれば"もういない"と判断できる」ようになります。
通知感覚を短くすれば精度はあがりますが、結果として通信量が増えるので必要に応じてバランスは調整する必要があります。
自動タイムアウト: クラッシュ対応
StopもHeartbeatもこないユーザーについてはRedisのTTLを使って「30秒後に自動削除」するようにしました。
さらに1分ごとにバッチ(Sidekiqジョブ)が動いて全体をチェックし、TTLが切れたユーザーを一括で削除するようにした結果、最大1分半でどんな異常も検知できるようになりました。
無人の時はジョブを止めるよう設計してあるので、必要な時に必要な分だけ通信することができるようになっています。
閲覧状態のステート構造について(補足)
今回の死活管理では、Stop / Heartbeat / Timeout といったイベントだけではなく、閲覧状態の「持ち方(ステート構造)」が非常に重要になります。
死活管理の設計意図がより理解しやすくなるよう、Redis でどのようにデータを管理しているかを補足します。
Redis に保持している2つのステート
閲覧状態は Redis 上で次の2種類のキーに分けて管理しています。
| キー | 内容 | TTL |
|---|---|---|
alive:{user}:{page} |
「このユーザーは今も閲覧中」という生存フラグ | 30秒 |
viewers:{page} |
ページごとの閲覧ユーザー一覧 | なし |
ここで重要なのは、
- TTLが付いているのは
alive:*の生存フラグだけ - ページごとのユーザー一覧 (
viewers:*) には TTL を付けない
という点です。
なぜ一覧には TTL を付けないのか?
ページごとの閲覧者一覧は、UI や Subscription でリアルタイムに参照される中心的なデータです。
TTL による自然消滅で管理してしまうと、
- TTL が切れるタイミングをコントロールできない
- 正常時にも勝手に消えてしまう
- データ構造が不安定になる
などの問題が起きるためです。
そのため、
- 生存確認専用の
alive:*は TTL を持つ - 実際の閲覧者一覧は TTL なしで管理し、必要に応じて明示的に削除する
という役割分離をしています。
TTLがあるのにバッチ削除が必要な理由
TTL が切れると alive:* は自然に削除されますが、
viewers:{page} の一覧側は自然には消えません。
そのため Sidekiq バッチによって、
- 一覧に載っているユーザーの
alive:*を参照 - 生存フラグが存在しないユーザーを一覧から削除
- そのユーザーを「離脱」と判断して配信
という後処理が必要になります。
要するに:
- TTL → 生存フラグが自然死したかどうかを判定する仕組み
- バッチ → 自然死したユーザーを一覧に反映させる仕組み
という補完関係になっています。
実際の動き
文章で説明してきましたが、実際の動き?(処理の流れ)が理解しやすいようにタイムライン形式でまとめましたのでご覧ください
| 時刻 | 状況 | 仕組み |
|---|---|---|
| 00:00 | ユーザーAがページを開く | Heartbeat送信(TTL30秒) |
| 00:10 | ユーザーAが画面を見続ける | TTL更新 |
| 00:20 | Aのブラウザがクラッシュ | 通知止まる |
| 00:50 | TTL切れ | Redisのaliveキー削除 |
| 01:00 | バッチが走る | 「ユーザーAが離脱」と判断し配信 |
これでゴーストユーザーが残ることはなくなりました。

まとめ
私たちが使用した技術はRailsとGraphQLですが、こと死活管理においてはGraphQL固有の機能を使用しているわけではないため、例えばFirebaseやSocket.ioでも同様の設計が可能です。
今回初めてリアルタイム機能(ページ閲覧管理)を作成しました。
つい「どう通信するか」を考えがちでしたが正確な閲覧状況を実現するために実際に重要なのは「いつ消すか」を決めることでした。
「死活管理」自体は地味ですが、これを正しく実装することでUXが大幅に向上したなと感じています。
ぜひ参考にしてみてください!
株式会社MyVision開発部のテックブログです! 採用情報はこちら corporate.my-vision.co.jp/engineering-careers
Discussion