npx skills add ...
npx skills add vtex/skills --skill vtex-io-application-performance
Apply when improving VTEX IO Node or .NET services for latency, throughput, and resilience: in-process LRU, VBase, stale-while-revalidate, AppSettings loading, request context, parallel client calls, and avoiding duplicate work. Covers application-level performance patterns that complement edge/CDN caching. Use when optimizing backends beyond route-level Cache-Control.
npx skills add vtex/skills --skill vtex-io-application-performance
Use this skill when you optimize VTEX IO backends (typically Node with @vtex/api / Koa-style middleware, or .NET services) for performance and resilience: caching, deduplicating work, parallel I/O, and efficient configuration loading—not only “add a cache.”
Promise.all) instead of serial waterfallsctx.clients (e.g. vbase) into client helpers or resolvers so caches are testable and explicitDo not use this skill for:
/_v/private vs public paths or Cache-Control at the edge → vtex-io-service-paths-and-cdn@cacheControl field semantics only → vtex-io-graphql-apitrySaveIfhashMatches when the client supports concurrency-safe updates (see Clients).ctx.state for per-request deduplication (e.g. “already loaded org for this request”). Use global module cache only for immutable or TTL-refreshed app data; account and workspace live on ctx.vtex—always include them in in-memory cache keys when the same pod serves multiple tenants.ctx.clients call and external HTTP request must have an explicit timeout. Use @vtex/api client options (timeout, retries, exponentialTimeoutCoefficient) to tune per-client behavior. Unbounded waits are the top cause of cascading failures in distributed systems.getCostCenter in the resolver and again inside a helper), deduplicate: call once, pass the result through, or stash in ctx.state. Serial waterfalls of 7+ calls that could be 3 parallel + 1 sequential are the top performance sink.Promise.all — Group independent calls into parallel phases. Phase 1: Promise.all([getOrderForm(), getCostCenter(), getSession()]). Phase 2 (depends on Phase 1): getSkuMetadata(). Phase 3 (depends on Phase 2): generatePrice(). Never await six calls sequentially when only two depend on each other.setManualPrice per cart item), use Promise.all instead of a sequential loop. Each await in a loop adds a full round-trip.sku:{region}:{skuId}) instead of composite blobs (e.g. allSkus:{sortedCartSkuIds}). Per-entity keys dramatically increase cache hit rates when items are added/removed.{ skuId, mappedId, isSpecialItem } at ~50 bytes) instead of the full API response (~10-50 KB per product). Reduces VBase storage, serialization time, and transfer size.vbase as a parameter — Clients don't have direct access to other clients. Pass ctx.clients.vbase as a parameter to client methods or utilities that need it. This keeps code testable and explicit about dependencies.current-operation.json (lock + progress), heartbeat extensions, checkpoint/resume, and TTL-based lock expiry to prevent zombie locks.service.json tuningtimeout — Maximum seconds before the platform kills a request. Set based on the longest expected operation; do not leave at the default if your resolver calls slow upstreams.memory — MB per worker. Increase if LRU caches or large payloads cause OOM; monitor actual usage before over-provisioning.workers — Concurrent request handlers per replica. More workers handle more concurrent requests but each shares the memory budget and in-process LRU.minReplicas / maxReplicas — Controls horizontal scaling under load. Note: all pods scale to zero when the app receives no requests for the duration of its ttl (max 60 minutes). minReplicas only governs the floor while traffic exists—it does not keep pods alive indefinitely. Design for cold starts: first request after idle spins up a new pod, so keep LRU warm-up cost low and avoid relying on in-memory state surviving idle periods.Pods can be shared across accounts, but every request is always scoped to a specific {account, workspace} by the platform—ctx.vtex.account and ctx.vtex.workspace are set by the runtime, not by the developer. All platform APIs (VBase, app buckets, Master Data, etc.) automatically partition data by account/workspace, so their responses are tenant-safe by design. The only risk is in-process state: LRU caches, module-level Maps, or global variables that you manage—these are not partitioned by the platform and must be keyed explicitly with ctx.vtex.account and ctx.vtex.workspace (plus entity id) so two consecutive requests for different accounts on the same pod cannot read each other’s entries.
Global or module-level maps must not store PII, tokens, or authorization-sensitive blobs keyed only by user id or email without account and workspace (and any other dimension needed for isolation).
Why this matters — Pods are multi-tenant: the same process may serve many accounts in sequence. VBase and similar APIs are scoped to the current account/workspace, but an in-memory Map is your responsibility. Missing account/workspace in the key risks cross-tenant reads from warm cache.
Detection — A module-scope Map keyed only by userId or email; or cache keys that omit ctx.vtex.account / ctx.vtex.workspace when the value is tenant-specific.
Correct — Build keys from ctx.vtex.account, ctx.vtex.workspace, and the entity id; never store app tokens in VBase/LRU as plain cache values; prefer ctx.clients and platform auth.
Wrong — globalUserCache.set(email, profile) keyed only by email, with no account/workspace segment—unsafe on shared pods even though a later VBase read would be account-scoped, because this map is not partitioned by the platform.
When VBase serves as an idempotency store (e.g. payment connectors storing transaction state) or a data-integrity store, writes must be awaited. Fire-and-forget writes risk silent failure: a successful upstream operation (e.g. a charge) whose VBase record is lost causes a duplicate on the next retry.
For payment connectors, the first line of defense is the acquirer/gateway idempotency feature: pass the VTEX paymentId as the idempotency key to the external payment provider (e.g. via an Idempotency-Key HTTP header). This ensures the provider itself rejects duplicate charges even if VBase state is lost. VBase remains essential for local state tracking and for returning the correct response to VTEX Gateway retries, but the external idempotency key prevents the worst outcome (double charge) at the provider level.
Why this matters — VTEX Gateway retries payment calls with the same paymentId. If VBase write fails silently after a successful authorization and the acquirer has no idempotency key, the connector sends another payment request—causing a duplicate charge. With the acquirer idempotency key in place, the provider rejects the duplicate; without it, VBase is the only safeguard.
Detection — A VBase saveJSON or saveOrUpdate call without await in a payment, settlement, refund, or any flow where the stored value is the only record preventing re-execution. Also check whether the external payment call includes an idempotency key header.
Correct — Use acquirer idempotency key and await VBase writes.
Wrong — No acquirer idempotency key and fire-and-forget VBase write.
Payment statuses and active transaction state must never be cached—they are strictly real-time. For other transactional data like order forms, cart simulations, and session state, the default is no cache, but short-lived caches (e.g. 1–5 minutes) can be acceptable when the use case tolerates brief staleness. This is a case-by-case decision: explicitly confirm that the consumer can handle stale data for the chosen TTL before caching.
Why this matters — Serving a cached order form may show phantom items or stale prices. Caching payment responses could return a previous transaction's status. However, for read-heavy scenarios where the same data is requested repeatedly within seconds (e.g. a product listing page fetching simulation data for multiple components), a short-lived cache can dramatically reduce load on upstream APIs without meaningful user impact.
Detection — LRU or VBase keys like orderForm:{id}, cartSim:{hash}, paymentResponse:{id}, or session:{token} used for read-through caching without an explicit TTL decision or justification. Long TTLs (>5 min) on transactional data are almost always wrong. Any cache on payment responses or active transaction state is wrong.
Correct — Separate reference data (always cache) from transactional data (default no cache, short TTL only with explicit justification).
Wrong — Long-lived cache on transactional data, or any cache on payment state.
Stale-while-revalidate or origin calls must not add unbounded latency to checkout-critical middleware if the platform SLA requires a fast response.
Why this matters — Blocking checkout on optional enrichment breaks conversion and reliability.
Detection — A cart or payment resolver awaits VBase refresh or external API before returning; no timeout or fallback.
Correct — Return stale or default; enqueue refresh; fail open where business rules allow.
Wrong — await fetchHeavyPartner() in the hot path with no timeout.
ctx.state flags when a resolver chain might call the same loader twice.ctx.clients calls in phased Promise.all groups.getCostCenter called in the resolver and again inside a helper; getSession called twice in the same flow. Each duplicate adds a full round-trip.sortedCartSkuIds means adding 1 item to a cart of 10 requires a full re-fetch instead of 1 lookup.console.log in hot paths — Logging full response objects with template literals produces [object Object]; use ctx.vtex.logger with JSON.stringify and redact sensitive data.account + workspace (and entity id) when values are tenant-specific?Promise.all phases) where safe?@vtex/api client options or equivalent)?service.json resource limits (timeout, memory, workers, replicas) tuned for the workload?ctx.vtex.logger (not console.log) with sensitive data redacted?vbase client methodsconst { paymentId } = authorization
// Pass VTEX paymentId as idempotency key to the acquirer
const response = await ctx.clients.paymentProvider.authorize(paymentData, {
headers: { 'Idempotency-Key': paymentId },
})
// Await VBase write for local state tracking
await ctx.clients.vbase.saveJSON<Transaction>('transactions', paymentId, transactionData)
return Authorizations.approve(authorization, { ... })const { paymentId } = authorization
// No idempotency key to the acquirer + no await on VBase
const response = await ctx.clients.paymentProvider.authorize(paymentData)
ctx.clients.vbase.saveJSON('transactions', paymentId, transactionData)
return Authorizations.approve(authorization, { ... })// Reference data: cached (changes rarely)
const costCenter = await getCostCenterCached(ctx, costCenterId);
const sellerList = await getSellerListCached(ctx);
// Transactional data: default live, but short cache is acceptable if justified
const orderForm = await ctx.clients.checkout.orderForm();
const simulation = await ctx.clients.checkout.simulation(payload);
// Acceptable: short-lived cache for a read-heavy page (e.g. simulation results
// reused across multiple components within the same page render, TTL < 60s)
// Only after confirming staleness is acceptable for this specific consumer.// Never cache payment responses or transaction status
const paymentCache = lru({ max: 1000, ttl: 300_000 });
// Avoid: long TTL on order form without explicit justification
const orderFormCache = lru({ max: 500, ttl: 600_000 }); // 10 min is too long// BEFORE: 7 sequential awaits, 2 duplicate calls
const settings = await getAppSettings(ctx); // 1
const session = await getSessions(ctx); // 2
const costCenter = await getCostCenter(ctx, ccId); // 3
const orderForm = await getOrderForm(ctx); // 4
const skus = await getSkuById(ctx, skuIds); // 5
const price = await generatePrice(ctx, costCenter); // 6 (calls getCostCenter AGAIN + getSession AGAIN)
for (const item of items) {
await setManualPrice(ctx, item); // 7, 8, 9... (sequential loop)
}
// AFTER: 3 phases, no duplicates, parallel mutations
const settings = await getAppSettings(ctx);
const [session, costCenter, orderForm] = await Promise.all([
getSessions(ctx),
getCostCenter(ctx, ccId),
getOrderForm(ctx),
]);
const skus = await getSkuMetadataBatch(ctx, skuIds); // per-entity VBase cache
const price = await generatePrice(ctx, costCenter, session); // reuse, no re-fetch
await Promise.all(items.map((item) => setManualPrice(ctx, item)));interface SkuMetadata {
skuId: string;
mappedSku: string | null;
isSpecialItem: boolean;
}
async function getSkuMetadataBatch(
ctx: Context,
skuIds: string[],
): Promise<Map<string, SkuMetadata>> {
const { vbase, search } = ctx.clients;
const results = new Map<string, SkuMetadata>();
// Phase 1: check VBase for each SKU in parallel
const lookups = await Promise.allSettled(
skuIds.map((id) => vbase.getJSON<SkuMetadata>("sku-metadata", `sku:${id}`)),
);
const missing: string[] = [];
lookups.forEach((result, i) => {
if (result.status === "fulfilled" && result.value) {
results.set(skuIds[i], result.value);
} else {
missing.push(skuIds[i]);
}
});
if (missing.length === 0) return results;
// Phase 2: fetch only missing SKUs from API
const products = await search.getSkuById(missing);
// Phase 3: cache ALL sibling SKUs (prewarm)
for (const product of products) {
for (const sku of product.items) {
const metadata: SkuMetadata = {
skuId: sku.itemId,
mappedSku: extractMapping(sku),
isSpecialItem: checkSpecial(sku),
};
results.set(sku.itemId, metadata);
// Fire-and-forget for prewarming (not idempotency-critical)
vbase
.saveJSON("sku-metadata", `sku:${sku.itemId}`, metadata)
.catch(() => {});
}
}
return results;
}