Next.js + Vercel Edge Functionsで、1万件超のQ&Aに対してレスポンス200ms以下を実現した方法
はじめに
宅建試験対策アプリ takkenai.jp を個人開発しています。
このアプリでは、宅建試験の過去問を中心に1万件を超えるQ&Aデータを扱っています。開発初期は問題の取得に平均680msかかっており、ユーザーが「次の問題へ」ボタンを押すたびに明らかな"待ち"が発生していました。
学習アプリにおいてテンポの悪さは致命的です。ユーザーは1セッションで数十問〜百問を連続で解くため、1問あたり数百msの遅延が体験全体を大きく損ないます。
本記事では、この問題をVercel Edge Functions + キャッシュ戦略の最適化で解決し、P95レスポンスタイムを180ms以下まで改善した具体的な方法を共有します。
改善前のアーキテクチャと課題
改善前の構成はシンプルなものでした。
ユーザー → Next.js API Routes (Node.js Runtime) → Supabase (PostgreSQL)
ボトルネックは主に3つありました。
| 項目 | 改善前の数値 |
|---|---|
| API Route Cold Start | 約250〜400ms |
| Supabaseクエリ(東京リージョン) | 約80〜150ms |
| JSON シリアライズ + ネットワーク | 約50〜130ms |
| 合計 P95 | 約680ms |
Node.jsランタイムのAPI Routesは、Vercelのサーバーレス関数としてデプロイされます。リクエスト頻度が低い時間帯ではコールドスタートが頻発し、ユーザー体験を直撃していました。
改善戦略の全体像
改善は3つのレイヤーで行いました。
- Edge Runtimeへの移行 — コールドスタートの排除
- 多層キャッシュ戦略 — DBアクセス頻度の削減
- データ構造の最適化 — ペイロードサイズの圧縮
最終的なアーキテクチャは以下のようになっています。
ユーザー
→ Vercel Edge Network (キャッシュヒット時はここで返却)
→ Edge Function (軽量ランタイム)
→ KVキャッシュ (Vercel KV / Upstash Redis)
→ Supabase (キャッシュミス時のみ)
1. Edge Runtimeへの移行
Next.js App Router の Route Handlers で runtime を 'edge' に指定するだけで、Edge Runtime上で動作するようになります。
// app/api/questions/[id]/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { getQuestion } from '@/lib/questions';
export const runtime = 'edge';
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } }
) {
const questionId = params.id;
// 1. KVキャッシュを確認
const cached = await getCachedQuestion(questionId);
if (cached) {
return NextResponse.json(cached, {
headers: {
'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=86400',
'X-Cache': 'HIT',
},
});
}
// 2. キャッシュミス → DBから取得してキャッシュに書き込み
const question = await getQuestion(questionId);
if (!question) {
return NextResponse.json({ error: 'Not found' }, { status: 404 });
}
await setCachedQuestion(questionId, question);
return NextResponse.json(question, {
headers: {
'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=86400',
'X-Cache': 'MISS',
},
});
}
Edge Runtimeに移行したことで、コールドスタートが250〜400ms → 3〜8msに激減しました。Edge Runtimeは V8 Isolates ベースで起動するため、Node.jsランタイムと比べて桁違いに高速です。
ただし、Edge RuntimeではNode.js固有のAPI(fs, cryptoの一部など)が使えません。Supabaseクライアントは @supabase/supabase-js v2以降であればEdge互換なので問題ありませんでしたが、もし非互換なライブラリに依存している場合は事前に確認が必要です。
2. 多層キャッシュ戦略
1万件のQ&Aデータは更新頻度が極めて低い(年に1回の法改正時にまとめて更新する程度)という特性があります。この特性を活かして、3層のキャッシュを組みました。
第1層:Vercel Edge Network(CDNキャッシュ)
Cache-Control ヘッダの s-maxage による自動キャッシュです。ヒットすればEdge Functionすら実行されず、レスポンスタイムは5〜15msになります。
第2層:Vercel KV(Upstash Redis)
CDNキャッシュが切れた場合のフォールバックです。Edgeロケーションから低レイテンシでアクセスできるRedis互換のKVストアを使っています。
第3層:Supabase PostgreSQL
KVにもキャッシュがない場合にのみアクセスします。
// lib/cache.ts
import { kv } from '@vercel/kv';
const CACHE_TTL = 60 * 60 * 24; // 24時間
interface QuestionData {
id: string;
year: number;
number: number;
category: string;
body: string;
choices: string[];
answer: number;
explanation: string;
}
export async function getCachedQuestion(
id: string
): Promise<QuestionData | null> {
try {
const data = await kv.get<QuestionData>(`q:${id}`);
return data;
} catch (e) {
// KV障害時はサイレントにフォールスルー
console.error('KV read error:', e);
return null;
}
}
export async function setCachedQuestion(
id: string,
data: QuestionData
): Promise<void> {
try {
await kv.set(`q:${id}`, data, { ex: CACHE_TTL });
} catch (e) {
console.error('KV write error:', e);
}
}
ポイントは、KVの障害がアプリ全体の障害にならないよう、try-catchでサイレントにフォールスルーさせていることです。キャッシュはあくまで高速化の手段であり、信頼性の根幹はDBに置いています。
3. データ構造の最適化
改善前は1問あたりのJSONペイロードが平均4.2KBありました。解説文にHTMLタグが含まれていたり、不要なメタデータ(作成日時、内部管理IDなど)がフロントに渡っていたためです。
以下の最適化を行い、平均ペイロードを1.1KBまで削減しました。
- フロントエンドで不要なフィールドの除去(
selectで必要カラムのみ取得) - 解説文のHTMLをMarkdownに統一し、レンダリングはクライアント側で実行
- 選択肢テキストの重複部分(「正しいものはどれか」等の共通prefix)をスキーマレベルで分離
ペイロードが小さくなったことでKVのストレージコストも下がり、全問キャッシュしてもVercel KVの無料枠(256MB)に十分収まるサイズになりました。
改善結果
| 指標 | 改善前 | 改善後 | 改善率 |
|---|---|---|---|
| P50 レスポンスタイム | 420ms | 12ms(CDNヒット時) | 97%減 |
| P95 レスポンスタイム | 680ms | 180ms(CDNミス + KVヒット時) | 74%減 |
| P99 レスポンスタイム | 1,200ms | 290ms(DBフォールバック時) | 76%減 |
| ペイロードサイズ(平均) | 4.2KB | 1.1KB | 74%減 |
| Supabase月間リクエスト数 | 約120万 | 約8万 | 93%減 |
| Supabase月額コスト | $25 | $0(無料枠内) | 100%減 |
特にSupabaseへのリクエストが93%減ったことで、無料枠に収まるようになった点は個人開発の経済面で大きなインパクトがありました。
学んだこと・注意点
Edge Runtimeは万能ではない。 今回うまくいったのは、Q&Aデータが「読み取りがほぼ100%」「更新頻度が極めて低い」という特性を持っていたからです。ユーザーの学習進捗の保存など、書き込みが頻繁な処理は従来のNode.jsランタイムのままにしています。適材適所が重要です。
stale-while-revalidate は個人開発の強い味方。 データが多少古くても問題ないケースでは、SWRパターンによってキャッシュヒット率を大幅に上げられます。宅建の過去問は内容が変わることがほぼないため、積極的に長いTTLを設定しています。
モニタリングなしに最適化はできない。 Vercelの Analytics と、Edge Functionのログに X-Cache: HIT/MISS ヘッダを仕込むことで、キャッシュヒット率を継続的に監視しています。現在のCDNキャッシュヒット率は約**87%**です。
まとめ
1万件超のQ&Aを抱える学習アプリでも、Edge Runtime + 多層キャッシュ + データ構造の最適化を組み合わせることで、P95レスポンスタイム200ms以下を個人開発の予算内で実現できました。
特に「読み取り主体 × 更新頻度低」なデータを扱うアプリケーションでは、Edge Runtimeへの移行コストに対するリターンが非常に大きいと感じています。
同じような課題を抱えている方の参考になれば幸いです。