

技術ブログをNotion APIをCMSとしてCloudflare Pages上のNext.js(Edge Runtime)で運用していたところ、記事詳細ページのTTFB(Time to First Byte)が4秒超という致命的な遅さだった。
SSGが使えないEdge Runtime環境で、どうやって高速化するか試行錯誤した記録をまとめる。
Cloudflare Pages + Next.js Edge Runtimeの速度特性
API呼び出し並列化による高速化の効果
CDN Cache Ruleの設定方法と劇的な効果
効果がなかった施策とその理由
計測方法と改善前後の数値比較
Cloudflare PagesでNext.jsを運用している方
SSRページの速度改善に取り組んでいる方
CDNキャッシュの活用方法を知りたい方
Next.js 15.1.0 + @cloudflare/next-on-pages
Cloudflare Pages(Edge Runtime必須)
Notion APIをCMSとして使用
export const runtime = 'edge' が全動的ルートに必要
Edge RuntimeではgenerateStaticParams()が使えず、全ページがSSR。記事詳細ページでは以下のAPI呼び出しが直列で走っていた:
Notion API: 記事データ取得(Slug検索)
Notion API: ブロック一覧取得(本文)
Notion API: 記事一覧取得(サイドバー用)
外部サイト: OGPデータ取得(リンクカード用)
さらにNotion APIへのレート制限対策として、リクエスト間に350ms のsleepを入れていた。
| ページ | TTFB(初回) | TTFB(2回目) |
|---|---|---|
| TOP / | 0.23s | 0.07s |
| 記事詳細 | 4.23s | 2.03s |
| 記事一覧 | 2.23s | 1.05s |
| カテゴリ | 1.50s | 0.72s |
| About | 0.36s | - |
記事詳細の4.23秒は完全にアウト。
getDetail()とgetList()をPromise.all()で同時実行に変更。
const blog = await getDetail(blogId);
const { contents } = await getList();
const [blog, { contents }] = await Promise.all([
getDetail(blogId),
getList(),
]);
同様にカテゴリ・タグページでも並列化:
const [{ contents }, category_show] = await Promise.all([
getList(),
getCategoryDetail(categoryId),
]);
| ページ | Before | After | 改善率 |
|---|---|---|---|
| 記事詳細 | 4.23s | 2.20s | 48%改善 |
| 記事一覧 | 2.23s | 1.21s | 46%改善 |
| カテゴリ | 1.50s | 0.85s | 43%改善 |
Edge Runtimeではリクエストごとに新しいWorkerインスタンスが起動する。つまりグローバル変数でのレート制限は意味がない。
// Before: 350msの無駄なsleep
let lastRequestTime = 0;
async function notionFetch(path, options) {
const elapsed = Date.now() - lastRequestTime;
if (elapsed < 350) {
await new Promise(r => setTimeout(r, 350 - elapsed));
}
lastRequestTime = Date.now();
// ...
}
// After: sleepなし
async function notionFetch(path, options) {
const res = await fetch(`${NOTION_API_BASE}${path}`, { /* ... */ });
return res.json();
}
リンクカード生成のためのOGPフェッチタイムアウトを3秒→1.5秒に短縮。
// Before
const response = await fetch(url, { signal: AbortSignal.timeout(3000) });
// After
const response = await fetch(url, { signal: AbortSignal.timeout(1500) });
遅いサイトのOGPを待つ時間を削減。効果は小さいが、最悪ケースの改善に寄与。
next.config.jsのheaders()でCDNキャッシュ用ヘッダーを設定。
async headers() {
return [{
source: '/blogs/:path*',
headers: [
{ key: 'Cache-Control', value: 'public, s-maxage=3600, stale-while-revalidate=86400, max-age=0' },
],
}];
}
結果: 効果なし。 Cloudflare PagesのWorker応答はCDNキャッシュレイヤーを通らないため、s-maxageを設定してもCDNにキャッシュされなかった。
Cache-ControlヘッダーだけではCDNキャッシュされない。これは重要な落とし穴。CloudflareダッシュボードからCache Ruleを設定。これが最も効果が大きかった。
Cloudflare Dashboard → ゾーン選択 → Caching → Cache Rules
Create rule
条件: URI Path starts with /blogs/ OR /categories/ OR /tags/ OR /archives/
Edge TTL: Ignore cache-control header and use this TTL → 3600秒
Deploy
| ページ | 施策①②後 | Cache Rule後 | 改善率 |
|---|---|---|---|
| 記事詳細 | 2.20s | 0.064s | 34倍 |
| 記事一覧 | 1.21s | 0.073s | 17倍 |
| カテゴリ | 0.85s | 0.067s | 13倍 |
cf-cache-status: HITが返り、Notion APIを呼ばずにCDNから直接返却。
| ページ | 改善前 | 最終 | 改善率 |
|---|---|---|---|
| 記事詳細 | 4.23s | 0.064s | 66倍 |
| 記事一覧 | 2.23s | 0.073s | 30倍 |
| カテゴリ | 1.50s | 0.067s | 22倍 |
Cache Ruleは無料プランでも使える。 API権限がなくてもダッシュボードから設定可能
初回アクセス(MISS)だけは1-2秒かかる。 許容できない場合はcron等でキャッシュを温める仕組みが必要
CDN-Cache-ControlヘッダーはPages Worker応答では効かない。 Cache Ruleを使うこと
Edge Runtimeのグローバル変数はリクエスト間で共有されない。 レート制限等はランタイムでは不要
最も効果が大きいのはCloudflare Cache Rule(CDNキャッシュ強制)。これだけで20-60倍以上高速化
API並列化は堅実に40-50%改善。コード変更だけで済む
Cache-ControlヘッダーだけではPages Worker応答はキャッシュされない(重要な落とし穴)
Edge Runtimeのグローバル変数はリクエスト間で保持されないことを理解する
初回アクセスの遅さが許容できるなら、Cache Ruleだけで十分な速度が得られる