Loading...
Loading...
Apply when implementing caching logic, CDN configuration, or performance optimization for a headless VTEX storefront. Covers which VTEX APIs can be cached (Intelligent Search, Catalog) versus which must never be cached (Checkout, Profile, OMS), stale-while-revalidate patterns, cache invalidation, and BFF-level caching. Use for any headless project that needs TTL rules and caching strategy guidance.
npx skill4agent add vtexdocs/ai-skills headless-caching-strategystale-while-revalidatestale-while-revalidate| API | Example Endpoints | Recommended TTL |
|---|---|---|
| Intelligent Search | | 2-5 minutes |
| Catalog (public) | | 5-15 minutes |
| Intelligent Search autocomplete | | 1-2 minutes |
| Intelligent Search top searches | | 5-10 minutes |
| API | Example Endpoints | Why Not Cacheable |
|---|---|---|
| Checkout | | Cart data is per-user, changes with every action |
| Profile | | Personal data, GDPR/LGPD sensitive |
| OMS (Orders) | | Order status changes, user-specific |
| Payments | | Financial transactions, must always be real-time |
| Pricing (private) | | May have per-user pricing rules |
Cache-ControlCache-Controlstale-while-revalidateCache-Control: public, max-age=120, stale-while-revalidate=60Frontend (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)Cache-Control// 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
});setCache-Controlmax-age > 0// 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);
});max-age// 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 { IdSku, productId } = req.body as { IdSku?: string; productId?: string };
if (productId) {
productCache.delete(productId);
console.log(`Cache invalidated for product ${productId}`);
}
// Also invalidate related search cache entries
// In production, use a more sophisticated invalidation strategy
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);
});Cache-Control// If you're using a CDN worker/edge function to add cache headers:
// 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;
}// 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/routes/catalog.ts
import { Router, Request, Response } from "express";
import { getCachedOrFetch, invalidateCache } from "../cache/redis-cache";
const router = Router();
const VTEX_ACCOUNT = process.env.VTEX_ACCOUNT!;
const VTEX_BASE = `https://${VTEX_ACCOUNT}.vtexcommercestable.com.br`;
// Category tree — long cache, changes rarely
router.get("/categories", async (_req: Request, res: Response) => {
try {
const result = await getCachedOrFetch(
"catalog:categories",
async () => {
const response = await fetch(`${VTEX_BASE}/api/catalog_system/pub/category/tree/3`);
return response.json();
},
{ ttlSeconds: 900, swrSeconds: 300 } // 15 min cache, 5 min SWR
);
res.set("X-Cache", result.cacheStatus);
res.json(result.data);
} catch (error) {
console.error("Error fetching categories:", error);
res.status(500).json({ error: "Failed to fetch categories" });
}
});
// Product details — moderate cache
router.get("/products/:productId", async (req: Request, res: Response) => {
const { productId } = req.params;
if (!/^\d+$/.test(productId)) {
return res.status(400).json({ error: "Invalid product ID" });
}
try {
const result = await getCachedOrFetch(
`catalog:product:${productId}`,
async () => {
const response = await fetch(
`${VTEX_BASE}/api/catalog_system/pub/products/search?fq=productId:${productId}`
);
return response.json();
},
{ ttlSeconds: 300, swrSeconds: 60 } // 5 min cache, 1 min SWR
);
res.set("X-Cache", result.cacheStatus);
res.json(result.data);
} catch (error) {
console.error("Error fetching product:", error);
res.status(500).json({ error: "Failed to fetch product" });
}
});
// Cart simulation — very short cache (same cart config may be checked by many users)
router.post("/simulation", async (req: Request, res: Response) => {
const cacheKey = `catalog:simulation:${JSON.stringify(req.body)}`;
try {
const result = await getCachedOrFetch(
cacheKey,
async () => {
const response = await fetch(
`${VTEX_BASE}/api/checkout/pub/orderForms/simulation`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(req.body),
}
);
return response.json();
},
{ ttlSeconds: 30, swrSeconds: 10 } // 30 sec cache, 10 sec SWR
);
res.set("X-Cache", result.cacheStatus);
res.json(result.data);
} catch (error) {
console.error("Error simulating cart:", error);
res.status(500).json({ error: "Failed to simulate cart" });
}
});
// Webhook for catalog changes — invalidates affected cache
router.post("/webhooks/catalog", async (req: Request, res: Response) => {
const { productId } = req.body as { productId?: string };
if (productId) {
await invalidateCache(`catalog:product:${productId}`);
}
// Invalidate category tree on any catalog change
await invalidateCache("catalog:categories");
res.status(200).json({ received: true });
});
export default router;// server/middleware/cache-headers.ts
import { Request, Response, NextFunction } from "express";
// Middleware to set appropriate cache headers based on route type
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);
const PORT = process.env.PORT || 3001;
app.listen(PORT, () => {
console.log(`BFF server running on port ${PORT}`);
});// 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}`;
}
// For trade-policy-specific pricing, include trade policy (not user ID)
function buildTradePolicyCacheKey(path: string, params: Record<string, string>, tradePolicy: string): string {
return `search:tp${tradePolicy}:${path}:${new URLSearchParams(params).toString()}`;
}stale-while-revalidate// Moderate TTL with stale-while-revalidate — balances freshness and performance
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;// Add cache observability to every cached response
import { Request, Response, NextFunction } from "express";
interface CacheMetrics {
hits: number;
misses: number;
stale: number;
}
const metrics: CacheMetrics = { hits: 0, misses: 0, stale: 0 };
export function trackCacheMetrics(req: Request, res: Response, next: NextFunction): void {
const originalJson = res.json.bind(res);
res.json = function (body: unknown) {
const cacheStatus = res.getHeader("X-Cache") as string;
if (cacheStatus === "HIT") metrics.hits++;
else if (cacheStatus === "MISS") metrics.misses++;
else if (cacheStatus === "STALE") metrics.stale++;
return originalJson(body);
};
next();
}
// Expose metrics endpoint for monitoring
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 };
}