mcpField
H
headless-caching-strategy

headless-caching-strategy

github.com/vtex開発471.1k 回インストール2 週間前に更新
リポジトリ

headless-caching-strategy について

ヘッドレス VTEX ストアの性能を改善する開発者向けのスキルで、API キャッシュの階層、無効化、データ分類を解説します。キャッシュ可能な公開データと、キャッシュしてはならない取引・個人データを区別し、TTL と stale-while-revalidate を扱います。

主な機能

  1. 01キャッシュ可能・不可の API 分類
  2. 02CDN と BFF のキャッシュ設計
  3. 03TTL と stale-while-revalidate の設定
  4. 04キャッシュ無効化の設計
  5. 05リクエストパラメーターによるキー作成

活用シーン

  1. 01検索・カタログ API のキャッシュ追加
  2. 02チェックアウトと個人データの非キャッシュ化
  3. 03カタログデータの無効化設定
  4. 04キャッシュ TTL とキーの確認

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/vtex/skills --skill headless-caching-strategy

インストール後、Claude Code で/headless-caching-strategyを入力して読み込まれたか確認します。

詳細

ヘッドレス VTEX のキャッシュとパフォーマンス

このスキルを使用する場合

ヘッドレス VTEX ストアフロントの構築やパフォーマンスの最適化を行う場合に、このスキルを使用します。適切なキャッシュは、ヘッドレスコマースで最も効果の高いパフォーマンス最適化です。

  • Intelligent Search および Catalog API の CDN またはエッジキャッシュの設定
  • 頻繁にリクエストされるデータに対する BFF レベルのキャッシュ(インメモリまたは Redis)の追加
  • キャッシュ可能な VTEX API レスポンスと、決してキャッシュしてはならないレスポンスの判断
  • カタログデータの変更時にキャッシュを無効化する処理の実装

以下にはこのスキルを使用しないでください。

判断ルール

  • キャッシュロジックを実装する前に、すべての VTEX API をキャッシュ可能またはキャッシュ不可に分類してください。
  • キャッシュ可能(公開、読み取り専用、パーソナライズされていない):Intelligent Search、Catalog の公開エンドポイント、人気検索、オートコンプリート。
  • キャッシュ不可(トランザクション、パーソナライズ済み、機密):Checkout、Profile、OMS、Payments、Pricing のプライベートエンドポイント。これらはどの階層でも決してキャッシュしてはなりません。
  • 鮮度とパフォーマンスのバランスを最適化するために、stale-while-revalidate を使用してください。バックグラウンドでデータを更新しながら、キャッシュ済みのデータをすぐに返します。
  • 中程度の TTL(2~15 分)とイベント駆動型の無効化を組み合わせてください。無効化の仕組みなしに TTL を数時間または数日に設定してはなりません。
  • ユーザー ID ではなく、リクエスト URL/パラメーターでキャッシュしてください。同じ取引ポリシーの匿名ユーザーには、同じカタログデータが適用されます。
  • キャッシュを階層化してください。フロントエンドから直接呼び出す API(Search)には CDN エッジキャッシュを使用し、プロキシ経由のカタログデータには BFF キャッシュ(Redis/インメモリ)を使用します。

推奨 TTL:

API推奨 TTLSWR
Intelligent Search(product_search)2~5 分60s
Catalog(カテゴリーツリー)5~15 分5 min
Intelligent Search(オートコンプリート)1~2 分30s
Intelligent Search(人気検索)5~10 分2 min
Catalog(商品詳細)5 分60s

決してキャッシュしてはならない API:

API理由
Checkout(/api/checkout/)カートデータはユーザーごとに異なり、操作のたびに変更されます
Profile(/api/profile-system/pvt/)個人データであり、GDPR/LGPD の対象となる機密情報です
OMS(/api/oms/pvt/orders)注文状況が変化し、ユーザーごとに異なります
Payments(/api/payments/)金融取引のため、常にリアルタイムで処理する必要があります
Pricing のプライベートエンドポイント(/api/pricing/pvt/)ユーザーごとの価格ルールが適用される場合があります

厳守事項

制約:公開 API データは必ず積極的にキャッシュする

検索結果、カタログデータ、カテゴリーツリー、その他の公開読み取り専用データは、適切な階層(CDN、BFF、またはその両方)で必ずキャッシュしてください。キャッシュがない場合、ユーザーからのリクエストのたびに VTEX API が直接呼び出されます。

これが重要な理由

キャッシュがない場合、ヘッドレスストアフロントでは、ページ表示、検索、カテゴリー閲覧のたびに API リクエストが発生します。その結果、VTEX API のレート制限をすぐに超過して(429 エラーやサービスの低下が発生し)、リクエストごとに 200~500ms の遅延が加わり、買い物客の利用体験が損なわれます。キャッシュされていない検索リクエストを同時に 10,000 人のユーザーが行うと、どのような API でも過負荷になります。

検出方法

ヘッドレスストアフロントがキャッシュ層(CDN キャッシュヘッダー、BFF キャッシュ、Cache-Control ヘッダーのいずれも)なしで Intelligent Search または Catalog API を呼び出している場合 → ただちに中止してください。公開された読み取り専用 API のレスポンスは、すべてキャッシュする必要があります。

正しい例

// BFF route with in-memory cache for category tree
import { Router, Request, Response } from "express";

const router = Router();

interface CacheEntry<T> {
  data: T;
  expiresAt: number;
  staleAt: number;
}

const cache = new Map<string, CacheEntry<unknown>>();

function getCached<T>(key: string): { data: T; isStale: boolean } | null {
  const entry = cache.get(key) as CacheEntry<T> | undefined;
  if (!entry) return null;

  const now = Date.now();
  if (now > entry.expiresAt) {
    cache.delete(key);
    return null;
  }

  return {
    data: entry.data,
    isStale: now > entry.staleAt,
  };
}

function setCache<T>(key: string, data: T, maxAgeMs: number, swrMs: number): void {
  const now = Date.now();
  cache.set(key, {
    data,
    staleAt: now + maxAgeMs,
    expiresAt: now + maxAgeMs + swrMs,
  });
}

const VTEX_ACCOUNT = process.env.VTEX_ACCOUNT!;
const CATALOG_BASE = `https://${VTEX_ACCOUNT}.vtexcommercestable.com.br/api/catalog_system/pub`;

// Category tree — cache for 15 minutes, SWR for 5 minutes
router.get("/categories", async (_req: Request, res: Response) => {
  const cacheKey = "category-tree";
  const cached = getCached<unknown>(cacheKey);

  if (cached && !cached.isStale) {
    res.set("X-Cache", "HIT");
    return res.json(cached.data);
  }

  // If stale, serve stale data and refresh in background
  if (cached && cached.isStale) {
    res.set("X-Cache", "STALE");
    res.json(cached.data);

    // Background refresh
    fetch(`${CATALOG_BASE}/category/tree/3`)
      .then((r) => r.json())
      .then((data) => setCache(cacheKey, data, 15 * 60 * 1000, 5 * 60 * 1000))
      .catch((err) => console.error("Background cache refresh failed:", err));
    return;
  }

  // Cache miss — fetch and cache
  try {
    const response = await fetch(`${CATALOG_BASE}/category/tree/3`);
    const data = await response.json();
    setCache(cacheKey, data, 15 * 60 * 1000, 5 * 60 * 1000);
    res.set("X-Cache", "MISS");
    res.json(data);
  } catch (error) {
    console.error("Error fetching categories:", error);
    res.status(500).json({ error: "Failed to fetch categories" });
  }
});

誤った例

// No caching — every request hits VTEX directly
router.get("/categories", async (_req: Request, res: Response) => {
  // This fires on EVERY request — 10,000 users = 10,000 API calls
  const response = await fetch(
    `https://mystore.vtexcommercestable.com.br/api/catalog_system/pub/category/tree/3`
  );
  const data = await response.json();
  res.json(data); // No cache headers, no BFF cache, no CDN cache
});

制約:トランザクションデータや個人データをキャッシュしてはなりません

Checkout API、Profile API、OMS API、Payments API からのレスポンスは、どのレイヤーでもキャッシュしてはなりません。CDN、BFF のメモリ、Redis、ブラウザーキャッシュのいずれも対象です。

重要な理由

トランザクションデータをキャッシュすると、致命的な障害につながる可能性があります。キャッシュされた OrderForm によって、購入者に古いカートの内容(商品や価格の誤り)が表示されることがあります。キャッシュされたプロフィールデータは、あるユーザーの個人情報が別のユーザーに漏えいする原因になり得ます(特に共有キャッシュを利用している場合)。キャッシュされた注文データには、古いステータスが表示されます。これらはいずれも、セキュリティ上の脆弱性、データプライバシーの侵害(GDPR/LGPD)、またはビジネスロジックの障害につながります。

検出方法

Checkout、注文、プロフィール、または決済 API のレスポンスに対して、キャッシュ処理(Redis の set、インメモリキャッシュ、max-age > 0 を指定した Cache-Control ヘッダーなど)が適用されている場合は、直ちに STOP してください。これらのエンドポイントは、常に最新データを返す必要があります。

正しい例

// BFF checkout route — explicitly no caching
import { Router, Request, Response } from "express";
import { vtexCheckout } from "../vtex-checkout-client";

export const checkoutRoutes = Router();

// Set no-cache headers for ALL checkout responses
checkoutRoutes.use((_req: Request, res: Response, next) => {
  res.set({
    "Cache-Control": "no-store, no-cache, must-revalidate, proxy-revalidate",
    Pragma: "no-cache",
    Expires: "0",
    "Surrogate-Control": "no-store",
  });
  next();
});

checkoutRoutes.get("/cart", async (req: Request, res: Response) => {
  const orderFormId = req.session.orderFormId;
  if (!orderFormId) {
    return res.status(400).json({ error: "No active cart" });
  }

  try {
    // Always fetch fresh — never cache
    const result = await vtexCheckout({
      path: `/api/checkout/pub/orderForm/${orderFormId}`,
      cookies: req.session.vtexCookies || {},
      userToken: req.session.vtexAuthToken,
    });

    req.session.vtexCookies = result.cookies;
    res.json(result.data);
  } catch (error) {
    console.error("Error fetching cart:", error);
    res.status(500).json({ error: "Failed to fetch cart" });
  }
});

誤った例

// CATASTROPHIC: Caching checkout data in Redis
import Redis from "ioredis";
const redis = new Redis();

checkoutRoutes.get("/cart", async (req: Request, res: Response) => {
  const orderFormId = req.session.orderFormId;
  const cacheKey = `cart:${orderFormId}`;

  // WRONG: cached cart could have wrong items, old prices, or stale quantities
  const cached = await redis.get(cacheKey);
  if (cached) {
    return res.json(JSON.parse(cached)); // Serving stale transactional data!
  }

  const result = await vtexCheckout({
    path: `/api/checkout/pub/orderForm/${orderFormId}`,
    cookies: req.session.vtexCookies || {},
  });

  // WRONG: caching cart data that changes with every user action
  await redis.setex(cacheKey, 300, JSON.stringify(result.data));
  res.json(result.data);
});

制約:キャッシュ無効化戦略を必ず実装してください

キャッシュを実装する場合は、必ず明確な無効化戦略を用意してください。キャッシュデータには適切な TTL を設定し、基盤となるデータが変更された際にキャッシュを強制的に無効化する仕組みが必要です。

重要な理由

無効化しなければ、キャッシュデータは古いままになり続けます。在庫切れの商品が引き続き購入可能に見えたり、価格変更が任意の TTL の期限切れまで反映されなかったり、新商品が表示されなかったりします。その結果、購入者の体験が損なわれ、注文失敗(在庫状況が古いため)や価格の誤表示につながります。

検出方法

キャッシュ実装に TTL(max-age、有効期限)がない場合、または無効化の仕組みがないまま TTL が非常に長い(数時間/数日)場合は、直ちに STOP してください。すべてのキャッシュに上限付き TTL を設定し、できればイベント駆動の無効化を利用してください。

正しい例

// Cache with TTL + manual invalidation endpoint + event-driven invalidation
import { Router, Request, Response } from "express";

const router = Router();

// In-memory cache with TTL tracking
const productCache = new Map<string, { data: unknown; expiresAt: number }>();

function setProductCache(productId: string, data: unknown, ttlMs: number): void {
  productCache.set(productId, {
    data,
    expiresAt: Date.now() + ttlMs,
  });
}

function getProductCache(productId: string): unknown | null {
  const entry = productCache.get(productId);
  if (!entry || Date.now() > entry.expiresAt) {
    productCache.delete(productId);
    return null;
  }
  return entry.data;
}

// Regular product endpoint with cache
router.get("/products/:productId", async (req: Request, res: Response) => {
  const { productId } = req.params;
  const cached = getProductCache(productId);

  if (cached) {
    return res.json(cached);
  }

  const response = await fetch(
    `https://${process.env.VTEX_ACCOUNT}.vtexcommercestable.com.br/api/catalog_system/pub/products/search?fq=productId:${productId}`
  );
  const data = await response.json();

  setProductCache(productId, data, 5 * 60 * 1000); // 5-minute TTL
  res.json(data);
});

// Manual invalidation endpoint (secured with API key)
router.post("/cache/invalidate", (req: Request, res: Response) => {
  const adminKey = req.headers["x-admin-key"];
  if (adminKey !== process.env.ADMIN_API_KEY) {
    return res.status(403).json({ error: "Unauthorized" });
  }

  const { productId, pattern } = req.body as { productId?: string; pattern?: string };

  if (productId) {
    productCache.delete(productId);
    return res.json({ invalidated: [productId] });
  }

  if (pattern === "all") {
    const count = productCache.size;
    productCache.clear();
    return res.json({ invalidated: count });
  }

  res.status(400).json({ error: "Provide productId or pattern" });
});

// Webhook endpoint for VTEX catalog change events
router.post("/webhooks/catalog-change", (req: Request, res: Response) => {
  const { productId } = req.body as { productId?: string };

  if (productId) {
    productCache.delete(productId);
    console.log(`Cache invalidated for product ${productId}`);
  }

  res.status(200).json({ received: true });
});

export default router;

誤った例

// Cache with no TTL and no invalidation — data becomes permanently stale
const cache = new Map<string, unknown>();

router.get("/products/:productId", async (req: Request, res: Response) => {
  const { productId } = req.params;

  // Once cached, this data NEVER expires — price changes, stock updates are invisible
  if (cache.has(productId)) {
    return res.json(cache.get(productId));
  }

  const response = await fetch(`https://mystore.vtexcommercestable.com.br/api/catalog_system/pub/products/search?fq=productId:${productId}`);
  const data = await response.json();
  cache.set(productId, data); // No TTL! No invalidation! Stale forever!
  res.json(data);
});

推奨パターン

ヘッドレス VTEX ストアフロントのキャッシュレイヤーアーキテクチャ:

Frontend (Browser)
    │
    ├── Direct to CDN (Intelligent Search)
    │   └── CDN Edge Cache (TTL: 2-5 min, SWR: 60s)
    │       └── VTEX Intelligent Search API
    │
    └── BFF Endpoints
        │
        ├── Cacheable routes (catalog, category tree)
        │   └── BFF Cache Layer (Redis/in-memory)
        │       └── VTEX Catalog API
        │
        └── Non-cacheable routes (checkout, profile, orders)
            └── Direct proxy to VTEX (NO CACHING)

Intelligent Search の CDN キャッシュヘッダー(エッジ関数の例):

// cloudflare-worker.ts or similar edge function
async function handleSearchRequest(request: Request): Promise<Response> {
  const url = new URL(request.url);

  // Only cache GET requests to Intelligent Search
  if (request.method !== "GET") {
    return fetch(request);
  }

  // Check CDN cache first
  const cacheKey = new Request(url.toString(), request);
  const cachedResponse = await caches.default.match(cacheKey);
  if (cachedResponse) {
    return cachedResponse;
  }

  // Fetch from VTEX
  const response = await fetch(request);
  const responseClone = response.clone();

  // Add cache headers
  const cachedRes = new Response(responseClone.body, responseClone);
  cachedRes.headers.set(
    "Cache-Control",
    "public, max-age=120, stale-while-revalidate=60"
  );

  // Store in CDN cache
  await caches.default.put(cacheKey, cachedRes.clone());

  return cachedRes;
}

stale-while-revalidate を使用した Redis ベースの BFF キャッシュ:

// server/cache/redis-cache.ts
import Redis from "ioredis";

const redis = new Redis(process.env.REDIS_URL!);

interface CacheOptions {
  ttlSeconds: number;
  swrSeconds?: number;
}

export async function getCachedOrFetch<T>(
  key: string,
  fetcher: () => Promise<T>,
  options: CacheOptions
): Promise<{ data: T; cacheStatus: "HIT" | "STALE" | "MISS" }> {
  const { ttlSeconds, swrSeconds = 0 } = options;

  // Try to get from cache
  const cached = await redis.get(key);
  if (cached) {
    const ttl = await redis.ttl(key);
    const isStale = ttl <= swrSeconds;

    if (isStale && swrSeconds > 0) {
      // Serve stale, refresh in background
      fetcher()
        .then((freshData) =>
          redis.setex(key, ttlSeconds + swrSeconds, JSON.stringify(freshData))
        )
        .catch((err) => console.error(`Background refresh failed for ${key}:`, err));

      return { data: JSON.parse(cached) as T, cacheStatus: "STALE" };
    }

    return { data: JSON.parse(cached) as T, cacheStatus: "HIT" };
  }

  // Cache miss — fetch and store
  const data = await fetcher();
  await redis.setex(key, ttlSeconds + swrSeconds, JSON.stringify(data));

  return { data, cacheStatus: "MISS" };
}

export async function invalidateCache(pattern: string): Promise<number> {
  const keys = await redis.keys(pattern);
  if (keys.length === 0) return 0;
  return redis.del(...keys);
}

ルートグループごとのキャッシュ戦略の適用:

// server/middleware/cache-headers.ts
import { Request, Response, NextFunction } from "express";

export function cacheHeaders(type: "public" | "private" | "no-cache") {
  return (_req: Request, res: Response, next: NextFunction) => {
    switch (type) {
      case "public":
        res.set({
          "Cache-Control": "public, max-age=120, stale-while-revalidate=60",
          Vary: "Accept-Encoding",
        });
        break;
      case "private":
        res.set({
          "Cache-Control": "private, max-age=60",
          Vary: "Accept-Encoding, Cookie",
        });
        break;
      case "no-cache":
        res.set({
          "Cache-Control": "no-store, no-cache, must-revalidate, proxy-revalidate",
          Pragma: "no-cache",
          Expires: "0",
          "Surrogate-Control": "no-store",
        });
        break;
    }
    next();
  };
}

// server/index.ts — apply cache strategies per route group
import express from "express";
import { cacheHeaders } from "./middleware/cache-headers";
import catalogRoutes from "./routes/catalog";
import { checkoutRoutes } from "./routes/checkout";
import { orderRoutes } from "./routes/orders";
import { profileRoutes } from "./routes/profile";

const app = express();
app.use(express.json());

// Cacheable routes — public catalog data
app.use("/api/bff/catalog", cacheHeaders("public"), catalogRoutes);

// Non-cacheable routes — transactional and personal data
app.use("/api/bff/checkout", cacheHeaders("no-cache"), checkoutRoutes);
app.use("/api/bff/orders", cacheHeaders("no-cache"), orderRoutes);
app.use("/api/bff/profile", cacheHeaders("no-cache"), profileRoutes);

よくある失敗例

  • セッションやユーザー ID に基づくキャッシュ:カタログデータにユーザーごとのキャッシュを作成すると(例:ユーザー ID をキーに商品検索結果をキャッシュする)、ストレージ使用量がユーザー数に応じて増加し、キャッシュの主な利点が失われます。公開 API のレスポンスは、リクエスト URL/パラメーターのみをキーにしてキャッシュしてください。取引ポリシーごとに価格が異なる場合は、キャッシュキーにユーザー ID ではなく取引ポリシーを含めてください。

    // Cache key based on request parameters only — not user identity
    function buildCacheKey(path: string, params: Record<string, string>): string {
      const sortedParams = Object.entries(params)
        .sort(([a], [b]) => a.localeCompare(b))
        .map(([k, v]) => `${k}=${v}`)
        .join("&");
      return `search:${path}:${sortedParams}`;
    }
    
  • 無効化せずに極端に長いキャッシュ TTL を設定する:TTL が数時間または数日だと、価格変更、在庫更新、新商品が購入者に表示されません。適度な TTL(2~15 分)を設定し、イベント駆動の無効化および stale-while-revalidate と組み合わせてください。

    // Moderate TTL with stale-while-revalidate
    const CACHE_CONFIG = {
      search: { ttlSeconds: 120, swrSeconds: 60 },      // 2 min + 1 min SWR
      categories: { ttlSeconds: 900, swrSeconds: 300 },  // 15 min + 5 min SWR
      product: { ttlSeconds: 300, swrSeconds: 60 },       // 5 min + 1 min SWR
      topSearches: { ttlSeconds: 600, swrSeconds: 120 },  // 10 min + 2 min SWR
    } as const;
    
  • キャッシュの監視や可観測性がない:ヒット率/ミス率/古いデータの割合を測定しなければ、キャッシュが効果的か、TTL が適切かを判断できません。X-Cache ヘッダーを追加し、可観測性プラットフォームでメトリクスを追跡してください。

    // Add cache observability to every cached response
    interface CacheMetrics {
      hits: number;
      misses: number;
      stale: number;
    }
    
    const metrics: CacheMetrics = { hits: 0, misses: 0, stale: 0 };
    
    export function getCacheMetrics(): CacheMetrics & { hitRate: string } {
      const total = metrics.hits + metrics.misses + metrics.stale;
      const hitRate = total > 0 ? ((metrics.hits / total) * 100).toFixed(1) + "%" : "N/A";
      return { ...metrics, hitRate };
    }
    

レビューチェックリスト

  • 公開読み取り専用 API(Search、Catalog)のレスポンスはすべて、CDN および/または BFF レベルでキャッシュされていますか?
  • トランザクション/個人情報を扱う API(Checkout、Profile、OMS、Payments)のレスポンスには、no-store ヘッダーを設定して明示的にキャッシュしないようにしていますか?
  • すべてのキャッシュに上限付き TTL(永久/無期限ではない)が設定されていますか?
  • キャッシュ無効化の仕組み(TTL とイベント駆動または手動による消去)がありますか?
  • キャッシュキーはユーザー ID ではなく、リクエストパラメーターに基づいていますか?
  • 鮮度とパフォーマンスの最適なバランスを得るために stale-while-revalidate を使用していますか?
  • TTL は極端に長い(数時間/数日)のではなく、適度な長さ(2~15 分)ですか?
  • キャッシュの可観測性(X-Cache ヘッダー、ヒット/ミスのメトリクス)が確保されていますか?

参考資料

コメント