Performance
Claude skill atlas-performance · for Frontend, Maps data, Release
Speed rules for the Atlas: Core Web Vitals targets and today's baseline, CDN caching and ISR, images and fonts, client JavaScript budget, the MapLibre globe, long tasks and INP, and how to measure and investigate a regression. Use when changing anything a reader downloads or waits for (pages, components, images, maps, dependencies, caching), when adding a client component or library, or when a page feels slow.
Readers on a mid-range phone should see text in under 2.5 s and get an answer to every tap within 200 ms. The globe is the product, but it must never delay the text or block the main thread. Measure first, then change one thing, then measure again.
Before you start
- Related skills:
atlas-architecture(the static/ISR rule and cache tags),atlas-maps-data(what the globe does),atlas-frontend(component patterns),atlas-seo(LCP hero as a real<img>),atlas-testing-release(CI and production checks). - Decisions: ADR-014 (JS budget 550 kB) and ADR-017 (MapLibre from
public/maplibre/<version>/) indocs/architecture/10-rozhodnuti-adr.md. - Budgets, measuring commands and the regression procedure: reference.
Targets and baseline
Pass/fail is field data (CrUX) at the 75th percentile, mobile and desktop separately. Lab numbers are diagnostics.
| Metric | Good (p75) | Atlas lab baseline, mobile throttled (2026-10-16) |
|---|---|---|
| LCP | ≤ 2.5 s | 2.25 s /, 2.61 s topic, 2.75 s /country/china, 2.61 s /region/east-asia |
| INP | ≤ 200 ms | not measured in the field; lab TBT 1.1–2.5 s on map pages (main risk) |
| CLS | ≤ 0.1 | 0 on all pages checked |
| TTFB (diagnostic) | ≤ 0.8 s | 0.37–0.57 s (CDN hit, from Prague) |
Numbers to beat: long tasks of 1.1–2.5 s total on map pages (one task of 1.3 s on the country
page: MapLibre init, GeoJSON parse, hydration), about 770 KB of JS transfer on the country and
region pages, maplibre-gl.mjs 289 KB, countries.geo.json 163 KB transferred (503 KB raw).
Rules
Delivery and caching
- Every public page is served from the CDN. The static/ISR rule itself lives in
atlas-architecture; verify it here:curl -sI <url> | grep -i x-vercel-cacheshowsHIT,PRERENDERorSTALEafter a warm-up. A repeatedMISSmeans the route turned dynamic or uncacheable. Find out why before merging. - Never send
Set-Cookie,Vary: Cookie,privateorno-storeon a public response. Each silently disables CDN caching.Cache-Control: public, max-age=0, must-revalidateon HTML is normal: the browser revalidates, the CDN keeps the page. - The data cache pattern (
unstable_cache+tags.*+PUBLIC_REVALIDATE_SECONDS, Reactcache(),updateTagon writes, never raising the interval) is defined inatlas-architecture. Performance adds: keep each cached item under 2 MB (Next skips larger ones) and split big payloads under their own tag, astags.country(iso3)andtags.indicator(id)do. - Functions run in
fra1(vercel.json), next to Supabase in Frankfurt. Don't add regions or move DB-reading code to the edge. - Static assets keep their headers from
next.config.ts:/maplibre/*immutable for a year,/data/*one day plus a week of stale-while-revalidate.
LCP
- The LCP element is in the server HTML. On map pages it is text: the globe starts only after
the page is idle (
requestIdleCallback, 1.5 s timeout) and only once its container has a size (AtlasGlobe). Keep that order; never start MapLibre during hydration. - An LCP photo is a real
<img>withsrc={photoUrl(src, PHOTO_WIDTH.hero)},srcSet={photoSrcSet(...)}, asizesthat matches the layout,width/height, andfetchPriority="high"(seeArticleHero). Neverloading="lazy"on it. - When the LCP photo is a CSS background (the first card of a list), announce it with
preload(photoUrl(photo, PHOTO_WIDTH.card), { as: "image", fetchPriority: "high" })fromreact-dom, as/topicsand/global-issuesdo. - At most one or two high-priority images per page. Next 16 deprecated
next/imagepriority; usepreloadorfetchPriority. - Fonts:
next/fontInter and Manrope,latinsubset,display: "swap"(src/app/layout.tsx). Don't add a font family, a preloaded subset or a CSS@importof web fonts.
Images
- Request only widths from
PHOTO_WIDTH(src/lib/images.ts: 256, 384, 640, 828, 1920) or other values in Next's defaultdeviceSizes/imageSizes; the optimizer refuses other widths. - Quality is 75 (
photoUrl). Next 16 accepts onlyimages.qualities(default[75]); a new quality needs a config change and a reason. - AVIF then WebP come from
images.formatsinnext.config.ts. OnlyPHOTO_ORIGINSand/images/are optimized. - Below-the-fold images use
loading="lazy"anddecoding="async". Every image haswidthandheightor anaspect-ratio.
Layout stability (CLS)
- Reserve space for everything that loads late: images, the globe, charts, embeds (
EmbedFramesets its height before the iframe exists), banners. Skeletons (FullPageSkeleton,RailSkeleton) match the final size. - Animate only
transformandopacity. The coin and spark animations stay; keep them on those properties. - Keep pages bfcache-friendly: no
unloadhandlers, nono-storeon HTML.
Client JavaScript
- Server Components by default;
"use client"only on the interactive leaf. - Keep
tests/unit/client-bundle.test.tsgreen (nozodor@supabase/ssrin client code; how to comply:atlas-frontend). - MapLibre JS and CSS never enter a shared bundle: the loader rules are in
atlas-maps-data(loadMapLibre(), CSS only insrc/app/[locale]/(map)/layout.tsx). - Load rarely shown, heavy widgets on demand:
next/dynamic(LazyCountryQuiz, the header'sMenuDialogwith the newsletter form) orlazy()(MapVisual→MapCanvas). Admin libraries (TipTap editor,DataTable) never reach a public page. - Next prefetches every link in view, including the target route's scripts, and Turbopack
gives each layout (map, full-width, pages, membership) its own copies of shared code. So
site-wide chrome (header and footer menus
MenuLink, the floating Newsletter and Support buttons, the cookie notice, "Full page view") uses<Link prefetchOnIntent>: prefetch on hover, focus or touch. Lighthouse counts prefetched scripts inresource-summary:script. - Before adding a client dependency or a large client component, run
npx next analyzeand compare the route's first-load JS. Over about 20 KB compressed needs a reason in the PR. - Third-party scripts use
next/scriptwithstrategy="lazyOnload"unless needed before interaction. Iframes go throughEmbedFrame(mounted near the viewport,loading="lazy").
MapLibre and geodata
- Loading (runtime import from
/maplibre/<version>/, lazy metric layers) followsatlas-maps-data. The budgets below are performance rules. - Load GeoJSON by URL (
GLOBE_DATA.countries), never inline it in JS, so the worker parses it off the main thread.countries.geo.jsonmust not grow beyond today's 163 KB transfer (503 KB raw;npm run data:optimisesimplifies to 2 decimals). Past about 1 MB raw, switch to vector tiles. - Hover and selection use
setFeatureState(globe/featureState.ts), neversetData. Keep layers few, withminzoom/maxzoom. - On touch screens (
isTouchScreen()) the globe does less:TOUCH_MAP_OPTIONS(no fades, 64-tile cache),touchStyle, idle spin at about 12 fps (useIdleSpin), first spin after the reader's first input or 10 s. New animations follow the same rule.
Long tasks and INP
- Aim for no main-thread task over 50 ms after hydration; anything over 200 ms is a bug.
- In event handlers, update the visible state first, then yield (
await new Promise((r) => setTimeout(r, 0)), orscheduler.yield()where supported) before heavy work. Mark non-urgent updates withstartTransition. - Map clicks: set the selection, yield, then run feature queries, panel fetches and
setFeatureStateloops. - Don't hydrate what doesn't need to be interactive: long lists and article text stay server
rendered. Debounce typing (
useDebouncedSearch).
How we do it in Atlas
LCP photo as a real image (src/components/atlas/ArticleHero.tsx, shortened):
<img
src={photoUrl(hero, PHOTO_WIDTH.hero)}
srcSet={photoSrcSet(hero, [PHOTO_WIDTH.card, 1200, PHOTO_WIDTH.hero])}
sizes="100vw"
alt={alt}
width={1600}
height={640}
fetchPriority="high"
decoding="async"
/>LCP photo that is a CSS background (src/app/[locale]/(map)/(full)/topics/page.tsx):
const firstPhoto = safeUrl(cards[0]?.hero);
if (firstPhoto)
preload(photoUrl(firstPhoto, PHOTO_WIDTH.card), { as: "image", fetchPriority: "high" });Checking the CDN: curl -sI https://atlas-of-todays-world.vercel.app/topics | grep -i x-vercel-cache.
Checklist
- [ ]
x-vercel-cacheisHIT/PRERENDERon the changed public routes (preview deploy). - [ ] No cookies, headers or
no-storein a public path; revalidate not raised. - [ ] LCP element in server HTML; LCP image
fetchPriority="high", not lazy. - [ ] Every image, embed and chart has a reserved size.
- [ ] New client code is a leaf; client-bundle test passes;
npx next analyzechecked for new dependencies. - [ ] Nothing starts MapLibre or heavy work before idle; no task over 200 ms in a trace.
- [ ] Measured before and after (numbers in the PR description).
Anti-patterns
- Reading
cookies()/headers()in a public layout: every page goes dynamic and misses the CDN. - Raising
revalidate"to save requests": breaks scheduled publishing. loading="lazy"on the hero, orfetchPriority="high"on every card.- Importing
maplibre-gl, a chart library or the editor into a shared layout. - Parsing GeoJSON on the main thread, or inlining it into the JS bundle.
- Optimising from one Lighthouse run on a fast laptop: use throttling, several runs, field data.
Known gaps (2026-10-16)
- Map pages block the main thread for 1.1–2.5 s (TBT), with a 1.3 s single task on
/country/china. INP is the main risk. - About 770 KB JS transfer on country and region pages, about 520 KB on home and topics.
- Mobile lab LCP is just over 2.5 s on topic, country and region pages, driven by FCP (render-blocking CSS/JS and fonts), not by images.
- No
preconnectto the tile host (server.arcgisonline.com) in the server HTML;warmUp()adds it only when the globe starts, then about 31 tiles load. - No field data: Vercel Analytics is installed, but no Speed Insights or
useReportWebVitals; low-traffic URLs have no CrUX data. - Lighthouse CI runs only nightly (
full-tests.yml) and only with the desktop preset.
Sources
Web Vitals · thresholds · optimize LCP · optimize INP · optimize CLS · Vercel CDN cache · MapLibre large data. Full list in reference.
Files of this skill
reference.md
Performance reference
Detail for atlas-performance.
Baseline measurements (2026-10-16)
Playwright + Edge, 412×823 mobile viewport, 4× CPU throttle, about 1.6 Mbps and 150 ms RTT, one run per page, production site. PageSpeed Insights returned 429 without an API key, so no CrUX numbers yet.
| Page | FCP | LCP (element) | CLS | TBT | Max long task | Transfer | JS |
|---|---|---|---|---|---|---|---|
/ | 2.25 s | 2.25 s (hero <p>) | 0 | 1.09 s | 401 ms | 1.0 MB | 524 KB |
/topics/types-of-migrants | 2.24 s | 2.61 s (hero image, AVIF) | 0 | 0.34 s | 331 ms | 0.88 MB | 516 KB |
/country/china | 2.07 s | 2.75 s (<p>) | 0 | 2.48 s | 1,288 ms | 1.26 MB | 769 KB |
/region/east-asia | 2.29 s | 2.61 s (<p>) | 0 | 1.69 s | 486 ms | 1.25 MB | 767 KB |
TTFB 0.37–0.57 s from Prague (CDN hit). HTML: 115–137 KB uncompressed on map pages, 367 KB on a
topic (RSC payload). Largest assets on /: maplibre-gl.mjs about 289 KB, countries.geo.json
about 163 KB, two woff2 fonts (48 + 24 KB), 31 ArcGIS tiles.
Reading: CLS is excellent. LCP sits at the 2.5 s line and follows FCP (render-blocking CSS, JS and fonts), not images. INP is the main risk: map pages run long tasks from MapLibre init, GeoJSON parsing and hydration.
Budget proposal
For the owner to confirm (ADR-014 already sets 550 kB of JS and LCP < 2.5 s). "Lab" means the throttled mobile setup above or Lighthouse mobile.
| Metric | Now (lab) | Budget | Enforced today |
|---|---|---|---|
| LCP, every public page | 2.25–2.75 s | ≤ 2.5 s | LHCI desktop, error (warn on /region/) |
| CLS | 0 | ≤ 0.05 lab, ≤ 0.1 field | no |
| TBT, map pages | 1.1–2.5 s | ≤ 600 ms, later ≤ 300 ms | no |
| Longest task after load | 331–1,288 ms | ≤ 200 ms | no |
| JS transfer, map pages (incl. MapLibre) | about 770 KB | ≤ 550 KB | LHCI resource-summary:script:size 550000, error |
| JS transfer, text pages (topic, news, author) | about 516 KB | ≤ 550 KB now; goal 170 KB excluding MapLibre | same |
countries.geo.json | 163 KB transfer | ≤ 163 KB | no |
| Field p75 (CrUX or RUM) | none | LCP ≤ 2.5 s, INP ≤ 200 ms, CLS ≤ 0.1 | no |
Note: Lighthouse counts only scripts loaded during its run. MapLibre starts at browser idle, so a Lighthouse number can look better than a full trace. Check the transfer in a trace too.
Suggested next steps: a mobile Lighthouse preset next to the desktop one in lighthouserc.json,
a TBT assertion on map pages, and field data (Vercel Speed Insights, a new dependency the owner
must approve, or useReportWebVitals sending to Vercel Analytics).
Measuring
Lab, locally
npm run build && npm run start # production server on :3000 (reads your .env.local, atlas-dev)
npx --yes @lhci/cli@0.14 autorun # the nightly Lighthouse config: lighthouserc.json
npx next analyze # bundle analyzer (Next 16.1+, Turbopack)npm run verify builds into .next-build when next dev is running.
In Chrome DevTools → Performance: CPU 4× slowdown, network "Slow 4G", record a reload, then
read LCP, long tasks (red corners) and the "Bottom-up" tab. Playwright traces work too
(trace: "on" in a local run of playwright.config.ts; CI keeps traces off because the repo
is public).
Production
curl -sI https://atlas-of-todays-world.vercel.app/country/china | grep -iE "x-vercel-cache|cache-control|x-vercel-id"Expect x-vercel-cache: HIT (or PRERENDER/STALE) and an id starting with fra1::.
- PageSpeed Insights (lab + CrUX field):
home,
country.
The API needs a key (
&key=…), otherwise it answers 429. - Nightly Lighthouse results: artifact "lighthouse" of Full tests.
- Vercel Analytics (page views, referrers; cookie-less): Vercel team → project → Analytics.
- Search Console Core Web Vitals report (field, once traffic is enough): report.
Investigating a regression
- Confirm it. Reproduce on the production URL with the throttled setup, three runs, and compare with the baseline table. One slow run is noise.
- Check delivery.
curl -sIthe page:x-vercel-cacheMISSon every request or a region other thanfra1points to caching. Look in the build log route table for a public route that is no longer static (the CI stepscripts/check-static-routes.mjsshould have failed; check why it didn't). Look for newcookies(),headers(),searchParamsorno-store. - Find the change.
git log --oneline -- <files of the route>since the last good deploy. Vercel keeps every deployment, so compare the old and new deployment URLs. - Split the metric.
- LCP: which element? TTFB, resource delay, load or render delay (DevTools LCP breakdown)? A lazy or late-discovered hero, a new font, or blocking CSS/JS.
- INP/TBT: record a trace, sort long tasks, read "Bottom-up". Typical causes: a client component that hydrates a big tree, a library added to a shared layout, MapLibre starting before idle, synchronous work in a click handler.
- CLS: DevTools "Layout shifts" track; find the element without a reserved size.
- JS size:
npx next analyzeon both commits; diff the route's first-load JS.
- Fix one thing at a time and measure again. Put the before and after numbers in the PR.
- Prevent it. Add or tighten a guard: an LHCI assertion, a unit test like
client-bundle.test.ts, or a rule in this skill.
Where things live
| Concern | File |
|---|---|
Cache tags, PUBLIC_REVALIDATE_SECONDS | src/lib/cache/tags.ts |
| Region | vercel.json (fra1) |
| Static asset headers, image formats, cache TTL | next.config.ts |
Photo widths, photoUrl, photoSrcSet | src/lib/images.ts |
| Fonts | src/app/layout.tsx (next/font/google) |
MapLibre loader, warmUp() | src/components/map/maplibre.ts |
| Globe start on idle | src/components/map/AtlasGlobe.tsx |
| Touch-screen globe options | src/components/map/globe/touch.ts, useIdleSpin.ts |
| Geodata build | scripts/fetch-geodata.mjs, scripts/optimise-geodata.mjs |
| Lazy metric layers | src/app/api/globe-layer/[id]/route.ts |
| Client bundle guard | tests/unit/client-bundle.test.ts |
| Static route guard | scripts/check-static-routes.mjs |
| Lighthouse CI | lighthouserc.json, job "Lighthouse" in .github/workflows/full-tests.yml |
Sources
- Web Vitals: https://web.dev/articles/vitals
- Thresholds: https://web.dev/articles/defining-core-web-vitals-thresholds
- TTFB: https://web.dev/articles/ttfb · FCP: https://web.dev/articles/fcp
- Optimize LCP: https://web.dev/articles/optimize-lcp
- Optimize INP: https://web.dev/articles/optimize-inp
- Long tasks in DevTools: https://web.dev/articles/long-tasks-devtools
- Optimize CLS: https://web.dev/articles/optimize-cls
- bfcache: https://web.dev/articles/bfcache
- Preconnect: https://web.dev/articles/preconnect-and-dns-prefetch
- Performance budgets: https://web.dev/articles/performance-budgets-101
- Image formats: https://web.dev/articles/choose-the-right-image-format
- Iframe lazy loading: https://web.dev/articles/iframe-lazy-loading
- Third-party facades: https://developer.chrome.com/docs/lighthouse/performance/third-party-facades
- Vercel CDN cache: https://vercel.com/docs/caching/cdn-cache
- Vercel function regions: https://vercel.com/docs/functions/configuring-functions/region
- MapLibre large data: https://maplibre.org/maplibre-gl-js/docs/guides/large-data/
- Next.js 16.4 local docs (
node_modules/next/dist/docs/01-app/):03-api-reference/02-components/image.md,03-api-reference/02-components/font.md,02-guides/lazy-loading.md,02-guides/scripts.md,02-guides/package-bundling.md,02-guides/production-checklist.md,01-getting-started/08-caching.md,03-api-reference/04-functions/use-report-web-vitals.md