WB × The Multiplier / Web & development
Case study
Core Web Vitals for an Image-Heavy Portfolio: 216 MB to 42 MB Without Losing a Pixel That Matters
A 13 MB JPG behind a plain img tag is not a portfolio piece, it is a speed bump. The numbers I have, the one I refuse to invent, and the code in between.
By Weston BeckwithUpdated 9 min read

On the morning of September 22, 2026, wbxcreative.com served 194 MB of raw portfolio JPGs and PNGs, the largest of them 13 MB, through plain img tags with the Next.js lint rule that objects to that switched off. By that evening the sources were capped at 1,600 px, the JPG and PNG files in the portfolio and images folders had gone from 216 MB to 42 MB across 221 files, and every gallery tile and page hero ran through next/image with a sizes attribute, AVIF and WebP negotiated per browser. Those folders also hold MP4 hero videos and PDF menus the script never touched, so du reads about 80 MB today. The 216 and the 42 are images only.
What I do not have is a Core Web Vitals score, before or after. PageSpeed's shared API quota was exhausted that day and the local Chromium turned out to be a snap stub, so no Lighthouse run happened. This piece has the numbers I can stand behind, and it stops where the measurement stops.
Context
WBX Creative's portfolio is the largest thing on the site: 185 items across eight categories on the morning of the audit (182 once three duplicates were removed the next day), from 43 vehicle wraps to nine pieces of apparel, each one a photograph or a flat export of the finished piece.
The audit that morning found the images were the original exports, never resized for the web, served through img tags with no width, height, or sizes, and the @next/next/no-img-element lint rule disabled. None of the 185 items had a description. The grid waited for a client effect to decide desktop or mobile before rendering anything, so to the prerendered HTML the gallery was empty until JavaScript ran.
There is a reason it stayed that way. In January 2025 I had tried to migrate the site to next/image as one of five optimization phases applied at once across more than ten files, and the result was malformed JSX that I reverted in full; the rebuild case study has that post-mortem. The lesson I wrote down was one kind of change at a time, a build after every change, and no broad automation across many files. That is why each kind of change in this pass got its own commit and its own build.
Approach
Cap the sources with sharp
scripts/optimize-images.mjs walks public/portfolio and public/images, and for every JPG and PNG: resize so the longest edge is at most 1,600 px with no enlargement, honor EXIF rotation, re-encode JPEGs at quality 82 with mozjpeg and PNGs at compression level 9, strip metadata, and rewrite the file in place only if the result is smaller.
const MAX = 1600;
let pipe = sharp(file, { failOn: "none" }).rotate()
.resize({ width: MAX, height: MAX, fit: "inside", withoutEnlargement: true });
pipe = ext === ".png"
? pipe.png({ compressionLevel: 9, effort: 8 })
: pipe.jpeg({ quality: 82, mozjpeg: true });
Why 1,600: the widest slot on the site is the lightbox, 90 percent of the viewport on a phone and a contained modal on desktop; grid tiles are 20 to 50 percent of the viewport. A 1,600 px source covers a two-column phone grid at 2x density and a laptop lightbox, and it is the largest file next/image will ever have to read. It resizes on demand, but every new width or format starts from the original, and a 13 MB original is a bad place to start. Vercel also caps optimizable sources at 8,192 px on either side; 1,600 keeps a wide margin.
The script rewrote 215 of 221 files; the six it left alone came out no smaller.
Put every visible image through next/image
Heroes use fill, sizes="100vw", and priority, which preloads the image and disables lazy loading. Google's LCP guidance is to give the likely LCP image high fetch priority, keep it in the initial HTML, and never lazy-load it. Grid tiles use fill inside an aspect-square box, loading="lazy", and a sizes string that mirrors the grid's breakpoints:
<Image
src={item.image}
alt={item.alt ?? item.title}
fill
sizes="(max-width: 640px) 50vw, (max-width: 1024px) 33vw, (max-width: 1280px) 25vw, 20vw"
loading="lazy"
/>
sizes is the part people skip. Without it, next/image assumes the image is the full viewport width and emits a 1x and 2x candidate; with it, the srcset is built from the configured device and image sizes and the browser picks the smallest width that fits. The aspect-square box reserves the space before the bytes arrive, which keeps layout shift near zero on a grid that lazy-loads.
Keep the grid crawlable
Google's lazy-loading guidance is specific: use native loading="lazy" or an IntersectionObserver, not scroll or click handlers, because Googlebot does not interact with the page, and make sure the image URLs appear in the src of the rendered HTML. The category pages now render the full desktop grid on the server, with every title and image URL in the HTML. After hydration, a component checks the viewport once and, on phones, swaps in a lighter grid that uses an IntersectionObserver with a 50 px margin to mount tiles as they approach. The crawler sees all 43 wraps; the phone loads four and then the ones you scroll toward.
Let the browser choose the format
next.config.ts sets images.formats to AVIF then WebP. AVIF sits at about 95 percent global support and WebP at about 97 percent as of September 2026, and browsers that support neither get the original. Each format is cached separately, so an image requested by an AVIF browser and a WebP-only browser is transformed once per format and size, and the storage grows with it. Vercel's Hobby plan includes 5,000 transformations a month and returns a 402 with the alt text showing once that runs out, while already-cached images keep working; Pro is usage-based.
Give every item words
Zero items had a description, and the alt text was the title. On September 23, three captioning agents went through all 185 items from the artwork itself and wrote alt text under 125 characters and a 20-to-45-word description for each. They also found 59 titles naming the wrong client, medium, or vehicle (HVAC business cards tagged as cigars, for one) and three duplicate entries, all corrected the same day. The gate now warns on any img without alt.
What changed
| Morning of 2026-09-22 | After | |
|---|---|---|
JPG and PNG source bytes in public/portfolio + public/images (MP4 and PDF files untouched) | 216 MB | 42 MB |
| Largest JPG or PNG | 13 MB | under 1 MB, 1,600 px longest edge |
| Files rewritten | 0 | 215 of 221 |
| Gallery markup | plain img, no sizes, lint rule off | next/image, fill + sizes, lazy tiles, priority heroes |
| Format served | original JPG or PNG | AVIF or WebP, negotiated |
| Gallery in server HTML | empty until a client effect ran | all items, titles and image URLs |
| Item descriptions | 0 of 185 | 182 of 182 after de-duplication, alt under 125 chars (2026-09-23) |
| Wrong titles | 59 | 0 |
| Guardrail | none | build gate: broken images fail, missing alt warns |
The table has no LCP, INP, or CLS, and I am not going to fill the gap with a plausible number. The thresholds they would be judged against are unchanged as of Google's Search Central page dated December 2025: LCP at or under 2.5 seconds, INP at or under 200 milliseconds, CLS at or under 0.1, each measured at the 75th percentile of page loads per web.dev. Several 2026 agency posts claim LCP tightened to 2.0 seconds; Google's own pages and the CrUX release notes through the August 2026 dataset say otherwise.
For context, the August 2026 CrUX dataset covered 18.3 million origins, and 68.1 percent of them passed LCP, 81.5 percent CLS, and 85.3 percent INP. LCP is the one most sites fail, and on an image-heavy page the LCP element is usually an image, which is why the source cap and the hero priority are the two changes I would bet on. Betting is not measuring. The field numbers will come from CrUX through Search Console, and I will publish them when they exist.
What I would do differently
Measure first, on a different machine if necessary. I should have found a real Chrome and run Lighthouse once before touching anything. A before number is worth more than a clean after.
Move off priority before Next 16 forces it. Next 16 deprecates the priority prop in favor of preload, and the docs now prefer loading="eager" or fetchPriority="high" in most cases. My heroes still say priority, which works on 15.5 and is deprecated on 16.
Scope the lint exception. @next/next/no-img-element is still off globally because the OG image routes have to use img inside ImageResponse, the upload previews on the contact and intake forms show files the visitor just attached, and one plain img survives on the creative services page. The right fix is a per-line disable in those places and the rule back on everywhere else, so the next 13 MB image cannot sneak in.
Watch the transformation count. AVIF plus WebP is the right default for a portfolio, and it also means one transformation per format and size. I want a month of Vercel's image usage before deciding whether AVIF earns its keep on a site this size.
Do it in this order. Sources first, markup second, grid third, captions fourth, each its own commit with a build between. The January 2025 version of me did everything at once and lost the day.
Stack
Next.js 15.5.26 (bumped from 15.4.8 on September 23, 2026, for the security patch; Next 16 is current and the upgrade is planned), React 19.1, TypeScript 5, Tailwind CSS 3.4, Framer Motion for the modal, sharp 0.34 for the source pass, Vercel for hosting and image optimization, Claude Code for the edits, and scripts/gate.mjs running inside npm run build so a broken image or a missing alt is caught before deploy.
The Next.js SEO checklist covers the metadata half of the same rebuild, and the case study covers the audit that found all of this. The portfolio is the test page if you want to see the result, and the technical services page is where this kind of pass starts for a client site. The rest of the resume is on my bio page.
Have a gallery that loads like a brochure PDF?
Send the URL. I will tell you the source cap, the sizes string, and what it would take, before any invoice.
Start a conversationQuestions people ask
- What is a good Core Web Vitals score in 2026?
- As of September 2026 Google's thresholds are unchanged: Largest Contentful Paint at or under 2.5 seconds, Interaction to Next Paint at or under 200 milliseconds, and Cumulative Layout Shift at or under 0.1, each measured at the 75th percentile of real page loads. Claims that LCP tightened to 2.0 seconds in 2026 do not appear on Google's own pages.
- Does lazy loading images hurt SEO?
- Not if the image URLs are in the rendered HTML. Google recommends native loading="lazy" or an IntersectionObserver, and warns against loading on scroll or click because Googlebot does not interact with the page. Never lazy-load the image that is likely to be the Largest Contentful Paint element.
- What size should source images be for next/image?
- Smaller than you think. next/image resizes on request, but it reads the original every time a new width or format is asked for, and Vercel caps sources at 8,192 px on either side. I cap portfolio sources at 1,600 px on the longest edge, which covers a two-column phone grid at 2x and a laptop lightbox.
- Should I use AVIF or WebP?
- Both, in that order. Setting formats to AVIF then WebP in next.config lets the browser negotiate; AVIF sits at about 95 percent global support and WebP at about 97 percent as of September 2026. Each format is cached separately, so expect more transformations on your host's image bill.
- Why did you not publish a Lighthouse score?
- Because I could not run one that day. PageSpeed Insights' shared API quota was exhausted and the local Chromium was a snap stub, so no lab run happened before or after. A score I invent is worse than no score. The field numbers will come from CrUX through Search Console, and I will publish them when they exist.
Sources
- https://web.dev/articles/vitals
- https://developers.google.com/search/docs/appearance/core-web-vitals
- https://web.dev/articles/optimize-lcp
- https://developers.google.com/search/docs/crawling-indexing/javascript/lazy-loading
- https://nextjs.org/docs/app/api-reference/components/image
- https://vercel.com/docs/image-optimization/limits-and-pricing
- https://caniuse.com/avif
- https://caniuse.com/webp
- https://developer.chrome.com/docs/crux/release-notes