Performance audit rules
Audit rules to verify that a Front-Commerce project delivers fast server responses and good client-side performance.
These rules cover both server-side performance (caching, data fetching,
lifecycle hooks) and client-side performance (fonts, images, Core Web Vitals).
Each rule describes a recurring finding from real Front-Commerce project audits.
Apply them to the project repository (skeleton structure:
front-commerce.config.ts, app/, extensions/) and, when the detection mode
requires it, to a running environment.
AUDIT-PERF-01 — Enable the native Front-Commerce caches
Severity: important — Detection: static
Rule: The cart cache, the current customer cache, and every cache offered by an installed extension (for example the Strapi content cache) must be enabled in all deployed environments, unless a documented reason disables them.
Why: These caches are opt-in. When they stay disabled, every request re-fetches the cart and customer data from the backend, which multiplies backend load and response times as traffic grows. This is the single most frequent finding in real Front-Commerce audits.
How to check:
-
Grep the environment files and deployment values:
grep -rn "FRONT_COMMERCE_CART_CACHE_ENABLE\|FRONT_COMMERCE_CURRENT_CUSTOMER_CACHE_ENABLE" \.env .env.distA violation is any variable that is absent, commented out, or set to anything other than
true. Also inspect the deployment configuration (CI variables, container environment, Helm values) — a value present in.env.distbut missing from the deployed environment is still a violation. -
Check that a persistent caching strategy is configured in
app/config/caching.js(typicallyimplementation: "Redis"withsupports: "*"). No strategy configured means DataLoader results are only cached per request. -
For each installed extension that ships a cache (Strapi, Magento price caches, and others), verify its cache-related configuration or environment variables are enabled. List extensions from
front-commerce.config.tsand cross-check each extension's caching documentation.
AUDIT-PERF-02 — Make DataLoader batching effective
Severity: important — Detection: static
Rule: Every makeDataLoader batch function must accept an array of keys
((ids: string[]), never (id: string)), and the DataLoader instance must be
created once per loader (typically in the loader constructor), never inside the
load() method or per resolver call.
Why: A batch function typed for a single id silently degrades to one HTTP call per entity — a category page listing 20 products triggers 20 upstream calls. A DataLoader instance re-created per call loses both batching and per-request memoization. Both mistakes have been observed repeatedly in integrator projects (one project had nine single-id batch functions).
How to check:
-
List all DataLoader creations:
grep -rn "makeDataLoader" extensions/ -
For each occurrence, read the batch function signature. A violation is a batch function whose first parameter is a single id, or a batch function that maps over ids and issues one upstream call per id when the upstream API offers a batch or search endpoint.
-
Check where the instance is created. A violation is a
makeDataLoader(...)call inside aload/loadByIdmethod body instead of the loader class constructor or module scope. -
Runtime confirmation (optional): start the app with
DEBUG=axios, load a category page, and count upstream calls. One call per listed product confirms broken batching.
AUDIT-PERF-03 — Encode every payload variant in the DataLoader namespace
Severity: critical — Detection: static + manual
Rule: When the payload fetched by a DataLoader depends on a configuration
axis (store, locale, currency, customer group, price grid, feature flag), that
axis must be part of the DataLoader namespace (for example
CatalogProductPrices_${gridId}) or handled by a variant-aware caching strategy
in app/config/caching.js.
Why: DataLoader caches by key within a namespace. If the same key maps to different payloads depending on the current store or configuration, one store's cached data is served to another store's visitors — wrong prices, wrong language, or another customer segment's data. This is a data-leak class bug, not just a slowdown.
How to check:
-
List custom loaders and their namespaces:
grep -rn "makeDataLoader(" extensions/ -
For each namespace, ask: does the batch function read anything that varies per store, locale, currency, or customer (a config value, a header, the current shop)? If yes, the varying value must appear in the namespace string or be covered by a per-variant strategy (such as
PerMagentoCustomerGroup) inapp/config/caching.js. -
Pay special attention to multi-store setups: check
front-commerce.config.ts(or the stores configuration) for multiple stores, then verify that every loader whose upstream call includes the store scope also includes it in its namespace.
AUDIT-PERF-04 — Fetch collections in parallel, never sequentially in a loop
Severity: important — Detection: static
Rule: Route loaders, resolvers, and loader classes must not await
independent calls sequentially — neither one after the other nor inside a for
loop. Independent calls use Promise.all (or Promise.allSettled when partial
failure is acceptable); per-item data moves into the child field resolver so
DataLoader batching applies.
Why: Sequential awaits serialize network latency: three 200 ms calls cost 600 ms instead of 200 ms. A fetch loop over a parent list also hides N+1 problems from reviewers and bypasses the DataLoader cache.
How to check:
-
Grep for awaits inside loops:
grep -rn "for (" --include="*.ts" --include="*.tsx" -A 3 app/routes extensions/ | grep "await"Also enable the ESLint rule
no-await-in-loopfor a mechanical pass. -
Read every Remix
loaderinapp/routes/that contains more than oneawait. A violation is two or more independentawaited calls not wrapped inPromise.all. -
In GraphQL resolvers, a violation is a parent resolver iterating over its children to fetch each child's data (
for (const item of items) { item.x = await ... }) instead of resolving the field on the child type.
AUDIT-PERF-05 — Set Cache-Control through the CacheControl service
Severity: important — Detection: static + runtime
Rule: Cacheable routes must define their Cache-Control headers through the
CacheControl service (app.services.CacheControl.setCacheable(...)) with a
CDN-oriented strategy (sMaxAge plus staleWhileRevalidate), never by setting
the header manually on the response.
Why: Manually written headers bypass Front-Commerce's guarantees (the service prevents caching responses that carry private data) and are the historical cause of cached-personalized-content incidents. Missing headers on the home page, category, product, and CMS pages forfeit CDN caching entirely, so every anonymous visitor hits the Node.js server.
How to check:
-
Detect hand-written headers — any match is a violation:
grep -rn '"Cache-Control"\|cache-control' app/ extensions/ --include="*.ts" --include="*.tsx" \| grep -v "CacheControl" -
Inventory service usage and compare with the key page types:
grep -rn "CacheControl.setCacheable" app/routes extensions/Custom routes serving anonymous, shared content (home variants, landing pages, CMS routes) without a
setCacheablecall are findings. -
Runtime: verify headers on a deployed URL for each page type:
curl --silent -I https://example.com/ | grep -i cache-controlExpect a
publicpolicy withs-maxageon shared pages; checkX-Cache: HITon a second request when a caching proxy is in place.
AUDIT-PERF-06 — Define shouldRevalidate deliberately on custom routes
Severity: important — Detection: static + manual
Rule: Routes and layouts whose loader data is stable across client-side
navigations must export a shouldRevalidate that skips useless revalidation —
and any shouldRevalidate returning false must still revalidate when the URL
params change.
Why: Without shouldRevalidate, Remix re-runs layout loaders on every
navigation, multiplying backend calls for data that did not change (menus,
footer content). The inverse trap is worse: a blanket return false on a
dynamic route freezes the first path's content — navigating from one product to
another keeps showing the first product's data.
How to check:
-
Inventory existing exports:
grep -rn "shouldRevalidate" app/routes -
For each
shouldRevalidatefound, read its body. A violation is a constantfalse(or a function ignoring its arguments) on a route with dynamic segments — it must comparecurrentParams/nextParams(orcurrentUrl/nextUrl) and revalidate when they differ. -
For routes with no
shouldRevalidate: identify layout routes whose loader fetches navigation or global content, then confirm (manual judgment) whether re-running the loader on every navigation is intended. Runtime confirmation: navigate between pages with the network panel open and count loader requests.
AUDIT-PERF-07 — Keep server lifecycle hooks and middlewares free of per-request I/O
Severity: important — Detection: static
Rule: onServerServicesInit and custom Express middlewares must only wire
objects and map data. Any I/O (HTTP call, Redis, filesystem) they need must be
memoized with a TTL or moved to a lazy code path, and per-process initialization
(such as the request handler) must happen once, not per request.
Why: These code paths run on every request, so their cost multiplies by request volume. One extra Redis or HTTP call per request has historically been enough to saturate production servers under moderate load.
How to check:
-
Locate the hooks and middlewares:
grep -rn "onServerServicesInit\|onServerInit\|app.use(" extensions/ app/ --include="*.ts" -
Read each
onServerServicesInitbody. A violation is anyawaiton a network, Redis, or filesystem call executed unconditionally (not behind a TTL memoization). -
Read each custom Express middleware. Violations: outbound calls on every request without caching, synchronous filesystem reads, or objects (handlers, clients) constructed inside the middleware body instead of once at module scope.
AUDIT-PERF-08 — Instrument custom code with Server-Timing or OpenTelemetry
Severity: minor — Detection: static + runtime
Rule: Custom loaders and routes performing external calls should record
timings through the ServerTimings service, and production observability
(OpenTelemetry instrumentation or equivalent) should be configured.
Why: Without instrumentation, a slow third-party API is indistinguishable from a slow Front-Commerce server. Projects lacking Server-Timing on their custom integrations systematically take longer to diagnose production slowness.
How to check:
-
Grep for usage in custom code:
grep -rn "ServerTimings" app/ extensions/No occurrence in a project with custom external integrations is a finding.
-
Runtime (non-production, or with
FRONT_COMMERCE_FORCE_ENABLE_SERVER_TIMINGS=true):curl --silent -I http://localhost:4000/ | grep -i server-timingVerify custom integrations appear as named timings, not only the built-in ones.
-
Check for OpenTelemetry configuration (instrumentation setup, OTel exporter environment variables) in the deployment configuration.
AUDIT-PERF-09 — Bound every server-side memoization
Severity: important — Detection: runtime + manual
Rule: Every memoization or module-scope cache in server code (memoize,
lodash-es/memoize, new Map() used as cache, LRU without limits) must be
bounded by a TTL, a maximum size, or both.
Why: An unbounded memoization keyed by request-derived values (URLs, ids, tokens) grows for the lifetime of the process. This is a slow memory leak that ends in out-of-memory crashes in production, typically days after deployment, which makes it expensive to trace back.
How to check:
-
Locate candidates:
grep -rn "memoize\|new Map()\|new WeakMap()\|lru" extensions/ app/ --include="*.ts" \| grep -v spec -
For each module-scope cache in server-only code, check for a bound: a TTL option, a
maxsize, or an explicit eviction call.lodashmemoizehas no bound by default — its presence on a function taking request-derived arguments is a violation unlessmemoize.Cacheis replaced by a bounded implementation. -
Runtime: run a sustained load test (for example
hey -c 10 -q 10 -z 60s) against pages exercising the suspected code and watch the process RSS. A monotonically growing RSS that never stabilizes confirms the leak.
AUDIT-PERF-10 — Serve optimized, preloaded fonts
Severity: minor — Detection: static
Rule: Custom fonts must be self-hosted in WOFF2 format, critical fonts must
be preloaded (in Front-Commerce, name them with the .priority.woff2 suffix),
and every @font-face must declare font-display: fallback or
font-display: optional.
Why: Non-preloaded or TTF-only fonts delay text rendering and cause layout shifts (poor LCP and CLS), directly hurting Core Web Vitals and SEO. Third-party font CDNs add a connection cost on first visit.
How to check:
-
Inventory font files and formats:
find app public -name "*.woff*" -o -name "*.ttf" -o -name "*.otf"Fonts shipped only as TTF/OTF are a finding. Critical (above-the-fold) fonts not named
*.priority.woff2are a finding. -
Check declarations:
grep -rn "font-display\|@font-face" app/ --include="*.scss" --include="*.css"A
@font-facewithoutfont-display: fallbackoroptionalis a violation. -
Detect third-party font loading (violation if a self-hosted equivalent is possible):
grep -rn "fonts.googleapis\|fonts.gstatic\|use.typekit" app/
AUDIT-PERF-11 — Serve optimized images through the Image component
Severity: important — Detection: static + runtime
Rule: All theme images must go through the Front-Commerce <Image>
component (which applies presets, next-gen formats, and lazy loading);
above-the-fold images must set the priority prop; source assets committed to
the repository must be reasonably sized (no multi-megabyte originals).
Why: A single unoptimized hero image can dominate the page weight — a real
audit found a 10 MB image on a home page. Missing priority on the LCP image
delays it behind lazy loading; missing lazy loading on below-the-fold images
wastes bandwidth. All of this degrades LCP and mobile experience.
How to check:
-
Find oversized committed assets — anything above ~500 KB deserves scrutiny:
find public app -type f \( -name "*.jpg" -o -name "*.jpeg" -o -name "*.png" -o -name "*.webp" \) -size +500k -
Detect raw
imgtags bypassing the component:grep -rn "<img " app/ --include="*.tsx"Each match outside justified cases (tracking pixels, email templates) is a finding.
-
Check the LCP candidates (home hero, first carousel slide, PDP main image): the component usage must include
priority. Grep the relevant components forpriority. -
Runtime: load the home page with DevTools, sort network requests by size, and identify images served above ~300 KB or without a next-gen format (
webp/avif).
AUDIT-PERF-12 — Meet Core Web Vitals targets and keep the JS bundle chunked
Severity: important — Detection: runtime
Rule: Key page types (home, category, product, CMS) must reach "good" Core Web Vitals thresholds (LCP ≤ 2.5 s, CLS ≤ 0.1, INP ≤ 200 ms) on a mobile Lighthouse run, and the client JavaScript must be split into reasonable chunks — no single vendor bundle carrying the whole application.
Why: Core Web Vitals are a search-ranking factor and the best proxy for perceived speed. An unchunked bundle forces every visitor to download and parse code for pages they never visit, and typically regresses INP and LCP together.
How to check:
-
Run Lighthouse against the deployed URL for each page type:
npx lighthouse https://example.com/ --preset=perf --form-factor=mobile --output=jsonRecord LCP, CLS, INP (or TBT as a lab proxy) per page type. Any metric in the "poor" range is a finding; "needs improvement" on the home page is worth reporting.
-
Analyze the client bundle:
npx vite-bundle-analyzerFindings: a chunk above ~500 KB gzipped, server-only code present in client chunks, or a heavy library (charting, maps, rich-text editor) loaded on routes that never use it instead of a dynamic
import(). -
Cross-check field data when available (CrUX for the origin) — lab-only results can miss real-device regressions.