記事のリライトに LLM を叩いていた。
1記事 5,000字ほどを改善指示と合わせて Claude Sonnet に投げると、応答まで3〜5分かかる。フロントエンドから POST してそのまま待たせると、どこかでタイムアウトが発生して 500 が返ってくる。UI 上は「リライト失敗しました」のトースト。しかしサーバ側では実は成功していて、LLM の応答から DB 書き込みまでは完了している。
このミスマッチが UX 的に最悪だった。ユーザーは失敗と思ってもう一度ボタンを押す。2回目のリライトが走り、API 料金も 2 倍になる。
解決策は非同期ジョブ化で明らかだった。ただし「どう非同期化するか」の選択肢はいくつかある。今回は SSE ではなく polling を選んだ判断が地味に効いた。この記事はその判断の背景と、in-memory FIFO で実装した具体コードの記録だ。
タイムアウトの正体

内訳を追ったところ、タイムアウトは複数の層で発生していた。
- Claude CLI 自体のタイムアウト:
llmCliTimeoutMs = 300000(5分)がデフォルト - Hono サーバの request timeout: 明示的に設定していないが、reverse proxy(Cloudflare Tunnel)で 100秒程度
- ブラウザの fetch timeout: 環境によって 30〜120秒
要するに、一番厳しい proxy / ブラウザ側の上限に引きずられて数分で切れる。LLM 応答が 4 分になった瞬間、UI からは成功に見えなくなる。
同期 vs 非同期の選択肢
一般論として、解決策は 3 つある。
1. 同期 HTTP を延命する
Keep-Alive を長く設定し、proxy timeout を上げ、fetch の AbortSignal を調整する方法だ。根本的な解決にはならない。LLM が 10 分応答しないケースも実際に起きる。スケールしない。
2. 非同期ジョブ + SSE(Server-Sent Events)で progress を push
クライアントが SSE で接続を張りっぱなしにして、サーバ側から progress event を流す方式。リアルタイム感はある。ただし複雑性が増す。コネクション管理・reconnect・proxy 越えの SSE には落とし穴が多い。
3. 非同期ジョブ + polling
サーバはジョブを受けて即 202 を返す。クライアントは N 秒ごとに /jobs/:id を叩いてステータスを確認する方式だ。実装はシンプルで、proxy / load balancer にも優しい。
content-factory では 3 の polling を選んだ。その理由を整理する。
なぜ SSE ではなく polling か

実際に踏んだ SSE の問題点を挙げる。
- Cloudflare Tunnel / 一部の CDN では、idle connection が途中で切断される
EventSourceは HTTP/1.1 ベースの運用が多く、HTTP/2 環境ではコネクション寿命が変わる- サーバ側のコネクション数が積み上がる
- クライアントがリロードすると接続が切れ、reconnect ロジックが必要になる
逆に polling の利点はこうだ。
- 普通の GET リクエストなので、どの proxy / load balancer でも透過する
- サーバ側は状態を保持するだけで「送信」を意識しない
- クライアントがリロードしても
GET /jobs/activeで実行中のジョブを再取得できる - 実装は
useQueryのrefetchIntervalに数値を渡すだけ
2秒粒度のリアルタイム性で十分なユースケース(リライトの progress)では、polling の単純さが勝る。SSE が本領を発揮するのは、100ms 単位の更新が必要な画面共有やリアルタイム協調編集のようなケースだ。
実装
サーバ側は in-memory FIFO + maxConcurrency=1 の超軽量ジョブキューで実装した。
RewriteJobService
type RewriteJob = {
id: string; // UUID
articleId: number;
status: "pending" | "running" | "completed" | "failed";
instruction: string;
result?: { updatedContent: string; improvedCount: number };
error?: string;
createdAt: number;
startedAt?: number;
completedAt?: number;
};
export class RewriteJobService {
private jobs = new Map<string, RewriteJob>();
private queue: string[] = [];
private running = false;
async enqueue(articleId: number, instruction: string): Promise<string> {
const id = crypto.randomUUID();
const job: RewriteJob = {
id, articleId, status: "pending", instruction, createdAt: Date.now(),
};
this.jobs.set(id, job);
this.queue.push(id);
// 実行は fire-and-forget で走らせる
void this.drain();
return id;
}
findActiveByArticleId(articleId: number): RewriteJob | null {
for (const job of this.jobs.values()) {
if (job.articleId === articleId && (job.status === "pending" || job.status === "running")) {
return job;
}
}
return null;
}
getById(id: string): RewriteJob | null {
return this.jobs.get(id) ?? null;
}
private async drain(): Promise<void> {
if (this.running) return;
this.running = true;
try {
while (this.queue.length > 0) {
const id = this.queue.shift()!;
const job = this.jobs.get(id);
if (!job) continue;
await this.runJob(job);
}
} finally {
this.running = false;
}
}
private async runJob(job: RewriteJob): Promise<void> {
job.status = "running";
job.startedAt = Date.now();
try {
const result = await this.rewriteService.execute(job.articleId, job.instruction);
job.result = result;
job.status = "completed";
} catch (error) {
job.error = error instanceof Error ? error.message : String(error);
job.status = "failed";
} finally {
job.completedAt = Date.now();
}
}
}
設計上の 3 つのポイントを押さえておきたい。
- in-memory Map + array queue: 1日数百件程度なら、過去ジョブがメモリに残っても問題ない
- maxConcurrency=1: 並行実行を禁止することで、LLM API の rate limit と DB 書き込みの競合を避ける
void this.drain(): enqueue した瞬間にバックグラウンド処理を開始し、ユーザーには即 jobId を返す
3 つのエンドポイント
router.post("/articles/:id/rewrite", async (c) => {
const articleId = parseIdParam(c.req.param("id"));
const { instruction } = validateRequest(rewriteSchema, await c.req.json());
const jobId = await rewriteJobService.enqueue(articleId, instruction);
c.status(202);
return c.json(successResponse({ jobId }));
});
router.get("/articles/:id/rewrite/active", (c) => {
const articleId = parseIdParam(c.req.param("id"));
const job = rewriteJobService.findActiveByArticleId(articleId);
return c.json(successResponse(job)); // null or RewriteJob
});
router.get("/rewrite-jobs/:jobId", (c) => {
const job = rewriteJobService.getById(c.req.param("jobId"));
if (!job) throw new NotFoundError("rewrite_job");
return c.json(successResponse(job));
});
役割は明確に分かれている。1つ目は ENQUEUE(202 で jobId を返す)、3つ目が polling の対象だ。
2つ目がとくに重要。ユーザーがリライト実行中にブラウザをリロードしても、GET /articles/:id/rewrite/active で現在走っているジョブを再取得できる。jobId を localStorage に保存する必要がない。SSE だと「reconnect して既存の状態を取り戻す」処理が必要になるが、polling + active-by-article なら不要だ。
クライアント側

React Query の useQuery で polling を実装した。
function useRunRewrite(articleId: number) {
const queryClient = useQueryClient();
const [jobId, setJobId] = useState<string | null>(null);
// アクティブジョブを最初に取得(リロード復旧)
const { data: initialActiveJob } = useQuery({
queryKey: ["rewrite-active", articleId],
queryFn: () => articlesApi.getActiveRewriteJob(articleId),
staleTime: 0,
});
useEffect(() => {
if (initialActiveJob && !jobId) setJobId(initialActiveJob.id);
}, [initialActiveJob]);
// jobId が決まったら polling
const { data: job } = useQuery({
queryKey: ["rewrite-job", jobId],
queryFn: () => articlesApi.getRewriteJob(jobId!),
enabled: !!jobId,
refetchInterval: (data) => {
if (!data) return 2000;
if (data.status === "running" || data.status === "pending") return 2000;
return false; // completed / failed で停止
},
});
const mutate = useMutation({
mutationFn: (instruction: string) =>
articlesApi.enqueueRewrite(articleId, instruction),
onSuccess: (res) => setJobId(res.jobId),
});
return { job, mutate };
}
refetchInterval に関数を渡すことで、実行中は 2 秒間隔・完了したら自動停止を 1 行で表現できる。完了後は job.result が埋まるので、そのまま UI 表示に使える。
アクティビティログで進行を見える化

ジョブの内部状態は polling で把握できる。ただし「全体として何が動いているか」もユーザーに伝えたい。左サイドバーに ActivityLogPanel を配置し、toast 通知を全量ミラーリングする形にした。
const useToasterStore = create<{ logs: LogEntry[]; push: (e: LogEntry) => void; clear: () => void }>(
(set) => ({
logs: [],
push: (e) => set((state) => ({
logs: [e, ...state.logs].slice(0, 100),
})),
clear: () => set({ logs: [] }),
}),
);
// 既存の toast をラップ
toast.success = new Proxy(toast.success, {
apply(target, thisArg, args) {
useToasterStore.getState().push({
level: "success",
message: args[0],
timestamp: Date.now(),
});
return Reflect.apply(target, thisArg, args);
},
});
toast はすぐ消えるが、ActivityLogPanel には最大 100 件残る。「さっき出たメッセージはなんだっけ?」という状況をこれで解消できる。
得たもの / 反省
得たもの
- 3〜5 分の LLM リライトが UX に響かなくなった: ユーザーはボタンを押した後すぐ別の操作に移り、ActivityLogPanel で状況を把握できる
- リロード復旧が無料で手に入った:
getActiveJobエンドポイントのおかげ - 実装量が驚くほど少ない: Service が 80行、ルートが 30行、フックが 40行ほど
反省
- ジョブがプロセスメモリに載っている: サーバ再起動で pending / running ジョブが消える。小規模運用なら許容範囲だが、本番環境では SQLite か Redis に出すべきだ
- polling コストはゼロではない: 2秒間隔で GET が飛ぶため、1ジョブあたり 4分 = 120 リクエスト。1日 100記事 × リライト 3回 = 約36,000回 / 日になる。1リクエストあたりの負荷は極小なのでサーバ的には許容範囲だが、把握しておきたい数字だ
- progress の粒度が粗い: 「今 review 段階」「今 drafting 段階」といった細かい状態は出せない。LLM 応答が 1 回の HTTP で完結するため、中間 state がないからだ。SSE でも同じ制約があるが、LLM ストリーミング API 経由なら token 単位の progress を出せる余地はある
まとめ

- 長時間 LLM 処理には非同期ジョブ + polling で十分(SSE は多くの場合で過剰)
- in-memory FIFO + maxConcurrency=1 でも個人運用レベルなら問題ない
/jobs/activeでリロード時の状態復旧を追加コストなしで実現できる- React Query の
refetchIntervalが polling 実装を大きく単純化する - ActivityLogPanel で toast を全量ミラーすることで、ユーザーに全体の進行を伝える
SSE はエレガントな技術だが、proxy 越え・reconnect・LB 越しで必ず何かが起きる。個人開発で「数秒粒度の更新で十分」なユースケースなら、polling が圧倒的に素朴で壊れにくい。
同じ構成で LLM 系の長時間処理を抱えているなら、まず in-memory ジョブキュー + polling から始めることを勧める。足りなくなったら SSE / WebSocket / Redis Queue に移行すればいい。多くの個人プロジェクトは、その手前で十分に機能する。


コメント