---
title: "Sample performance audit report"
description: "The full deliverable of a Magento performance audit, on a fictional store: eleven findings, each with the evidence behind it, what the fix costs, and a plan ordered by return. It ends with the probes, so you can run the same checks on your own store."
language: "en"
canonical: "https://zapolu.com/downloads/sample-audit-report/"
---

# Performance audit: deep dive (sample report)

> **This is a sample.** The client "Acme Outdoor s.r.o." does not
> exist, and the identifying details are changed. The findings and the
> figures are composites, anonymized, drawn from real performance
> audits that Luboš Zápotočný carried out across a decade of
> e-commerce engineering work, before this practice was founded. They
> are not engagements delivered under the Zapolu brand, which has no
> client track record of its own yet; the record behind them is the
> prior work set out on the
> [About page](https://zapolu.com/about/). What the sample shows is
> the format: how a finding is evidenced, what its fix costs, and how
> the work is ranked. A delivered report gives every finding the
> treatment F1 gets below; this sample shortens six of the eleven to
> keep it readable.

| | |
|---|---|
| **Client** | Acme Outdoor s.r.o. (fictional) |
| **Store** | acme-outdoor.example (Magento 2.4.6, Luma frontend) |
| **Stack** | Varnish 7, Redis 7, MySQL 8, 2 app nodes behind a load balancer |
| **Report date** | 5 June 2026 |
| **Field data** | 7 May to 3 June 2026 (28 days) |
| **APM traces** | 21 May to 3 June 2026 (14 days), plus one load test |
| **Tooling** | New Relic, CrUX, Lighthouse, k6, varnishstat, MySQL slow log |
| **Author** | Luboš Zápotočný, Zapolu s.r.o. |
| **Engagement** | Deep dive, 7 business days, fixed price |

**For a brief overview,** read sections 1 and 5. The sections in
between contain the supporting evidence.

## 1. Executive summary

The store's slowness originates on the server side. Field LCP on
catalog pages is **4.6 s at p75**
(target: ≤ 2.5 s), driven by a **1.9 s TTFB**. The TTFB is high
because the full-page cache is effectively disabled: Varnish serves
only **34 %** of catalog requests, and 38 % of all server time goes
into rendering product pages at 2.2 s average. On the campaign
evening of 26 May the checkout p95 reached **6.1 s** with **3.4 % of
requests failing**, while application CPU never exceeded 12 %.

Eleven findings follow. Seven of them cost a day of work or less, and
two of those seven (F2 and F8) are changes to configuration files
alone. Rows 1 to 6 of the fix plan remove every failure a customer
can feel: the checkout stops failing at peak, and checkout p95 during
campaigns comes down from 6.1 s to 1.9 s. Largest Contentful Paint
does not reach Google's 2.5 s threshold on those rows alone. The
budget:

| After | What changes | Category LCP | Product LCP |
|---|---|---|---|
| today | | 4.6 s | 4.6 s |
| row 2 | F1, catalog TTFB 1.9 s → 1.2 s | 3.9 s | 3.9 s |
| row 5 | F5, product hero images | 3.9 s | 3.3 s |
| row 7 | F4 interim, dead tags and deferral | 3.5 s | 2.9 s |
| row 8 | F4, the storefront rebuild | 2.7 s | 2.1 s |

Only the rebuild in row 8 puts product pages inside the threshold.
Category pages end at 2.7 s, still short of it; what would close the
rest we did not investigate (section 6). The rows are ordered by
return, so the plan can be halted at any point.

In financial terms: during the three campaign hours on the evening of
26 May, roughly 230 checkouts ended in an error. At the store's
average order value of 1 800 CZK, that is 414 000 CZK of attempted
orders that failed, about €17 000 at 24.3 CZK/EUR, and it does not
count the customers who abandoned the checkout after a six-second
wait. The remaining findings are stated in milliseconds; a revenue
figure for them would be speculative, so we have omitted it.

## 2. What we measured, and how

- **Real-user metrics:** Core Web Vitals from a 28-day CrUX query,
  segmented by page type (home / category / product / checkout).
  Synthetic Lighthouse runs were used only to reproduce findings, not
  to score them. Where the store stands against Google's thresholds:

  | Metric (p75, field data) | Store | Threshold | Verdict |
  |---|---|---|---|
  | LCP | 4.6 s | < 2.5 s | fail |
  | INP | 280 ms | < 200 ms | fail |
  | CLS | 0.04 | < 0.1 | pass |
  | FCP | 2.9 s | < 1.8 s | fail |
  | TTFB | 1.9 s | < 0.8 s | fail |
- **Server-side trace:** New Relic transaction profile over 14 days,
  showing where the server time actually goes:

  | Transaction | Share of server time | Avg response |
  |---|---|---|
  | `catalog/product/view` | 38 % | 2.2 s |
  | `catalog/category/view` | 17 % | 1.8 s |
  | checkout REST (cart + shipping) | 11 % | 1.4 s (6.1 s p95 at peak) |

  Plus the MySQL slow query log, the external-services profile, and
  Varnish hit/miss statistics per URL pattern from `varnishstat`.
- **Load test:** a replay of the campaign traffic shape from the
  evening of 26 May (0 → 420 req/s over 20 minutes) against staging,
  with the same cache state as production.
- **Configuration review:** PHP and OPcache settings, Composer
  autoloader, `env.php`, the Varnish VCL, and MySQL settings, each
  against Magento's documented recommendations, sized up where the
  codebase outgrows the documented figure.
- **Bundle and asset audit:** what ships on first view of a product
  page, what blocks rendering, and what is unnecessary payload.

Most findings quote the exact command or query we used, partly so
your team can verify our work, partly because the same probes run on
any Magento store, including yours. Section 7 collects them so they
can be run unchanged. Every number below says where it came from, and
the findings table carries a column stating whether each expected
impact was measured, reproduced under load, or estimated.

## 3. Findings overview

**High**: customers are affected, or revenue depends on it.
**Medium**: staff are affected daily, or data is silently corrupted.
**Low**: minor effort that removes a real operational risk.

| # | Finding | Severity | Effort | Expected impact | Basis |
|---|---------|----------|--------|-----------------|-------|
| F1 | Full-page cache is effectively disabled and cannot be invalidated | High | 1–2 days | −700 ms TTFB at p75 on catalog | measured |
| F2 | PHP runtime and autoloader misconfigured for Magento | High | 0.5 day | −400 ms on every uncached request | estimated |
| F3 | Checkout blocks on a synchronous ERP stock call | High | 3–5 days | −2.8 s checkout p95 at peak; removes timeout failures | load test |
| F4 | Frontend ships 2.3 MB of JavaScript, render-blocking | High | project | −1.2 s LCP (interim mitigations: −0.4 s) | estimated |
| F5 | Product images: 1.6 MB PNG heroes, no responsive sizes | Medium | 1 day | −0.6 s LCP on product pages | measured |
| F6 | Indexers left on "Update on Save" | Medium | 0.5 day | Admin saves 30 s → ~2 s; eliminates stock drift | estimated |
| F7 | PHP-FPM pool exhausted by session locks at peak | High | 2 days | Checkout survives campaign traffic | load test |
| F8 | No grace mode in Varnish | Low | 0.5 day | Absorbs the stampede after every cache flush | estimated |
| F9 | Two extensions observe the same event; one is abandoned | Medium | 1 day | −300 ms on add-to-cart | measured |
| F10 | 41 GB of dead indexer temp tables in MySQL | Low | 0.5 day | Backups shrink 60 %; restore time roughly halves | measured |
| F11 | Admin order grid filters on an unindexed column | Low | 0.5 day | Order grid 12 s → under 1 s | estimated |

**Basis** says where the expected impact comes from. *Measured*: we
observed it on this store. *Load test*: we reproduced it on staging
under campaign load. *Estimated*: it follows from the
profile and the configuration, and nobody has observed it yet.

## 4. Detailed findings

### F1: Full-page cache is effectively disabled and cannot be invalidated (High)

**What we saw.** Varnish reports a 34 % hit rate on catalog URLs.
For a catalog this size, with this crawler traffic, above 90 % is
normal. Every category and product page carries
`X-Magento-Cache-Debug: MISS`. There are two independent causes:

1. A customized `default.xml` marks a header block (a store-switcher
   that reads the customer session) as `cacheable="false"`. In
   Magento, one uncacheable block in the layout makes the **entire
   page** uncacheable. The block arrived in an extension update in
   November 2025; the hit rate collapsed the same week.
2. Varnish is enabled in the admin
   (`system/full_page_cache/caching_application = 2`), but `env.php`
   has no `http_cache_hosts` section, so Magento cannot send purge
   requests to Varnish at all. The team works around it by restarting
   Varnish after every deploy, which is why deployments are disruptive (see F8).

How we verified it:

```
$ curl -sI https://acme-outdoor.example/tents | grep -i cache-debug
X-Magento-Cache-Debug: MISS
# same on every catalog URL we probed

$ grep -R 'cacheable="false"' app/design app/code | wc -l
1
# the store-switcher block, default.xml

$ php -r 'var_export(array_key_exists("http_cache_hosts", (include "app/etc/env.php")));'
false
# Magento cannot purge Varnish
```

**Fix.** Render the switcher via a private-content section (customer
data JS) so the page stays cacheable, which is the documented pattern
for session-dependent fragments, and add `http_cache_hosts` to
`env.php` so tag-based invalidation works and the restarts can stop.

**Effort / impact / risk.** 1–2 days including regression tests.
Expected −700 ms TTFB at p75 on catalog pages (measured: cached
catalog responses serve in 80 ms vs. 1.9 s uncached). Risk: low; both
changes are confined and reversible per deploy.

### F2: PHP runtime and autoloader misconfigured for Magento (High)

**What we saw.** The codebase contains ~125 000 PHP files; OPcache is
configured to hold 10 000. The Composer autoloader was never
optimized: `vendor/composer/autoload_static.php` contains no class
map, and the New Relic traces show whole seconds spent in
`ObjectManager` factory resolution on cold paths. Current versus
recommended:

| Setting | Current | Recommended |
|---|---|---|
| `opcache.max_accelerated_files` | 10 000 | 100 000 |
| `opcache.memory_consumption` | 128 | 512 |
| `opcache.validate_timestamps` | On, revalidate every 2 s | Off; reset on deploy |
| `opcache.enable_cli` | Off | On (indexers and crons run PHP too) |
| `realpath_cache_size` | 4M | 32M |
| `realpath_cache_ttl` | 120 | 7200 |
| Composer autoloader | not optimized | `dump-autoload --optimize` in the build |

How we verified it:

```
$ find . -type f -name '*.php' | wc -l
124862
# PHP files in the codebase

$ php -i | grep 'opcache.max_accelerated_files'
opcache.max_accelerated_files => 10000
# holds 8 % of them

$ grep -c 'classMap' vendor/composer/autoload_static.php
0
# autoloader was never optimized
```

**Why.** With these values, PHP re-stats and re-compiles a large part
of the codebase continuously; the profiles attribute over half of
uncached render time to class loading and page assembly rather than
business logic.

**Fix.** One config change plus one build step. The only coupling to
respect: with `validate_timestamps` off, the deploy pipeline must
reset OPcache on release. We have documented the exact step for your
current pipeline in the fix plan.

**Effort / impact / risk.** 0.5 day. Estimated −400 ms on every
uncached request (and faster crons and indexers as a side effect).
Risk: low, provided the deploy step lands together with the config.

### F3: Checkout blocks on a synchronous ERP stock call (High)

**What we saw.** On the shipping step, the trace shows a synchronous
HTTPS call to the ERP's stock endpoint with a 3-second timeout and
one retry; the external-services profile attributes 92 % of all
external wait time to this one endpoint. At campaign load the ERP
slows down, the calls eat their full timeout, and checkout requests
stack up. This is where the 6.1 s p95 and the 3.4 % failures come
from. The load test reproduces it on staging.

**Why.** Real-time stock confirmation was added after an oversell
incident in 2025. The intent is sound, but it puts a third-party
system's latency inside every checkout.

**Fix.** Move stock reconciliation out of the request path: reserve
against the local inventory, confirm asynchronously, and alert on
divergence. Overselling stays covered, and checkout stops waiting for
the ERP. The same pattern is set out in our post on
[why ERP and storefront stock diverge](https://zapolu.com/blog/erp-eshop-stock-mismatch/).

**Effort / impact / risk.** 3–5 days including the divergence alert.
Removes ~2.8 s from checkout p95 at peak and the timeout failures
entirely (both measured in the load test with the call stubbed).
Risk: medium; it needs a written sign-off on the new oversell window
(estimated at < 0.1 % of campaign orders, vs. 3.4 % of checkouts
failing today).

### F4: Frontend ships 2.3 MB of JavaScript (High)

**What we saw.** First view of a product page loads 2.3 MB of
JavaScript across 61 files; main-thread blocking time is 2.9 s on a
mid-range phone. Four of the fourteen marketing tags no longer
have a working account behind them.

**Why.** Partly the Luma frontend architecture, partly eight years of
accumulated tags. No single tag dominates the total.

**Fix, short term (this quarter).** Remove the four dead tags, defer
the analytics bundle, lazy-load the reviews widget below the fold.
Estimated −0.4 s LCP, 2 days.

**Fix, real (separate decision).** A storefront rebuild on Hyvä. It
replaces the Luma JavaScript layer instead of deferring parts of it,
so the payload is settled by the architecture rather than managed tag
by tag. It is a 6–10 week project and deserves its own scoped
proposal, not a line in a fix list. We mention it here so that
the short-term estimate above is not read as the maximum achievable.

### F7: PHP-FPM pool exhausted by session locks at peak (High)

**What we saw.** During the load test, failures begin at ~280 req/s
while CPU sits at 12 %. The FPM status page shows all workers busy;
the traces show them waiting on Redis session locks: customers
polling the checkout success page hold locks that queue every other
request from the same session.

**Fix.** Disable session locking for the polling endpoints, raise the
worker count to what the memory headroom actually allows (the current
number dates from a smaller instance type), and add an FPM saturation
alert; this outage was undetected because the CPU graphs appeared healthy.

**Effort / impact / risk.** 2 days including a re-run of the load
test. With F3 done as well, staging survives 420 req/s with checkout
p95 at 1.9 s. Risk: low.

### F5, F6, F8, F9, F10, F11 in brief

- **F5 Images:** product heroes are 1.6 MB PNGs served at one size to
  every device. WebP conversion + responsive sizes via the CDN:
  1 day, −0.6 s LCP on product pages (measured on a converted sample).
- **F6 Indexers:** switched to "Update on Save" during a bulk-import
  incident in February 2026 and never switched back. Admin product
  saves take 30 s, and the mismatch between storefront stock and
  warehouse reality traces to reindex races. Restore scheduled
  indexing: 0.5 day. The 30 s is measured; the figure after the fix
  is an estimate.
- **F8 Varnish grace:** every cache flush currently sends the full
  traffic wave to PHP. Grace mode serves stale objects while the
  cache refills: 0.5 day. The effect is an estimate.
- **F9 Duplicate observers:** two extensions subscribe to the same
  checkout event; one has been abandoned by its vendor since 2023 and
  re-fetches the quote on every call. Remove it (its feature is
  unused): 1 day including verification, −300 ms on add-to-cart
  (measured in the trace).
- **F10 Database housekeeping:** the database carries 41 GB of
  `catalogrule_product__temp*` tables (leftovers of interrupted
  indexer runs on an older Magento version, which never cleaned them
  up) plus a 12-million-row custom queue table with no retention.
  One `information_schema` query finds them:

  ```
  mysql> SELECT COUNT(*) tables, ROUND(SUM(data_length+index_length)
      -> /1024/1024/1024, 1) gb FROM information_schema.tables
      -> WHERE table_name LIKE 'catalogrule\_product\_\_temp%';
  +--------+------+
  | tables | gb   |
  +--------+------+
  |     96 | 41.2 |
  +--------+------+
  ```

  Dropping the leftovers and adding pruning: 0.5 day. Backups shrink
  from 68 GB to 27 GB; restore time roughly halves, which is an
  estimate from the size change and matters most on the day a restore
  is actually needed.
- **F11 Admin order grid:** a custom column added for the fulfillment
  team filters on an unindexed attribute; the grid takes 12 s to load
  and occupies a worker each time. One index migration: 0.5 day. The
  12 s is measured; the figure after the fix is an estimate.

## 5. The plan, sorted by return on effort

| Order | Items | Effort | What you get |
|-------|-------|--------|--------------|
| 1 | F2 + F8 | 1 day | Every uncached request −400 ms; cache flushes no longer cause a load spike |
| 2 | F1 | 1–2 days | Catalog TTFB −700 ms; deploys stop requiring a Varnish restart |
| 3 | F6 + F10 + F11 | 1.5 days | Admin usable again; stock drift stops; backups halve |
| 4 | F7 | 2 days | Checkout no longer fails at peak |
| 5 | F5 + F9 | 2 days | Product-page LCP −0.6 s; add-to-cart −300 ms |
| 6 | F3 | 3–5 days | Checkout p95 under 2 s during campaigns |
| 7 | F4 short-term | 2 days | LCP −0.4 s while the storefront decision is made |
| 8 | F4 rebuild | separate proposal | Product-page LCP inside the 2.5 s threshold |

Rows 1–6 total 10.5 to 13.5 engineering days and address every
failure a customer can feel. Stop after any row and the work up to it
stands on its own. Row 6 is the only one that touches order-path
architecture; whoever executes it, have the design reviewed by a
senior engineer.

Re-measure after row 6 with the same field-data query this report
started from. If the numbers do not move as predicted, contact us and we
will determine the cause.

## 6. What we did not review

Security posture, SEO, accessibility, and code quality outside the
profiled hot paths are out of scope for this engagement. The audit
covers the default store view; the B2B store view shares the stack
but was not separately measured. The budget in section 1 leaves
category pages 0.2 s above the 2.5 s threshold, and we did not
establish what accounts for that: it needs a pass over the category
templates, which this engagement did not cover. One access limitation:
our audit user could not read the Varnish admin socket during the
window, so Varnish runtime counters come from `varnishstat` snapshots
exported by the hosting provider rather than from live inspection.

## 7. Appendix: the probes, to run on your own store

These are the read-only checks this audit started from. They run on
any Magento 2 store and need nothing beyond a shell and a database
client. Run them before
you commission an audit from anyone, ourselves included: if they all
come back healthy, the problem is somewhere this report does not
look.

**1. Is the full-page cache actually serving?** Request a category
URL twice and read the debug header.

```
$ curl -sI https://your-store.example/some-category | grep -i cache-debug
```

Healthy: `HIT` on the second request. `MISS` every time is F1.

**2. Is anything making pages uncacheable?** One `cacheable="false"`
block anywhere in the layout makes the whole page uncacheable.

```
$ grep -R 'cacheable="false"' app/design app/code | wc -l
```

Healthy: `0`.

**3. Can Magento purge Varnish at all?**

```
$ php -r 'var_export(array_key_exists("http_cache_hosts", (include "app/etc/env.php")));'
```

Healthy: `true`, if Varnish is the configured cache.

**4. Does OPcache hold the whole codebase?** Compare the two numbers.

```
$ find . -type f -name '*.php' | wc -l
$ php -i | grep 'opcache.max_accelerated_files'
```

Healthy: the second number is larger than the first.

**5. Was the Composer autoloader optimized in the build?**

```
$ grep -c 'classMap' vendor/composer/autoload_static.php
```

Healthy: not `0`.

**6. Are the indexers scheduled?**

```
$ php bin/magento indexer:show-mode
```

Healthy: every row reads "Update by Schedule". Anything on "Update on
Save" is F6. (Command and mode names from
[Adobe's indexer CLI reference](https://experienceleague.adobe.com/en/docs/commerce-operations/configuration-guide/cli/manage-indexers),
checked on 1 August 2026.)

**7. How much dead weight is in the database?**

```
mysql> SELECT COUNT(*) tables, ROUND(SUM(data_length+index_length)
    -> /1024/1024/1024, 1) gb FROM information_schema.tables
    -> WHERE table_name LIKE '%\_\_temp%';
```

Healthy: a handful of tables. The store in this report had 96 of
them, at 41 GB.

**8. What is the checkout waiting on?** This one needs your APM
rather than a shell: open the checkout transaction, group by external
service, and read the share of wall time. In this report one endpoint
accounted for 92 % of it, which is F3.

## 8. Next steps

The fix plan can be executed by any competent team, including your
own: every finding names the specific change to make. If you
want us to execute it, rows 1–6 fit a two-to-three-week fixed-price
engagement under our standard
[engagement terms](https://zapolu.com/downloads/zapolu-engagement-template.pdf).

Questions about any finding: [info@zapolu.com](mailto:info@zapolu.com),
or [book a call](https://zapolu.com/contact/) and bring your own
numbers; we will advise during the call whether an audit is warranted.