Perceived Speed Is the Only Speed That Matters

You have spent eight lessons grinding bytes off the wire. AVIF replaces JPEG, a Worker pool keeps the main thread free, srcset and sizes cut the pixel count to match the device. By every objective metric, your pipeline now ships less data, faster, to more devices than the team you joined six months ago could have imagined.

Then you load the page on a hotel Wi-Fi connection, and the first thing the user sees is a column of blank rectangles slowly filling in from the top.

The bytes are smaller. The page is faster. But the experience is not. There is a gap between when layout commits and when pixels arrive, and on a slow connection that gap is wide enough to feel broken. The user is not measuring kilobytes; they are measuring the moment their content becomes legible. That moment is what every image performance technique you have left to learn is really about.

This lesson covers the last three tools you need: loading="lazy" to stop wasting bandwidth on images the user might never scroll to, fetchpriority="high" to give the browser permission to download your hero image early, and the Low-Quality Image Placeholder (LQIP) technique that replaces blank boxes with a blurred preview while the real pixels are in flight.

By the end you will have a LazyImage.svelte component that defers below-the-fold work, prioritises Largest Contentful Paint (LCP) candidates correctly, and renders a tiny base64 blur the moment the page paints, long before the network has finished delivering anything substantial.

The Three Problems on One Page

Open any e-commerce product page in a throttled DevTools session and you will see all three problems at once.

The hero image at the top is the LCP candidate. It is the single most important paint event in the page, and the browser has to discover it, decide it matters, and request it. By default, the browser is conservative: every image is fetchpriority="auto", which usually translates to “low” until the layout engine has confirmed the image is in the viewport. On a fast connection this is fine. On a slow one, your hero image waits politely behind every CSS file, every web font, and every analytics script.

Below the fold there are eight thumbnails the user might scroll to and might not. The default behaviour for an <img> element without a loading attribute is eager: the browser starts downloading all of them immediately. This is reasonable for the first thumbnail. It is bandwidth theft for the eighth, especially on mobile.

Between the moment layout commits and the moment any of those images arrive, the screen is full of empty rectangles. The page has reserved the right amount of space (you added width and height in the previous lesson, so there is no shift), but those slots look broken. Users do not know whether the page is working or whether something has failed. On a slow connection this gap can be three or four seconds long.

Each of these problems has a precise tool, and the tools compose cleanly. None of them are new, loading="lazy" shipped in 2019, fetchpriority reached cross-browser support in 2023, and LQIP techniques predate both, but together they are the difference between a fast pipeline and a page that feels fast.

loading=“lazy”: Don’t Pay for What the User Won’t See

The loading attribute on <img> accepts two values: eager (the default) and lazy. Setting it to lazy tells the browser to defer the request until the image is close to the viewport. Close, not in. Browsers use a generous threshold (a few hundred pixels in most implementations) so the image has time to download before it actually scrolls into view.

<!-- Below-the-fold thumbnail. The browser defers the request until the user
     scrolls within a few hundred pixels of it. -->
<img
	src="thumb-400w.webp"
	srcset="thumb-400w.webp 400w, thumb-800w.webp 800w"
	sizes="(max-width: 640px) 50vw, 25vw"
	width="400"
	height="300"
	alt="A red enamel teapot on a white background"
	loading="lazy"
	decoding="async"
/>

Two things are worth understanding about how this works in practice.

First, the heuristic is viewport-based, not user-interaction-based. The browser does not wait for the user to scroll. It waits for the image to be within a configurable distance of the visible region. On a tall product page, scrolling down a few rows is enough to start the next batch of requests well before the user reaches them. The page feels continuous, not chunky.

Second, lazy loading is free for above-the-fold images as long as you do not apply it. If you set loading="lazy" on the hero image, you have just told the browser to deprioritise the most important paint event on the page. The browser will fetch it, but it will fetch it after most other resources, and your LCP score will suffer.

This is the rule: loading="lazy" for everything below the fold, never for the hero. The priority prop in the component you will build encodes exactly this distinction.

fetchpriority=“high”: Give the Hero Image a Head Start

fetchpriority is the second half of the loading-attribute story. Where loading controls when the browser requests an image, fetchpriority controls how the request is scheduled relative to everything else in the page.

The attribute accepts three values: high, low, and auto (the default). For images, auto is the browser’s own heuristic, which roughly maps to “low until layout proves you are visible, then high”. This is fine for most images but actively wrong for the LCP candidate. The hero image is provably the most important resource on the page, and you know that at HTML parse time. Telling the browser explicitly skips the discovery step and pulls the request to the front of the queue.

<!-- Hero image, above the fold. eager + high gives it the earliest possible start. -->
<img
	src="hero-1600w.webp"
	srcset="hero-400w.webp 400w, hero-800w.webp 800w, hero-1600w.webp 1600w"
	sizes="(max-width: 768px) 100vw, 800px"
	width="1600"
	height="900"
	alt="A mountain range at sunset"
	loading="eager"
	fetchpriority="high"
	decoding="async"
/>

The numbers are real. On a Lighthouse mobile run with a typical 4G profile, adding fetchpriority="high" to the LCP image typically shaves 300 to 800 milliseconds off the LCP timing. That is enough to move a “Needs Improvement” score into “Good”, and the change is one attribute.

There is a caveat that catches people out. fetchpriority="high" is not free; it is a finite priority budget. If you mark four different images on the page as high priority, you have effectively told the browser that none of them are. The browser still has to schedule them in some order, and the priority hint becomes noise. Use it on exactly one image per page - the LCP candidate - and let everything else default to auto or low (via loading="lazy").

The interaction between loading and fetchpriority is also worth being explicit about. They are independent attributes, and the four combinations have distinct meanings:

  • loading="eager" + fetchpriority="high" - request immediately, prioritise above other resources. The hero treatment.
  • loading="eager" + fetchpriority="auto" - request immediately, normal priority. The default for above-the-fold images that are not the LCP candidate.
  • loading="lazy" + fetchpriority="auto" - defer until near the viewport, normal priority when requested. The default for below-the-fold content.
  • loading="lazy" + fetchpriority="high" - rare. Tells the browser to defer the request but prioritise it when it eventually fires. Useful for an image that is just below the fold but will be the LCP after a small scroll, though in practice you almost always want eager + high for that case.

The component built later in this lesson collapses this into a single priority prop. priority={true} produces eager + high; priority={false} produces lazy + auto. Two states, no per-attribute decisions at the call site.

decoding=“async”: A Quiet Win

While we are talking about <img> attributes, decoding="async" is worth a single paragraph. The default decoding behaviour is auto, which lets the browser choose. In practice this often means the browser blocks the main thread briefly while it converts the encoded bytes into a paintable bitmap. decoding="async" tells it explicitly that you do not need this image to be ready synchronously with the surrounding DOM, which lets the browser hand the decode work off to a background thread.

You should use decoding="async" on every image, full stop. It has no downsides, and on pages with many images it removes a category of small-but-visible main-thread stalls. The component built below sets it unconditionally.

The Blank-Box Problem That Lazy Loading Doesn’t Solve

Set loading="lazy" and fetchpriority="high" correctly and your bytes are arriving in the right order. The hero image is downloading early, the thumbnails are deferred until they matter, and the request waterfall in DevTools looks healthy.

But there is still a moment, especially on slow connections, where the image slot has been laid out (because you set width and height) and the pixels have not yet arrived. That slot is empty. It is the right size, in the right place, with no layout shift, and it is also a featureless grey rectangle staring back at the user.

You have probably seen the workaround a thousand times without naming it: a tiny, blurred preview of the image renders instantly, and the full-resolution version fades in over the top a moment later. Medium popularised this years ago. Most modern photo-heavy sites use some variant. The technique has a name - Low-Quality Image Placeholder, or LQIP - and once you know how to generate one, the cost is essentially zero.

The idea is straightforward. Alongside each full-resolution image, you store a very small version (typically 20 pixels on the long edge) encoded as a base64 string. That string lives in your database next to the image record, so it ships with the page HTML, not over a separate network request. When the page renders, the browser already has the placeholder; it can paint it immediately and apply a CSS blur to mask the low resolution. The full image arrives later and replaces it.

A 20-pixel WebP is roughly 400 to 800 bytes. Base64-encoding inflates it by about 33%, so the value you store and ship is in the range of 500 to 1100 bytes per image. On a page with twenty images, that is around 15 to 20 kilobytes of inline placeholder data that is less than a single icon font, and it is the difference between blank rectangles and a page that looks like a page from the moment it paints.

Generating LQIP in the Worker

The LQIP generation step belongs in the same Web Worker as the rest of the encode pipeline, for the same reason every other expensive operation does: the main thread should never block on image processing.

The work itself is mechanical: given the cached ImageData that the load message already decoded, resize it to 20 pixels on the long edge, encode it to WebP at very low quality, and base64-encode the resulting bytes. The result is a string you can paste straight into a data: URL.

Add this to the existing worker, alongside the encode-variants handler from the previous lesson:

// src/lib/workers/image-optimizer.worker.ts - additions

import resize from '@jsquash/resize'
import { encode as encodeWebp } from '@jsquash/webp'

// LQIP target. 20px on the long edge keeps the encoded payload below ~800 bytes.
// Quality is intentionally very low; the CSS blur hides the artifacts.
const LQIP_LONG_EDGE = 20
const LQIP_QUALITY = 50

Extend the message contract:

// src/lib/workers/image-optimizer.worker.ts - message types

export type InboundMessage =
	| { type: 'load'; buffer: ArrayBuffer }
	| { type: 'encode'; quality: number; token: number }
	| { type: 'encode-variants'; token: number }
	| { type: 'encode-lqip'; token: number } // ← new

export type OutboundMessage =
	| { type: 'ready' }
	| { type: 'encoded'; buffer: ArrayBuffer; token: number }
	| { type: 'variants'; variants: VariantResult[]; token: number }
	| { type: 'lqip'; dataUrl: string; token: number } // ← new
	| { type: 'error'; message: string; token: number }

Add the handler. The shape mirrors the existing encode-variants block - read cachedImageData, do the work, post the result back:

// ctx.onmessage - add after the encode-variants handler

if (msg.type === 'encode-lqip') {
	const { token } = msg

	if (!cachedImageData) {
		ctx.postMessage({
			type: 'error',
			message: 'No image loaded. Send a load message first.',
			token
		} satisfies OutboundMessage)
		return
	}

	try {
		const { width: srcW, height: srcH } = cachedImageData
		const ratio = LQIP_LONG_EDGE / Math.max(srcW, srcH)

		// Math.max(1, ...) protects against a 0-width result on extremely tall
		// or extremely wide source images.
		const targetW = Math.max(1, Math.round(srcW * ratio))
		const targetH = Math.max(1, Math.round(srcH * ratio))

		const tiny = await resize(cachedImageData, { width: targetW, height: targetH })
		const webpBuffer = await encodeWebp(tiny, { quality: LQIP_QUALITY })

		// Convert ArrayBuffer → base64 without going through a string per byte.
		// The btoa(String.fromCharCode(...new Uint8Array(...))) idiom is fine for
		// payloads this small (under 1KB); avoid it for anything multi-megabyte.
		const bytes = new Uint8Array(webpBuffer)
		let binary = ''
		for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i])
		const base64 = btoa(binary)
		const dataUrl = `data:image/webp;base64,${base64}`

		ctx.postMessage({ type: 'lqip', dataUrl, token } satisfies OutboundMessage)
	} catch (err) {
		ctx.postMessage({
			type: 'error',
			message: err instanceof Error ? err.message : 'LQIP generation failed',
			token
		} satisfies OutboundMessage)
	}
}

A few details that matter.

The placeholder is encoded once, not on every render. It is a deterministic function of the source image, so generating it during upload and storing the resulting dataUrl string in the database is the correct architecture. The component that consumes it never re-encodes anything; it just reads the string and renders it.

WebP is the right format for the placeholder, even though we want maximum browser compatibility everywhere else. Every browser that supports loading="lazy" (the technique we are pairing this with) also supports inline WebP, so there is no fallback story to worry about. WebP at quality 50 produces noticeably smaller files than JPEG at the same perceived quality, especially at extreme downscales.

The btoa(String.fromCharCode(...)) conversion is only safe because the LQIP payload is tiny. For larger buffers you would chunk the conversion or use the FileReader API. At under a kilobyte, the simple form is correct.

Storing LQIP in the Database

In a production app, the upload pipeline already writes a row per image. Add the placeholder string to that row. Keeping it on the same record means you fetch it for free with the rest of the image metadata, with no extra network round-trip.

The exact schema depends on your stack, but the shape is the same everywhere:

// Whatever ORM or query builder you are using, the column is a single string.
// The value comes straight from the worker's `lqip.dataUrl` message.

interface ImageRecord {
	id: string
	baseUrl: string // e.g. "https://cdn.example.com/images/abc123"
	alt: string
	width: number // intrinsic width of the largest variant
	height: number // intrinsic height of the largest variant
	lqip: string // "data:image/webp;base64,UklGRi…" - typically 500–1100 bytes
}

The lqip column is just text. SQLite, Postgres, MySQL - they all store this fine. The only constraint is to not reach for a BLOB or BYTEA type; you want the value as a string so it serialises naturally in your +page.server.ts load function and arrives at the component as a prop.

There is a temptation to skip the database step and generate the LQIP on the server every time the page loads, perhaps via an image proxy. Resist this. The whole point of the technique is that the placeholder is already in the HTML when the browser parses it. Adding a server-side generation step turns it back into a network request, which is exactly what you are trying to avoid.

Building LazyImage.svelte

The component is small but has more responsibilities than ResponsiveImage from the previous lesson. It still needs to handle srcset, sizes, format negotiation and CLS prevention, but now it also needs to track whether the full image has loaded, so it can fade out the placeholder at the right moment.

The state machine has exactly two states. Either the placeholder is showing (the default), or the full image has finished decoding and the placeholder is hidden. The transition is driven by the <img> element’s onload event. There is no third state, no error case worth modelling separately, and no need for any reactive complexity beyond a single $state boolean.

<!-- src/lib/components/LazyImage.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
	 *   lqip     - base64 data URL produced by the worker; rendered immediately
	 *   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
		lqip: string
		sizes?: string
		priority?: boolean
	}

	let { src, alt, width, height, lqip, sizes = '100vw', priority = false }: Props = $props()

	// One piece of reactive state. Flips to true once the full image's onload fires.
	let loaded = $state(false)

	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'))
	const fallbackSrc = $derived(`${src}-800w.jpg`)
</script>

<div
	class="lazy-image"
	style:aspect-ratio="{width} / {height}"
	style:background-image="url({lqip})"
>
	<picture>
		<source type="image/avif" srcset={avifSrcset} {sizes} />
		<source type="image/webp" srcset={webpSrcset} {sizes} />
		<img
			src={fallbackSrc}
			{alt}
			{width}
			{height}
			{sizes}
			loading={priority ? 'eager' : 'lazy'}
			fetchpriority={priority ? 'high' : 'auto'}
			decoding="async"
			class:loaded
			onload={() => (loaded = true)}
		/>
	</picture>
</div>

<style>
	.lazy-image {
		position: relative;
		display: block;
		width: 100%;
		overflow: hidden;
		background-size: cover;
		background-position: center;
		/* Blur the LQIP background so the 20px source looks intentional rather
		   than pixelated. The filter applies to the background-image only. */
		filter: blur(12px);
		transform: scale(1.05); /* hides blur edge artefacts */
	}

	.lazy-image :global(img) {
		display: block;
		width: 100%;
		height: 100%;
		object-fit: cover;
		opacity: 0;
		transition: opacity 300ms ease-out;
	}

	.lazy-image :global(img.loaded) {
		opacity: 1;
	}

	/* Once the full image has loaded, the wrapper no longer needs the blur. */
	.lazy-image:has(img.loaded) {
		filter: none;
		transform: none;
	}
</style>

Several things in this component are worth slowing down for.

The wrapper carries the placeholder, the <img> carries the full image. The LQIP renders as a CSS background-image on the wrapper. This means it paints immediately, with no JavaScript, no decode delay, and no separate request. The <img> element starts at opacity: 0 and transitions to opacity: 1 when its onload event fires. The cross-fade is what gives the technique its name: the blur-up.

The blur lives on the wrapper, not the placeholder image itself. A 20-pixel image scaled up to a 1600-pixel container without a blur looks pixelated and obviously wrong. With a generous blur(12px), it looks like an artistic out-of-focus preview. The slight scale(1.05) hides the soft edges that CSS blur introduces around the bounding box.

The :has(img.loaded) selector is the trigger. Once the full image’s onload fires and the loaded class is added, the wrapper’s filter and transform reset to their default values. The blur disappears, revealing the now-fully-loaded image underneath. There is no second state machine, no Svelte transition, no JavaScript orchestration, just CSS responding to a class change.

Aspect-ratio is on the wrapper, not the image. This guarantees the placeholder slot is exactly the right shape from the first paint, regardless of when the full image arrives. The width and height attributes on the <img> are still important (they tell the browser how to allocate intrinsic dimensions and serve as a fallback for older browsers without aspect-ratio support), but the wrapper is what physically reserves the space.

loaded is the only $state. Everything else (the srcsets, the fallback URL, the decisions about loading and fetchpriority) derives from props that do not change after mount. Adding more reactive variables for “is the placeholder visible” or “has the cross-fade finished” would be overengineering. CSS handles those questions perfectly.

A note on SSR and hydration

This component is fully SSR-safe out of the box, but the behaviour at the seam between server-rendered HTML and client-side hydration is worth knowing about explicitly.

During server rendering, loaded initialises to false, which means the SSR’d HTML carries <img class=""> (no loaded class) and the wrapper renders the blurred LQIP. That is the correct first paint on the user’s screen, the page is interactive instantly because the browser does not need to wait for any JavaScript to render the placeholder.

When hydration runs, three cases play out cleanly:

  • Image already in the HTTP cache (warm visit): the browser typically fires onload synchronously during hydration. loaded flips to true, the loaded class lands on the <img>, and the cross-fade completes in one frame. Users perceive no blur at all.
  • Image still loading (cold visit): loaded stays false through hydration. The blurred placeholder remains visible, the <img> finishes downloading some milliseconds later, and onload fires. Same code path as a fully client-side render.
  • JavaScript disabled or failed to hydrate: the LQIP background renders (it is pure CSS), the <img> loads (it is pure HTML), and the cross-fade simply does not animate. The image still appears once it has loaded, just without the fade. This is the right graceful-degradation story for an enhancement-style component.

The one thing to avoid is using $effect to set loaded = true based on complete or naturalWidth checks during mount. Doing that introduces a frame of “loaded but not faded” before the CSS transition kicks in, which produces a visible pop. Trust the onload handler; it is the right primitive on every browser path.

Usage

<!-- src/routes/products/[slug]/+page.svelte -->
<script lang="ts">
	import LazyImage from '$lib/components/LazyImage.svelte'

	let { data } = $props()
	// data.product.image: { baseUrl, alt, width, height, lqip }
</script>

<!-- Hero image: above the fold, LCP candidate. priority=true. -->
<LazyImage
	src={data.product.image.baseUrl}
	alt={data.product.image.alt}
	width={data.product.image.width}
	height={data.product.image.height}
	lqip={data.product.image.lqip}
	sizes="(max-width: 768px) 100vw, (max-width: 1200px) 66vw, 800px"
	priority={true}
/>

<!-- Gallery thumbnails: below the fold. priority defaults to false → lazy + auto. -->
{#each data.product.gallery as image (image.id)}
	<LazyImage
		src={image.baseUrl}
		alt={image.alt}
		width={image.width}
		height={image.height}
		lqip={image.lqip}
		sizes="(max-width: 640px) 50vw, 25vw"
	/>
{/each}

The call site is essentially the same as ResponsiveImage from the previous lesson, with one new required prop: lqip. Making it required (rather than optional with a fallback) is deliberate. The placeholder is generated automatically by the upload pipeline and stored alongside every other image field, so the database always has a value. Forgetting to plumb it through becomes a TypeScript error rather than a silent visual regression.

Testing It on a Real Connection

The temptation is to verify this in DevTools on a fast network and call it done. Resist that. The whole point of LQIP is that it makes slow connections feel fast, and your local development server is the opposite of a slow connection.

In Chrome DevTools, open the Network tab and switch the throttling profile to “Slow 4G” or, for a more dramatic test, “Slow 3G”. Reload the page. You should see, in order:

  1. The HTML arrives with the LQIP data: URLs already inlined.
  2. The wrapper divs paint immediately as blurred placeholders, with the correct aspect ratio.
  3. The hero image’s request starts almost immediately (because of fetchpriority="high").
  4. Below-the-fold image requests do not start. They are deferred.
  5. As each image arrives, it cross-fades in over its placeholder.
  6. Scrolling triggers a wave of new requests for the deferred images.

If the placeholder never paints, your lqip prop is empty or malformed - check the data:image/webp;base64,… prefix. If the cross-fade never fires, the onload handler is not running; check that the <img> element actually has a valid src. If the hero image takes too long to start downloading, check that priority={true} is set on the call site and that the resulting attributes are present in the rendered HTML.

The Lighthouse mobile audit is the other essential check. With LQIP and correct priority hints in place, you should see LCP timings drop substantially compared to a baseline that uses neither. The exact numbers depend on the page, but a 200–600ms improvement on a 4G profile is typical.

Common Mistakes and Anti-Patterns

Setting loading=“lazy” on the LCP image

This is the most damaging mistake of the three. Marking the hero image as lazy tells the browser the image is below the fold, even though it is the first thing the user will see. The browser dutifully deprioritises the request, and your LCP regresses by anywhere from 500ms to several seconds. Always set priority={true} on the LCP candidate.

Marking multiple images as fetchpriority=“high”

high is a relative hint. If everything is high, nothing is. The browser picks one anyway, but the choice is no longer yours. Use priority={true} on exactly one image per page (usually the hero) and let everything else default.

Generating LQIP at request time instead of upload time

Some teams have an image proxy that synthesises LQIPs on the fly from URL parameters. This works, but it puts the placeholder behind a network request, which defeats the purpose. The placeholder must be inline in the HTML to paint instantly. Generate it once during upload, store it on the image record, and ship it with the page.

Storing the LQIP as a separate file

If your placeholder is at https://cdn.example.com/images/abc123-lqip.webp, the browser has to make an extra network request to render it. By the time that request returns, the full image is often already on its way. The placeholder needs to be inline as a data: URL, not a separate file. The 30%+ inflation from base64 encoding is real but irrelevant, at under a kilobyte per image, it disappears in the noise.

Forgetting decoding=“async”

Decode work happens on the main thread by default. On a page with twenty images, those small decode stalls add up to a visible jank during scrolling. decoding="async" is free; set it on every image.

Cross-fading with a JavaScript timer instead of onload

A setTimeout based reveal is brittle: it fires whether the image has actually loaded or not, leading to either a flash of empty space (timer too short) or a delayed reveal (timer too long). The onload handler fires exactly when the browser has the decoded bitmap ready to paint. Use it.

Putting the blur filter on the placeholder image instead of the wrapper

If you put filter: blur(...) directly on an <img> element holding a 20-pixel WebP, the blur applies but the underlying pixels still look obviously low-resolution at the bounding edges. Putting the placeholder on the wrapper as a background-image and blurring the wrapper itself produces a much cleaner edge.

Performance and Scaling Considerations

The LQIP payload size budget per image is small but not zero. A typical 20-pixel WebP at quality 50 produces around 600 bytes of binary data, which becomes around 800 bytes after base64 encoding. On a list page showing forty product cards, that is roughly 32 kilobytes of inline placeholder data. This is comparable to a single icon font or a medium-sized stylesheet, and on most pages it fits well within the budget for first-paint resources.

If the page renders many more images than that - say, a feed view with hundreds of items - you have a different problem to solve, and the answer is usually pagination or virtual scrolling rather than per-image optimisation. A page that needs four hundred LQIPs in the initial HTML is a page that needs to render fewer items at first.

The encoding cost in the worker is negligible. Resizing to 20 pixels and encoding at quality 50 takes single-digit milliseconds even on modest hardware. There is no reason to defer the LQIP generation step or run it in a separate background job, it can run alongside the variant generation in the same encode-variants flow if you prefer to combine them, or as a separate encode-lqip message as shown above. The latter is slightly cleaner because the LQIP is independent of the responsive variant set; you might want to regenerate one without the other later.

There is one operational consideration. Every existing image in your database needs a backfill if you are adding LQIP support to a live system. The cleanest approach is a one-off script that streams every image through the same worker pipeline and writes the resulting dataUrl back to the row. For a thousand images this takes a few minutes; for a million it is an overnight job. Plan for it before you start enforcing the column as required at the schema level.

Conclusion

The first eight lessons in this track were about making images small. This one was about making them feel instant, which is a different problem with different tools.

loading="lazy" is the bandwidth tool. It stops the browser from downloading images the user has not yet asked to see, and it does this with a single attribute and zero JavaScript.

fetchpriority="high" is the LCP tool. It tells the browser, definitively, that the hero image is the most important resource on the page, and that it should be scheduled accordingly. Used on exactly one image per page, it routinely shaves hundreds of milliseconds off the largest paint.

LQIP is the perception tool. It replaces the gap between layout and pixels with something the eye can interpret, so the page never looks broken even on a slow connection. The implementation is small - twenty pixels, one base64 string, a wrapper div with a blur - but the difference is the visual difference between a fast site and a finished one.

The LazyImage.svelte component built in this lesson combines all three. Every image rendered through it gets format negotiation, size selection, aspect-ratio reservation, lazy loading, correct priority hints, and an instant blurred preview, with a single priority={true} flag flipping the right two attributes for the LCP candidate. That is a substantial perceived-performance baseline to have in one place, and it composes cleanly with everything from the previous lessons.

The next lesson covers the build-time half of this story: @sveltejs/enhanced-img, the SvelteKit preprocessor that performs a similar set of transformations on images you import directly into components at build time. The mental model to carry forward: the runtime pipeline you have just finished is for images that arrive after deployment - user uploads, CMS content, anything dynamic. The build-time pipeline is for images you ship with the codebase. Most production applications use both.

Key Takeaways

  • loading="lazy" defers requests for below-the-fold images until the browser is confident the user will see them. It is essentially free and should be the default for everything that is not the hero.
  • fetchpriority="high" pulls the LCP image to the front of the request queue. Use it on exactly one image per page, the hero, and let every other image default.
  • A single priority prop collapses the four-attribute decision (loading, fetchpriority, plus eager vs lazy semantics) into one boolean at the call site. true for the LCP image, false for everything else.
  • LQIP closes the gap between layout commit and image arrival by inlining a tiny blurred preview as a data: URL. The placeholder paints with the page, never as a separate request.
  • LQIP generation belongs in the existing Worker pipeline as a new encode-lqip message: resize to 20px on the long edge, encode WebP at quality 50, base64-encode the result, store the string on the image record.
  • The placeholder lives on a wrapper element as a CSS background-image; the full <img> cross-fades over it via an onload-driven class change. One piece of $state, one CSS transition, no orchestration code.
  • decoding="async" should be on every image, always. It removes a category of small main-thread stalls at no cost.

Further Reading

See Also