The 4000px Mistake: A Desktop Image on a Mobile Connection
You have been doing everything right. You dropped an image into the pipeline, your Web Worker compressed it to a crisp WebP in under 300 milliseconds, and the encoded file weighs a satisfying 180KB instead of the original 4MB. That is a 97% reduction. You ship it.
Three days later, you run Lighthouse on mobile and it flags the image as a critical performance problem.
The culprit is not the format, the codec, or the quality setting. It is the dimensions. Your 180KB WebP is 4000 pixels wide. The phone rendering it has a 390-point CSS width. It downloaded every one of those 4000 pixels, decoded them into a full-resolution bitmap in memory, and then immediately discarded around 3600 of them because they could never be displayed. The browser dutifully did the work, the user paid the bandwidth cost, and the only reward was a slightly blurry image scaled down by the renderer.
Compression addresses file weight per pixel. Responsive delivery addresses how many pixels you send in the first place. Both matter. You cannot skip one and call the job done.
This lesson adds the second half of the image pipeline: the delivery layer. By the end, you will understand srcset, sizes, and <picture> well enough to build a ResponsiveImage.svelte component that serves the right format at the right dimensions to every device, while also avoiding the layout instability that makes pages feel unstable as images load.
Why Compression Is Only Half the Problem
Before diving into the attributes and elements, it is worth understanding exactly where the wasted bytes live.
When a browser downloads an image, it ultimately needs to paint it onto a screen. The screen has a physical pixel density (called DPR - Device Pixel Ratio) and a CSS layout width. A 390-point-wide container on a 3x Retina display needs at most 1170 physical pixels to look perfectly sharp. It does not benefit from a 4000-pixel image at all; the rendering engine simply scales it down, discarding the excess after doing the decode work.
Here is the rough arithmetic for a common scenario. A phone with a 390-point display at 3x DPR downloading a 4000-pixel wide image receives approximately 4000 / 1170, or about 3.4 times more data than it can ever use. On a fast connection you might not notice. On a 4G connection throttled by a busy airport, that multiplier is the difference between an image that loads in one second and one that takes three and a half seconds.
Multiply this across a page with six or eight images and you have a download budget problem that no amount of WebP encoding can fix.
The solution is not one universally small image, either. A 1600-pixel wide desktop monitor served a 400-pixel wide image looks noticeably soft. The correct answer is a family of sizes: generate multiple variants at different widths, and let the browser choose the most appropriate one based on the current viewport and DPR.
That is exactly what srcset and sizes exist to do.
srcset: Describing the Options
The srcset attribute on an <img> element gives the browser a menu of image sources to choose from. There are two descriptor syntaxes: pixel density descriptors (1x, 2x) and width descriptors (400w, 800w, 1600w). The width descriptor approach is the one you should always use for photographs and content images.
Here is why. Density descriptors only describe the DPR of the image relative to the base src. They contain no information about the physical width of the image or the layout width of the container. A browser seeing 2x knows the image is intended for high-density displays, but it has no way to calculate whether downloading it is worth it given the current viewport width.
Width descriptors are more expressive. srcset="hero-400w.webp 400w, hero-800w.webp 800w, hero-1600w.webp 1600w" tells the browser exactly how wide each candidate image is in pixels. Combined with the sizes attribute (covered next), the browser has everything it needs to do the math itself and pick the smallest source that will still look sharp.
<!-- Avoid: density descriptors only describe DPR, not layout width -->
<img src="hero.webp" srcset="hero@2x.webp 2x, hero@3x.webp 3x" alt="A mountain range at sunset" />
<!-- Preferred: width descriptors give the browser real pixel dimensions -->
<img
src="hero-800w.webp"
srcset="hero-400w.webp 400w, hero-800w.webp 800w, hero-1600w.webp 1600w"
alt="A mountain range at sunset"
/> The src attribute on the second version serves as a fallback for browsers that do not understand srcset at all. In practice, support is universal in 2026, but it is also used by the browser as the default 800w selection when no sizes hint is present and the browser assumes a full-viewport layout.
sizes: Telling the Browser Your Layout
Here is a subtlety that trips up almost every developer when they first encounter srcset. The browser begins downloading images very early in the page load cycle, before CSS has been parsed and before the layout has been calculated. It sees the srcset menu of options but has no idea how wide the image will actually be rendered on screen.
Without additional guidance, the browser falls back to a conservative assumption: it treats the image as if it will be displayed at the full viewport width. This is usually wrong. A hero image on a desktop layout might occupy 50% of a 1400-pixel viewport, meaning 700 pixels. The browser that assumes 1400 pixels and downloads the 1600w variant has wasted roughly half the bandwidth.
The sizes attribute solves this by letting you declare the intended layout width in advance, using media queries that mirror your CSS breakpoints.
<img
srcset="hero-400w.webp 400w, hero-800w.webp 800w, hero-1600w.webp 1600w"
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 800px"
src="hero-800w.webp"
alt="A mountain range at sunset"
/> Read this sizes value from left to right. On viewports up to 640px wide, the image will be displayed at the full viewport width (100vw). On viewports between 641px and 1024px, it will be half the viewport width (50vw). On anything wider, it will be a fixed 800 pixels. The final value is the fallback; it should represent the most common or worst-case display width for browsers that do not match any media query.
With this information, the browser’s selection logic becomes precise. A 390px mobile viewport at 2x DPR needs an image that is 780 physical pixels wide. The browser finds the smallest srcset candidate that meets or exceeds 780px, which is hero-800w.webp. Done. No oversized download.
The key insight is that sizes describes the layout, not the image. You are not saying anything about the image dimensions here; you are telling the browser how much screen real estate the image will occupy so it can make the right choice from the srcset menu.
The picture Element: Adding Format Fallback
srcset handles the size selection problem. The <picture> element handles the format selection problem. These are orthogonal concerns and they compose cleanly.
A <picture> element wraps one or more <source> elements followed by a terminal <img> element. The browser tests each <source> in order and uses the first one it supports. The <img> element at the end is the universal fallback and, crucially, it is also the accessibility anchor: its alt attribute is the one that screen readers and search engines see.
<picture>
<source
type="image/avif"
srcset="hero-400w.avif 400w, hero-800w.avif 800w, hero-1600w.avif 1600w"
sizes="..."
/>
<source
type="image/webp"
srcset="hero-400w.webp 400w, hero-800w.webp 800w, hero-1600w.webp 1600w"
sizes="..."
/>
<img
src="hero-800w.jpg"
alt="A mountain range at sunset"
width="800"
height="533"
loading="lazy"
decoding="async"
/>
</picture> The order matters
Standard of these days is to place AVIF first, then WebP, then JPEG as the fallback <img>. AVIF support is now broad (Chrome, Firefox, Safari 16+), so most users receive the most efficient format. Older browsers gracefully step down to WebP, and a browser without WebP support receives the JPEG fallback.
Notice that alt, width, height, loading, and decoding all live on the <img> element, not on <source>. The <source> elements only describe format and srcset. Every semantic and accessibility property belongs to <img>. This is a common source of confusion: developers sometimes try to add alt to <source> and wonder why it has no effect.
Preventing Cumulative Layout Shift
There is a second, entirely separate class of image performance problem that srcset does not address: layout instability. CLS (Cumulative Layout Shift) measures how much page content moves after the initial render. An image that loads after the text causes text to jump downward. If this happens repeatedly across several images, the page feels physically unstable and Lighthouse will penalise it.
The root cause is straightforward. A browser that does not know an image’s dimensions before it downloads cannot reserve space for it in the layout. It renders the surrounding content first, then when the image arrives, it inserts it and pushes everything else down.
The fix is equally straightforward, and it has been part of HTML for decades: always provide width and height attributes on your <img> element.
<!-- Wrong: browser cannot reserve space, content will shift when image loads -->
<img src="hero-800w.jpg" alt="A mountain range" />
<!-- Correct: browser reserves the exact right amount of space before the image loads -->
<img src="hero-800w.jpg" alt="A mountain range" width="800" height="533" /> When both width and height are present, modern browsers automatically calculate the image’s aspect ratio and reserve a proportional slot in the layout. The image slot is there from the start; the actual pixels just fill it in when they arrive. Zero shift.
There is an important clarification here. These width and height values describe the intrinsic dimensions of the image in pixels. They do not control how the image is displayed on screen; that is CSS’s job. The values should reflect the actual pixel dimensions of your full-size image file, or at minimum, the dimensions of the largest srcset candidate.
For variable-width containers where you cannot hardcode pixel values, aspect-ratio in CSS provides the same guarantee:
/* When the image is inside a fluid container, aspect-ratio prevents CLS
even without explicit pixel dimensions in the HTML. */
.product-image-wrapper {
width: 100%;
aspect-ratio: 16 / 9;
overflow: hidden;
}
.product-image-wrapper img {
width: 100%;
height: 100%;
object-fit: cover;
} The object-fit: cover declaration is worth a moment of attention. Without it, a constrained container would distort the image to fit the available space. cover instead scales the image so it fills the container while maintaining its aspect ratio, cropping the edges if necessary. This is almost always the correct visual behaviour for content images inside fixed-aspect containers.
Building ResponsiveImage.svelte
With all the theory in place - srcset for size selection, sizes for layout hints, <picture> for format negotiation, and width/height for CLS prevention. The implementation is straightforward to encapsulate in a single component.
Centralising it means you can evolve the srcset naming convention, add new breakpoints, or swap in a new format in one place rather than hunting across the codebase.
Before writing the component, create the shared breakpoint config. Both the component and the worker (built in the next section) import from here, so a single edit propagates to both the generation pipeline and the display layer:
// src/lib/config/image-pipeline.ts
export const IMAGE_WIDTHS = [400, 800, 1600] as const
export type ImageWidth = (typeof IMAGE_WIDTHS)[number] Here is the full component in TypeScript Svelte 5 style:
<!-- src/lib/components/ResponsiveImage.svelte -->
<script lang="ts">
/**
* Props:
* src - base URL without extension or size suffix
* e.g. "https://cdn.example.com/images/abc123"
* alt - required accessible description
* width - intrinsic width in pixels (reserves layout space, prevents CLS)
* height - intrinsic height in pixels
* sizes - CSS sizes string; defaults to full viewport width
* priority - true → loading="eager" + fetchpriority="high" (use for LCP images)
*/
import { IMAGE_WIDTHS } from '$lib/config/image-pipeline'
interface Props {
src: string
alt: string
width: number
height: number
sizes?: string
priority?: boolean
}
let { src, alt, width, height, sizes = '100vw', priority = false }: Props = $props()
const buildSrcset = (ext: string) =>
IMAGE_WIDTHS.map((w) => `${src}-${w}w.${ext} ${w}w`).join(', ')
const avifSrcset = $derived(buildSrcset('avif'))
const webpSrcset = $derived(buildSrcset('webp'))
// 800w JPEG is the safe fallback for browsers without AVIF or WebP support.
const fallbackSrc = $derived(`${src}-800w.jpg`)
</script>
<picture>
<source type="image/avif" srcset={avifSrcset} {sizes} />
<source type="image/webp" srcset={webpSrcset} {sizes} />
<img
src={fallbackSrc}
{alt}
{width}
{height}
loading={priority ? 'eager' : 'lazy'}
fetchpriority={priority ? 'high' : 'auto'}
decoding="async"
/>
</picture> A few details worth noting:
sizeson<source>, not<img>: When using<picture>, thesizeshint belongs on the<source>elements. The<img>carriesalt,width,height, and loading attributes - the semantic and accessibility layer. These are intentionally separated.buildSrcsetis a plain function: It runs only when the outer$derivedexpressions call it, and those already tracksrcthrough the closure. No unnecessary reactive subscriptions.prioritycontrols LCP: Set it totruefor above-the-fold hero images to avoid a lazy-load penalty on your Largest Contentful Paint image.lazyis the correct default for everything else.$derivedkeeps srcsets in sync: Wheneversrcchanges (e.g. the user navigates to a new product), all three srcset strings re-derive without any manual intervention.
Usage:
<!-- src/routes/products/[slug]/+page.svelte -->
<script lang="ts">
import ResponsiveImage from '$lib/components/ResponsiveImage.svelte'
let { data } = $props()
// data.product.imageBase: "https://cdn.example.com/images/abc123"
</script>
<!-- Hero image: above the fold, so priority=true to avoid LCP penalty -->
<ResponsiveImage
src={data.product.imageBase}
alt={data.product.imageAlt}
width={1600}
height={900}
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 66vw, 800px"
priority={true}
/>
<!-- Gallery thumbnails: below the fold, lazy is correct here -->
{#each data.product.gallery as image (image.id)}
<ResponsiveImage
src={image.base}
alt={image.alt}
width={800}
height={600}
sizes="(max-width: 640px) 50vw, 25vw"
/>
{/each} Generating the Size Variants with @jsquash/resize
Everything above assumes you have the variant files to serve: the hero-400w.webp, hero-800w.webp, and hero-1600w.webp that the srcset strings point to. Those variants need to be generated during the upload flow, inside the Web Worker pipeline you built in previous lessons.
The package is @jsquash/resize, and it plugs into the decode-encode pipeline between the decode and encode steps. The order is critical: you must decode first, resize second, and then encode. Resizing after encoding defeats the purpose because you would be scaling a compressed artifact rather than working with lossless pixel data.
Extending the existing worker, not replacing it
The worker built in earlier lessons uses a deliberate two-phase protocol. A load message decodes the source file once and caches the raw ImageData. Subsequent encode messages re-compress those cached pixels at different quality levels without repeating the expensive decode step. That design is what makes the quality slider feel instant and the batch pool efficient.
Adding responsive variants does not require a new worker or a new worker contract. It requires one new message type: encode-variants. Like encode, it reads cachedImageData that load already placed in memory. The difference is that it iterates over a set of target widths, resizes the cached pixels at each breakpoint, encodes to both WebP and AVIF, and then posts all results back in a single message. The existing load and encode handlers are completely untouched.
This additive approach preserves three things:
- Backward compatibility - the single-file quality slider and the batch pool continue working without any changes.
- Decode efficiency - the image is decoded exactly once per file, regardless of how many size variants are generated.
- Separation of concerns -
encodeandencode-variantsare independent message types. You can call either after a singleload, or both in sequence.
Installing @jsquash/resize and @jsquash/avif
pnpm add @jsquash/resize @jsquash/avif Adding encode-variants to the worker
Add the new imports and constants near the top of the existing worker file, below the existing imports:
// src/lib/workers/image-optimizer.worker.ts - new additions at the top
import resize from '@jsquash/resize'
import { encode as encodeAvif } from '@jsquash/avif'
import { IMAGE_WIDTHS } from '../config/image-pipeline'
const QUALITY = { webp: 80, avif: 72 } as const Extend the message contract by adding two new union members. The existing InboundMessage and OutboundMessage types are additive, every existing consumer continues to compile and run without modification:
// src/lib/workers/image-optimizer.worker.ts - updated message types
export type VariantResult = { width: number; webp: ArrayBuffer; avif: ArrayBuffer }
export type InboundMessage =
| { type: 'load'; buffer: ArrayBuffer }
| { type: 'encode'; quality: number; token: number }
| { type: 'encode-variants'; token: number } // ← new
export type OutboundMessage =
| { type: 'ready' }
| { type: 'encoded'; buffer: ArrayBuffer; token: number }
| { type: 'variants'; variants: VariantResult[]; token: number } // ← new
| { type: 'error'; message: string; token: number } Add the handler inside ctx.onmessage, after the existing encode block:
// ctx.onmessage - add after the existing encode handler
if (msg.type === 'encode-variants') {
const { token } = msg
if (!cachedImageData) {
ctx.postMessage({
type: 'error',
message: 'No image loaded. Send a load message first.',
token
} satisfies OutboundMessage)
return
}
try {
const variants: VariantResult[] = []
for (const targetWidth of IMAGE_WIDTHS) {
// Skip widths wider than the source - upscaling introduces softness, not detail.
if (targetWidth > cachedImageData.width) continue
const targetHeight = Math.round(
(cachedImageData.height / cachedImageData.width) * targetWidth
)
// resize() operates on ImageData and returns new ImageData.
// This is lossless pixel arithmetic; no compression artifact is introduced here.
const resized = await resize(cachedImageData, {
width: targetWidth,
height: targetHeight
})
// Encode both formats in parallel - independent operations on the same pixels.
// Roughly halves the per-breakpoint encoding time versus running sequentially.
const [webpBuffer, avifBuffer] = await Promise.all([
encodeWebp(resized, { quality: QUALITY.webp }),
encodeAvif(resized, { quality: QUALITY.avif })
])
variants.push({ width: targetWidth, webp: webpBuffer, avif: avifBuffer })
}
// Transfer all ArrayBuffers back in one message.
// Transferable semantics: ownership moves from worker to main thread - no copying.
const transferables = variants.flatMap((v) => [v.webp, v.avif])
ctx.postMessage({ type: 'variants', variants, token } satisfies OutboundMessage, transferables)
} catch (err) {
ctx.postMessage({
type: 'error',
message: err instanceof Error ? err.message : 'Variant generation failed',
token
} satisfies OutboundMessage)
}
} The Promise.all for encoding both WebP and AVIF per variant is a deliberate parallelism choice. Encoding AVIF and encoding WebP are independent operations on the same resized ImageData; there is no reason to run them sequentially. This roughly halves the encoding time for the format pair at each size.
Handling variants on the main thread
How the main thread handles the variants message depends on your deployment target.
In the demo app, images never leave the browser. Add VariantUrl, variantUrls, and a variantsGenerating flag to the optimizer. The flag drives a spinner while AVIF encoding finishes; the URL array drives the download panel that replaces it when all variants are ready.
Note the pendingVariantToken pattern. Variant quality is fixed in the worker (QUALITY constants, not the slider), so variants only need to be generated once per image load. Tracking the request with its own token means a quality-slider change, which bumps encodeToken , does not silently swallow the arriving variants response:
// src/lib/optimizer.svelte.ts - additions
export type VariantUrl = { width: number; webpUrl: string; avifUrl: string }
// New reactive state alongside the existing optimizedUrl / optimizedSize:
let variantUrls = $state<VariantUrl[]>([])
let variantsGenerating = $state<boolean>(false) Inside squash(), revoke and clear the previous variants before starting a new file:
// Inside squash(), add before the existing URL revoke block:
for (const v of variantUrls) {
URL.revokeObjectURL(v.webpUrl)
URL.revokeObjectURL(v.avifUrl)
}
variantUrls = []
variantsGenerating = false Inside attachHandlers, declare pendingVariantToken and send encode-variants once after the first successful encode:
// At the start of attachHandlers:
let pendingVariantToken = -1
// Inside the 'encoded' handler, after settle(blob):
if (pendingVariantToken === -1) {
pendingVariantToken = ++encodeToken
variantsGenerating = true
w.postMessage({ type: 'encode-variants', token: pendingVariantToken })
} Handle the response and revoke the previous set before issuing new URLs:
// Inside w.onmessage, add after the 'encoded' block:
if (msg.type === 'variants') {
if (msg.token !== pendingVariantToken) return
for (const v of variantUrls) {
URL.revokeObjectURL(v.webpUrl)
URL.revokeObjectURL(v.avifUrl)
}
variantUrls = msg.variants.map(({ width, webp, avif }) => ({
width,
webpUrl: URL.createObjectURL(new Blob([webp], { type: 'image/webp' })),
avifUrl: URL.createObjectURL(new Blob([avif], { type: 'image/avif' }))
}))
variantsGenerating = false
return
} Also update destroy() to revoke variant URLs when the component unmounts:
// Inside destroy():
for (const v of variantUrls) {
URL.revokeObjectURL(v.webpUrl)
URL.revokeObjectURL(v.avifUrl)
}
variantUrls = [] Expose variantUrls and variantsGenerating in the return object of createOptimizer. On the optimizer page, add the variants panel inside the single-mode results section, after the download button:
{#if o.variantsGenerating}
<div class="optimizer__variants-loading" role="status" aria-live="polite">
<span class="optimizer__spinner" aria-hidden="true"></span>
Generating responsive variants…
</div>
{:else if o.variantUrls.length > 0}
<section class="optimizer__variants" aria-label="Responsive variants">
<h2 class="optimizer__variants-heading">Responsive variants</h2>
<div class="optimizer__variants-grid">
{#each o.variantUrls as v (v.width)}
<div class="optimizer__variant-card">
<span class="optimizer__variant-size">{v.width}w</span>
<a href={v.webpUrl} download="image-{v.width}w.webp" class="optimizer__variant-link"
>WebP</a
>
<a
href={v.avifUrl}
download="image-{v.width}w.avif"
class="optimizer__variant-link optimizer__variant-link--avif">AVIF</a
>
</div>
{/each}
</div>
</section>
{/if} The spinner appears immediately after the comparison slider while AVIF encoding (the slower format) completes for each width. The download grid replaces it when all six files (three widths times two formats) are ready.
In a production app with a CDN, the optimizer uploads each buffer to object storage and writes only the base URL to the database. The variant URLs are never stored explicitly the ResponsiveImage.svelte derives them at render time from the shared naming convention:
// src/lib/optimizer.svelte.js (production upload handler excerpt)
worker.onmessage = async (event) => {
if (!event.data.success) {
status = 'error'
worker.terminate()
return
}
const { variants } = event.data
const imageId = crypto.randomUUID()
const uploadPromises = variants.flatMap(({ width, webp, avif }) => [
uploadToStorage(`${imageId}-${width}w.webp`, new Blob([webp], { type: 'image/webp' })),
uploadToStorage(`${imageId}-${width}w.avif`, new Blob([avif], { type: 'image/avif' }))
])
// If you also need a JPEG fallback for legacy browsers (optional):
// uploadPromises.push(uploadToStorage(`${imageId}-800w.jpg`, fallbackBlob));
await Promise.all(uploadPromises)
// The database record stores only the base URL.
// ResponsiveImage.svelte derives all variant URLs from this base at render time.
const baseUrl = `${CDN_BASE}/${imageId}`
optimizedBaseUrl = baseUrl
status = 'done'
worker.terminate()
} The naming convention is the contract between your upload pipeline and ResponsiveImage.svelte. The component appends -400w.webp, -800w.avif, and so on; the storage layer creates those exact filenames. Store one base URL, derive the rest.
This is also why the $derived() calls in the component are not just syntactic convenience. They are the correct reactive expression of a mathematical relationship: given a base URL, the full set of variant URLs is deterministic. There is nothing to keep in sync.
Common Mistakes and Anti-Patterns
Putting alt on source instead of img
The <source> element has no alt attribute. Screen readers, search engine crawlers, and Lighthouse all look at the <img> element for the accessible description. This is a very common misunderstanding because <source> appears visually “above” <img> in the markup and feels more prominent. Always put alt, width, height, and all loading attributes on <img>.
<!-- Wrong: alt on source has no effect, the image is inaccessible -->
<picture>
<source type="image/avif" srcset={avifSrcset} alt="A mountain range" />
<img src={fallbackSrc} />
</picture>
<!-- Correct: alt belongs on img, always -->
<picture>
<source type="image/avif" srcset={avifSrcset} {sizes} />
<img src={fallbackSrc} alt="A mountain range" {width} {height} />
</picture> Omitting sizes and relying on the browser default
A srcset without sizes tells the browser the physical pixel dimensions of each candidate but not how large the image will be rendered. The browser defaults to assuming full viewport width. On a 1400px desktop displaying a two-column layout where images are 400px wide, this causes the browser to download the 1600w candidate when the 400w candidate is perfectly sufficient. Always provide sizes.
Resizing after encoding
Running resize() on a compressed image re-introduces artifacts because you are resampling already-compressed data. The correct pipeline is always: decode the original to raw pixels, resize the raw pixels, then encode to the target format. If you find yourself calling a resize function on a file path or a Blob URL, you are operating on compressed data.
Uploading only the original and resizing at serve time
Some CDNs offer on-the-fly image resizing via URL parameters. This approach can work, but it adds latency on the first request for each size, and it means your server is doing resize work that you have already paid for with the client-side pipeline. Pre-generating all variants during upload means every request, from the first one, is served directly from storage at full speed.
Hardcoding pixel widths in the srcset names
If you ever need to add a 2400w variant for very large monitors, you want that in one place. A component that hardcodes 400w, 800w, 1600w as string literals needs to be updated in sync with the worker’s IMAGE_WIDTHS. They will drift apart.
The ResponsiveImage.svelte component built in this lesson already avoids this: it imports IMAGE_WIDTHS from src/lib/config/image-pipeline.ts and derives all srcset strings through buildSrcset. The worker imports the same constant. Adding a new breakpoint to the config propagates to both sides automatically. Watch out for future components or copy-paste that reintroduces the hardcoded approach.
Performance and Scaling Considerations
A three-variant pipeline (400w, 800w, 1600w) produces six files per uploaded image when both WebP and AVIF are generated. For a photo gallery with 500 images, that is 3000 files, which is entirely manageable with flat-key object storage. The storage cost is low because modern formats are small, and the performance gain per served request is substantial.
The more interesting scaling question is encoding time. Generating three size variants in two formats means six encode operations per upload. With the Worker approach from earlier lessons, all six run off the main thread, so the UI stays responsive. The two formats at each size run in parallel within the worker. The net user-perceived latency for a typical 3MB JPEG is roughly 1 to 3 seconds on modern hardware, which is acceptable for an upload flow.
AVIF encoding is the slowest operation in the chain, typically 2 to 5 times slower than WebP for equivalent quality. If upload speed is critical and your audience has strong WebP support, you can defer AVIF generation to a background job and serve only WebP initially. Most visitors will never notice, because WebP is already excellent at the quality settings used here.
For sites that serve images from a CMS or static asset directory rather than user uploads, the calculus is different. Build-time tools like @sveltejs/enhanced-img can generate variants ahead of time without any runtime cost. Lesson 11 covers exactly this scenario: when to use the build-time pipeline, when to use the client-side pipeline, and how to use both in the same application without them conflicting.
A Brief Look Ahead: What enhanced:img Does
SvelteKit ships @sveltejs/enhanced-img, a build-time preprocessor that applies a very similar transformation to images imported directly into components. When you write <enhanced:img src="./hero.jpg" alt="..." />, the preprocessor generates AVIF and WebP variants at build time, writes the <picture> element with srcset and sizes automatically, and inlines the intrinsic dimensions.
This is powerful for static content: site logos, blog hero images, product thumbnails that live in your repository. It does not help with images that arrive at runtime through user uploads or a headless CMS, because those images do not exist at build time.
The full enhanced:img walkthrough is Lesson 11. The mental model to carry in until then: enhanced:img is your pipeline for assets you own at build time; the @jsquash Worker pipeline is your pipeline for assets that arrive at runtime. A production SvelteKit application often uses both.
Conclusion
Compression reduces the weight of each pixel. Responsive delivery reduces the number of pixels you send. Both are necessary; neither is sufficient alone.
The srcset attribute with width descriptors gives the browser a menu of candidate images. The sizes attribute gives the browser the layout information it needs to choose correctly from that menu. The <picture> element adds format negotiation on top, so browsers that support AVIF receive the most efficient format while older browsers receive WebP or JPEG without any JavaScript involvement.
Adding width and height attributes to every <img> lets the browser reserve space before the image loads, eliminating the layout shifts that degrade both user experience and Core Web Vitals scores.
And the @jsquash/resize integration closes the loop: the upload pipeline that generates your compressed files now generates them at multiple widths, creating the variant family that srcset needs to do its work.
The ResponsiveImage.svelte component developed in this lesson is small, but it encodes a significant amount of browser knowledge into one reusable place. Every image on your site that goes through this component benefits from format negotiation, size selection, aspect-ratio reservation, and lazy loading by default. That is a substantial performance baseline to have baked in before you write a single line of page-specific code.
Key Takeaways
srcsetwith width descriptors (400w,800w,1600w) gives the browser a size menu; thesizesattribute tells it how large the image will be rendered so it can choose the right entry.- The
<picture>element handles format negotiation: AVIF first, WebP second, JPEG as the universal fallback, with all accessibility attributes on the terminal<img>. widthandheightattributes on<img>(oraspect-ratioin CSS for fluid containers) prevent Cumulative Layout Shift by reserving space before the image loads.- The resize step belongs between decode and encode in the Worker pipeline; resizing compressed output introduces artifacts.
- Extend the existing worker with a new
encode-variantsmessage type rather than replacing it; the two-phaseload/encodeprotocol, the quality slider, and the batch pool all continue working without any changes. - A shared
IMAGE_WIDTHSconfig constant keeps the worker’s generation pipeline and the component’s srcset strings in sync automatically.
Further Reading
- Responsive images on web.dev
- The picture element on MDN
- Cumulative Layout Shift on web.dev
- @jsquash/resize on GitHub
- srcset and sizes: a deep dive on MDN
See Also
- Lesson 9: Lazy Loading, Priority Hints, and Blur-Up - the next layer above srcset: lazy loading,
fetchpriority, and the LQIP placeholder that paints before the network responds. - Lesson 10: Persisting the Pipeline - where the variants this lesson generates actually go: presigned PUTs to object storage and the database row that ties them together.
- Lesson 11: SvelteKit’s Built-In Image Tools - the build-time pipeline that emits the same
picture-element shape automatically for committed repository assets.