Guide

Image Optimization for Web Developers

By the Smol team11 min read

Image optimization for web developers comes down to three decisions in this order: serve a modern format, serve the right pixel width, and make sure the largest image above the fold is discoverable in the initial HTML. Everything else is rounding error. On 73% of mobile pages the Largest Contentful Paint element is an image, so that one file decides whether the page passes.

That figure is from the HTTP Archive Web Almanac 2024 performance chapter, which puts it plainly: “Most LCP elements, or 73% of mobile pages, are images. Interestingly, this percentage is 10% higher on desktop pages.”

We build Smol, a local Mac compression app, so this page has an obvious bias. It is also the page that says sharp in a build step is the right answer for a CI pipeline, and means it. Smol is for the other half of the job: the ad-hoc, local, drag-a-folder-in half that never makes it into the pipeline.

Which image problem should you fix first?

Ranked by bytes recovered per hour of engineering time, on a typical marketing or content site.

ProblemFixWhy it ranks here
Hero image is a 4 MB JPEG straight from the designerResize to the largest rendered width, then encode AVIF with a WebP fallbackOne file, one change, and it is the LCP element on most pages. Nothing else is close.
LCP image has loading="lazy"Remove it. Add fetchpriority="high"A one-character win. web.dev: “Never lazy-load your LCP image.”
Images served at intrinsic size to every viewportsrcset with w descriptors plus a sizes attributeA phone downloading a 2,400 px asset to paint 390 CSS pixels is pure waste.
Everything is JPEG or PNGAVIF first, WebP second, original as the <img> fallbackMeasured on our test corpus: 47% off a photo, 66% off a Retina screenshot.
No width and height on <img>Set both, or an aspect-ratioThis is a CLS fix, not an LCP one, but it is free.
A folder of one-off assets nobody ran through anythingA desktop pass, or an agent tool callThis is the gap a build step does not cover, because these files never enter the repo.

Notice that only one row is about the encoder. Choosing a better JPEG encoder is worth single-digit percentages. Choosing a better format and a smaller pixel grid is worth multiples.

Why is the hero image almost always what fails LCP?

Largest Contentful Paint is “good” at 2.5 seconds or less and “poor” above 4.0 seconds, assessed at the 75th percentile of page loads, segmented across mobile and desktop. Those are web.dev’s own thresholds, and the qualifying elements include <img>, <image> inside SVG, <video>, elements with a CSS url() background, and block-level elements containing text.

The useful part is the breakdown. Google splits LCP into four sub-parts with rough budget guidance for each:

Sub-partWhat it isTarget share of LCP
Time to First ByteRequest start until the first byte of the HTML response~40%
Resource load delayTTFB until the browser starts loading the LCP resource<10%
Resource load durationLoading the LCP resource itself~40%
Element render delayResource finishes loading until the element paints<10%

Compression only touches the third row. Which is why a 4 MB hero is such a reliable failure: it inflates the one sub-part that is allowed to be large, and it usually drags resource load delay up with it, because oversized hero images tend to arrive via a CSS background or a client-rendered component rather than as an <img> in the served HTML. The preload scanner cannot see a background image. It sees an <img src> immediately.

One more number worth carrying into a planning meeting. The Web Almanac found that in 2024, 16% of mobile websites were lazy-loading their LCP image, down from 18% in 2022. Sixteen percent of the web is still shipping the one anti-pattern web.dev calls out by name. Check yours before you touch an encoder.

<!-- The above-the-fold hero. Eager, prioritised, dimensioned. -->
<img src="/hero-1600.avif"
     srcset="/hero-800.avif 800w, /hero-1600.avif 1600w, /hero-2400.avif 2400w"
     sizes="(min-width: 1100px) 1040px, 100vw"
     width="1600" height="900"
     fetchpriority="high" decoding="async"
     alt="…">

No loading attribute at all on that one. The default is eager, and being explicit about it invites someone to “tidy” it into lazy later.

Which format should you actually ship in 2026?

AVIF, with a WebP source and the original as the <img> fallback. The gap is large enough that it dwarfs any argument about encoders. These are our own numbers, measured at matched SSIM on 25–26 September 2026 and documented in full in the best image compressor for Mac:

SourceJPEGWebPAVIFAVIF vs JPEG
Kodak test photo (kodim07)91,868 B63,206 B48,230 B−47%
Retina screenshot, UI and text716,970 B—241,943 B−66%

The screenshot row is the one developers underweight. Flat-colour UI captures, docs screenshots and product shots on plain backgrounds are where AVIF wins hardest, and they are also the images most likely to be sitting in a repo as 700 KB PNGs nobody has looked at since 2023.

Adoption is still behind the capability. Per the Web Almanac 2024, JPEG and PNG together account for 87% of LCP images, with JPEG at 61% of desktop pages, PNG at 26%, and WebP at 7%. AVIF is measurable but small. If your competitors are on that curve, this is free ground.

The negotiation mechanism is <picture> plus type. MDN is precise about the rule that makes it safe: “If the user agent does not support the given type, the <source> element is skipped.” Order matters, first match wins, and the <img> is the floor.

<picture>
  <source type="image/avif" srcset="/card-400.avif 400w, /card-800.avif 800w" sizes="(min-width: 640px) 380px, 100vw">
  <source type="image/webp" srcset="/card-400.webp 400w, /card-800.webp 800w" sizes="(min-width: 640px) 380px, 100vw">
  <img src="/card-800.jpg" width="800" height="600" loading="lazy" decoding="async" alt="…">
</picture>

Repeat sizes on every <source>. It does not inherit.

An interoperability trap we hit while testing, and it is worth knowing before you make Apple’s encoder part of a pipeline. macOS 27’s sips writes AVIF, but it writes large images as a tiled grid: Pillow 12.1.0 refused to decode a 3,840 × 2,525 AVIF that sips produced, reporting Invalid image grid, and libavif’s own avifdec logged the same error before recovering. Browsers were fine. A Python thumbnailing service in the same pipeline would not have been. Test your decoders, not just your browsers.

How do you write srcset and sizes without guessing?

Work backwards from CSS, not from the asset. MDN describes the browser’s algorithm in its responsive images guide: it looks at screen size, pixel density, zoom, orientation and network, works out which sizes media condition is first true, reads the slot width from that, and picks the srcset candidate matching that slot.

Two consequences fall out of that, and both bite in practice. The browser evaluates sizes before layout is resolved, so sizes="100vw" on an image that actually renders in a 380 px card makes the browser download the largest candidate you offered. And percentages are not valid slot widths, which is the single most common reason a hand-written sizes silently does nothing.

Pick your w candidates from the real breakpoints. A workable default set:

SlotCandidate widthsReasoning
Full-bleed hero800, 1600, 2400Covers 1× phone, 1× desktop and 2× desktop.
Content-width figure (~720 px)720, 1440Two rungs is enough. Three is over-fitting.
Card grid (~380 px)400, 800The 2× card is the common case on phones.
Avatar / icon (~48 px)48, 96Below about 100 px, format matters more than width.

Generating those rungs is exactly the kind of work that should be mechanical. Smol exposes the resize controls needed for it: image quality 0–100, a maximum dimension up to 32,768 px, and four resize modes (fit, fill, width, height). Its defaults are quality 75 with a 2,000 × 2,000 fit ceiling and metadata stripping on, which is a sane starting point for web assets and the wrong one for print originals.

Where does compression belong: build step or desktop app?

Build step. If images enter your repository and get deployed by CI, the correct answer is a library in the pipeline, and on Node that library is sharp. Its own description of itself is accurate: “The typical use case for this high speed Node-API module is to convert large images in common formats to smaller, web-friendly JPEG, PNG, WebP, GIF and AVIF images of varying dimensions.” It claims resizing “typically 4x-5x faster than using the quickest ImageMagick and GraphicsMagick settings” on the back of libvips, and it installs with no extra runtime dependencies on most modern macOS, Windows and Linux systems. Next.js, Astro, SvelteKit and Nuxt image components all sit on it or on something equivalent.

We are not going to pretend a drag-and-drop Mac app belongs in that slot. A build step is deterministic, reviewable, runs on every commit, and does not depend on somebody remembering.

Here is the half a build step does not cover, and it is larger than it looks:

SituationBuild stepLocal pass
Assets committed to the repoRight answerRedundant
A 40-image press kit going out over email todayNot in the repo, never will beRight answer
Screenshots for a README or a changelogOften bypasses the image pipeline entirelyRight answer
Client-supplied originals before they touch the repoRuns too late — you already committed 4 MBRight answer
A CMS where editors upload directlyDepends on the CMS doing itOnly if editors have the tool
One image, right now, in a pull requestWorks, but it is a commit and a CI waitRight answer

The row about committing 4 MB is the expensive one, because git keeps it forever. We wrote about that specific failure separately in the methods comparison for compressing images on Mac, and the throughput reality is in the compressor roundup: on 30 distinct 1,600 × 1,200 JPEGs totalling 15,790,158 bytes, a sips shell loop finished in 3.29 s at 48.9% saved, ImageOptim in 6.79 s at 5.8% (it is lossless, so that is expected), and Smol in 43.0 s at 58.3%. A shell loop is thirteen times faster than we are. If your batch is scripted and recurring, script it.

Can an agent do this inside the editing session?

Yes, and this is the one part of the workflow that genuinely did not exist two years ago. Smol runs a Model Context Protocol server on your Mac. Claude Code, Codex and Google Antigravity each connect with one click from the AI panel, and the agent then has compression, conversion, upscaling and metadata stripping as ordinary tool calls.

What that changes concretely: when an agent is editing a component that references hero.png, it can read the file, generate the AVIF and WebP rungs at the widths the component’s own sizes attribute implies, write the <picture> block, and delete the original, without a browser upload or a second terminal. It is also the interface we used to produce every Smol measurement on this site, which is reasonable evidence it works rather than a promise that it does.

One genuinely useful control for this: the neural encoder accepts a targetKb between 4 and 4,096, so “get this under 100 KB as AVIF” is an instruction rather than a search. Asked for exactly that from a 352,861-byte JPEG, it returned 51,140 bytes. An agent can hold a byte budget the way it holds a type error.

This is a developer convenience, not a reason to buy. If your assets are in CI, sharp in a pre-commit hook does the same job with no app running.

When is Smol the wrong tool for a web project?

Four cases, and they cover most professional front-end work.

Your images are in the repo and CI deploys them. Use sharp. A build step cannot be forgotten, and a desktop app can. This is not a close call.

Your images are user-uploaded. Then the transform has to happen on a server or at an image CDN, because you cannot put a Mac app in that path. Cloudinary, imgix, Cloudflare Images and a self-hosted sharp service are all reasonable; we are not any of them.

You need lossless optimization of existing PNGs and JPEGs. ImageOptim is free and it is the only tool in our roundup that produced pixel-identical output, verified at absolute error zero. It saved 5.8% on our 30-file batch, which is what lossless looks like. Its last release was 29 October 2023, and for this narrow job that does not matter.

You are on Linux or Windows, or in a container. Smol is macOS only. Every CI runner on earth is not a Mac.

Where a local app does earn its $29: the assets that never reach the pipeline. Press kits, README screenshots, client-supplied originals, one-off exports, and the folder somebody AirDropped you an hour before launch. Smol is $29 once, it writes JPEG, PNG, WebP, AVIF, HEIC and GIF, it takes a whole folder, and it runs entirely on your machine. For the adjacent jobs there is compressing images for the web and converting to AVIF on Mac.

If you arrived here from a different job, the vertical pages for photographers, real estate listings, podcasters and legal and HR teams cover the same tooling against different constraints.

Frequently asked questions

What is the most common cause of a failing LCP score?

An oversized image. The HTTP Archive Web Almanac 2024 found the LCP element is an image on 73% of mobile pages, and 10% more often than that on desktop. LCP is good at 2.5 seconds or less at the 75th percentile, so the single largest above-the-fold image usually decides the result. Fix its format and pixel width before anything else.

Should I use AVIF or WebP for images on my site?

Serve AVIF first with WebP as a fallback source inside a picture element. At matched SSIM on our test corpus, one Kodak photo was 91,868 bytes as JPEG, 63,206 as WebP and 48,230 as AVIF. A Retina screenshot went from 716,970 bytes to 241,943 as AVIF while scoring higher. If a browser does not support a source type, MDN specifies it is skipped, so the fallback costs nothing.

Is it ever right to lazy-load the hero image?

No. web.dev states plainly that you should never lazy-load your LCP image because it always adds resource load delay and hurts LCP. Use no loading attribute at all, or loading="eager", plus fetchpriority="high". The Web Almanac found 16% of mobile sites were still lazy-loading their LCP image in 2024, down from 18% in 2022.

Do I still need sizes if I use srcset?

Yes, whenever your srcset uses w descriptors. The browser resolves sizes before layout is known, so without it, or with a wrong value like 100vw on a 380 pixel card, it downloads a larger candidate than the slot needs. Percentages are not valid slot widths, which is the usual reason a hand-written sizes attribute appears to do nothing.

Should image compression run in a build step or on the desktop?

In a build step, if the images live in your repository. sharp is the right tool on Node: it converts to web-friendly JPEG, PNG, WebP, GIF and AVIF, and claims resizing 4x to 5x faster than the quickest ImageMagick settings. A desktop app covers the assets that never enter the repo, such as press kits, README screenshots and client-supplied originals.

Can Claude Code or Codex compress images for me directly?

Yes. Smol ships a Model Context Protocol server that Claude Code, Codex and Google Antigravity connect to in one click, giving the agent compression, conversion, upscaling and metadata stripping as tool calls on your own Mac. Its neural encoder also accepts a targetKb between 4 and 4096, so a byte budget can be stated rather than searched for.

Keep reading