Xserver の共用レンタルで動いている WordPress サイトに、チャット機能を入れようとしたことはありますか。もしこれから入れようとしているなら、最初に WebSocket だけへ全部賭けないほうがいい、というのが、私がこの数日でたどり着いた感触です。
2025年の秋、私はある WordPress サイトにチャット機能を組み込もうとしていました。最初は WebSocket で作るつもりでした。メッセージを送ったらすぐ相手の画面に出て、未読が付いて、画面を更新しなくても会話が流れていく。そういう動きを作るなら WebSocket がいちばん自然だと、当時の私は思い込んでいたからです。ところが Xserver の共用レンタル環境では、その通りにはいきませんでした。ローカルでは普通に動くのに、本番に上げると WebSocket だけがつながらない。コンソールに接続エラーが出て、そこから先へ進めない。次に SSE を試すと、今度は接続もできてイベントも届くのに、配信のタイミングが安定しない。届くときは届くのに、しばらく音沙汰がなかったり、急にまとめて来たりする。2、3日かけて検索しながら調べていくうちに、私の環境では、ごく普通の HTTP リクエストを繰り返す Long Polling 方式が、いちばん素直に動くことが分かりました。
この記事は、そのときの試行錯誤の記録です。あとから自分が見返したときに、こういう順番で詰まって、こう判断したんだったな、と思い出せるように、検証の流れをそのまま書いています。最終的な完成形を提示するというより、Xserver の共用レンタルでチャットを入れたい人が、最初の判断をしやすくなることを意図しています。
この記事の前提
Xserver の共用レンタルプラン上で、WordPress にチャット機能を入れようとしたときの体験記です。WebSocket、SSE、Long Polling の3方式を順番に試しました。VPS や専用サーバー、自前で Node.js を立てられる環境では、また違う答えになります。
記事に貼っているコードは「考え方を確認するための最小サンプル」で、コピペで本番運用するためのものではありません。実装するときは、ご自身の環境に合わせて調整してください。
検証環境:WordPress 6.8.3 / Xserver 共用レンタル / PHP 8.3.21
検証実施:2025年秋 / 記事更新:2026年5月5日
図1:WebSocket、SSE、Long Polling の3方式が、それぞれどんな通信の流れになるかを並べたものです。常時つなぎっぱなしにするのか、一方向に流し続けるのか、リクエストを繰り返すのか、という違いがあります。
WebSocket でいけると思っていた
チャットを作る、と聞いて最初に思い浮かんだのが WebSocket でした。ブラウザとサーバーの間で接続を開きっぱなしにして、双方向にデータをやり取りする仕組みです。チャットや通知、共同編集のように、サーバー側からすぐ画面に反映したい場面ではよく使われています。私もその方向で組み始めました。WordPress 側にチャット画面を用意して、別に Node.js のプロセスを立てて、そこへ WebSocket をつなぐ構成です。ローカルではすんなり動きました。ブラウザ側のコードは、こんな最小構成から始めています。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 |
const ws = new WebSocket("wss://example.com/ws"); ws.addEventListener("open", () => { ws.send("hello"); }); ws.addEventListener("message", (ev) => { console.log("recv:", ev.data); }); ws.addEventListener("close", () => { console.log("closed"); }); |
サーバー側は、Node.js の ws ライブラリで、受け取ったメッセージを全クライアントに流し直すだけのコードです。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 |
import { WebSocketServer } from "ws"; const wss = new WebSocketServer({ port: 8080 }); wss.on("connection", (socket) => { socket.on("message", (msg) => { for (const client of wss.clients) { if (client.readyState === 1) { client.send(msg.toString()); } } }); }); |
ローカルでは、ブラウザを2つ開いて、片方で送ったメッセージがもう片方のコンソールに出てくる、というところまで確認できました。ここまでは順調でした。止まったのは、本番に上げてからです。WordPress サイトのほうは普通に表示できるのに、WebSocket の接続だけが成立しません。ブラウザの DevTools を開くと、接続側でエラーが出ています。正直に書いておくと、当時のエラーメッセージやステータスコードまではきちんと残しておらず、ここに細かくは書けません。あとからメモを取っておけばよかったと思いました。覚えているのは、通常の HTTP リクエストは通るのに、WebSocket だけが通らない、という症状だけです。
最初は自分のコードを疑いました。ws:// ではなく wss:// を使っているか。HTTPS のページから読み込んでいるので、混在コンテンツになっていないか。証明書のチェーンに問題はないか。同一ドメインで試したらどうか。CORS まわりのヘッダーは大丈夫か。ひとつずつつぶしていきましたが、症状は変わりません。普通の fetch や XMLHttpRequest は通るのに、WebSocket だけが通らない。この時点で、コードの書き方というより、サーバー環境との相性の話になっているな、と感じ始めました。
Upgrade が通らないと、そこで終わりだった
WebSocket は、最初から WebSocket として通信が始まるわけではありません。最初は普通の HTTP として接続して、リクエストヘッダーに Upgrade: websocket を載せます。サーバーが OK を返したら、そこから WebSocket 通信へ切り替わる。この Upgrade と呼ばれる握手を、最初に通す必要があります。次の2枚の図が、その握手の流れと、共用レンタルでそれが弾かれる構造です。
図2:WebSocket は、まず普通の HTTP でつないで、Upgrade ヘッダーを載せて握手します。サーバーが応じて初めて、双方向の WebSocket 通信に切り替わります。この握手が通らなければ、そもそも始まりません。
図3:共用レンタルでは、利用者が触れないプロキシや WAF が中間にいくつも挟まっています。Upgrade を含むリクエストが、そのどれかで止まることがあります。私の環境では、ここで握手が通りませんでした。
自分でサーバーを細かく設定できる環境、たとえば VPS のようにリバースプロキシや WAF まで自分で触れるなら、ここを通すのはそれほど難しくありません。Nginx の設定で Upgrade ヘッダーをきちんと転送するように書けばよいだけです。ただ、共用レンタルでは話が違います。利用者が直接触れない中間層が、いくつか挟まっている。プロキシ、WAF、サーバー側で共通的に効いているフィルター。Upgrade を含むリクエストが、これらのどれかで止まってしまうことがあります。私の検証環境では、結局この最初の握手が通りませんでした。2、3日、検索でぶつかった情報をひと通り試して、共用レンタルで正面突破するのは無理だな、という見切りをつけました。途中で、手元の Node.js を ngrok で外に出して、ブラウザからはそこへつなぐ、という逃げ道も考えましたが、これも環境的にトンネル自体を通せず、現実的な選択肢にはなりませんでした。WebSocket そのものが悪いわけではないと思っています。VPS や Cloudflare、Fly.io のようなプラットフォームを前提にできるなら、いまでも第一候補です。ただ、いま動かそうとしているのは Xserver の共用レンタル上の WordPress サイトで、ここで WebSocket を成立させるのは、私の環境では諦めることにしました。
SSE は通った、でもタイミングがそろわなかった
WebSocket がだめなら、次の候補は SSE でした。Server-Sent Events の略で、サーバーからブラウザへ、一方向にイベントを流し続ける仕組みです。双方向ではないので、メッセージの送信は別経路でやる必要があります。たとえば、投稿は普通の HTTP POST で受けて、配信側だけ SSE にする。受信専用なら、HTTP の枠の中で扱える分、共用レンタルでも通りやすいのではないか、と期待していました。PHP 側のサンプルはこんな形です。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
<?php header("Content-Type: text/event-stream"); header("Cache-Control: no-cache"); header("Connection: keep-alive"); @ini_set("output_buffering", "off"); @ini_set("zlib.output_compression", 0); while (ob_get_level() > 0) { @ob_end_flush(); } @ob_implicit_flush(true); $i = 0; while (true) { $i++; echo "event: msg\n"; echo "data: " . json_encode([ "n" => $i, "ts" => time() ]) . "\n\n"; flush(); usleep(1000 * 1000); } |
ブラウザ側は、EventSource でつなぎます。
|
1 2 3 4 5 |
const es = new EventSource("/sse.php"); es.addEventListener("msg", (ev) => { console.log("msg:", ev.data); }); |
ローカルでは予想通り動きました。1秒ごとにイベントが流れて、コンソールにも順番に並んでいきます。本番に上げてみると、接続自体は通りました。ブラウザの Network タブを見ても、EventSource の通信が pending のまま開いていて、データも届きます。WebSocket のように最初から失敗するわけではありません。ただ、配信のタイミングが安定しませんでした。期待していた間隔できれいに届くこともあれば、しばらく無音が続いたあとに何件かまとめて来ることもある。同じコードを何度開き直しても、毎回そろった挙動になるわけではない、という不安定さがありました。下の図が、そのときに起きていたことの理解です。
図4:SSE は接続を開いたまま、サーバー側から少しずつ書き出します。ところが中間のプロキシやバッファがデータをいったん溜めて、まとまってから流すと、ブラウザに届くタイミングがそろわなくなります。
チャットでこれが起きると、けっこう困ります。送信側は普通に投稿しているつもりなのに、相手の画面では数秒から数十秒遅れて、しかもまとめて表示される。いま送ったのに、なんで反応がないんだろう、と思った頃に、急に複数件が並んで出てくる。これだと、リアルタイム感のために導入した意味が薄れてしまいます。
SSE は HTTP なのに、流し続けるのが難しかった
SSE は HTTP の上で動いています。なので、WebSocket より共用レンタルでも素直に通るだろう、と最初は思っていました。実際、接続できないわけではありません。問題は、接続できたあとの、流し続ける部分でした。普通の HTTP レスポンスは、必要なデータをまとめて返して、すぐ接続を閉じます。SSE はそうではなく、接続を開いたまま、サーバー側から少しずつ書き出していきます。間に立っているプロキシやサーバー側のバッファが、この少しずつ書き出すを期待通りに通してくれるかどうか、が問題になります。サーバーやプロキシの設定によっては、書き出されたデータをいったん溜めて、ある程度まとまってからまとめてクライアントへ送ることがあります。バッファリングと呼ばれる挙動です。私の環境で起きていたタイミングの乱れも、おそらくこのあたりが影響していたのだろう、と理解しています。
Nginx 配下の構成では、SSE を即時配信させるために X-Accel-Buffering: no のようなヘッダーをレスポンスに付ける、というやり方が知られています。ただ、共用レンタルではサーバー全体の設定を自分で変えられるわけではありません。アプリ側でできることだけが、手元のカードになります。私の環境では、PHP 側で output_buffering を切ったり、flush() を細かく入れたりしましたが、それでも期待したほど安定しませんでした。ローカルでは綺麗に出るのに、本番ではなぜか今日は遅い、みたいな状態を何度も見ているうちに、これを本番のチャットで採用するのは怖いな、という感覚になりました。SSE そのものは便利な仕組みです。ニュース配信や進捗通知のように、届くタイミングが多少前後しても問題にならない用途なら、共用レンタルでも実用範囲かもしれません。今回はチャットだったので、即時性が落ちるのが致命的でした。
最後に残ったのが Long Polling だった
WebSocket がだめ、SSE もタイミングが安定しない。残った選択肢が Long Polling でした。名前だけ見ると、ちょっと古めかしい印象があるかもしれません。実際、WebSocket や SSE と並べると、仕組みとしては地味です。でも、共用レンタルで動かすことだけを考えると、これがいちばん相性がよかったのです。動き方はとてもシンプルで、ブラウザがサーバーに、新しいメッセージはありますか、と聞きにいきます。サーバーは、すぐ返せる新着があればその場で返し、なければしばらく待ちます。一定時間待っても新着がなければ、空のレスポンスで返す。レスポンスを受け取ったブラウザは、また次の問い合わせを始めます。下の図が、その繰り返しです。
図5:Long Polling は、新着があるかを問い合わせ、なければサーバー側で少し待ち、あれば返す、を繰り返します。常時接続でもストリーミングでもなく、1回ごとに完結した HTTP リクエストです。
図7:WebSocket は握手が通らず、SSE はバッファで配信が乱れ、最後に Long Polling で安定する。私がたどった切り分けの順序を、そのまま図にしたものです。
仕組みとしては、HTTP リクエストと HTTP レスポンスの繰り返しです。常時接続でもなく、ストリーミングでもありません。普通の HTTP に収まっている、という、ただそれだけの性質が、共用レンタルでは大きな安心材料になりました。WebSocket のような Upgrade はありません。SSE のように、接続を開いたままデータを流し続ける必要もありません。1回1回が完結したリクエストなので、間にプロキシやキャッシュ、WAF が挟まっていても、普通のページアクセスと同じ扱いで通っていきます。実際に試してみたら、Xserver の共用レンタル環境でも、私の環境では素直に動きました。WebSocket と SSE で詰まり続けたあとだったので、ああ、ようやく動くものに辿り着いた、という気分でした。
Long Polling の最小構成を、ひとまず書いてみた
ここからは、考え方を確認するための最小サンプルです。実運用では、メッセージの取得部分をデータベース検索に置き換えます。サーバー側(PHP)では、リクエストパラメータの since から、これより新しいメッセージがあるかを見ます。なければ少し待って、もう一度見にいく。これを、決めた制限時間まで繰り返します。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 |
<?php header("Content-Type: application/json; charset=utf-8"); header("Cache-Control: no-store"); $since = isset($_GET["since"]) ? (int) $_GET["since"] : 0; $deadline = microtime(true) + 18.0; function get_new_messages($since) { // 実運用ではDB検索に置き換える // 例:id > $since のメッセージを取得する if (time() % 5 === 0) { $id = $since + 1; return [[ "id" => $id, "text" => "new message " . $id, "ts" => time() ]]; } return []; } $messages = []; $last_id = $since; while (microtime(true) < $deadline) { $messages = get_new_messages($since); if (!empty($messages)) { $last_id = end($messages)["id"]; break; } usleep(250000); } echo json_encode([ "messages" => $messages, "last_id" => $last_id ], JSON_UNESCAPED_UNICODE); |
ブラウザ側は、レスポンスが返ってきたら次のリクエストを投げる、というループを回します。タブが見えていないときは間引く、エラー時は徐々に間隔を広げる、といった制御も入れています。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 |
let lastId = 0; let backoff = 300; async function poll() { while (true) { if (document.hidden) { await sleep(1500); continue; } try { const res = await fetch(`/poll.php?since=${lastId}`, { cache: "no-store" }); const data = await res.json(); if (Array.isArray(data.messages) && data.messages.length) { for (const m of data.messages) { console.log("recv:", m); } lastId = data.last_id; } else { await sleep(60); } backoff = 300; } catch (e) { console.log("poll error", e); await sleep(backoff); backoff = Math.min(8000, Math.floor(backoff * 1.6)); } } } function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } poll(); |
図6:ブラウザ側は、レスポンスを受け取るたびに次の問い合わせを投げるループです。タブが隠れていれば間引き、新着がなければ少し休み、エラーが続けば間隔を広げる、という制御を入れています。
これだけのコードでも、Xserver の共用レンタル環境で安定して動きました。WebSocket のような滑らかな即時性はありませんが、メッセージは数秒以内に届くので、チャットとしては、待たされている、という感覚はあまりありません。
投げ続けるだけでは、まずいと気づいた
ただ短い間隔でリクエストを投げ続ければよい、というものでもありませんでした。気軽に書くと、サーバーへの負担がじわじわ増えていきます。WordPress は1回のリクエストで読み込むものが多くなりやすいので、ポーリングの間隔や待ち方は、最初からきちんと考えておきたいところです。私が実装中に意識したのは、おおきく4つのことでした。
ひとつめは、サーバー側の待ち時間を伸ばしすぎないことです。PHP の max_execution_time やサーバー側のタイムアウトに引っかかると、レスポンスが途中で切れてしまいます。逆に短すぎると、空のレスポンスが増えて、リクエスト回数だけ多くなる。私の環境では、サーバー側の待ち時間を15から20秒くらいにしました。max_execution_time が30秒に設定されている前提で、少し余裕を持たせる形です。30秒の壁にギリギリで引っかかると、たまにエラーになるユーザーが出てきて切り分けが面倒になるので、最初から余裕を見ておくのが楽でした。
ふたつめは、新着がないときに、すぐ次を投げないことです。新着なしのレスポンスを受け取ったあと、ブラウザがゼロ秒で次のリクエストを投げると、サーバー側の処理がほぼ常時走り続けることになります。チャットを開いている人が多いほど、この負荷は積み上がる。そこで、空レスポンスのあとに短いスリープを入れています。コードでは await sleep(60); の部分です。たった60ミリ秒ですが、これがあるかないかで、サーバー側のリクエスト数の見え方が変わります。
みっつめは、タブが見えていないときは間引くことです。チャット画面を開いたまま、別のタブで作業している人は珍しくありません。バックグラウンドのタブまで同じ頻度で通信し続ける必要はないので、document.hidden を見て、見えていないときは頻度を落とすようにしました。小さな工夫ですが、複数タブで開きっぱなしにする使い方を考えると、地味に効きます。来てもすぐ返すだけのリクエストが減るので、ピーク時の余裕が変わってきます。
よっつめは、エラーが出たら、リトライ間隔を広げることです。通信エラーが起きたときに、ブラウザがすぐ次を投げ続けると、障害時にさらに負荷を上乗せしてしまいます。サーバーが一時的に落ちているときに、復旧前のサーバーへ大量のリトライをぶつけると、復旧そのものが遅れることもあります。そこで、エラーのたびに待ち時間を少しずつ伸ばすようにしました。最初は短く、失敗が続くほど長く、最大値も決めておく。指数バックオフと呼ばれる定番の作法です。下の図が、その伸び方です。派手な仕組みではありませんが、これだけでも、障害時に勝手に暴走しない、という運用上の安心感がだいぶ違います。
図9:エラーが続くほど、次のリトライまでの待ち時間を少しずつ伸ばします。最初は短く、失敗が重なるほど長く、上限で頭打ちにする。これが指数バックオフです。
3方式を並べて、自分の環境で起きたことを整理してみた
| 方式 | 仕組み | 私の環境での結果 | 感じたこと |
|---|---|---|---|
| WebSocket | 双方向の常時接続(Upgrade で切り替え) | 接続そのものが成立しなかった | VPS など、自分でサーバー側を制御できる場合に向いている |
| SSE | サーバーからブラウザへの一方向ストリーミング | 接続もイベント受信もできたが、配信タイミングが安定しない | HTTP に乗っているが、バッファリングの影響を受けやすい |
| Long Polling | HTTP リクエストとレスポンスの繰り返し | 共用レンタル上で安定して動いた | 地味だが、共用レンタルでは現実的な選択肢になる |
この表だけ見ると、Long Polling が一番よい方式に見えるかもしれません。ただ、そういう話ではありません。WebSocket には WebSocket のよさがありますし、SSE にも SSE の使いどころがあります。VPS や専用サーバー、あるいは Cloudflare Workers や Fly.io のような環境なら、WebSocket を選ぶ場面は多いと思います。今回の話は、あくまで Xserver の共用レンタル上の WordPress サイトに、チャット機能を入れようとした私の検証結果です。同じプロジェクトを別のサーバーで動かしていたら、たぶん別の答えになっていたはずです。その前提では、Long Polling が一番扱いやすく、トラブルも少ない選択肢でした。
即時性は、別のやり方でも作れると気づいた
Long Polling を採用するときに気になるのが、WebSocket ほどの即時性が出ないことです。でも、チャットの体験として考えると、すべての動きを完全にリアルタイムにする必要はありません。ユーザー目線で、ちゃんと動いている、と感じられればよいので、画面の見せ方で吸収できる部分があります。たとえば、自分が送ったメッセージは、サーバーの返答を待たずに先に画面へ並べてしまうやり方があります。送信ボタンを押した瞬間に、自分のメッセージはもう表示されている。サーバーから失敗しましたと返ってきたときだけ、あとからエラー表示に差し替える。この見せ方を、楽観的 UI 更新(Optimistic UI Update)と呼びます。LINE や、よくあるチャットアプリも、内部的には似た作りになっていることが多いです。下の図が、その流れです。
図8:送信ボタンを押した瞬間に、自分のメッセージを先に画面へ出してしまいます。サーバーから失敗が返ったときだけ、あとからエラー表示に差し替える。これが楽観的 UI 更新です。
相手から届くメッセージのほうは、Long Polling で数秒から十数秒以内に取りに行きます。業務システムでミリ秒単位の即時性が要件、というのでなければ、この体感で困る場面は意外と少ないです。大事なのは、リアルタイム通信に見せるために無理な構成を押し通すことではなく、いま動かせる環境で、ストレスなく動く形に落とすことです。今回の検証で、いちばん腹落ちしたのは、この部分でした。
本当に WebSocket が要るなら、環境を変える
とはいえ、要件によってはどうしても WebSocket が欲しい、という場面もあります。同時編集、ライブ配信、対戦ゲームのような用途では、Long Polling や SSE で代替するのは厳しいです。そういう場合は、共用レンタルの中で粘るより、環境のほうを変えたほうが早いと思います。VPS で Node.js を立てて、自前でリバースプロキシを設定する。あるいは、Cloudflare のようなプラットフォームの WebSocket 対応の仕組みに乗る。最初の構築コストはかかりますが、構成の自由度はぐっと上がります。ただ、最初から VPS 前提で組むと、運用の手間も一段増えます。サーバーの監視、OS のアップデート、Node.js のバージョン管理。とりあえず動くものを早く出したい段階では、共用レンタルで Long Polling から始めて、本当に必要になったら WebSocket 構成に移す、という順番のほうが、私には現実的でした。
WordPress 7.0 のコアも、同じ場所に着地していた
この記事を書き直しているタイミングで、WordPress 7.0 のリアルタイム共同編集の方針が公開されていました。複数人で同じ投稿を同時に編集できる機能で、Google ドキュメントの共同編集に近いものをコアに入れる、という話です。気になって Dev Note を読んでみたら、同期方式のデフォルトが HTTP ポーリングになっていました。検討段階では WebRTC も候補だったものの、共用レンタルやネットワーク制限のある環境で動かないことを理由に、最終的に却下した、と書かれています。WebSocket に対応したホスティングではフィルターで切り替えられるけれど、デフォルトはポーリング、という構成です。細かい背景は WordPress 7.0「Armstrong」リリースまとめ のほうに書きました。今回の記事の文脈で言うと、共用レンタルで動くことを最優先したら、コア側もほぼ同じ場所に着地している、という事実は、ちょっと心強かったです。自分の選択がそれほど外れていなかったんだな、と思えました。
記録は、残せるうちに残しておく
同じように、Xserver の共用レンタル上の WordPress でチャット機能を入れようとしている方は、最初から WebSocket だけに絞らず、Long Polling も視野に入れておくと、切り分けが楽になると思います。最新の方式から試して動かなければ古い方式に降りる順番でも、枯れた方式から試して必要があれば新しい方式に挑む順番でも、どちらでも構いません。大事なのは、自分のサーバー環境で何が動いて何が動かないかを、早めに把握しておくことです。
もうひとつ、これは次にやるときの自分に向けたメモも兼ねて書いておきます。検証中に出たエラーメッセージや挙動は、スクリーンショットでも走り書きのメモでも、あとで読める形で残しておくほうがいいです。私は今回それを残し損ねました。だからこの記事も、記憶に頼って書ける範囲までしか書けていません。当時の DevTools のスクリーンショットがあれば、もう少し具体的な話を書けたはずなのに、と思います。共用レンタルでの検証は、本番環境固有の問題に当たることが多いです。あとから、あれ、どうやって解決したんだっけ、と思い出せなくなる前に、画面のキャプチャと、そのときの状況を1、2行で残しておく。あなたがこれから同じ環境にぶつかるなら、その一手間が、未来のあなたを助けてくれるはずです。
参考にした資料
- MDN Web Docs: Protocol upgrade mechanism(確認日: 2026年5月5日)
- MDN Web Docs: WebSocket API(確認日: 2026年5月5日)
- MDN Web Docs: Server-sent events(確認日: 2026年5月5日)
- MDN Web Docs: EventSource(確認日: 2026年5月5日)









コメント