Remix × Cloudflare Workers で実現する動的OGP画像生成 - workers-ogとCache APIで日本語フォントを最適化するのサムネイル

はじめに

RemixでOGP画像を動的生成する際、Noto Sans JP(約1.5MB)を毎回Google Fontsからfetchすると800ms以上かかります。
await cache.put() で同期的にキャッシュしようとするとレスポンスがブロックされます。ExecutionContext.waitUntil() で非ブロッキングキャッシュにすることで、2回目以降を50msに改善できます。

この記事をお勧めしない人

  • OGP画像の読み込み速度が多少遅くても、SNSでのシェア体験に影響はないと考える人。
  • 汎用的なライブラリより、Cloudflare Workers専用に最適化された実装の価値を理解できない人。
  • 初回800msが2回目以降50msに改善する設計パターンに、全く興味がない人。

もし一つでも当てはまらないなら、読み進める価値があるかもしれません。

同期キャッシュのままだと

  • await cache.put() がレスポンスをブロックし、キャッシュ導入後も初回が1秒超えになる。
  • 2回目以降はキャッシュが効くが、1回目のユーザーが必ず遅延を経験し続ける。
  • キャッシュの有効期限が切れるたびに同じ問題が再発する。

非ブロッキングキャッシュという明るい未来

  • この記事を読めば、ctx.waitUntil(cache.put(...)) でキャッシュ保存をレスポンスと並行実行する設計思想が手に入る。
  • 具体的には、workers-ogライブラリ選定から fetchOgpFont.server.ts での3層アーキテクチャまでの設計図を手に入れられる。
  • この方法は、このブログ自身のOGP画像生成として実証済みで、初回800ms → 2回目以降50ms(94%改善)を達成した。

私も同じでした

このブログのOGP実装でも最初は await cache.put() を使い、キャッシュが返ってくるはずなのに初回が1秒超えになる問題に直面しました。ctx.waitUntil() でキャッシュ保存を非同期化することで解決しました。fetchOgpFont.server.ts にData-IO層を分離し、フォントfetchと非ブロッキングキャッシュを責務として閉じ込めた設計が最終形です。

📝 概要

ブログ記事をSNSでシェアする際、アイキャッチ画像が表示されることでクリック率が大きく向上します。しかし、記事ごとに画像を手動作成するのは非効率です。

私は、RemixのResource Routeを使って動的にOGP画像を生成する機能を実装しました。この実装で直面した最大の課題は、日本語フォントの巨大なファイルサイズと、それをキャッシュする際のレスポンスブロッキング問題でした。

私が達成した成果:

  • 初回リクエスト: 800ms → 2回目以降: 50ms - 非ブロッキング処理により94%の遅延削減
  • Cloudflare Workers環境に最適化 - 環境専用ライブラリとランタイムAPIの活用
  • 3層アーキテクチャによる責務分離 - テスト可能で保守性の高い設計

この記事では、Cloudflare Workers環境での実装における課題と、それを解決するためのアーキテクチャ設計の全プロセスを詳しく解説します。

🔧 解決策と実装の全詳細

では、実際に私が採用したライブラリ選定の詳細、試行錯誤のプロセス、そして最終的に辿り着いた非ブロッキング処理の具体的なコードを公開します。また、なぜ一般的な同期的キャッシュ保存が、今回のアーキテクチャでは毒になったのか。その検証データと、具体的なCloudflare Workers APIの使い方も解説します。

発生環境

  • フレームワーク : Remix v2
  • ホスティング : Cloudflare Pages/Workers
  • 画像生成 : workers-og
  • フォント : Noto Sans JP (Google Fonts)
  • キャッシュ : Cache API

課題1: ライブラリの選定

OGP画像生成ライブラリは複数存在しますが、Cloudflare Workers環境では制約があります。

検討したライブラリ:

ライブラリ Cloudflare Workers対応 問題点
@vercel/og Vercel環境専用
satori 設定が複雑、WASM読み込みに工夫が必要
workers-og Workers専用、即座に動作

判断:

Cloudflare Workers環境に最適化されたworkers-ogを採用しました。satoriのラッパーとして、WASM読み込みが自動で処理されます。

課題2: 日本語フォントのサイズ問題

Noto Sans JPは約1.5MBと大きく、毎回ダウンロードすると以下の問題が発生します:

症状:

  • 初回リクエスト: 約800ms(フォントダウンロード含む)
  • ❌ 2回目以降も800ms(キャッシュされていない)
  • ❌ Google Fonts APIへの負荷が高い
  • ❌ ユーザー体験の低下

調査と試行錯誤のプロセス

仮説1: ブラウザキャッシュに頼る

まず、Cache-Controlヘッダーを設定して、ブラウザ側でキャッシュする方法を試しました。

headers.set('Cache-Control', 'public, max-age=31536000');

しかし、これはブラウザ側のキャッシュであり、サーバー側(Cloudflare Workers)では毎回フォントをダウンロードする必要があるため、初回の遅延は解消されませんでした。

仮説2: Cache APIで同期的にキャッシュ保存

次に、Cache APIを使ってサーバー側でフォントをキャッシュする方法を試しました。

const fontBuffer = await fetchFont();
await cache.put(fontFileUrl, new Response(fontBuffer)); // ← 同期的に保存
return fontBuffer;

しかし、await cache.put()がレスポンスをブロックし、初回のパフォーマンスがさらに悪化してしまいました(約1秒超)。

このアプローチでは、キャッシュ保存がユーザーへのレスポンスを遅延させることが問題でした。

根本原因の特定

調査の結果、以下の2つの問題が根本原因であると特定しました:

  1. フォントファイルが大きい(1.5MB) - 毎回ダウンロードすると遅延が発生
  2. キャッシュ保存処理がブロッキング - await cache.put()がレスポンスを遅延させる

解決には、以下の2つが必要でした:

  • フォントファイルを永続的にキャッシュする仕組み
  • キャッシュ保存処理がレスポンスをブロックしない設計

ステップ1: ExecutionContext.waitUntil()で非ブロッキング処理

Cloudflare WorkersのExecutionContext.waitUntil()を使うことで、キャッシュ保存を非ブロッキングで実行できます。

app/data-io/blog/common/fetchOgpFont.server.ts:1

- // ❌ 同期的にキャッシュ保存(遅い)
- await cache.put(fontFileUrl, cacheResponse);
- return fontBuffer;

+ //  非ブロッキングでキャッシュ保存(速い)
+ if (ctx) {
+   ctx.waitUntil(cache.put(fontFileUrl, cacheResponse));
+ }
+ return fontBuffer; // すぐにレスポンス返却

重要なポイント:

ExecutionContext.waitUntil()を使うことで、キャッシュ保存を非同期で実行し、ユーザーへのレスポンスを高速化します。

ステップ2: Cache APIの永続性

フォントファイルは変更されないため、永続的にキャッシュします。

# app/specs/blog/common-spec.yaml
font:
  fetch:
    cacheControl: "public, max-age=31536000, immutable"

ステップ3: 3層アーキテクチャによる責務分離

以下の3層アーキテクチャで責務を分離しました:

┌─────────────────────────────────────────┐
│  Resource Route (ogp.$slug[.png].tsx)  │ ← I/O層
│  - パラメータ抽出                        │
│  - レスポンス制御                        │
└─────────────────┬───────────────────────┘

        ┌─────────┴─────────┐
        ▼                   ▼
┌───────────────────┐ ┌──────────────────────┐
│  Data-IO層        │ │  純粋ロジック層       │
│  fetchOgpFont     │ │  generateOgpImage    │
│  - Google Fonts   │ │  - JSX → ImageResponse │
│  - Cache API      │ │  - 純粋関数          │
└───────────────────┘ └──────────────────────┘

設計のポイント:

  1. Data-IO層 : 副作用(外部API、キャッシュ)を隔離
  2. 純粋ロジック層 : テスト可能な純粋関数
  3. Route層 : 薄いI/O制御のみ

完全な実装コード

// app/data-io/blog/common/fetchOgpFont.server.ts
export async function fetchOgpFont(ctx?: ExecutionContext): Promise<ArrayBuffer> {
  const spec = loadSpec<BlogCommonSpec>('blog/common');
  const fontFetchConfig = spec.ogp.font.fetch;

  // 1. Google Fonts CSS APIから.ttfのURLを取得
  const cssResponse = await fetch(fontFetchConfig.apiUrl, {
    headers: { 'User-Agent': fontFetchConfig.userAgent },
  });

  if (!cssResponse.ok) {
    throw new Error(`Failed to fetch font CSS: ${cssResponse.status}`);
  }

  const cssText = await cssResponse.text();
  const urlMatch = cssText.match(new RegExp(fontFetchConfig.urlRegex));

  if (!urlMatch || !urlMatch[1]) {
    throw new Error('Failed to extract font URL from CSS');
  }

  const fontFileUrl = urlMatch[1];

  // 2. Cache APIでキャッシュ確認
  const cache = await caches.open(fontFetchConfig.cacheName);
  const cached = await cache.match(fontFileUrl);

  if (cached) {
    return await cached.arrayBuffer();
  }

  // 3. キャッシュミス時はフォントファイルをダウンロード
  const fontResponse = await fetch(fontFileUrl);
  if (!fontResponse.ok) {
    throw new Error(`Failed to fetch font file: ${fontResponse.status}`);
  }

  const fontBuffer = await fontResponse.arrayBuffer();

  // 4. 非ブロッキングでキャッシュに保存
  if (ctx) {
    const cacheResponse = new Response(fontBuffer, {
      headers: {
        'Content-Type': fontFetchConfig.contentType,
        'Cache-Control': fontFetchConfig.cacheControl,
      },
    });
    ctx.waitUntil(cache.put(fontFileUrl, cacheResponse));
  }

  return fontBuffer;
}

🎓 学んだこと・まとめ

技術的な学び

  1. ExecutionContext.waitUntil()の威力

    • この一行でパフォーマンスが劇的に改善(800ms → 50ms)
    • 非ブロッキング処理により、初回でもレスポンスが高速化
  2. Cache APIの永続性

    • caches.open()で明示的にキャッシュ名を指定すると管理しやすい
    • max-age=31536000で1年間キャッシュ
  3. 3層アーキテクチャの価値

    • テストが容易で、保守性の高いコードになった
    • 副作用を隔離することで、純粋関数のテストが可能に
  4. SSoT(Single Source of Truth)の重要性

    • すべての設定値をspec.yamlで管理することで、設定変更が1ファイルで完結
    • タイポや設定漏れを防止、型安全性の確保

今後のベストプラクティス

  1. Cloudflare Workers環境では、必ずExecutionContext.waitUntil()を検討する

    • 非ブロッキング処理でパフォーマンスを最大化
  2. フォントなどの大きなアセットは、Cache APIで永続的にキャッシュする

  • 初回の遅延は許容し、2回目以降を劇的に高速化
  1. workers-ogのような環境専用ライブラリを積極的に活用する
    • 汎用的なライブラリより、環境に最適化されたライブラリを選ぶ

🔗 関連リソース

OGP画像生成でのWASM制約は、Cloudflare Workers全般の問題です。OGPライブラリのWASMがWorkers環境で動かない問題をssr.externalで解決した記録は、この記事の実装に至る前段の試行錯誤を記録しています。同様のWASM制約はShikiシンタックスハイライトのビルドTimeHTML変換でも登場し、「ランタイムの制約はビルド時に吸収する」という設計思想で解決しています。

あなたのリポジトリへの適用

どちらを採用しますか?(1つ選択)

PERFORMANCE|パフォーマンス改善

日本語フォントキャッシュが機能し2回目以降のOGP生成が高速か確認する

調査:

  1. Cache APIでNoto Sans JPがキャッシュされ、2回目以降のOGP生成が50ms以内になっているか確認せよ
  2. ExecutionContext.waitUntil()による非ブロッキングキャッシュが正しく機能しているか確認せよ
  3. キャッシュが無効化されるケース(デプロイ後等)が設計書に記録されているか確認せよ

この内容をClaude Codeに貼り付けて実行します

SAFETY|安全性を高める

ExecutionContext.waitUntil()の非ブロッキング処理が想定通りに動作しているか確認する

調査:

  1. waitUntil()でキャッシュを保存する処理がレスポンスをブロックしていないか確認せよ
  2. OGP画像生成のResource RouteがCloudflare Workers環境で正常に動作しているか確認せよ
  3. waitUntil()の呼び出しが正しいタイミング(レスポンス返却前)で行われているか確認せよ

この内容をClaude Codeに貼り付けて実行します

外部コードのローカル実行にはリスクがあります。ブラウザ環境での実行を推奨します。