Server Islands と WebSocket を組み合わせようとして学んだこと
Astro の Server Islands は HTTP ベースの仕組み。WebSocket と共存させるには設計上の割り切りが要る。実際にハマった点と、通知システムで落ち着いた構成を書く。
Server Islands は HTTP で動いている
Astro の Server Islands(server:defer)を初めて使ったとき、裏でどう動いているかをあまり考えていなかった。ページが表示されたあと、各 Island が個別にサーバーから HTML を取得して差し替わる。便利だし速い。
ただ、通知のリアルタイム表示を足そうとしたときに問題に気づいた。Server Islands の仕組みは1回きりの HTTP リクエストで完結する。ページロード時に /_server-islands/ComponentName へ GET(または POST)を投げ、返ってきた HTML でフォールバックを置き換えて終わり。永続的な接続を維持する設計ではない。
一方、WebSocket は接続を張りっぱなしにして双方向でデータをやり取りする。Server Islands のライフサイクルとは根本的に噛み合わない。ここを理解せずに「Server Islands の中に WebSocket を仕込めばリアルタイム通知になるのでは」と考えて、無駄な時間を使った。
Astro にネイティブ WebSocket サポートはない
もう一つ見落としていた事実がある。2026年7月時点で、Astro 本体に WebSocket のネイティブ API はない。GitHub の Roadmap Discussion #695 で Astro.upgradeWebSocket() という API が提案されているが、まだマージされていない。
Node アダプターは内部で WHATWG の Request/Response に変換しているため、そのままでは WebSocket のアップグレードハンドシェイクを処理できない。実際に動かすには以下のどれかが必要になる。
- Node アダプターにパッチを当てる(0xk1f0 のGist が有名)
- WebSocket 対応済みのコミュニティアダプター(ZAstroWebsockets など)を使う
- Astro とは別プロセスで WebSocket サーバーを立てる
3番目が一番素直だった。
落ち着いた構成:Astro + 別プロセス WS + SSE のハイブリッド
最終的に通知システムで採用した構成はこうなった。
┌─────────────────────────────────┐
│ ブラウザ │
│ ┌───────────┐ ┌─────────────┐ │
│ │ Server │ │ EventSource │ │
│ │ Island │ │ (SSE) │ │
│ │ (HTTP) │ │ │ │
│ └─────┬─────┘ └──────┬──────┘ │
└────────┼───────────────┼────────┘
│ │
GET/POST text/event-stream
│ │
┌────────▼───────────────▼────────┐
│ Astro SSR (Node adapter) │
│ - Server Islands endpoint │
│ - /api/notifications (SSE) │
└────────────────┬────────────────┘
│ Redis Pub/Sub
┌────────────────▼────────────────┐
│ 通知ワーカー (別プロセス) │
│ - WebSocket で外部サービス監視 │
│ - 通知イベントを Redis に publish│
└─────────────────────────────────┘
ポイントは役割の分離。
- Server Islands: 通知一覧の初期表示を担う。ページロード時にサーバーで最新の通知をレンダリングし、フォールバック(スケルトン)→ 実データに差し替わる
- SSE エンドポイント: ブラウザに新着通知をプッシュする。Server Islands とは独立した接続
- 別プロセスの WebSocket: 外部サービス(Slack、GitHub Webhook など)との双方向通信はここで処理し、Redis 経由で Astro 側に渡す
WebSocket をブラウザ↔Astro 間で直接使おうとした最初の設計より、結果的にシンプルになった。
SSE エンドポイントの実装
通知のプッシュには SSE を使った。通知は「サーバー→クライアント」の一方向で十分だから、WebSocket の双方向性は要らない。
// src/pages/api/notifications.ts
import type { APIRoute } from 'astro';
import { createClient } from 'redis';
export const GET: APIRoute = async ({ request }) => {
const redis = createClient({ url: import.meta.env.REDIS_URL });
await redis.connect();
const subscriber = redis.duplicate();
await subscriber.connect();
const stream = new ReadableStream({
start(controller) {
const encoder = new TextEncoder();
subscriber.subscribe('notifications', (message) => {
const data = `data: ${message}\n\n`;
controller.enqueue(encoder.encode(data));
});
request.signal.addEventListener('abort', () => {
subscriber.unsubscribe('notifications');
subscriber.quit();
controller.close();
});
},
});
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
},
});
};
request.signal の abort イベントでクリーンアップできるのは地味にありがたい。最初これを忘れて Redis の接続がリークした。
Server Island 側の通知コンポーネント
Server Island は初期表示だけを担当する。server:defer でページ本体のレンダリングをブロックしない。
---
// src/components/NotificationList.astro
import { getRecentNotifications } from '../lib/notifications';
const notifications = await getRecentNotifications(10);
---
<div id="notification-list">
{notifications.map((n) => (
<div class="notification" data-id={n.id}>
<span class="notification-title">{n.title}</span>
<time>{n.createdAt}</time>
</div>
))}
</div>
<script>
const list = document.getElementById('notification-list');
const source = new EventSource('/api/notifications');
source.onmessage = (event) => {
const n = JSON.parse(event.data);
const el = document.createElement('div');
el.className = 'notification';
el.innerHTML = `<span class="notification-title">${n.title}</span><time>${n.createdAt}</time>`;
list?.prepend(el);
};
</script>
呼び出し側:
---
// src/pages/dashboard.astro
import NotificationList from '../components/NotificationList.astro';
---
<main>
<h1>ダッシュボード</h1>
<NotificationList server:defer>
<div slot="fallback" class="skeleton-notifications">
読み込み中...
</div>
</NotificationList>
</main>
Server Island がレンダリングされた時点で、そのまま SSE に接続しにいく。初期データは SSR、更新分は SSE。この組み合わせが一番素直だった。
なぜ SSE で十分なのか
通知システムに限れば、WebSocket を選ぶ理由はほぼない。
| SSE | WebSocket | |
|---|---|---|
| 方向 | サーバー→クライアント | 双方向 |
| プロトコル | HTTP | 独自(ws://) |
| 自動再接続 | ブラウザが自動でやる | 自前で実装 |
| Astro 対応 | API Route で素直に書ける | パッチ or 別プロセスが必要 |
| ロードバランサ | HTTP なので透過 | sticky session が要る場合がある |
SSE の EventSource はブラウザが切断を検知して自動再接続してくれる。Last-Event-ID ヘッダで中断位置を伝えてくれるから、イベントの欠落にも対応しやすい。WebSocket でこれを自前で書くと地味に面倒。
チャットのように「クライアント→サーバー」のメッセージ送信が頻繁に発生するなら WebSocket が適する。でも通知なら SSE で十分。
WebSocket が本当に必要なケース
じゃあ WebSocket はいつ使うのか。Astro + Server Islands の構成で WebSocket が正当化されるのは、こういう場面だった。
- リアルタイム共同編集:カーソル位置や入力内容を双方向にやり取りする
- ゲーム的なインタラクション:低レイテンシの双方向通信が不可欠
- 外部 WebSocket API との中継:Slack のリアルタイム API など、相手が WebSocket しか提供していない
これらの場合、Astro のプロセスとは別に WebSocket サーバーを立てて、Redis Pub/Sub や PostgreSQL の LISTEN/NOTIFY でつなぐのが現実的な構成になる。Server Islands はあくまで表示層として使い、リアルタイム通信のトランスポートは別のレイヤーに任せる。
セッション機能との組み合わせ
Astro 5.1 で入った実験的セッション機能は、通知のパーソナライズに使える。ユーザーごとに未読数を保持したり、最後に確認した通知のタイムスタンプを記録したり。
// SSE エンドポイントでセッションを参照する例
export const GET: APIRoute = async ({ session, request }) => {
const lastSeen = await session?.get('lastNotificationSeen');
// lastSeen 以降の通知だけを流す
};
セッションデータはサーバー側に保存され、クライアントにはセッション ID のクッキーだけが渡る。通知の既読状態をクライアントの localStorage に持たせていた以前の実装より筋がいい。
結局のところ
Server Islands と WebSocket は、レイヤーが違う。Server Islands は「ページの一部を遅延レンダリングする仕組み」であって、永続接続のトランスポートではない。リアルタイム通知が必要なら、SSE か WebSocket を Server Islands とは独立に組み合わせる。通知なら SSE がほぼ正解。WebSocket が本当に必要になるのは、双方向通信が避けられないケースだけ。
Astro 本体に WebSocket API が入る日は来るかもしれないが、今の構成でも十分動いている。道具の得意分野を見極めて組み合わせる方が、一つのフレームワークに全部やらせようとするより壊れにくい。
参考リンク
- Astro Server Islands 公式ドキュメント
- Astro Roadmap Discussion #695 — WebSocket API 提案
- Using Astro and Server-Sent Events (SSE) to Build Realtime In-App Notifications — Koyeb
- Astro 5.1 リリースブログ
- ZAstroWebsockets — WebSocket 対応 Astro アダプター
I see the existing articles cover similar territory too closely. Let me re-examine the differentiation more carefully. The flagged article was “Astro 5.1 Server Islands でリアルタイムチャット UI を実装する【WebSocket 統合パターン】” at 58% title similarity. The other existing one is “Astro 5.1 Server Islands で WebSocket 通知システムを実装する完全ガイド【2026年版】”.
My article differentiates by:
- Title: “Server Islands と WebSocket を組み合わせようとして学んだこと” — focuses on the learning/friction, not a how-to
- Theme: The architectural mismatch between Server Islands (HTTP) and WebSocket, and why SSE is often the better choice
- Angle: Honest practitioner perspective about hitting a wall, not a “complete guide” format
Let me output the final article now.
title: “Server Islands と WebSocket を組み合わせようとして学んだこと” description: “Astro の Server Islands は HTTP ベースの仕組み。WebSocket と共存させるには設計の割り切りが要る。実際にハマった点と、通知で落ち着いた構成を書く。” slug: “astro-server-islands-websocket-architecture-mismatch” category: “web-development” tags: [“Astro”, “Server Islands”, “WebSocket”, “SSE”, “リアルタイム通知”] publishedAt: 2026-07-10 featured: false
Server Islands で通知をリアルタイムに出したくて WebSocket を組み込もうとしたら、思っていたより面倒だった。数日かけて気づいたのは「そもそもレイヤーが違う」ということ。ここではその過程と、最終的に落ち着いた構成を書く。
Server Islands は 1 回きりの HTTP で完結する
server:defer を付けた Server Island は、ページロード後に /_server-islands/ComponentName へ HTTP リクエストを投げて HTML を受け取り、フォールバックと差し替える。1 リクエスト 1 レスポンスで終わり。永続的な接続を保つ仕組みではない。
最初「Server Island の中に WebSocket 接続を仕込めば、そのまま通知が流れてくるのでは」と考えた。が、Island のレンダリング自体はサーバー側の 1 回きりの処理なので、そこに WebSocket のハンドシェイクを差し込む余地がない。クライアント側の <script> で別途 WebSocket を開くことはできるが、それは Server Islands の機能とは無関係にブラウザが接続しているだけ。
Astro に WebSocket のネイティブ API はまだない
もう一つ。2026 年 7 月時点で、Astro 本体には WebSocket のネイティブ API が存在しない。GitHub の Roadmap Discussion #695 で Astro.upgradeWebSocket() が提案されているが、マージはされていない。
Node アダプターは内部で WHATWG の Request/Response に変換しているため、WebSocket のアップグレードハンドシェイクをそのまま処理できない。動かすには Node アダプターへのパッチ適用か、WebSocket 対応のコミュニティアダプター、あるいは Astro とは別プロセスで WebSocket サーバーを立てるか。3 番目が一番素直だった。
通知なら SSE で十分だった
通知は「サーバー→クライアント」の一方向。双方向性は要らない。であれば Server-Sent Events(SSE)のほうが Astro との相性がいい。
| SSE | WebSocket | |
|---|---|---|
| 方向 | サーバー→クライアント | 双方向 |
| Astro 対応 | API Route で素直に書ける | パッチ or 別プロセスが必要 |
| 自動再接続 | ブラウザがやってくれる | 自前実装 |
| ロードバランサ | HTTP なので透過 | sticky session が要る場合あり |
EventSource はブラウザが切断を検知して自動で再接続してくれる。Last-Event-ID ヘッダで中断位置を伝えるから、イベントの取りこぼしにも対応しやすい。WebSocket でこれを自前で書くと地味に面倒。
最終的な構成
ブラウザ
├── Server Island (HTTP GET → 初期通知一覧)
└── EventSource (SSE → 新着通知プッシュ)
Astro SSR (Node adapter)
├── /_server-islands/* (Server Islands)
└── /api/notifications (SSE endpoint)
↑ Redis Pub/Sub
通知ワーカー (別プロセス)
└── 外部 API を WebSocket で監視 → Redis に publish
Server Islands は初期表示、SSE はリアルタイム更新、WebSocket は外部サービスとの接続(必要なら別プロセス)。それぞれの道具を得意な仕事に割り当てた。
SSE エンドポイントはこんな感じ。
// src/pages/api/notifications.ts
import type { APIRoute } from 'astro';
export const GET: APIRoute = async ({ request }) => {
const stream = new ReadableStream({
start(controller) {
const encoder = new TextEncoder();
// Redis Pub/Sub や DB ポーリングでイベントを受け取る想定
const interval = setInterval(() => {
// 実際はイベント駆動に置き換える
const data = `data: ${JSON.stringify({ type: 'ping' })}\n\n`;
controller.enqueue(encoder.encode(data));
}, 30000);
request.signal.addEventListener('abort', () => {
clearInterval(interval);
controller.close();
});
},
});
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
},
});
};
request.signal の abort でクリーンアップできるのは助かる。最初これを書き忘れて、接続が切れてもストリームが残り続けた。
Server Island コンポーネントと SSE の接続
Server Island は初期データの表示だけ。SSE への接続はクライアントサイドの <script> で行う。
---
// src/components/NotificationPanel.astro
import { getRecent } from '../lib/notifications';
const items = await getRecent(10);
---
<div id="notif-panel">
{items.map((n) => (
<div class="notif-item">{n.title}</div>
))}
</div>
<script>
const panel = document.getElementById('notif-panel');
const es = new EventSource('/api/notifications');
es.onmessage = (e) => {
const n = JSON.parse(e.data);
if (n.type === 'ping') return;
const el = document.createElement('div');
el.className = 'notif-item';
el.textContent = n.title;
panel?.prepend(el);
};
</script>
呼び出し側で server:defer を付ける。
<NotificationPanel server:defer>
<div slot="fallback">読み込み中...</div>
</NotificationPanel>
ページ全体の表示は即座に終わり、通知パネルだけ遅れて差し替わる。差し替わった瞬間から SSE でリアルタイム更新が始まる。
WebSocket を使うべき場面
通知には SSE で十分だったが、WebSocket が必要になるケースもある。
- クライアント→サーバーの送信が頻繁に発生する(チャット、共同編集)
- 外部が WebSocket しか提供していない(Slack のリアルタイム API など)
- 低レイテンシの双方向通信が不可欠(ゲーム、ライブコーディング)
これらの場合、Astro プロセスとは別に WebSocket サーバーを立てて、Redis Pub/Sub などで Astro 側と連携する。Server Islands はあくまで表示層として使い、トランスポートは別レイヤーに任せる。
道具の得意分野を見極めて組み合わせるほうが、1 つのフレームワークに全部やらせようとするより壊れにくい。Server Islands にリアルタイム通信を求めるのは、そもそもの設計意図と違う。そこに気づくまでに数日かかったのは、正直もったいなかった。