個人開発でVercelのコールドスタート問題に悩んで、最終的にEdge Runtimeに移行した話
はじめに
個人開発で宅建試験対策のAIアプリ「takkenai.jp」を作っています。1250問以上の問題をAIが解説してくれるWebアプリで、Next.js + Vercelという王道構成で運用しています。
ある日、ユーザーから「問題を解き始めるときに、最初の1問だけやたら表示が遅い」という報告をもらいました。
自分でも試してみると——確かに遅い。体感で3〜5秒くらい待たされる。2問目以降はサクサクなのに、最初の1問だけがもたつく。
これが、Vercelのコールドスタート問題との戦いの始まりでした。
問題発覚:「最初の1問だけ遅い」の正体
再現と計測
まずはちゃんと数字で把握しようと思い、Vercelのダッシュボードとconsole.timeで計測しました。
// app/api/questions/route.ts(当時のコード)
import { NextResponse } from 'next/server';
export async function GET(request: Request) {
console.time('question-fetch');
const questions = await fetchQuestionsFromDB();
const aiExplanation = await generateExplanation(questions[0]);
console.timeEnd('question-fetch');
return NextResponse.json({ questions, aiExplanation });
}
計測結果がこちらです。
| 状態 | レイテンシ(p50) | レイテンシ(p95) |
|---|---|---|
| ウォームスタート | 180ms | 320ms |
| コールドスタート | 2,800ms | 4,500ms |
p95で4.5秒。個人開発のアプリで、問題を表示するだけに4秒半はさすがにきつい。
コールドスタートの発生頻度
Vercel Functionsのログを1週間分集計してみると、こんな感じでした。
- 1日あたりのAPIコール数: 約800〜1,200回
- コールドスタート発生率: 約18〜22%
- 特に多い時間帯: 早朝5〜7時、深夜1〜3時(アクセスが途切れる時間帯の直後)
個人開発のアプリなので、当然トラフィックにムラがあります。勉強アプリという性質上、通勤時間帯にアクセスが集中し、それ以外の時間は閑散としている。結果として、約5回に1回はコールドスタートに当たるという状況でした。
原因調査:なぜこんなに遅いのか
Vercel Serverless Functions(Lambda)の仕組み
Vercelのデフォルトのサーバレス関数は、AWSのLambdaベースで動いています。リクエストが来るたびにコンテナが起動し、一定時間アイドル状態が続くとコンテナが破棄される仕組みです。
コールドスタート時に何が起きているかを分解すると:
- コンテナの起動: 約500〜800ms
- Node.jsランタイムの初期化: 約300〜500ms
- モジュールの読み込み(import): 約400〜800ms
- DB接続の確立: 約200〜400ms
- 実際のビジネスロジック: 約150〜300ms
ビジネスロジック自体は大したことないのに、そこに到達するまでに2秒近くかかっているわけです。
最初にやった小手先の対策(効果薄)
Edge Runtimeに移行する前に、いくつか試しました。
① dynamic importの削減
// Before: dynamic import
const { PrismaClient } = await import('@prisma/client');
// After: top-level import
import { PrismaClient } from '@prisma/client';
② グローバル変数でDB接続を使い回し
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
};
export const prisma = globalForPrisma.prisma ?? new PrismaClient();
if (process.env.NODE_ENV !== 'production') {
globalForPrisma.prisma = prisma;
}
③ バンドルサイズの削減
不要な依存を削り、Route Handlerのバンドルサイズを1.2MBから680KBまで減らしました。
結果は——コールドスタートが4,500msから3,200msに改善。
悪くはないけど、まだ3秒。ユーザー体験として全然ダメです。
試行錯誤:Edge Runtimeへの移行
Edge Runtimeとは何が違うのか
VercelのEdge Runtimeは、Lambda(Node.js)ではなく、V8 Isolateベースの軽量ランタイム上で動作します。Cloudflare Workersと似た仕組みで、コンテナ起動のオーバーヘッドが極めて小さいのが特徴です。
| 比較項目 | Serverless (Lambda) | Edge Runtime |
|---|---|---|
| ランタイム | Node.js | V8 Isolate |
| コールドスタート | 1〜5秒 | 0〜50ms |
| 最大実行時間(Hobby) | 10秒 | 25秒 |
| 使えるAPI | Node.js全般 | Web Standard APIs |
| リージョン | 単一 | エッジ(グローバル) |
コールドスタートが0〜50ms。桁が2つ違う。これは試す価値がある。
移行でぶつかった壁
ただし、Edge Runtimeには制約があります。ここでかなりやらかしました。
壁①:Prismaがそのまま動かない
Edge RuntimeはNode.jsのネイティブAPIが使えないため、通常のPrisma Clientは動きません。
最初は何も考えずに export const runtime = 'edge' を追加して、デプロイしたら500エラーの嵐でした。
Error: PrismaClient is unable to run in an edge runtime.
解決策として、Prisma Accelerate(Prismaのエッジ対応プロキシ)を導入しました。
// lib/db.ts
import { PrismaClient } from '@prisma/client/edge';
import { withAccelerate } from '@prisma/extension-accelerate';
export const prisma = new PrismaClient({
datasourceUrl: process.env.DATABASE_URL,
}).$extends(withAccelerate());
壁②:一部のnpmパッケージが動かない
cryptoモジュールに依存しているパッケージがいくつか使えませんでした。認証周りで使っていた処理を、Web Crypto APIベースに書き換える必要がありました。
// Before: Node.js crypto
import crypto from 'crypto';
const hash = crypto.createHash('sha256').update(data).digest('hex');
// After: Web Crypto API
const encoder = new TextEncoder();
const data = encoder.encode(input);
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
const hashArray = Array.from(new Uint8Array(hashBuffer));
const hash = hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
壁③:レスポンスボディサイズの制限
Edge Runtimeでは、レスポンスボディが4MBに制限されています。問題データを大量に返すAPIエンドポイントが1つあり、これに引っかかりました。ページネーションを導入して対処しました。
最終的なRoute Handlerの構成
// app/api/questions/route.ts
import { NextResponse } from 'next/server';
import { prisma } from '@/lib/db';
// この1行を追加するだけ…と言いたいが、裏側の対応が色々必要だった
export const runtime = 'edge';
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const category = searchParams.get('category');
const page = parseInt(searchParams.get('page') ?? '1', 10);
const limit = 20;
try {
const questions = await prisma.question.findMany({
where: category ? { category } : undefined,
skip: (page - 1) * limit,
take: limit,
cacheStrategy: { ttl: 300 }, // Prisma Accelerateのキャッシュ: 5分
});
return NextResponse.json({
questions,
page,
hasMore: questions.length === limit,
});
} catch (error) {
console.error('Failed to fetch questions:', error);
return NextResponse.json(
{ error: 'Internal Server Error' },
{ status: 500 }
);
}
}
結果:数字で見る改善効果
移行後、2週間分のデータを集計しました。
| 指標 | Before (Lambda) | After (Edge) | 改善率 |
|---|---|---|---|
| コールドスタート p50 | 2,800ms | 22ms | 99.2%減 |
| コールドスタート p95 | 4,500ms | 48ms | 98.9%減 |
| ウォームスタート p50 | 180ms | 95ms | 47%減 |
| コールドスタート発生率 | 18〜22% | 実質0%(体感不可) | — |
コールドスタートのレイテンシが2,800ms → 22ms。もはやコールドスタートという概念が消えたに等しい。
ウォームスタート時も180ms → 95msに改善しています。これはEdge Runtimeがユーザーに近いエッジロケーションで実行されることと、Prisma Accelerateのキャッシュが効いているためです。
ユーザーからの「最初だけ遅い」という報告も、移行後はゼロになりました。
移行してわかったこと・注意点
Edge Runtimeが向いているケース
- 読み取り中心のAPI(今回のような問題データの取得)
- レスポンスが軽量(JSON数十KB程度)
- Node.js固有のAPIに依存していない処理
向いていないケース
- 重い計算処理(CPU時間の制限がある)
- 大きなnpmパッケージへの依存(バンドルサイズ制限)
-
ファイルシステム操作(
fsモジュールは使えない)
実際、takkenai.jpでもAI解説生成の一部エンドポイントはLambdaのまま残しています。外部AI APIの呼び出しでストリーミングレスポンスを返す部分は、Edge Runtimeでも動くのですが、タイムアウトやエラーハンドリングの都合でNode.jsランタイムの方が扱いやすかったためです。
全部をEdgeにする必要はなく、エンドポイントごとに使い分けるのがベストだと感じました。
まとめ
個人開発でトラフィックが安定しないアプリにおいて、Vercelのコールドスタートは地味に致命的です。特にユーザーが「さあ勉強するぞ」と思って開いた最初の1問で3〜5秒待たされるのは、離脱に直結します。
Edge Runtimeへの移行は、Prisma周りの対応など多少の手間はかかりましたが、コールドスタートのレイテンシが99%減という劇的な改善が得られました。
同じ問題で悩んでいる個人開発者の方の参考になれば幸いです。
今回の話の舞台になった takkenai.jp は、AIが宅建試験の過去問1250問以上を丁寧に解説してくれるWebアプリです。2025年の宅建試験を目指している方は、ぜひ使ってみてください。コールドスタートはもう気にならないはずです。