AIで問題生成する学習アプリに「待ち時間」が発生したので、pendingキュー+通知で「待たないUX」にした話
はじめに
個人開発の学習アプリ Q-Master には、AI(Claude API)で4択問題を自動生成する機能があります。
テキストや画像を渡すと、ひっかけ選択肢付きの本格的な4択問題が生成されます。とても便利な機能なのですが、ひとつ大きな問題がありました。
API呼び出しに10〜30秒かかる。その間、ユーザーは何もできない。
ローディング画面を頑張っても、待ち時間そのものは消せません。この記事は、「速くする」のではなく 「待たせない」 設計に切り替えた話です。
フロントエンドだけでAI APIを叩くという選択
まず前提として、Q-Masterは サーバーを持っていません。
ブラウザから直接Claude APIを呼んでいます。理由はシンプルで、個人開発でサーバーの維持コスト(金銭的にも運用的にも)をゼロにしたかったからです。PWA対応でオフラインでも動く設計にしているので、サーバー依存を極力なくしたいという方針もあります。
const response = await fetch('https://api.anthropic.com/v1/messages', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': apiKey,
'anthropic-version': '2023-06-01',
'anthropic-dangerous-direct-browser-access': 'true'
},
body: JSON.stringify({
model: 'claude-sonnet-4-5-20250929',
max_tokens: 8192,
system: systemPrompt,
messages: [{ role: 'user', content: userPrompt }]
})
});
anthropic-dangerous-direct-browser-access: true というヘッダーが必要です。名前のとおり、本来ブラウザから直接APIを叩くのは推奨されていません。APIキーがクライアントサイドに存在するためです。
Q-Masterでは、APIキーをユーザー自身が入力・管理する形にしています。localStorage に btoa() でBase64エンコードして保存しています。これは暗号化ではなく難読化に過ぎませんが、ユーザー自身のキーをユーザー自身のブラウザに保存するので、現実的には許容範囲だと判断しました。
最初の実装:同期モード
最初に実装したのは、シンプルな同期モードでした。
[生成ボタン] → ローディング画面表示 → API呼び出し → プレビューモーダル表示
ローディング中はフルスクリーンのモーダルオーバーレイが表示され、ユーザーはAPIが応答するまで何もできません。
生成が終わると、プレビューモーダルが表示されます。生成された問題の一覧が出て、チェックボックスで追加する問題を選べます。この同期モード自体は「生成結果をすぐ確認して選別したい」ケースで今も使っています。
問題は、API呼び出しに10〜30秒かかること。その間、画面の前で待つしかありません。
学習アプリにとって、待ち時間は致命的です。スキマ時間で使うアプリなので、30秒待つくらいなら問題を解きたい。「問題を生成しておいて、あとで確認する」ができれば、ユーザーの時間を無駄にしないはずです。
方針転換:「速くする」ではなく「待たせない」
API応答を速くするのは自分ではコントロールできません。であれば、待っていることをユーザーに意識させない設計に変えるべきだと考えました。
設計の方針:
- 生成をバックグラウンドで実行し、フォームは即座に解放する
- 生成結果はIndexedDBに一時保存する
- 完了したら通知で知らせる
- ユーザーは好きなタイミングで結果を確認できる
pendingキュー設計
バックグラウンド生成のために、IndexedDBに pending_questions ストアを作りました。
// db.js - onupgradeneeded 内
if (!database.objectStoreNames.contains('pending_questions')) {
const pendingStore = database.createObjectStore('pending_questions', { keyPath: 'id' });
pendingStore.createIndex('created_at', 'created_at', { unique: false });
pendingStore.createIndex('status', 'status', { unique: false });
}
レコードの構造は次のとおりです。
{
id: "uuid",
status: "pending" | "generating" | "completed" | "error",
questions: [], // 生成された問題の配列
targetSetId: null, // 追加先の問題セットID
newSetName: null, // 新規セット名
error: null, // エラーメッセージ
created_at: timestamp,
completed_at: timestamp
}
status のステートマシンはこうなっています。
pending → generating → completed
↘ error
バックグラウンド生成の流れ
async function generateQuestionsBackground() {
// 1. pendingリクエストをDBに作成
const pendingRequest = await QuizDB.addPendingRequest({
targetSetId,
newSetName
});
// 2. ステータスを「生成中」に更新
await QuizDB.updatePendingRequest(pendingRequest.id, { status: 'generating' });
// 3. フォームをリセットしてUIを即座に解放
resetGeneratorForm();
showToast('バックグラウンドで問題を生成中...完了時に通知します');
// 4. バックグラウンドでAPI呼び出し
try {
const response = await callClaudeAPI(systemPrompt, userPrompt);
const result = parseAIResponse(response);
validateQuestions(result.questions, questionType);
// 5. 完了 → DBに結果を保存 + 通知を追加
await QuizDB.updatePendingRequest(pendingRequest.id, {
status: 'completed',
questions: result.questions,
completed_at: Date.now()
});
await NotificationUI.addNotification({
type: 'ai_generation',
title: 'AI問題生成完了',
message: `${result.questions.length}問の問題が生成されました`,
data: { pendingRequestId: pendingRequest.id }
});
} catch (error) {
// 6. エラー → DBにエラー情報を保存 + エラー通知
await QuizDB.updatePendingRequest(pendingRequest.id, {
status: 'error',
error: error.message
});
await NotificationUI.addNotification({
type: 'error',
title: 'AI問題生成エラー',
message: error.message
});
}
}
ポイントは ステップ3 です。API呼び出しの await の 前に フォームをリセットしてトースト通知を出しています。JavaScriptのイベントループを利用して、UIスレッドを即座に解放します。
ユーザーから見ると:
- 「バックグラウンド生成」ボタンを押す
- 「バックグラウンドで生成中...」のトースト通知が一瞬出る
- フォームが即座にクリアされ、次の操作ができる
- 数十秒後、ベルアイコンに通知バッジが表示される
- 好きなタイミングで通知を開いて結果を確認する
待ち時間がゼロになったわけではなく、待ち時間を意識しなくてよくなったのです。
通知システムの実装
完了を知らせるために、通知システムを作りました。
ベルアイコンとバッジ
ヘッダーにベルアイコンを配置し、未読の通知数をバッジで表示します。
async updateBadge() {
const count = await QuizDB.getUnreadNotificationCount();
const badge = document.getElementById('notification-badge');
if (badge) {
badge.textContent = count > 99 ? '99+' : count;
badge.style.display = count > 0 ? 'flex' : 'none';
}
}
通知パネル
ベルアイコンをクリックすると通知パネルが開き、通知の一覧が表示されます。各通知にはタイプに応じたアイコンと、AI生成完了の場合は「確認する」アクションボタンが付きます。
// 通知をクリック → 保留中のリクエストからプレビュー表示
async handleConfirmClick(notificationId, pendingId) {
const pendingRequest = await QuizDB.getPendingRequest(pendingId);
if (pendingRequest && pendingRequest.questions) {
// 同期モードと同じプレビューモーダルを再利用
showPreviewModal(pendingRequest.questions);
}
await QuizDB.markNotificationAsRead(notificationId);
await this.updateBadge();
}
ここで重要なのは、プレビューモーダルを同期モードと共有していることです。バックグラウンド生成の結果確認画面を新たに作るのではなく、同期モードで使っているプレビューモーダルをそのまま再利用しています。
通知のデータ構造
通知はIndexedDBの notifications ストアに保存されます。
{
id: "uuid",
type: "ai_generation" | "error", // 通知タイプ
title: "AI問題生成完了",
message: "5問の問題が生成されました",
data: { pendingRequestId: "uuid" }, // 関連データへのリンク
read: false, // 既読フラグ
created_at: timestamp
}
通知は30日後に自動削除されます。
AI出力の揺れに対応する守備的バリデーション
AI(LLM)の出力は、プロンプトで指定しても100%同じ形式にはなりません。「このJSON形式で出力して」と指定しても、微妙に異なる構造が返ってくることがあります。
Q-Masterでは validateQuestions() で複数のフォールバック処理を入れています。
// 問題文のフィールド名の揺れに対応
if (!q.body_md) {
if (q.question) q.body_md = q.question;
else if (q.body) q.body_md = q.body;
else if (q.text) q.body_md = q.text;
}
// 選択肢が配列で返ってきた場合、オブジェクトに変換
if (Array.isArray(q.choices)) {
q.choices = {
A: q.choices[0] || '',
B: q.choices[1] || '',
C: q.choices[2] || '',
D: q.choices[3] || ''
};
}
// 選択肢の値がオブジェクトの場合、textプロパティを抽出
if (typeof q.choices.A === 'object') {
q.choices.A = q.choices.A.text || q.choices.A.label || String(q.choices.A);
}
// 解説フィールドの揺れに対応
if (!q.explanation_md) {
if (q.explanation) q.explanation_md = q.explanation;
else if (q.rationale) q.explanation_md = q.rationale;
}
実際に遭遇したAI出力の揺れパターン:
| 期待するフィールド | 実際に返ってきたケース |
|---|---|
body_md |
question, body, text
|
choices: {A: "...", B: "..."} |
choices: ["...", "..."](配列形式) |
choices: {A: "..."} |
choices: {A: {text: "...", isCorrect: false}}(オブジェクト形式) |
explanation_md |
explanation, rationale
|
プロンプトで指定しても起きるので、受け取る側で正規化するのが現実的な対策です。
この設計が向いているケース / 向かないケース
向いている
- 結果をリアルタイムに使わなくていい操作(問題生成、バッチ処理など)
- フロントエンドだけで完結させたいプロジェクト
- 「ユーザーの手が空く」ことが価値になるアプリ(学習アプリ、生産性ツールなど)
向かない
- 結果を即座に使いたい操作(チャットの返答、リアルタイム翻訳など)
- 複数のバックグラウンド処理を同時に管理する必要がある場合(キューの管理が複雑になる)
- サーバーサイドでのバッチ処理が適切な規模のもの
まとめ
AI機能を組み込んだときに直面する「待ち時間」は、APIの速度改善では解決しないことが多いです。
今回のアプローチは:
- 同期モードと非同期モードの2つを用意する — 用途に応じて使い分け
- 非同期の結果はIndexedDBに一時保存する — ブラウザを閉じても消えない
- 通知システムで完了を知らせる — ユーザーは好きなタイミングで確認
- 既存のUIコンポーネントを再利用する — プレビューモーダルの共有
「待ち時間を短くする」のではなく 「待っていることを意識させない」 設計にしたことで、AI生成中も学習を続けられる体験になりました。
フロントエンドだけでも、IndexedDBと通知UIの組み合わせで十分実用的な非同期体験は作れます。
Q-Master: https://hiroe28.github.io/Q-Master/
GitHub: https://github.com/Hiroe28/Q-Master

Discussion