Skip to content

perf: homepage Lighthouse fixes (images, LCP, fonts, Zenodo CORS, a11y) - #239

Merged
mluena merged 6 commits into
developfrom
perf/landing-lighthouse
Sep 7, 2026
Merged

mluena merged 6 commits into
developfrom
perf/landing-lighthouse

Conversation

@mluena

@mluena mluena commented Sep 7, 2026

Copy link
Copy Markdown
Collaborator

Summary

A Lighthouse run on the homepage (app-dev.earthmonitor.org, 2026-09-07) scored 61 for performance with an LCP of 4.5 s and a Speed Index of 6.1 s. Nearly all of it was image weight: 26 MiB of images for a page that shows them at thumbnail size. This PR works through the report's flagged items.

Performance

  • Image optimizer back on. images.unoptimized: true had been set when the social feed landed. Geostory covers are now served as WebP at 96/256px instead of the original JPEG (the largest went from 23.5 MiB → 12 KiB); Fosstodon avatars ~190 KiB → ~3 KiB. Optimized variants are cached 30 days.
  • Hero background was a 1.2 MiB CSS PNG, discovered only after the stylesheet. Now a preloaded, viewport-sized next/image with fetchPriority="high": 57–87 KiB as WebP.
  • Fosstodon post pictures rendered through next/image instead of CSS backgrounds, so they are resized to the card.
  • Inter used display: 'block' (text hidden up to 1.7 s on a throttled run) → swap.
  • Preconnect to the API, Fosstodon, Zotero and the Esri tile server; dns-prefetch Plausible.
  • Geostories fetched once: the globe page requested the full list a second time with no params (another 730 KiB) only to look up the selected story.
  • Dropped the direct sharp 0.32 dependency; Next 16 ships the 0.34 its optimizer needs.

Fixed

  • Zenodo CORS error on every load: Zenodo sends no Access-Control-Allow-Origin, so the publications list silently lost its Zenodo records. New /api/zenodo/records route handler fetches server-side, forwards only allowlisted query params, caches 15 min.
  • Accessibility: carousel dots are 24px targets (discs stay 8px, aria-current added); author name is no longer an h4 under an h1-only page; image-only feed cards carry a hidden description so they no longer read as identical links.
  • SEO: Cesium's hidden credit anchors (no href) are mounted into detached elements so they never enter the document.

Not addressed (for awareness)

  • Cesium chunk (1.1 MiB, ~3 s CPU) is the globe itself; deferring it is a design call.
  • The 13 KiB "legacy JS" polyfills are Next's own injected module; browserslist cannot remove them.
  • api.earthmonitor.org serves over HTTP/1.1 and the geostories JSON is 733 KiB per call — backend items.
  • Security headers (CSP, HSTS, COOP) are flagged but unscored; deployment concern.
  • Visual change: dots now sit 16px apart instead of 8px.

Test plan

  • yarn check-types, yarn lint, yarn build
  • e2e: live-updates-carousel, search, geostories, publications, social-media, mobile-drawer, navigation (45 passed; publications spec retargeted to the proxy path)
  • Local prod server: /_next/image for a 23.5 MiB cover returns 12 KiB WebP; bg returns 57–87 KiB WebP; <img> and preload carry fetchPriority="high"; preconnect tags present; /api/zenodo/records returns 3 hits with s-maxage=900
  • Re-run Lighthouse on the preview deployment in an incognito profile (extensions cost ~300 ms in the original report)
  • Check the homepage on a phone: starfield still anchored bottom-right, dot strip centred between the arrows
  • Confirm the Docker image serves /_next/image (standalone output traces Next's nested sharp)

Lighthouse on the homepage scored 61 with an LCP of 4.5 s, almost all of it
image weight: 26 MiB of images for a page that shows them at thumbnail size.

- Turn Next's image optimizer back on (`images.unoptimized` was set when the
  social feed landed) and allow the API media host. Geostory covers are
  served as WebP at 96/256px instead of the original JPEG: the largest one
  went from 23.5 MiB to 12 KiB. Fosstodon avatars go from ~190 KiB to ~3 KiB.
- Render Fosstodon post pictures through `next/image` instead of as CSS
  backgrounds, so they are resized to the card as well.
- Replace the 1.2 MiB CSS PNG starfield with a preloaded, viewport-sized
  `next/image` with `fetchPriority="high"`: 57–87 KiB as WebP, discoverable
  from the HTML, no longer waiting on the stylesheet.
- Inter used `display: 'block'`, hiding all text for up to 1.7 s on a
  throttled run; swap in with the size-adjusted fallback instead.
- Preconnect to the API, Fosstodon, Zotero and the Esri tile server, which the
  page hits right after hydration.
- Fetch the geostories list once: the globe page requested it a second time
  with no params (another 730 KiB) only to look up the selected story.
- Drop the direct `sharp` 0.32 dependency; Next 16 ships the 0.34 it needs.

Covers and avatars are cached by the optimizer for 30 days; the first
request for a cover still pays for the origin download.
Zenodo's API sends no `Access-Control-Allow-Origin`, so every homepage load
logged a CORS error and the publications list silently lost its Zenodo
records. `/api/zenodo/records` fetches them on the server, forwards only the
query parameters the app uses, and caches the answer for 15 minutes so one
copy serves every visitor instead of each of them hitting Zenodo's
rate-limited API.

The e2e stubs now target the proxy path; the Zenodo stub no longer needs a
CORS header since it is same-origin.
- Carousel dots were 8px buttons, below the 24px minimum target size. Each
  disc now sits inside a 24px button; the strip math follows the button size
  and the active dot exposes `aria-current`.
- The live feed author name was an `h4` under a page whose only heading is
  the `h1`; it is a paragraph now.
- Cards with a picture had only the author as link text, so several cards
  read as identical links. Each card now carries a hidden description of
  what it opens.
- Cesium appends credit widgets whose anchors have no `href`; they were
  hidden by CSS but still flagged as uncrawlable. They are mounted into
  detached elements, so they never enter the document. The visible credits
  are read off `viewer.creditDisplay` as before.
@vercel

vercel Bot commented Sep 7, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
oemc Ready Ready Preview Sep 7, 2026 9:08pm UTC

Request Review

`NEXT_PUBLIC_API_IMAGES_URL` ends with a slash on Vercel, so covers were
requested as `https://api.earthmonitor.org//media/g31.jpg`. The media server
tolerated that, but the image optimizer matches the path against `/media/**`
and answered 400 for every cover on the preview. Trailing slashes are now
stripped before the path is appended.

The allowed media host is read from the same env var, so a preview pointed at
another media server stays within the optimizer's allowlist.
The 79px covers looked soft once they went through the image optimizer: on a
1x screen they were a 96px WebP at quality 75, where a browser used to
downsample the multi-megapixel original.

The image is now declared at twice its box, so the optimizer serves 256px on
1x screens and 384px on 2x and the browser downsamples a detailed bitmap, and
it is encoded at quality 90. Next 16 only encodes listed qualities, so 90 is
added to `images.qualities`. Per cover this is ~4 KiB (1x) to ~7 KiB (2x),
against 1.6 KiB before and up to 23 MiB originally.
The live feed panel slides in from the right for 700ms after load. The test
read the strip's box, then the buttons' boxes in a second call; while the
panel was still moving the two readings disagreed, and a seventh button
appeared to intersect the strip. The old 8px dots with 8px gaps usually hid
this, the edge-to-edge 24px buttons do not, so the test failed in CI where
the page is slower to settle.

The test now waits for every finite animation on the page to finish and takes
all the rectangles in one call, from the same frame.
@mluena
mluena deployed to production September 7, 2026 21:07 — with GitHub Actions Active
@mluena
mluena merged commit 243138a into develop Sep 7, 2026
2 checks passed
@mluena
mluena deleted the perf/landing-lighthouse branch September 7, 2026 21:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant