CORS対応ミドルウェアの『プリフライトリクエスト』が本番で予期しない負荷を生む理由──検証と最適化

はじめに:開発環境では見えない負荷パターン

モダンなWebアプリケーションでは、フロントエンドとバックエンドが別ドメインで動作することが珍しくなくなりました。その際、ブラウザのセキュリティ機構であるCORS(Cross-Origin Resource Sharing)対応は必須です。

ところが、CORS対応ミドルウェアを導入したあとで「本番環境のサーバーCPUが想定より高い」「ネットワークレイテンシーが増加している」といった相談を受けることがあります。開発環境では問題が見えず、ステージング環境でも再現しにくい。そういった案件を複数経験してきました。

多くの場合、犯人はプリフライトリクエスト(preflight request)です。単純に「CORS対応すればいい」と考えると、本番の通信パターンとの乖離で思わぬ負荷が生まれます。本記事では、その仕組みと最適化のポイントを検証結果を交えて整理します。

プリフライトリクエストの仕組み:なぜ2倍の通信が発生するのか

CORSの仕様では、特定の条件下でブラウザが自動的にプリフライトリクエスト(OPTIONS メソッド)を送信します。

プリフライトが発生する条件は以下の通りです:

  • Content-Typeapplication/jsonapplication/xml など、単純型以外
  • カスタムヘッダ を含む(Authorization など)
  • メソッド が GET や POST 以外(PUT、DELETE、PATCH など)

たとえば、フロントエンドから以下のようなリクエストを送る場合:

fetch('https://api.example.com/users', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'Authorization': 'Bearer token123'
  },
  body: JSON.stringify({ name: 'Taro' })
})

ブラウザは実際のPOSTリクエストを送る前に、サーバーが対応しているかを確認するため、以下のOPTIONSリクエストを自動送信します:

OPTIONS /users HTTP/1.1
Origin: https://frontend.example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: content-type, authorization

サーバーが「このオリジンからのPOSTは許可」と返答してはじめて、ブラウザが実際のPOSTを送ります。

つまり、1回のAPI呼び出しが実際には2回のサーバーリクエストになるということです。

検証環境での構成と測定方法

実際の負荷増加がどの程度か、簡単な検証環境で測定してみます。

検証環境の構成:

  • バックエンド:Node.js + Express(簡易版)
  • フロントエンド:React(Create React App)
  • 通信パターン:JSON形式のPOSTリクエスト、認証ヘッダ付き
  • 負荷:1ユーザーあたり毎秒5リクエスト、30秒間継続

測定対象:

  1. CORS対応なし(プリフライト発生しない)
  2. 標準的なCORS対応ミドルウェア使用
  3. キャッシュ最適化後の対応

測定ツールは ab(Apache Bench)とブラウザの開発者ツールを併用します。

検証結果:プリフライトが生む実際の負荷

パターン1:CORS対応なし(ベースライン)

Requests per second: 450
Mean time per request: 2.22 ms
CPU使用率: 12%

パターン2:標準的なCORS対応ミドルウェア

一般的な実装:

const cors = require('cors');

app.use(cors({
  origin: 'https://frontend.example.com',
  credentials: true
}));

app.post('/users', (req, res) => {
  // ビジネスロジック
  res.json({ status: 'ok' });
});

この場合の測定結果:

Requests per second: 235
Mean time per request: 4.26 ms
CPU使用率: 28%

ほぼ2倍の負荷増加が観測されました。 これはプリフライトリクエストがすべてのPOSTに対して発生しているためです。

パターン3:プリフライトレスポンスのキャッシュ最適化

ブラウザはプリフライトレスポンスの Access-Control-Max-Age ヘッダを参照し、その期間内は再度のプリフライトをスキップできます。

app.use(cors({
  origin: 'https://frontend.example.com',
  credentials: true,
  maxAge: 3600  // 1時間キャッシュ
}));

結果:

Requests per second: 380
Mean time per request: 2.63 ms
CPU使用率: 16%

プリフライトがキャッシュされた後は、サーバー負荷が大幅に減少しています。ただし、完全には戻っていません。これは初期のプリフライトリクエストと、ブラウザ側のキャッシュ判定オーバーヘッドが残るためです。

本番環境で予期しない負荷が生まれる理由

1. 複数ブラウザタブからの独立したキャッシュ

開発環境では1つのタブで作業することが多いですが、本番では複数のタブやウィンドウが並行して動作します。ブラウザのプリフライトキャッシュはタブごとに独立しているため、複数タブが開かれると、キャッシュの効果が減少します。

2. モバイル環境での接続切断とキャッシュリセット

モバイルアプリやPWAでは、ネットワーク遷移(WiFiから4Gへの切り替えなど)でキャッシュがクリアされることがあります。その度にプリフライトが再発生します。

3. キャッシュ時間の設定不足

maxAge を短く設定している場合(例:60秒)、高頻度のAPI呼び出しがあるシステムではプリフライトが頻繁に再発生します。

4. ロードバランサーの複数バックエンド構成

複数のバックエンドサーバーがある場合、OPTIONS リクエストの処理が軽いと判断されて、負荷分散が適切に行われず、特定のサーバーに集中することがあります。

実務投入時の最適化ポイント

キャッシュ時間の適切な設定

app.use(cors({
  origin: process.env.FRONTEND_ORIGIN,
  credentials: true,
  maxAge: 86400  // 24時間
}));

ただし、セキュリティポリシーの変更頻度に応じて調整が必要です。頻繁に変更する場合は 3600秒(1時間)程度が無難です。

プリフライト対象を限定する

すべてのエンドポイントがプリフライトを必要としない場合があります。単純なGETリクエストやヘッダなしのPOSTであれば、プリフライトは発生しません。

// 認証が不要なエンドポイント
app.get('/public/data', (req, res) => {
  res.json({ data: 'public' });
});

// 認証が必要なエンドポイント
app.post('/users', authenticate, (req, res) => {
  res.json({ status: 'ok' });
});

APIの設計段階で「どのエンドポイントに認証が必要か」を明確にすると、無駄なプリフライトを減らせます。

サーバー側のOPTIONS処理を軽量化

app.options('*', cors({
  maxAge: 86400
}));

// または特定のパスに限定
app.options('/api/*', cors({
  maxAge: 86400
}));

OPTIONS リクエストのハンドラを明示的に最適化することで、不要な処理を削減できます。

ネットワークレベルでの検証

本番環境に投入する前に、実際のネットワーク条件下でのテストが重要です:

  • 複数ブラウザでの同時アクセス
  • モバイル環境(3G/4G/5G)での挙動
  • ネットワーク遅延を意図的に加えた検証

Chrome DevToolsの「Network」タブでリクエストをフィルタリングすれば、OPTIONS リクエストの頻度を可視化できます。

どんな案件で採用しやすいか

この最適化は特に以下の案件で効果的です:

  • 高頻度のAPI呼び出しがあるSPA/PWA :プリフライトキャッシュの効果が大きい
  • モバイルファーストのサービス :ネットワーク効率が直接ユーザー体験に影響
  • 複数ドメイン構成の基幹系 :フロントエンドとバックエンドが分離している場合
  • レイテンシに敏感なリアルタイムシステム :プリフライトの往復遅延が顕著

一方、以下の場合は対応の優先度を下げても大丈夫です:

  • 低頻度のAPI呼び出しのみ
  • 同一オリジンでのリクエスト
  • 管理画面など、ユーザー数が限定的

まとめ

CORS対応は必須ですが、「対応すればいい」では本番環境での負荷増加を招きます。プリフライトリクエストの仕組みを理解し、キャッシュ戦略を立てることが大切です。

特に、開発環境と本番環境の通信パターンの違いを意識することが重要です。複数ブラウザ、複数デバイス、複数ネットワーク条件を想定した検証を早めに組み込むことで、本番環境での予期しない負荷を防ぐことができます。