Skip to main content
Version: next

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:

  1. Grep the environment files and deployment values:

    grep -rn "FRONT_COMMERCE_CART_CACHE_ENABLE\|FRONT_COMMERCE_CURRENT_CUSTOMER_CACHE_ENABLE" \
    .env .env.dist

    A 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.dist but missing from the deployed environment is still a violation.

  2. Check that a persistent caching strategy is configured in app/config/caching.js (typically implementation: "Redis" with supports: "*"). No strategy configured means DataLoader results are only cached per request.

  3. 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.ts and 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:

  1. List all DataLoader creations:

    grep -rn "makeDataLoader" extensions/
  2. 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.

  3. Check where the instance is created. A violation is a makeDataLoader(...) call inside a load/loadById method body instead of the loader class constructor or module scope.

  4. 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:

  1. List custom loaders and their namespaces:

    grep -rn "makeDataLoader(" extensions/
  2. 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) in app/config/caching.js.

  3. 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:

  1. 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-loop for a mechanical pass.

  2. Read every Remix loader in app/routes/ that contains more than one await. A violation is two or more independent awaited calls not wrapped in Promise.all.

  3. 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:

  1. Detect hand-written headers — any match is a violation:

    grep -rn '"Cache-Control"\|cache-control' app/ extensions/ --include="*.ts" --include="*.tsx" \
    | grep -v "CacheControl"
  2. 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 setCacheable call are findings.

  3. Runtime: verify headers on a deployed URL for each page type:

    curl --silent -I https://example.com/ | grep -i cache-control

    Expect a public policy with s-maxage on shared pages; check X-Cache: HIT on 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:

  1. Inventory existing exports:

    grep -rn "shouldRevalidate" app/routes
  2. For each shouldRevalidate found, read its body. A violation is a constant false (or a function ignoring its arguments) on a route with dynamic segments — it must compare currentParams/nextParams (or currentUrl/nextUrl) and revalidate when they differ.

  3. 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:

  1. Locate the hooks and middlewares:

    grep -rn "onServerServicesInit\|onServerInit\|app.use(" extensions/ app/ --include="*.ts"
  2. Read each onServerServicesInit body. A violation is any await on a network, Redis, or filesystem call executed unconditionally (not behind a TTL memoization).

  3. 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:

  1. Grep for usage in custom code:

    grep -rn "ServerTimings" app/ extensions/

    No occurrence in a project with custom external integrations is a finding.

  2. Runtime (non-production, or with FRONT_COMMERCE_FORCE_ENABLE_SERVER_TIMINGS=true):

    curl --silent -I http://localhost:4000/ | grep -i server-timing

    Verify custom integrations appear as named timings, not only the built-in ones.

  3. 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:

  1. Locate candidates:

    grep -rn "memoize\|new Map()\|new WeakMap()\|lru" extensions/ app/ --include="*.ts" \
    | grep -v spec
  2. For each module-scope cache in server-only code, check for a bound: a TTL option, a max size, or an explicit eviction call. lodash memoize has no bound by default — its presence on a function taking request-derived arguments is a violation unless memoize.Cache is replaced by a bounded implementation.

  3. 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:

  1. 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.woff2 are a finding.

  2. Check declarations:

    grep -rn "font-display\|@font-face" app/ --include="*.scss" --include="*.css"

    A @font-face without font-display: fallback or optional is a violation.

  3. 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:

  1. 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
  2. Detect raw img tags bypassing the component:

    grep -rn "<img " app/ --include="*.tsx"

    Each match outside justified cases (tracking pixels, email templates) is a finding.

  3. Check the LCP candidates (home hero, first carousel slide, PDP main image): the component usage must include priority. Grep the relevant components for priority.

  4. 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:

  1. Run Lighthouse against the deployed URL for each page type:

    npx lighthouse https://example.com/ --preset=perf --form-factor=mobile --output=json

    Record 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.

  2. Analyze the client bundle:

    npx vite-bundle-analyzer

    Findings: 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().

  3. Cross-check field data when available (CrUX for the origin) — lab-only results can miss real-device regressions.