Skip to content
Back to skills

Lcp Audit

ASecurity

LCP-resource priority — lazy hero, no fetchpriority, late-discovered or cross-origin LCP image, framework priority prop missing.

  • 2 stars
  • 0 votes
  • 0 copies
  • 0 views
  • Added October 20, 2026
ai-agentsgoshellbashreactvueangularnextjsnodeapifrontend

Works with

  • cli
  • api

Security analysis

A100/100

Scanned October 20, 2026

npx -y skills add adnanmokhtar/refract --skill lcp-audit --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Lcp Audit?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Lcp Audit
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/adnanmokhtar-lcp-audit/badge)](https://www.skillsdirectory.com/skills/adnanmokhtar-lcp-audit)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: lcp-audit
description: LCP-resource priority — lazy hero, no fetchpriority, late-discovered or cross-origin LCP image, framework priority prop missing.
kind: skill
pack: frontend
allowed-tools: [Read, Grep, Glob, Bash]
---

# lcp-audit

## Premise

The Largest Contentful Paint element — usually the hero image or a large heading — must be **discovered and fetched first**. The common failure is the opposite: the hero is lazy-loaded (so the browser delays it), or it has no priority hint (so it queues behind less important resources), or it's a late-discovered CSS background with no preload. `frontend-principles` § LCP & images states the MUST — the LCP image is eager + prioritized and never carries `loading="lazy"`, which is lawful **below the fold only**. This skill is the detector that proves it on a real tree: the rule says exactly one high-priority element per view, and only a scan can tell you whether the route has zero or three.

Every finding cites the element at `<file:line>` + the matched pattern + the fix. "LCP is slow" without the cited element is not a finding. The greps find candidates; the element is confirmed by § Identifying the LCP element (lab) or by `web-vitals-field` *(performance pack, when co-installed)*, which attributes field LCP to the real element and sub-part.

**Closure verbs** — exactly one per finding. This skill emits `report-with-fix` (priority hint missing on the confirmed LCP element), `dismiss` (the § False positives carve-outs: lawful lazy below the fold, a text LCP element, an already-prioritised hero), and `halt-handoff` (format / dimensions / `sizes` → `image-optimization`; font + critical CSS on a text LCP → `font-optimization`).

## Pre-flight greps

Run as **one** script from the repo root; shell state does not survive between tool calls. Grep **N** feeds detector **§N**. Every `rg` names its path: given no path and a piped stdin, `rg` reads stdin and reports zero hits. An `rg` error on stderr (exit 2) is a failed grep, not zero hits. Tag bodies are read with `-U`, so a multi-line `<Image … />` prints one line per attribute: count tags, not lines.

```bash
ROOTS=( $(rg -l --pcre2 '"(?:react|react-dom|vue|svelte|@angular/core|solid-js|preact|astro|next|nuxt)"\s*:' -g '**/package.json' -g '!**/node_modules/**' . | xargs -n1 dirname | sort -u) ); [ ${#ROOTS[@]} -eq 0 ] && ROOTS=(.)
X=(-g '!**/node_modules/**' -g '!**/{dist,build,out,coverage,.next,.nuxt,.output,.svelte-kit,.angular,storybook-static}/**' -g '!**/*.min.*'
   -g '!**/{test,tests,__tests__,spec,e2e,__mocks__,mocks,__fixtures__,stories,playwright,cypress,locales,i18n,generated,__generated__,docs}/**'
   -g '!**/*.{test,spec,e2e,stories,story,mock}.*' -g '!*.{md,mdx,snap,map,lock}' -g '!ai/**')
NC=(rg -v --pcre2 '^[^:]+:\d+:\s*(//|/\*|\*(\s|/|$)|#|<!--|\{/\*)')
UI=(-g '*.{js,jsx,ts,tsx,mjs,vue,svelte,astro,html}')
TB='(?:[^>{<\x60]|\{(?:[^{}]|\{[^{}]*\})*\})*'
TL='(?:[^>]|=>)*?'   # a lookahead body that also enters {…}: `alt={t('catalog.bannerAlt')}`, `className={cn('…')}`
# the marker counts only inside a class / id / alt / data-* value — never an identifier (`src={heroImageSrc}`) — and
# Tailwind's object-cover / bg-cover is a fit utility, not a hero
HERO='\b(?:class|className|id|alt|data-[\w-]+)\s*=\s*(?:"[^"]*?|\x27[^\x27]*?|\{[^}]*?)(?i:hero|banner|(?<!object-)(?<!bg-)cover|masthead|splash|jumbotron|above-?(?:the-)?fold|\blcp\b)'
IMG='<(?:img|Image|NuxtImg|NuxtPicture|Picture|enhanced:img)\b'
# 0. Lanes and versions.
rg -n --no-heading '"(next|nuxt|@nuxt/image|astro|@angular/core|@sveltejs/enhanced-img|vite-imagetools|web-vitals|@lhci/cli|lighthouse)"\s*:' -g '**/package.json' -g '!**/node_modules/**' "${ROOTS[@]}"
# 1. Lazy on an LCP candidate (an image tag carrying a hero marker and loading=lazy; multi-line tag bodies read with -U).
rg -n -U --no-heading --pcre2 "${IMG}(?=${TL}${HERO})(?=${TL}\\bloading=[\"'{]*lazy)${TB}>" "${UI[@]}" "${X[@]}" "${ROOTS[@]}" | "${NC[@]}"
# 2. Hero image with no priority hint (fetchpriority / priority / preload absent from the tag).
rg -n -U --no-heading --pcre2 "${IMG}(?=${TL}${HERO})(?!${TL}\\b(?:fetch[pP]riority|priority|preload)\\b)${TB}>" "${UI[@]}" "${X[@]}" "${ROOTS[@]}" | "${NC[@]}"
# 3. Late-discovered image: a CSS / style-object background url, a Tailwind arbitrary background, or a CSS custom
#    property carrying a url (`'--brand-bg': \`url(${…})\`` read by `bg-[image:var(--brand-bg)]`) (3a) vs the image preloads that exist (3b).
rg -n --no-heading --pcre2 'background(?:-image)?\s*:\s*[^;]*\burl\(|backgroundImage\s*:|\bbg-\[(?:url|image:)|["\x27]--[\w-]+["\x27]\s*:\s*\x60?url\(' -g '*.{css,scss,sass,less,vue,svelte,tsx,jsx,astro}' "${X[@]}" "${ROOTS[@]}" | "${NC[@]}"
rg -n --no-heading --pcre2 '<link\b(?=[^>]*\brel=["\x27]?preload)(?=[^>]*\bas=["\x27]?image)|\brel\s*:\s*["\x27]preload["\x27][^}]*\bas\s*:\s*["\x27]image|ReactDOM\.preload\(|\bpreload\([^)]*as\s*:\s*["\x27]image' "${UI[@]}" -g '**/{nuxt,next,astro}.config.*' "${X[@]}" "${ROOTS[@]}" | "${NC[@]}"
# 4. Cross-origin image sources (4a) vs preconnects declared (4b).
rg -n --no-heading --pcre2 "${IMG}${TB}\\b(?:src|ngSrc)=[\"'{]*(?:https?:)?//" "${UI[@]}" "${X[@]}" "${ROOTS[@]}" | "${NC[@]}"
rg -n --no-heading --pcre2 '\brel=["\x27]?(?:preconnect|dns-prefetch)|\brel\s*:\s*["\x27](?:preconnect|dns-prefetch)|ReactDOM\.preconnect\(' "${UI[@]}" -g '**/{nuxt,next,astro}.config.*' "${X[@]}" "${ROOTS[@]}" | "${NC[@]}"
# 5. Framework priority prop: 5a Next `priority` (deprecated from Next 16; read the major from grep 0), 5b raw <img> in a project whose lane ships a component.
rg -n -U --no-heading --pcre2 "<Image\\b${TB}\\bpriority\\b(?!=\\{?false)" -g '*.{tsx,jsx,js,ts}' "${X[@]}" "${ROOTS[@]}" | "${NC[@]}"
rg -c --pcre2 '<img\b' "${UI[@]}" "${X[@]}" "${ROOTS[@]}"
```

A hit is a **candidate**, never a finding: the hero marker is a naming convention, and the real LCP element is whatever § Identifying the LCP element names. Two blind spots are structural:

- **An unmarked hero is invisible to greps 1 and 2.** A first-in-page `<img>` with no `hero`/`banner`/`cover` class is the LCP element as often as a marked one. The lab arm finds it; the grep never will.
- **Grep 3a cannot tell a hero background from a decorative one.** Read the selector, then check 3b for a matching `<link rel="preload" as="image">`.

**Measured** (2026-10-05, read-only). Scratch fixture of 5 positives and 3 decoys: every positive hits; the `fetchpriority="high"` cover, the commented-out image and the SVG icon stay out. Benchmark fixtures: 0 on every fixture except `slow-marketplace` (1: the lazy `hero__image` its DEFECTS file seeds; 4: one preconnect). Real repos: a Nuxt 4 storefront with three themes (grep 2: 2 tags, a banner list whose first item is `:loading="index === 0 ? 'eager' : 'lazy'"` with no `fetchpriority`, and a cross-sell banner; 1: 0), a Vue 3 + Vite admin SPA (3a: 2) and an Angular 19 admin template (3a: 17 background URLs, all decorative on read). The first version of the tag-body pattern ran across a template literal for 20 lines; it now stops at `<` and a backtick. Re-measured 2026-10-15 on a real NestJS + React monorepo (Vite SPA, Tailwind 4): grep 1 was 9 tags and grep 2 26, 8 and 23 of them only Tailwind `object-cover` on list thumbnails, plus a `heroImageSrc` identifier; with the marker read inside class / id / alt / data-* values and `(?<!object-)(?<!bg-)cover`, grep 1 is 0 and grep 2 is 2 tags — one of them the catalog hero banner whose marker sits inside `alt={… t('portal.catalog.bannerAlt')}`, which the brace-blind lookahead had missed. 3a 0 → 4: a sign-in shell sets `'--brand-bg': \`url(${…})\`` and paints it with `bg-[image:var(--brand-bg)]` — a late-discovered LCP candidate. Fixtures: the `slow-marketplace` seed still hits; one `object-cover` gallery thumbnail in `a11y-states` drops out.

## The 3,000-hit path

1. **One element per route and viewport.** Run § Identifying the LCP element on the top routes first; a grep hit on a component no route paints above the fold is dismissed by one read.
2. **Group** by (component, pattern). A shared `HeroSection` mounted by 30 routes is one finding at the component.
3. **Rank** by route traffic (RUM or analytics when present, else landing, product/detail, campaign routes).
4. **Read the top 30 groups.** Ledger the rest as `Live, unreviewed (k groups / n hits)` with the command that continues them, never `checked`.

## Scans for

### 1. Lazy-loaded LCP candidate

```
BAD:  <img src="/hero.jpg" loading="lazy" class="hero">     // defers the LCP element
GOOD: <img src="/hero.jpg" fetchpriority="high">             // eager + prioritized
```

Grep: `loading=["']lazy["']` on `<img>` near hero/banner/above-fold markers (class/id containing `hero`, `banner`, `cover`, `masthead`, or the first image in a page/layout). Lazy is correct *below* the fold — flag only above-the-fold candidates.

### 2. Hero image without `fetchpriority="high"`

The LCP image should declare high priority so it jumps the queue. Grep above-the-fold `<img>` lacking `fetchpriority="high"` (raw HTML) or the framework's priority prop (§ per-framework).

### 3. Late-discovered LCP image with no preload

A CSS `background-image` or a JS-injected hero isn't in the initial HTML, so the browser discovers it late.

```
GOOD: <link rel="preload" as="image" href="/hero.avif"
            imagesrcset="/hero-800.avif 800w, /hero-1600.avif 1600w" fetchpriority="high">
```

Flag a CSS-background or dynamically-set LCP image with no matching `<link rel="preload" as="image">`.

### 4. Cross-origin LCP image with no preconnect

If the LCP image is served from a CDN/image host on another origin, the connection setup (DNS + TCP + TLS) is on the critical path.

```
GOOD: <link rel="preconnect" href="https://images.cdn.example">
```

Flag an LCP image from a cross-origin host with no `<link rel="preconnect">` (or `dns-prefetch`).

### 5. Framework priority primitive missing on the hero

| Framework | LCP-image priority | Below-fold default |
|---|---|---|
| **Next 16+** | `<Image preload>` (inserts a `<link rel="preload">` in the `<head>`), or `fetchPriority="high"` / `loading="eager"`; the docs prefer the last two in most cases. `priority` is **deprecated from 16.0.0** in favour of `preload` | `loading="lazy"` |
| **Next ≤ 15** | `<Image priority>` (sets `fetchpriority="high"` + eager + preload) | `loading="lazy"` |
| **Nuxt** | `<NuxtImg preload :loading="'eager'">` | lazy |
| **Astro ≥ 5.10** | `<Image priority>` (sets `loading="eager"`, `decoding="sync"`, `fetchpriority="high"`); older Astro: `loading="eager"` + `fetchpriority="high"` | lazy |
| **Angular** | `NgOptimizedImage` `ngSrc` + `priority` (emits preload + `fetchpriority=high`) | non-priority |
| **Plain React/Vue/Svelte** | `fetchpriority="high"` + `width`/`height` on the `<img>` | `loading="lazy"` |

Flag a hero using the framework image component without its priority flag, or a raw `<img>` where the framework offers an optimized component. Read the major from grep 0 before writing the fix: a `priority` prop on Next 16+ still works but is the deprecated form (grep 5a), so the fix there is `preload` or `fetchPriority="high"`, never a new `priority`. Sources: Next.js `Image` API reference, version history row for v16.0.0 (docs for 16.3.8, fetched 2026-10-05); Astro `astro:assets` reference, `priority` "Added in astro@5.10.0" (fetched 2026-10-05).

## Output

```
LCP audit — <route set>

Findings: 3

1. components/Hero.tsx:14                              [report-with-fix]
   <Image src="/hero.jpg" /> on the LCP element (next 16.1, lab-inferred mobile) — no priority hint.
   Fix: <Image src="/hero.jpg" fetchPriority="high" />  (or `preload`; not the deprecated `priority`).

2. pages/index.vue:8                                   [report-with-fix]
   <img class="masthead" loading="lazy"> — lazy on the LCP candidate delays LCP.
   Fix: drop loading="lazy"; <NuxtImg preload :loading="'eager'" .../>.

3. styles/home.css:22                                  [report-with-fix]
   .hero { background-image: url(/cover.avif) } — late-discovered, no preload.
   Fix: <link rel="preload" as="image" href="/cover.avif" fetchpriority="high"> in <head>.

LCP element: components/Hero.tsx:14 (lab-inferred, lighthouse-ci mobile, Lighthouse 12.6.1 largest-contentful-paint-element)
Verification: 9 candidates → 3 findings · 0 unconfirmed · 6 refuted

Class coverage:
  §1  lazy LCP candidate          checked (2 hits → 1 finding)
  §2  no priority hint            checked (4 tags → 1 finding, 3 not the LCP element)
  §3  late-discovered, no preload checked (3 background urls → 1 finding)
  §4  cross-origin, no preconnect N/A (LCP image is same-origin)
  §5  framework priority prop     checked (folded into finding 1)
```

`[report-with-fix]`: the element is the identified LCP element and the fix is a local change in the project's own primitive. `[report-flagged]`: a link is unproven (no harness, so the element is unidentified) and the row names the run that settles it. The coverage block lists **all five** classes on every run: `checked (k hits → m findings)`, `N/A (<reason>)`, or `blocked (<reason>)` when a grep failed. A partial review reads `Live, unreviewed (k groups / n hits)`, never `checked`.

## False positives / gotchas

- Only the **single** LCP element gets `fetchpriority="high"`. If a scan would mark two heroes high-priority, flag it — competing high-priority fetches cancel the benefit.
- The LCP element differs by viewport (mobile hero ≠ desktop hero) and by route — confirm the real element with `web-vitals-field` attribution (`attribution.target` — the `web-vitals` 4+ name; 6.2.3's `LCPAttribution` has `target`, `url`, `resourceLoadDelay`, `elementRenderDelay`) *(performance pack, when co-installed)* before "fixing" the wrong image. Absent that pack there is no field attribution, and the lab arm below is how the element is identified instead — a label is not a substitute for running it.
- A heading/text LCP element has no image to preload — its LCP cost is font + render-blocking CSS/JS, not image priority (→ `font-optimization` for font-display / preload / swap-CLS, and critical CSS).
- This skill owns only the LCP element's **priority** (fetchpriority / preload / eager). The LCP image's **format, dimensions, and responsive sizing** belong to `image-optimization` — run both on a hero image.
- Preloading an image the page doesn't actually use wastes bandwidth and can *hurt* LCP — preload only the confirmed LCP resource.

## Identifying the LCP element without the performance pack

Every detector on this page targets *the* LCP element, so the scan is worthless until one element is named. On a frontend-only install — the default — there is no field attribution, and "pick it from the lab trace" is not a procedure. This is:

1. **Run `lighthouse-ci` (this pack) on the route** at the viewport in question, against the production build. It already runs for the LCP *number*; the same report carries the element.
2. **Read the element from the JSON report**, not from the score. Under LHCI 0.15.1 (Lighthouse 12.6.1) that is `largest-contentful-paint-element` or `lcp-phases-insight`; under Lighthouse 13 the legacy audit is gone and `lcp-breakdown-insight` (renamed from `lcp-phases-insight`) carries the node. Both insights declare `replacesAudits: ['largest-contentful-paint-element']` (unpacked 12.6.1 and 13.5.0, 2026-10-05). The element audit is informative; it names the node (selector / snippet), and that is the citation. It is not the oracle a fix is proved by (step 6).
3. **Map the node back to source.** The report gives you rendered markup, not a file — grep the selector, the `alt` text, the class, or the image path against the source tree to land on `<file:line>`. That grep is the finding's citation; a node with no source anchor is not a finding (§ Halt conditions).
4. **Repeat per viewport.** Mobile and desktop routinely resolve to different elements, and a fix applied to the wrong one is a no-op that reads as a fix. Run at minimum the mobile form factor, since that is the one CWV is scored on.
5. **Label the result honestly**: `LCP element: <file:line> (lab-inferred, lighthouse-ci <form-factor>)`. Lab inference is a real measurement of one synthetic load — it is not a claim about what users saw. Never print a field figure this skill did not read.
6. **Prove the fix on an assertable audit**, median of 5 runs (`lighthouse-ci` § Lighthouse ids by version). §1 and §2: `lcp-lazy-loaded` and `prioritize-lcp-image` on Lighthouse 12.6.1, `lcp-discovery-insight` on both 12.6.1 and 13.x (it replaces those two). §3: `lcp-discovery-insight`. §4: `uses-rel-preconnect` on 12.6.1, `network-dependency-tree-insight` on 13.x. Fails before the fix, passes after; the LCP number itself is cost, read under the noise band ([`proof-oracles.md`](../../../../snippets/proof-oracles.md) § "Lighthouse median").

**If `lighthouse-ci` cannot run at all** (no dev server, no build, no Chrome), the element is unknown, and this skill reports `LCP element: unidentified — scan not run` rather than falling back to "the first big image." Guessing the hero and prioritising it is how two elements end up marked high-priority, which § False positives already names as self-cancelling.

## When to run

- When `lighthouse-ci` (this pack) reports LCP > 2.5s. Also on `bundle-perf` / `web-vitals-field` *(performance pack, when co-installed)* attributing field LCP to an image with high `resourceLoadDelay`/`resourceLoadDuration`; absent that pack, `lighthouse-ci` is the only trigger and the run says so.
- After adding or restyling a hero / above-the-fold media region.
- Before launch on any public, SEO-relevant, image-led page.

## Halt conditions

- Halt on any LCP finding without the cited element at `<file:line>` + the matched pattern.
- Halt if `fetchpriority="high"` is proposed on more than one element per view.
- Halt if a preload is proposed for an image not confirmed to be the LCP resource (§ Identifying the LCP element, or field attribution when the performance pack is present). On a frontend-only install only the lab arm exists — the halt still stands, and the finding records `confirmed by: lighthouse-ci <element audit for the runner's version> (<form-factor>)` rather than implying field evidence it never had.
- Halt if any detector fired before an element was identified. Five detectors that all target "the LCP element" produce five guesses when nothing named one.
- Halt if the LCP element is text but the fix targets image priority — re-scope to font/CSS.
- Halt if a fix writes `priority` on Next 16+ (deprecated) or `preload` on Next ≤ 15 (absent) — read the major from grep 0 first.
- Halt before reporting any candidate that has not been through [`refute-before-report.md`](../../../../snippets/refute-before-report.md) (§1 restate … §9) and [`precedents/performance.md`](../../../../snippets/precedents/performance.md) § 83 and § 87 (lazy below the fold, text LCP).

## Related

- `code-splitting.md` (ai-pattern) — the LCP-critical component must never be lazy-loaded (a chunk round-trip before paint); that pattern's detector 7 hands off here, and this skill rejects any lazy proposal in the LCP subtree.
- `image-optimization` / `font-optimization` — the two halves this skill deliberately does not own: format/dimensions/responsiveness for an image LCP, `font-display`/preload/swap-CLS for a text LCP. This skill only sets priority.
- `lighthouse-ci` — the lab measurement that triggers this scan; it reports the number, this skill names the element.
- Cross-pack (`performance`, when co-installed): `web-vitals-field` supplies `attribution.target` — the only source that proves which element users actually saw as LCP. Absent that pack, every finding here is lab-inferred and must be labelled as such; never print a field figure this skill did not read.

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.