コンテンツへスキップ
·6分で読めます

エッジファンクション:正体、使いどころ、使うべきでないとき

エッジランタイム、V8 Isolate、コールドスタートの神話、ジオルーティング、A/Bテスト、エッジでの認証、そして一部をNode.jsに戻した理由。エッジコンピューティングのバランスの取れた考察。

シェア:X / TwitterLinkedIn

「エッジ」という言葉はあちこちで飛び交っている。Vercelが言う。Cloudflareが言う。Denoが言う。Webパフォーマンスに関するカンファレンストークでは、必ずと言っていいほど「エッジで実行する」というフレーズが出てくる。まるでアプリを高速にする魔法の呪文のように。

私はそれを信じた。ミドルウェア、APIルート、一部のレンダリングロジックまでエッジランタイムに移した。そのうちいくつかは素晴らしい判断だった。しかし別のいくつかは、午前2時にコネクションプールのエラーをデバッグした3週間後、こっそりNode.jsに戻した。

この記事はその経験のバランスの取れたバージョンだ。エッジとは実際に何なのか、どこで本当に輝くのか、どこで絶対にダメなのか、そしてアプリケーションの各部分にどのランタイムを使うかをどう判断しているのか。

エッジとは何か?#

まず地理の話から始めよう。誰かがあなたのウェブサイトにアクセスすると、リクエストはデバイスからISPを経由してインターネットを横断し、サーバーに到達して処理され、レスポンスがまた同じ道を戻ってくる。サーバーがus-east-1(バージニア)にあり、ユーザーが東京にいる場合、この往復はおよそ14,000kmをカバーする。光ファイバー中の光の速度で、片道だけで約70msだ。DNS解決、TLSハンドシェイク、処理時間を加えると、ユーザーが最初の1バイトを見るまでに200〜400msかかることになる。

「エッジ」とは、グローバルに分散されたサーバー上でコードを実行することを意味する。これまで静的アセットを配信してきたのと同じCDNノードだが、今やロジックも実行できるようになった。バージニアに1台のオリジンサーバーがある代わりに、コードが世界中の300以上のロケーションで実行される。東京のユーザーは東京のサーバーにアクセスする。パリのユーザーはパリのサーバーにアクセスする。

レイテンシの計算はシンプルで説得力がある:

従来型(シングルオリジン):
  東京 → バージニア: ~140ms 往復(物理だけ)
  + TLSハンドシェイク: ~140ms追加(もう1往復)
  + 処理: 20-50ms
  合計: ~300-330ms

エッジ(ローカルPoP):
  東京 → 東京のエッジノード: ~5ms 往復
  + TLSハンドシェイク: ~5ms追加
  + 処理: 5-20ms
  合計: ~15-30ms

これは初回レスポンスで10〜20倍の改善だ。これは本物で、測定可能で、特定の操作では革新的だ。

しかしマーケティングが曖昧にしている点がある:エッジはフルサーバー環境ではない。根本的に異なるものだ。

V8 Isolate vs Node.js#

従来のNode.jsはフルオペレーティングシステムのプロセスで動作する。ファイルシステムにアクセスでき、TCP接続を開け、子プロセスを生成でき、環境変数をストリームとして読み取れる。基本的にLinuxプロセスができることは何でもできる。

エッジファンクションはNode.jsでは動作しない。V8 Isolate上で動作する。これはChromeを動かしているのと同じJavaScriptエンジンだが、コアまで削ぎ落とされたものだ。V8 Isolateは軽量なサンドボックスと考えるとよい:

typescript
// Node.jsでは動くがエッジでは動かない
import fs from "fs";
import { createConnection } from "net";
import { execSync } from "child_process";
 
const file = fs.readFileSync("/etc/hosts");        // ❌ ファイルシステムなし
const conn = createConnection({ port: 5432 });     // ❌ 生のTCPなし
const result = execSync("ls -la");                  // ❌ 子プロセスなし
process.env.DATABASE_URL;                           // ⚠️  利用可能だが静的、デプロイ時に設定

エッジで利用できるのはWeb APIサーフェスだ。ブラウザで利用可能なのと同じAPI群:

typescript
// これらはすべてエッジで動作する
const response = await fetch("https://api.example.com/data");
const url = new URL(request.url);
const headers = new Headers({ "Content-Type": "application/json" });
const encoder = new TextEncoder();
const encoded = encoder.encode("hello");
const hash = await crypto.subtle.digest("SHA-256", encoded);
const id = crypto.randomUUID();
 
// Web Streams API
const stream = new ReadableStream({
  start(controller) {
    controller.enqueue("chunk 1");
    controller.enqueue("chunk 2");
    controller.close();
  },
});
 
// Cache API
const cache = caches.default;
await cache.put(request, response.clone());

制約は現実的で厳しい:

  • メモリ: Isolateあたり128MB(Cloudflare Workers)、一部プラットフォームでは256MB
  • CPU時間: 実際のCPU時間で10〜50ms(ウォールクロック時間ではない。await fetch()はカウントされないが、5MBのペイロードに対するJSON.parse()はカウントされる)
  • ネイティブモジュール不可: C++バインディングが必要なもの(bcrypt、sharp、canvas)は使えない
  • 永続的接続不可: リクエスト間でデータベース接続を保持できない
  • バンドルサイズ制限: 通常、ワーカースクリプト全体で1〜5MB

これはCDN上のNode.jsではない。異なるメンタルモデルを持つ異なるランタイムだ。

コールドスタート:神話と現実#

エッジファンクションは「コールドスタートゼロ」だと聞いたことがあるだろう。これは...ほぼ正しく、比較すると本当に劇的だ。

従来のコンテナベースのサーバーレスファンクション(AWS Lambda、Google Cloud Functions)は次のように動作する:

  1. リクエストが到着
  2. プラットフォームがコンテナをプロビジョニング(利用可能なものがなければ)
  3. コンテナがOSを起動
  4. ランタイムが初期化(Node.js、Pythonなど)
  5. コードがロードされ初期化される
  6. リクエストが処理される

ステップ2〜5がコールドスタートだ。Node.js Lambdaの場合、通常200〜500ms。Java Lambdaの場合、2〜5秒になることもある。.NET Lambdaの場合、500ms〜1.5s。

V8 Isolateは異なる動作をする:

  1. リクエストが到着
  2. プラットフォームが新しいV8 Isolateを作成(またはウォームなものを再利用)
  3. コードがロードされる(デプロイ時にすでにバイトコードにコンパイル済み)
  4. リクエストが処理される

ステップ2〜3は5ms未満。多くの場合1ms未満だ。Isolateはコンテナではない。起動すべきOSも、初期化すべきランタイムもない。V8はマイクロ秒単位で新しいIsolateを作成する。「コールドスタートゼロ」というフレーズはマーケティング用語だが、現実(5ms未満の起動)はほとんどのユースケースでゼロと変わらないほど近い。

しかし、エッジでもコールドスタートが問題になるケースがある:

大きなバンドル。 エッジファンクションが2MBの依存関係を取り込む場合、そのコードはロードしてパースする必要がある。バリデーションライブラリと日付フォーマットライブラリをエッジミドルウェアにバンドルした時にこれを身をもって学んだ。コールドスタートが2msから40msになった。それでも速いが、「ゼロ」ではない。

まれなロケーション。 エッジプロバイダーは数百のPoPを持っているが、すべてのPoPがコードをウォームに保っているわけではない。ナイロビから1時間に1リクエストしか来なければ、そのIsolateはリクエスト間でリサイクルされる。次のリクエストは再び起動コストを支払う。

リクエストあたり複数のIsolate。 エッジファンクションが別のエッジファンクションを呼び出す場合(またはミドルウェアとAPIルートの両方がエッジの場合)、1つのユーザーリクエストに対して複数のIsolateを起動する可能性がある。

実践的なアドバイス:エッジファンクションのバンドルは小さく保つこと。必要なものだけをインポートする。積極的にツリーシェイキングする。バンドルが小さいほどコールドスタートが速くなり、「コールドスタートゼロ」の約束がより実現する。

typescript
// ❌ エッジでこれをやってはいけない
import dayjs from "dayjs";
import * as yup from "yup";
import lodash from "lodash";
 
// ✅ こうする — 組み込みAPIを使用
const date = new Date().toISOString();
const isValid = typeof input === "string" && input.length < 200;
const unique = [...new Set(items)];

エッジファンクションの最適なユースケース#

広範な実験の結果、明確なパターンが見つかった:エッジファンクションは、リクエストがオリジンサーバーに到達する前に素早い判断を下す必要がある場合に優れている。ゲートキーパー、ルーター、トランスフォーマーであって、アプリケーションサーバーではない。

1. ジオロケーションベースのリダイレクト#

これがキラーユースケースだ。リクエストは最も近いエッジノードにヒットし、そのノードはすでにユーザーの位置を把握している。APIコール不要、IPルックアップデータベース不要。プラットフォームがジオデータを提供する:

typescript
// middleware.ts — すべてのリクエストでエッジ上で実行
import { NextRequest, NextResponse } from "next/server";
 
export const config = {
  matcher: ["/", "/shop/:path*"],
};
 
export function middleware(request: NextRequest) {
  const country = request.geo?.country ?? "US";
  const city = request.geo?.city ?? "Unknown";
  const region = request.geo?.region ?? "Unknown";
 
  // 国別ストアにリダイレクト
  if (request.nextUrl.pathname === "/shop") {
    const storeMap: Record<string, string> = {
      DE: "/shop/eu",
      FR: "/shop/eu",
      GB: "/shop/uk",
      JP: "/shop/jp",
      TR: "/shop/tr",
    };
 
    const storePath = storeMap[country] ?? "/shop/us";
    if (request.nextUrl.pathname !== storePath) {
      return NextResponse.redirect(new URL(storePath, request.url));
    }
  }
 
  // 下流で使用するためにジオヘッダーを追加
  const response = NextResponse.next();
  response.headers.set("x-user-country", country);
  response.headers.set("x-user-city", city);
  response.headers.set("x-user-region", region);
  return response;
}

これは5ms以内で、ユーザーのすぐそばで実行される。代替案 — リクエストをオリジンサーバーまで送ってIPルックアップを行い、リダイレクトして戻す — はオリジンから遠いユーザーにとって100〜300msのコストがかかる。

2. クライアントフリッカーなしのA/Bテスト#

クライアントサイドのA/Bテストは、恐ろしい「元のコンテンツのフラッシュ」を引き起こす。ユーザーはJavaScriptがバージョンBに切り替える前に、一瞬バージョンAを見てしまう。エッジでは、ページがレンダリングを始める前にバリアントを割り当てることができる:

typescript
import { NextRequest, NextResponse } from "next/server";
 
export function middleware(request: NextRequest) {
  // ユーザーがすでにバリアント割り当てを持っているか確認
  const existingVariant = request.cookies.get("ab-variant")?.value;
 
  if (existingVariant) {
    // 正しいバリアントページにリライト
    const url = request.nextUrl.clone();
    url.pathname = `/variants/${existingVariant}${url.pathname}`;
    return NextResponse.rewrite(url);
  }
 
  // 新しいバリアントを割り当て(50/50分割)
  const variant = Math.random() < 0.5 ? "control" : "treatment";
 
  const url = request.nextUrl.clone();
  url.pathname = `/variants/${variant}${url.pathname}`;
 
  const response = NextResponse.rewrite(url);
  response.cookies.set("ab-variant", variant, {
    maxAge: 60 * 60 * 24 * 30, // 30日間
    httpOnly: true,
    sameSite: "lax",
  });
 
  return response;
}

リライトはネットワークレベルで行われるため、ユーザーはフリッカーを目にしない。ブラウザはA/Bテストだったことすら知らない — バリアントページを直接受け取るだけだ。

3. 認証トークンの検証#

認証がJWTを使用している場合(データベースセッションルックアップを行わない場合)、エッジは最適だ。JWT検証は純粋な暗号処理で、データベースは不要:

typescript
import { jwtVerify, importSPKI } from "jose";
import { NextRequest, NextResponse } from "next/server";
 
const PUBLIC_KEY = `-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA...
-----END PUBLIC KEY-----`;
 
export async function middleware(request: NextRequest) {
  const token = request.cookies.get("session-token")?.value;
 
  if (!token) {
    return NextResponse.redirect(new URL("/login", request.url));
  }
 
  try {
    const key = await importSPKI(PUBLIC_KEY, "RS256");
    const { payload } = await jwtVerify(token, key, {
      algorithms: ["RS256"],
      issuer: "https://auth.example.com",
    });
 
    // ユーザー情報をヘッダーとして下流に渡す
    const response = NextResponse.next();
    response.headers.set("x-user-id", payload.sub as string);
    response.headers.set("x-user-role", payload.role as string);
    return response;
  } catch {
    // トークンが期限切れまたは無効
    const response = NextResponse.redirect(new URL("/login", request.url));
    response.cookies.delete("session-token");
    return response;
  }
}

このパターンは強力だ:エッジミドルウェアがトークンを検証し、ユーザー情報を信頼されたヘッダーとしてオリジンに渡す。APIルートはトークンを再検証する必要がない — request.headers.get("x-user-id")を読むだけだ。

4. ボット検出とレート制限#

エッジファンクションは、不要なトラフィックがオリジンに到達する前にブロックできる:

typescript
import { NextRequest, NextResponse } from "next/server";
 
// シンプルなインメモリレートリミッター(エッジロケーションごと)
const rateLimitMap = new Map<string, { count: number; timestamp: number }>();
 
export function middleware(request: NextRequest) {
  const ip = request.headers.get("x-forwarded-for")?.split(",").pop()?.trim()
    ?? "unknown";
  const ua = request.headers.get("user-agent") ?? "";
 
  // 既知の悪質なボットをブロック
  const badBots = ["AhrefsBot", "SemrushBot", "MJ12bot", "DotBot"];
  if (badBots.some((bot) => ua.includes(bot))) {
    return new NextResponse("Forbidden", { status: 403 });
  }
 
  // シンプルなレート制限
  const now = Date.now();
  const windowMs = 60_000; // 1分
  const maxRequests = 100;
 
  const entry = rateLimitMap.get(ip);
  if (entry && now - entry.timestamp < windowMs) {
    entry.count++;
    if (entry.count > maxRequests) {
      return new NextResponse("Too Many Requests", {
        status: 429,
        headers: { "Retry-After": "60" },
      });
    }
  } else {
    rateLimitMap.set(ip, { count: 1, timestamp: now });
  }
 
  // メモリリークを防ぐための定期的なクリーンアップ
  if (rateLimitMap.size > 10_000) {
    const cutoff = now - windowMs;
    for (const [key, val] of rateLimitMap) {
      if (val.timestamp < cutoff) rateLimitMap.delete(key);
    }
  }
 
  return NextResponse.next();
}

1つ注意点がある:上記のレート制限マップはIsolateごと、ロケーションごとだ。300のエッジロケーションがある場合、それぞれが独自のマップを持つ。厳密なレート制限には、Upstash RedisやCloudflare Durable Objectsのような分散ストアが必要だ。しかし、おおまかな不正利用防止には、ロケーションごとの制限が驚くほどうまく機能する。

5. リクエストリライティングとパーソナライゼーションヘッダー#

エッジファンクションは、リクエストがオリジンに到達する前に変換するのに優れている:

typescript
import { NextRequest, NextResponse } from "next/server";
 
export function middleware(request: NextRequest) {
  const response = NextResponse.next();
  const url = request.nextUrl;
 
  // デバイスベースのコンテンツネゴシエーション
  const ua = request.headers.get("user-agent") ?? "";
  const isMobile = /mobile|android|iphone/i.test(ua);
  response.headers.set("x-device-type", isMobile ? "mobile" : "desktop");
 
  // Cookieからフィーチャーフラグ
  const flags = request.cookies.get("feature-flags")?.value;
  if (flags) {
    response.headers.set("x-feature-flags", flags);
  }
 
  // i18nのロケール検出
  const acceptLanguage = request.headers.get("accept-language") ?? "en";
  const preferredLocale = acceptLanguage.split(",")[0]?.split("-")[0] ?? "en";
  const supportedLocales = [
    "en", "tr", "de", "fr", "es", "pt", "ja", "ko", "it",
    "nl", "ru", "pl", "uk", "sv", "cs", "ar", "hi", "zh",
  ];
  const locale = supportedLocales.includes(preferredLocale)
    ? preferredLocale
    : "en";
 
  if (!url.pathname.startsWith(`/${locale}`) && !url.pathname.startsWith("/api")) {
    return NextResponse.redirect(new URL(`/${locale}${url.pathname}`, request.url));
  }
 
  return response;
}

エッジが失敗するところ#

ここはマーケティングページがスキップするセクションだ。私はこれらの壁すべてにぶつかった。

1. データベース接続#

これが最大の問題だ。従来のデータベース(PostgreSQL、MySQL)は永続的なTCP接続を使用する。Node.jsサーバーは起動時にコネクションプールを開き、リクエスト間でそれらの接続を再利用する。効率的で、実績があり、よく理解されている。

エッジファンクションではこれができない。各Isolateは一時的だ。接続を開く「起動」フェーズがない。仮に接続を開けたとしても、Isolateは1つのリクエスト後にリサイクルされ、接続セットアップ時間が無駄になる可能性がある。

typescript
// ❌ このパターンはエッジでは根本的に機能しない
import { Pool } from "pg";
 
const pool = new Pool({
  connectionString: process.env.DATABASE_URL,
  max: 10, // 10のコネクションプール
});
 
// エッジの各呼び出しは以下を行う:
// 1. 新しいPoolを作成(呼び出し間で確実に再利用できない)
// 2. データベースへのTCP接続を開く(データベースはエッジではなくus-east-1にある)
// 3. データベースとのTLSハンドシェイク
// 4. クエリを実行
// 5. Isolateがリサイクルされたら接続を破棄
 
// PgBouncerのようなコネクションプーリングサービスを使っても、
// エッジ → オリジンデータベースのネットワークレイテンシは依然として発生する

データベースの往復問題は根本的だ。データベースは1つのリージョンにある。エッジファンクションは300のリージョンにある。エッジからのすべてのデータベースクエリは、エッジロケーションからデータベースリージョンまで往復しなければならない。東京のユーザーが東京のエッジノードにヒットするが、データベースがバージニアにある場合:

東京のエッジファンクション
  → バージニアのPostgreSQLへのクエリ: ~140ms 往復
  → 2番目のクエリ: ~140ms追加
  → 合計: 2つのクエリだけで280ms

バージニアのNode.jsファンクション(DBと同じリージョン)
  → PostgreSQLへのクエリ: ~1ms 往復
  → 2番目のクエリ: ~1ms追加
  → 合計: 2つのクエリで2ms

このシナリオでは、エッジファンクションはデータベース操作で140倍遅い。エッジファンクションの起動が速かったかどうかは関係ない — データベースの往復がすべてを支配する。

これがHTTPベースのデータベースプロキシが存在する理由だ(Neonのサーバーレスドライバー、PlanetScaleのfetchベースドライバー、SupabaseのREST API)。これらは機能するが、依然として単一リージョンのデータベースへのHTTPリクエストを行っている。「TCPが使えない」問題は解決するが、「データベースが遠い」問題は解決しない。

typescript
// ✅ これはエッジで動作する(HTTPベースのデータベースアクセス)
// しかしデータベースがエッジノードから遠い場合は依然として遅い
import { neon } from "@neondatabase/serverless";
 
export const runtime = "edge";
 
export async function GET(request: Request) {
  const sql = neon(process.env.DATABASE_URL!);
  // これはNeonデータベースへのHTTPリクエストを行う
  // 動作するが、レイテンシはデータベースリージョンまでの距離に依存する
  const posts = await sql`SELECT * FROM posts WHERE published = true LIMIT 10`;
  return Response.json(posts);
}

2. 長時間実行タスク#

エッジファンクションにはCPU時間の制限があり、通常は実際の計算時間で10〜50ms。ウォールクロック時間はより寛容(通常30秒)だが、CPU集約型の操作はすぐに制限に達する:

typescript
// ❌ これらはエッジでCPU時間制限を超過する
export const runtime = "edge";
 
export async function POST(request: Request) {
  const data = await request.json();
 
  // 画像処理 — CPU集約型
  //(またsharpはネイティブモジュールなので使えない)
  const processed = heavyImageProcessing(data.image);
 
  // PDF生成 — CPU集約型 + Node.js APIが必要
  const pdf = generatePDF(data.content);
 
  // 大量データ変換
  const result = data.items // 100,000アイテム
    .map(transform)
    .filter(validate)
    .sort(compare)
    .reduce(aggregate, {});
 
  return Response.json(result);
}

ファンクションが数ミリ秒以上のCPU時間を必要とするなら、リージョナルなNode.jsサーバーに置くべきだ。議論の余地はない。

3. Node.js専用の依存関係#

これは人を不意打ちする。驚くほど多くのnpmパッケージがNode.jsの組み込みモジュールに依存している:

typescript
// ❌ これらのパッケージはエッジで動作しない
import bcrypt from "bcrypt";            // ネイティブC++バインディング
import sharp from "sharp";              // ネイティブC++バインディング
import puppeteer from "puppeteer";      // ファイルシステム + child_processが必要
import nodemailer from "nodemailer";    // netモジュールが必要
import { readFile } from "fs/promises"; // Node.jsファイルシステムAPI
import mongoose from "mongoose";         // TCP接続 + Node.js API
 
// ✅ エッジ互換の代替手段
import { hashSync } from "bcryptjs";    // 純粋なJS実装(遅い)
// 画像の場合:別のサービスまたはAPIを使用
// メールの場合:HTTPベースのメールAPI(Resend、SendGrid REST)を使用
// データベースの場合:HTTPベースのクライアントを使用

何かをエッジに移す前に、すべての依存関係をチェックすること。依存関係ツリーの3階層下に埋もれた1つのrequire("fs")が、ランタイムでエッジファンクションをクラッシュさせる — ビルド時ではない。デプロイして、すべて正常に見え、最初のリクエストがそのコードパスにヒットしたとき、不可解なエラーが発生する。

4. 大きなバンドルサイズ#

エッジプラットフォームには厳しいバンドルサイズ制限がある:

  • Cloudflare Workers: 1MB(無料)、5MB(有料)
  • Vercel Edge Functions: 4MB(圧縮後)
  • Deno Deploy: 20MB

UIコンポーネントライブラリ、バリデーションライブラリ、日付ライブラリをimportするまでは十分に聞こえる。バレルファイルからインポートしたために@/componentsディレクトリ全体を取り込んでしまい、エッジミドルウェアが3.5MBに膨れ上がったことがある。

typescript
// ❌ バレルファイルのインポートは必要以上に多くを取り込む可能性がある
import { validateEmail } from "@/lib/utils";
// utils.tsが20の他のモジュールから再エクスポートしている場合、すべてがバンドルされる
 
// ✅ ソースから直接インポートする
import { validateEmail } from "@/lib/validators/email";

5. ストリーミングとWebSocket#

エッジファンクションはストリーミングレスポンス(Web Streams API)は可能だが、長寿命のWebSocket接続は別の話だ。一部のプラットフォームはエッジでWebSocketをサポートしている(Cloudflare Workers、Deno Deploy)が、エッジファンクションの一時的な性質により、ステートフルで長寿命の接続には不向きだ。

Next.jsエッジランタイム#

Next.jsでは、ルートごとにエッジランタイムにオプトインするのが簡単だ。すべてを切り替える必要はない — どのルートをエッジで実行するかを正確に選択する。

ミドルウェア(常にエッジ)#

Next.jsのミドルウェアは常にエッジで実行される。これは設計上の意図だ — ミドルウェアはマッチするすべてのリクエストをインターセプトするので、高速でグローバルに分散されている必要がある:

typescript
// middleware.ts — 常にエッジで実行、オプトイン不要
import { NextRequest, NextResponse } from "next/server";
 
export function middleware(request: NextRequest) {
  // マッチするすべてのリクエストの前に実行される
  // 高速に保つ — データベース呼び出しなし、重い計算なし
  return NextResponse.next();
}
 
export const config = {
  // 特定のパスでのみ実行
  matcher: [
    "/((?!_next/static|_next/image|favicon.ico|robots.txt|sitemap.xml).*)",
  ],
};

エッジでのAPIルート#

任意のルートハンドラーがエッジランタイムにオプトインできる:

typescript
// app/api/hello/route.ts
export const runtime = "edge"; // この1行でランタイムが変わる
 
export async function GET(request: Request) {
  return Response.json({
    message: "Hello from the edge",
    region: process.env.VERCEL_REGION ?? "unknown",
    timestamp: Date.now(),
  });
}

エッジでのページルート#

ページ全体をエッジでレンダリングすることもできるが、実行する前によく考えるべきだ:

typescript
// app/dashboard/page.tsx
export const runtime = "edge";
 
export default async function DashboardPage() {
  // 注意:ここではNode.js APIは使えない
  // データフェッチはfetch()またはエッジ互換クライアントを使用する必要がある
  const data = await fetch("https://api.example.com/dashboard", {
    headers: { Authorization: `Bearer ${process.env.API_KEY}` },
    next: { revalidate: 60 },
  }).then((r) => r.json());
 
  return (
    <main>
      <h1>Dashboard</h1>
      {/* データをレンダリング */}
    </main>
  );
}

エッジランタイムで利用可能なもの#

使えるものと使えないものの実用的なリファレンス:

typescript
// ✅ エッジで利用可能
fetch()                          // HTTPリクエスト
Request / Response               // Web標準のリクエスト/レスポンス
Headers                          // HTTPヘッダー
URL / URLSearchParams            // URL解析
TextEncoder / TextDecoder        // 文字列エンコーディング
crypto.subtle                    // 暗号操作(署名、ハッシュ)
crypto.randomUUID()              // UUID生成
crypto.getRandomValues()         // 暗号学的乱数
structuredClone()                // ディープクローン
atob() / btoa()                  // Base64エンコード/デコード
setTimeout() / setInterval()     // タイマー(ただしCPU制限に注意)
console.log()                    // ロギング
ReadableStream / WritableStream  // ストリーミング
AbortController / AbortSignal    // リクエストキャンセル
URLPattern                       // URLパターンマッチング
 
// ❌ エッジで利用不可
require()                        // CommonJS(importを使用)
fs / path / os                   // Node.js組み込みモジュール
process.exit()                   // プロセス制御
Buffer                           // 代わりにUint8Arrayを使用
__dirname / __filename           // import.meta.urlを使用
setImmediate()                   // Web標準ではない

エッジでの認証:完全パターン#

認証について深く掘り下げたい。最もインパクトのあるエッジユースケースの1つだが、間違えやすいからだ。

うまく機能するパターン:エッジでトークンを検証し、信頼されたクレームを下流に渡し、ミドルウェアでは決してデータベースに触れない。

typescript
// lib/edge-auth.ts — エッジ互換の認証ユーティリティ
import { jwtVerify, SignJWT, importSPKI, importPKCS8 } from "jose";
 
const PUBLIC_KEY_PEM = process.env.JWT_PUBLIC_KEY!;
const ISSUER = "https://auth.myapp.com";
const AUDIENCE = "https://myapp.com";
 
export interface TokenPayload {
  sub: string;
  email: string;
  role: "user" | "admin" | "moderator";
  iat: number;
  exp: number;
}
 
export async function verifyToken(token: string): Promise<TokenPayload | null> {
  try {
    const publicKey = await importSPKI(PUBLIC_KEY_PEM, "RS256");
    const { payload } = await jwtVerify(token, publicKey, {
      algorithms: ["RS256"],
      issuer: ISSUER,
      audience: AUDIENCE,
      clockTolerance: 30, // 30秒のクロックスキュー許容
    });
 
    return payload as unknown as TokenPayload;
  } catch {
    return null;
  }
}
 
export function isTokenExpiringSoon(payload: TokenPayload): boolean {
  const now = Math.floor(Date.now() / 1000);
  const fiveMinutes = 5 * 60;
  return payload.exp - now < fiveMinutes;
}
typescript
// middleware.ts — 認証ミドルウェア
import { NextRequest, NextResponse } from "next/server";
import { verifyToken, isTokenExpiringSoon } from "./lib/edge-auth";
 
const PUBLIC_PATHS = ["/", "/login", "/register", "/api/auth/login"];
const ADMIN_PATHS = ["/admin"];
 
export async function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl;
 
  // 公開パスでは認証をスキップ
  if (PUBLIC_PATHS.some((p) => pathname === p || pathname.startsWith("/api/public"))) {
    return NextResponse.next();
  }
 
  // トークンを抽出
  const token = request.cookies.get("auth-token")?.value;
  if (!token) {
    return NextResponse.redirect(new URL("/login", request.url));
  }
 
  // トークンを検証(純粋な暗号処理 — データベース呼び出しなし)
  const payload = await verifyToken(token);
  if (!payload) {
    const response = NextResponse.redirect(new URL("/login", request.url));
    response.cookies.delete("auth-token");
    return response;
  }
 
  // ロールベースのアクセス制御
  if (ADMIN_PATHS.some((p) => pathname.startsWith(p)) && payload.role !== "admin") {
    return NextResponse.redirect(new URL("/unauthorized", request.url));
  }
 
  // 検証済みユーザー情報を信頼されたヘッダーとしてオリジンに渡す
  const response = NextResponse.next();
  response.headers.set("x-user-id", payload.sub);
  response.headers.set("x-user-email", payload.email);
  response.headers.set("x-user-role", payload.role);
 
  // トークンのリフレッシュが必要かシグナル
  if (isTokenExpiringSoon(payload)) {
    response.headers.set("x-token-refresh", "true");
  }
 
  return response;
}
typescript
// app/api/profile/route.ts — オリジンサーバーが信頼されたヘッダーを読む
export async function GET(request: Request) {
  // これらのヘッダーはJWT検証後にエッジミドルウェアによって設定された
  // 自社インフラから来ているので信頼できる
  const userId = request.headers.get("x-user-id");
  const userRole = request.headers.get("x-user-role");
 
  if (!userId) {
    return Response.json({ error: "Unauthorized" }, { status: 401 });
  }
 
  // ここでデータベースにアクセスできる — オリジンサーバー上にいて、
  // データベースのすぐ隣で、コネクションプールを持っている
  const user = await db.user.findUnique({ where: { id: userId } });
 
  return Response.json(user);
}

重要な洞察:エッジが高速な部分(暗号検証)を行い、オリジンが低速な部分(データベースクエリ)を行う。それぞれが最も効率的な場所で実行される。

重要な注意点が1つ:これはJWTでのみ機能する。認証システムがすべてのリクエストでデータベースルックアップを必要とする場合(セッションIDクッキーによるセッションベース認証など)、エッジは助けにならない — 依然としてデータベースを呼び出す必要があり、つまりオリジンリージョンへの往復が発生する。

エッジキャッシュ#

エッジでのキャッシュは興味深いところだ。エッジノードはレスポンスをキャッシュでき、同じURLへの後続リクエストはオリジンにヒットせずにエッジから直接配信される。

Cache-Controlの正しい設定#

typescript
// app/api/products/route.ts
export const runtime = "edge";
 
export async function GET(request: Request) {
  const url = new URL(request.url);
  const category = url.searchParams.get("category") ?? "all";
 
  const products = await fetch(
    `${process.env.ORIGIN_API}/products?category=${category}`,
  ).then((r) => r.json());
 
  return Response.json(products, {
    headers: {
      // CDNで60秒キャッシュ
      // 最大5分間、再検証中は古いコンテンツを配信
      // クライアントは10秒キャッシュ可能
      "Cache-Control": "public, s-maxage=60, stale-while-revalidate=300, max-age=10",
 
      // これらのヘッダーでVaryし、異なるバリアントが異なるキャッシュエントリを取得
      Vary: "Accept-Language, Accept-Encoding",
 
      // 対象を絞った無効化のためのCDN固有キャッシュタグ
      "Cache-Tag": `products,category-${category}`,
    },
  });
}

stale-while-revalidateパターンはエッジで特に強力だ。何が起こるかを説明する:

  1. 最初のリクエスト:エッジがオリジンからフェッチし、レスポンスをキャッシュして返す
  2. 60秒以内のリクエスト:エッジがキャッシュから配信(オリジンレイテンシ0ms)
  3. 61〜360秒のリクエスト:エッジが古いキャッシュバージョンを即座に配信するが、バックグラウンドでオリジンから新鮮なバージョンをフェッチ
  4. 360秒後:キャッシュが完全に期限切れ、次のリクエストはオリジンに行く

ユーザーはほぼ常にキャッシュされたレスポンスを受け取る。鮮度のトレードオフは明示的で調整可能だ。

動的構成のためのEdge Config#

VercelのEdge Config(および他のプラットフォームの同様のサービス)は、すべてのエッジロケーションに複製されるキーバリュー構成を保存できる。フィーチャーフラグ、リダイレクトルール、再デプロイせずに更新したいA/Bテスト構成に非常に有用だ:

typescript
import { get } from "@vercel/edge-config";
import { NextRequest, NextResponse } from "next/server";
 
export async function middleware(request: NextRequest) {
  // Edge Configの読み取りは非常に高速(~1ms)
  // データがすべてのエッジロケーションに複製されているため
  const maintenanceMode = await get<boolean>("maintenance_mode");
 
  if (maintenanceMode) {
    return NextResponse.rewrite(new URL("/maintenance", request.url));
  }
 
  // フィーチャーフラグ
  const features = await get<Record<string, boolean>>("feature_flags");
  if (features?.["new_pricing_page"] && request.nextUrl.pathname === "/pricing") {
    return NextResponse.rewrite(new URL("/pricing-v2", request.url));
  }
 
  // 動的リダイレクト(再デプロイせずにリダイレクトを更新)
  const redirects = await get<Array<{ from: string; to: string; permanent: boolean }>>(
    "redirects",
  );
 
  if (redirects) {
    const match = redirects.find((r) => r.from === request.nextUrl.pathname);
    if (match) {
      return NextResponse.redirect(
        new URL(match.to, request.url),
        match.permanent ? 308 : 307,
      );
    }
  }
 
  return NextResponse.next();
}

これは真のゲームチェンジャーだ。Edge Config以前は、フィーチャーフラグの変更にはコード変更と再デプロイが必要だった。今はダッシュボードでJSON値を更新するだけで、数秒でグローバルに伝播する。

本当のパフォーマンス計算#

マーケティングの計算ではなく、正直な計算をしよう。データベースへのクエリが必要な典型的なAPIエンドポイントについて、3つのアーキテクチャを比較する:

シナリオ:ユーザープロフィールAPI(2つのデータベースクエリ)#

アーキテクチャA:従来のリージョナルNode.js

東京のユーザー → バージニアのオリジン: 140ms
  + DBクエリ1(同じリージョン): 2ms
  + DBクエリ2(同じリージョン): 2ms
  + 処理: 5ms
  = 合計: ~149ms

アーキテクチャB:HTTPデータベースを持つエッジファンクション

東京のユーザー → 東京のエッジ: 5ms
  + DBクエリ1(バージニアへのHTTP): 145ms
  + DBクエリ2(バージニアへのHTTP): 145ms
  + 処理: 3ms
  = 合計: ~298ms ← リージョナルより遅い

アーキテクチャC:リージョナルデータベース(リードレプリカ)を持つエッジファンクション

東京のユーザー → 東京のエッジ: 5ms
  + DBクエリ1(東京レプリカへのHTTP): 8ms
  + DBクエリ2(東京レプリカへのHTTP): 8ms
  + 処理: 3ms
  = 合計: ~24ms ← 最速、ただしマルチリージョンDBが必要

アーキテクチャD:認証はエッジ + データはリージョナル

東京のユーザー → 東京のエッジミドルウェア: 5ms(JWT検証)
  → バージニアのオリジン: 140ms
  + DBクエリ1(同じリージョン): 2ms
  + DBクエリ2(同じリージョン): 2ms
  + 処理: 5ms
  = 合計: ~154ms
  (ただし認証はすでに検証済み — オリジンは再検証不要)
  (そして未認証リクエストはエッジでブロック — オリジンに到達しない)

まとめ:

  1. エッジ + オリジンデータベース = しばしば遅くなる。リージョナルサーバーだけを使う方がましなことも
  2. エッジ + マルチリージョンデータベース = 最速。ただし最も高価で複雑
  3. ゲートキーピングはエッジ + データはリージョナル = 最も実用的なバランス
  4. 純粋なエッジ(データベースなし) = リダイレクトや認証チェックには無敵

アーキテクチャDは私がほとんどのプロジェクトで使用しているものだ。エッジは得意なこと(高速な判断、認証、ルーティング)を処理し、リージョナルNode.jsサーバーは得意なこと(データベースクエリ、重い計算)を処理する。

エッジが本当に勝つとき:データベース不要の操作#

データベースが関与しない場合、計算は完全に逆転する:

リダイレクト(エッジ):
  東京のユーザー → 東京のエッジ → リダイレクトレスポンス: ~5ms

リダイレクト(リージョナル):
  東京のユーザー → バージニアのオリジン → リダイレクトレスポンス: ~280ms
静的APIレスポンス(エッジ + キャッシュ):
  東京のユーザー → 東京のエッジ → キャッシュされたレスポンス: ~5ms

静的APIレスポンス(リージョナル):
  東京のユーザー → バージニアのオリジン → レスポンス: ~280ms
ボットブロック(エッジ):
  どこかの悪質ボット → エッジ(最寄り) → 403レスポンス: ~5ms
  (ボットはオリジンサーバーに到達しない)

ボットブロック(リージョナル):
  どこかの悪質ボット → バージニアのオリジン → 403レスポンス: ~280ms
  (ボットがオリジンリソースを消費した)

データベースを必要としない操作では、エッジは20〜50倍速い。これはマーケティングではない — 物理だ。

私の判断フレームワーク#

エッジファンクションを本番環境で1年間使った後、新しいエンドポイントやロジックのすべてに使用するフローチャートがこれだ:

ステップ1:Node.js APIが必要か?#

fsnetchild_process、またはネイティブモジュールをインポートする場合 — Node.jsリージョナル。議論の余地なし。

ステップ2:データベースクエリが必要か?#

はいの場合、ユーザーの近くにリードレプリカがなければ — Node.jsリージョナル(データベースと同じリージョン)。データベースの往復が支配的になる。

はいの場合、グローバルに分散されたリードレプリカがあれば — エッジでHTTPベースのデータベースクライアントを使用可能。

ステップ3:リクエストに関する判断(ルーティング、認証、リダイレクト)か?#

はいの場合 — エッジ。これがスイートスポットだ。リクエストがオリジンに到達する前に何が起こるかを決定する高速な判断を行っている。

ステップ4:レスポンスはキャッシュ可能か?#

はいの場合 — 適切なCache-Controlヘッダーを持つエッジ。最初のリクエストがオリジンに行っても、後続のリクエストはエッジキャッシュから配信される。

ステップ5:CPU集約的か?#

重要な計算(画像処理、PDF生成、大量データ変換)を伴う場合 — Node.jsリージョナル

ステップ6:レイテンシ感度はどの程度か?#

バックグラウンドジョブやWebhookの場合 — Node.jsリージョナル。誰も待っていない。 すべてのミリ秒が重要なユーザー向けリクエストの場合 — 他の基準を満たすならエッジ

チートシート#

typescript
// ✅ エッジに最適
// - ミドルウェア(認証、リダイレクト、リライト、ヘッダー)
// - ジオロケーションロジック
// - A/Bテスト割り当て
// - ボット検出 / WAFルール
// - キャッシュフレンドリーなAPIレスポンス
// - フィーチャーフラグチェック
// - CORSプリフライトレスポンス
// - 静的データ変換(DBなし)
// - Webhook署名検証
 
// ❌ Node.jsリージョナルに維持
// - データベースCRUD操作
// - ファイルアップロード / 処理
// - 画像操作
// - PDF生成
// - メール送信(HTTP APIを使用するが、それでもリージョナル)
// - WebSocketサーバー
// - バックグラウンドジョブ / キュー
// - ネイティブnpmパッケージを使用するすべて
// - データベースクエリを伴うSSRページ
// - データベースにアクセスするGraphQLリゾルバー
 
// 🤔 場合による
// - 認証(JWTならエッジ、セッションDBならリージョナル)
// - APIルート(DBなしならエッジ、DBありならリージョナル)
// - サーバーレンダリングページ(データがキャッシュ/fetchからならエッジ、DBならリージョナル)
// - リアルタイム機能(初回認証はエッジ、永続的接続はリージョナル)

実際にエッジで実行しているもの#

このサイトの内訳は以下の通り:

エッジ(ミドルウェア):

  • ロケール検出とリダイレクト
  • ボットフィルタリング
  • セキュリティヘッダー(CSP、HSTSなど)
  • アクセスログ
  • レート制限(基本的)

Node.jsリージョナル:

  • ブログコンテンツレンダリング(Veliteを通じたMDX処理にNode.js APIが必要)
  • Redisにアクセスするするルート
  • OG画像生成(より多くのCPU時間が必要)
  • RSSフィード生成

静的(ランタイムなし):

  • ツールページ(ビルド時にプリレンダリング)
  • ブログ投稿ページ(ビルド時にプリレンダリング)
  • すべての画像とアセット(CDN配信)

最高のランタイムは、しばしばランタイムなしだ。ビルド時に何かをプリレンダリングして静的アセットとして配信できるなら、それは常にどんなエッジファンクションよりも速い。エッジは、すべてのリクエストで本当に動的である必要があるもののためのものだ。

正直なまとめ#

エッジファンクションは従来のサーバーの代替ではない。補完だ。アーキテクチャツールボックスの追加ツールであり、適切なユースケースには非常に強力で、不適切なユースケースには積極的に害を与えるものだ。

私が何度も立ち返るヒューリスティック:ファンクションが単一リージョンのデータベースにアクセスする必要がある場合、ファンクションをエッジに置いても助けにならない — 害になる。 ホップを追加しただけだ。ファンクションはより速く実行されるが、その後データベースに戻るために100ms以上費やす。最終結果:すべてを1つのリージョンで実行するよりも遅い。

しかし、リクエスト自体にある情報だけで判断できる場合 — ジオロケーション、Cookie、ヘッダー、JWT — エッジは無敵だ。あの5msのエッジレスポンスは合成ベンチマークではない。本物であり、ユーザーは違いを感じる。

すべてをエッジに移すな。すべてをエッジから遠ざけるな。各ロジックを物理法則が有利な場所に置け。

関連記事