An in-page performance overlay that hunts down your Largest Contentful Paint element, render-blocking resources, and long tasks — on any page — and tells you what to do about each one.
lcp-hunter drops a small, draggable panel onto the page. It outlines the LCP
element in place, lists the scripts and stylesheets blocking the first paint,
counts main-thread long tasks, and pairs every finding with a one-line fix and a
"Learn more →" deep-link. It ships two ways from one codebase:
- a Manifest V3 browser extension (Chrome / Edge / Firefox-compatible) with a toolbar button that toggles the overlay on the active tab, and
- a bookmarklet — a single
javascript:URL — for when you don't want to install anything.
No build server, no account, no data leaves the page. It reads the same
PerformanceObserver entries the browser already exposes.
A compact panel, top-right, in three sections (rendered in a Shadow DOM so the page's own CSS can't touch it):
┌─────────────────────────────────────┐
│ lcp-hunter – × │
├─────────────────────────────────────┤
│ LARGEST CONTENTFUL PAINT │
│ Time 1.24 s [ Good ] │
│ Element img#hero [hero.avif] │
│ LCP is an image — set │
│ fetchpriority="high", preload it… │
│ Measuring LCP with DevTools → │
│ Image CDNs & fetchpriority → │
├─────────────────────────────────────┤
│ RENDER-BLOCKING RESOURCES │
│ Found 2 [ 1 JS · 1 CSS ] │
│ JS vendor.js │
│ CSS app.css │
│ Add defer/async or split & … │
│ Dynamic imports & code-splitting → │
├─────────────────────────────────────┤
│ LONG TASKS (MAIN-THREAD) │
│ Count 3 [ 240 ms blocking ]│
│ Longest 220 ms │
│ 220 ms @ 1.10 s · self │
│ Break up long tasks and yield … │
│ Profiling event handlers for INP → │
├─────────────────────────────────────┤
│ lcp-hunter · frontend-performance… │
└─────────────────────────────────────┘
At the same time, a labelled purple box is drawn directly over the LCP element
(LCP · 1.24 s), and it re-anchors as you scroll or resize the window.
- LCP is rated Good / Needs work / Poor against the standard
2500 ms / 4000 ms thresholds. If the LCP element is an image you get an
image-specific tip (
fetchpriority, preloading, don't lazy-load it). - Render-blocking counts parser-blocking
<script src>in<head>(noasync/defer/type="module") and blocking<link rel="stylesheet">(ignoring themedia="print"swap trick and disabled sheets). - Long tasks are collected via the
longtaskobserver. The badge shows a total-blocking-time estimate (Σ of each task's time over the 50 ms budget) — a good proxy for what will hurt your INP.
Drag the header to move it, – to minimise, × to remove it.
Not published to any store or registry. You load it unpacked, straight from source. That's the whole install.
git clone https://github.com/frontend-performance/lcp-hunter.git
cd lcp-hunter
npm install
npm run build # bundles src/ → extension/overlay.js + extension/popup.jsThen load the extension/ folder unpacked:
Chrome / Edge / Brave
- Open
chrome://extensions(oredge://extensions). - Turn on Developer mode (top-right).
- Click Load unpacked and select the
extension/folder. - Pin lcp-hunter, open any
http(s)page, click the icon, hit Toggle overlay.
Firefox (uses the same MV3 manifest)
- Open
about:debugging#/runtime/this-firefox. - Click Load Temporary Add-on… and pick
extension/manifest.json. - Use the toolbar button as above. (Temporary add-ons are removed on restart.)
Clicking the button again toggles the overlay off — the popup re-injects the
same bundle, which flips visibility via window.__lcpHunter.
The extension ships without icon PNGs to keep the repo binary-free, so the toolbar shows a default placeholder icon. That's expected; drop PNGs into
extension/and add an"icons"block tomanifest.jsonif you want your own.
npm install
npm run build # writes dist/bookmarklet.txt- Open
dist/bookmarklet.txtand copy its entire contents (it's one longjavascript:line). - Create a new bookmark (bookmark any page, then edit it).
- Replace the bookmark's URL with the copied text. Name it "LCP Hunter".
- Navigate to any page and click the bookmark. Click it again to toggle off.
The bookmarklet is the same overlay core, self-contained in the URL — no
network request, nothing to trust but the code you can read in src/overlay.ts.
Some sites with a strict
script-srcContent-Security-Policy will block bookmarklets. On those pages, use the extension (it injects via thescriptingAPI and isn't subject to the page CSP).
One small codebase, bundled by esbuild into a single IIFE:
src/analysis.ts— pure, DOM-free logic:rateLcp,isRenderBlockingScript,isRenderBlockingStylesheet,describeElement,summarizeLongTasks,formatMs. Nowindow, nodocument. This is the part that's unit-tested in Node.src/overlay.ts— the DOM shell. It wires upPerformanceObservers forlargest-contentful-paintandlongtask(bothbuffered: true), scans the document for blocking resources, and renders the panel + element outline into a Shadow DOM. It exposeswindow.__lcpHunter = { toggle, destroy }; running the bundle a second time toggles the overlay instead of stacking a new one.build.mjs— esbuild bundlesoverlay.tsto a minified IIFE and writes:dist/overlay.jsandextension/overlay.js(injected by the extension),dist/bookmarklet.txt=javascript:+ URI-encoded overlay, andextension/popup.js(fromextension/popup.ts).
- The extension (
extension/manifest.json,popup.html,popup.ts) usesactiveTab+scriptingonly. The popup callschrome.scripting.executeScript({ files: ['overlay.js'] })on the active tab. Repeated injections share the tab's isolated world, so the second click finds the existingwindow.__lcpHunterand toggles.
Because the two entry points (extension injection and bookmarklet) run the exact same bundle, there's a single source of truth for the overlay behaviour.
lcp-hunter/
├── src/
│ ├── analysis.ts # pure, DOM-free, unit-tested logic
│ └── overlay.ts # the overlay (DOM shell around analysis.ts)
├── extension/
│ ├── manifest.json # MV3: action popup, activeTab + scripting
│ ├── popup.html
│ ├── popup.ts # bundled → popup.js
│ ├── popup.js # (generated by npm run build)
│ └── overlay.js # (generated by npm run build)
├── test/
│ └── analysis.test.ts # Node built-in test runner (via tsx)
├── dist/ # (generated) overlay.js + bookmarklet.txt
├── build.mjs # esbuild build script
├── tsconfig.json # type-checks src/
├── tsconfig.extension.json # type-checks extension/popup.ts with @types/chrome
├── package.json
├── LICENSE
└── README.md
| Command | What it does |
|---|---|
npm run build |
Type-checks, then bundles the overlay, bookmarklet, and popup. |
npm run typecheck |
tsc --noEmit over src/ and the extension popup (no emit). |
npm test |
Runs the unit tests (node --test --import tsx test/*.test.ts). |
- LCP and long-task observers require a Chromium-based browser or a recent Firefox. Where an entry type isn't supported, that section degrades gracefully ("waiting…" / "no long tasks yet") instead of erroring.
- The overlay guards on
typeof PerformanceObserverand wraps eachobserve()in atry/catch, so it never throws on an unsupported page.
- Node.js >= 20 to build (the overlay itself has no runtime dependencies).
Deep-dives on frontend-performance.com behind each finding:
- LCP: Measuring LCP with Chrome DevTools · Understanding Core Web Vitals thresholds
- LCP images: Image CDNs and
fetchpriority· Lazy-loading images without hurting LCP - Render-blocking JS: Dynamic imports and route-based splitting · Tree-shaking and dead-code elimination
- Long tasks / INP: Profiling event handlers for INP · Optimizing INP with
scheduler.yield()
MIT © frontend-performance