A 100KB WebP That Google Cannot Understand Is Still a Missed Opportunity
You have built something genuinely impressive. Images arrive in the browser as 4MB JPEGs and leave as 100KB AVIF variants in three responsive widths, with a worker pool keeping the main thread free, a base64 LQIP painting before the network responds, fetchpriority hints prioritising the LCP candidate, and a build-time pipeline catching every static asset on the way to production. By every objective performance metric, your site ships less data and feels faster than it ever has.
Then a marketing colleague pulls up Google Search Console and asks why product images are not appearing in image search. A blind user emails to say the gallery is unusable with VoiceOver. An audit comes back flagging a hundred images as missing meaningful descriptions. The page is fast, and the people who could not see it before still cannot see it.
This is the gap that no amount of compression closes. A 100KB image that no one can find, no one can describe, and no one can interpret is, from the perspective of half its potential audience, no better than a 4MB image that loads slowly.
Performance is the floor of image quality, not the ceiling. The ceiling is whether the image is legible to every system that needs to read it: search engines indexing your catalogue, screen readers narrating your gallery, social previews generating their thumbnails, and accessibility audits validating your compliance.
This final lesson in the optimisation track covers the part of the job that does not have a Lighthouse score. Semantic file naming, the actual craft of writing alt text, structured data with ImageObject, and “the practical centrepiece” - an alt-text enforcement pattern in your Svelte 5 upload flow that uses $state and $derived to prevent the upload button from activating until every image has been described. By the end you will have a pipeline that is not just fast but also findable, by every reader who matters.
Why Alt Text Is the Single Highest-Leverage Detail
Of everything in this lesson, alt text does the most work for the least effort, and it is also the thing teams most consistently get wrong.
The alt attribute on an <img> is the textual replacement for the image. It is what a screen reader narrates when the user reaches the image. It is what a browser displays if the image fails to load. It is one of the strongest signals search engines use to understand what an image depicts and what page it belongs to. And it is what reasonable accessibility law in most jurisdictions explicitly requires. A single string of text, usually under 125 characters, sits at the centre of accessibility, SEO, and resilience all at once.
The mistake is to treat alt text as a checkbox. alt="image", alt="photo", alt="IMG_4023", or worst of all an alt="" on an image that genuinely conveys content. Each of these technically populates the attribute, satisfies the linter, and passes a naive automated audit. None of them describe anything. The screen reader user hears “image” and learns nothing. The Google crawler indexes the surrounding page text but skips the picture. The marketing team wonders why image search delivers zero traffic.
There is also a second, opposite mistake: stuffing alt text with keywords. alt="best red enamel teapot tea kettle kitchen mug coffee black friday sale 50% off buy now" is worse than alt="image", because it actively misleads the screen reader user and triggers spam heuristics on the search side. Modern crawlers detect keyword stuffing easily, and the text-to-speech experience is genuinely cruel.
The right mental model is to imagine a reader who can read the page text but cannot see the image. Your alt text should be the sentence that fills the gap their eye would have filled. It should describe what the image actually shows, in the same tone of voice as the rest of the page, without repeating information that is already present in the surrounding caption or heading. Length is bounded by usefulness (usually 80 to 125 characters) but the content is what matters.
<!-- Wrong: tells the screen reader nothing, tells search engines less. -->
<img src="hero.jpg" alt="image" />
<!-- Wrong in a different way: keyword spam, harms both audiences. -->
<img src="hero.jpg" alt="best mountain landscape sunset photo nature wallpaper" />
<!-- Right: a literal description of what is in the image, in plain language. -->
<img
src="hero.jpg"
alt="Snow-capped peaks of the Dolomites at sunset, with a single hiker silhouetted on a ridge in the foreground."
/> When Alt Text Should Be Empty
There is one case where alt="" is correct, and it confuses people because it looks like the wrong answer. Some images are decorative. They add visual rhythm to a page but carry no information that is not already in the surrounding text. A divider flourish above a section heading. A background pattern in a card. A small icon next to a button whose label already says “Search”. For these images, the right alt value is the empty string.
The empty string is not the same as a missing attribute. Omitting alt entirely tells the screen reader to fall back to reading the file name, which is almost always wrong (alt="" reads nothing; no alt attribute reads IMG-4023.jpg). The empty string is an explicit signal: “I have considered this image, and I have decided it conveys no information; please skip it.”
<!-- Decorative divider. The empty alt tells the screen reader to skip it. -->
<img src="divider-flourish.svg" alt="" role="presentation" />
<!-- Icon next to a button whose label already says "Search". The icon is redundant. -->
<button type="submit">
<img src="magnifier.svg" alt="" />
Search
</button> The mental test is the gap test. Cover the image with your hand and read the page. If the page is missing information, the alt text needs to fill it. If the page is complete without the image, the alt text should be empty. This single rule eliminates 90% of alt-text errors I have ever seen in production.
Alt Text for the Three Common Image Types
Different categories of image have different alt-text strategies, and being explicit about them produces consistent quality across a site without requiring writers to think from scratch each time.
Content images - photos, illustrations, diagrams, anything that the page is genuinely about - get descriptive alt text in plain language. The goal is to convey what the image shows to a reader who cannot see it. Avoid repeating the page heading; the screen reader already read that. Avoid the words “image of” or “picture of” at the start; the reader already knows it is an image.
Functional images - icons inside buttons, logos that link home, image-only links - get alt text describing the function, not the appearance. The alt for a logo that links to the homepage is alt="Home", not alt="Company logo". The alt for a magnifying glass icon inside a search button is empty (the button label already says Search) or alt="Search" if the icon stands alone with no text. Describe what activating the image does, not what it looks like.
Informational graphics - charts, infographics, complex diagrams - need a short alt describing the gist plus a longer description elsewhere on the page (in the body text, in a <figcaption>, or via aria-describedby pointing to a hidden block). Trying to fit a full data summary into 125 characters of alt text fails both the screen reader user and the search engine. Treat the alt as the headline; treat the surrounding markup as the article.
<!-- Content image: describe what is in the picture. -->
<img
src="product.jpg"
alt="A red enamel teapot with a brass handle, photographed on a cream linen background."
/>
<!-- Functional image: describe the action, not the appearance. -->
<a href="/" aria-label="Home">
<img src="logo.svg" alt="" />
</a>
<!-- Informational graphic: short alt + longer description nearby. -->
<figure>
<img
src="quarterly-revenue.png"
alt="Quarterly revenue chart showing year-on-year growth across all four quarters."
/>
<figcaption>
Q4 closed at £18.2m, an increase of 24% over Q4 the previous year. Q1–Q3 showed steady
single-digit growth, with the bulk of the increase concentrated in the holiday quarter.
</figcaption>
</figure> Filenames Carry Weight Too
Search engines read image file names. They are a weaker signal than alt text and a much weaker signal than the surrounding page content, but they are not zero. More importantly, file names are the one piece of metadata that survives every transformation in your pipeline, they show up in CDN logs, in the srcset URLs, in the rendered HTML. A meaningful name is essentially free if you build the habit early.
The rule is the same rule as alt text, just shorter. Describe what the image is, in lowercase, with hyphens between words. Avoid the camera’s auto-generated names. Avoid the date stamp. Avoid the order in which you happened to upload them.
Wrong: IMG_4023.jpg
Wrong: photo-1.jpg
Wrong: hero_FINAL_v2_FINAL.jpg
Right: red-enamel-teapot.jpg
Right: dolomites-sunset-hiker.jpg
Right: quarterly-revenue-2026-q4.png For user-uploaded content where the file name comes from whatever the user’s camera or phone happened to produce, you have two options. The lazy one is to rename to a UUID at upload time and rely entirely on alt text and surrounding markup for SEO. This is fine for product galleries where the file name is never exposed and the catalogue is structured by other metadata. The thorough option is to derive a slug from the alt text or product title at upload time and use it as the file name (with a UUID suffix for uniqueness). The latter is more work but pays off in image search rankings on content sites.
The pipeline you built in earlier lessons stores ${imageId}-${width}w.${ext} as the filename pattern. Replacing imageId (a UUID) with a slug-suffixed-with-UUID is a one-line change in the upload handler:
// Before: stable but opaque.
const imageId = crypto.randomUUID()
const fileName = `${imageId}-${width}w.${ext}`
// After: descriptive, still globally unique, still URL-safe.
const slug = slugify(altText) // "red-enamel-teapot"
const imageId = `${slug}-${crypto.randomUUID().slice(0, 8)}`
const fileName = `${imageId}-${width}w.${ext}`
// → "red-enamel-teapot-3f9a2b1c-800w.webp" The slug derivation is a function of the alt text, which is one more reason to treat the alt as a required field. A meaningful alt produces a meaningful filename produces a meaningful URL. None of these by themselves moves a page up the rankings, but in aggregate they describe a site that takes its content seriously, and search engines reward that.
Structured Data: Telling Search Engines Exactly What They Are Looking At
Alt text and file names are unstructured signals. Structured data is the structured one. By embedding a JSON-LD ImageObject in the page head, you give search engines an unambiguous record of what the image is, who created it, what licence it carries, and where to find higher-resolution versions. This is the metadata that drives the rich-result image previews you see in Google Image Search and the social-card previews when a link is shared on Slack, Discord, or X.
The minimum viable ImageObject is small:
<!-- src/routes/products/[slug]/+page.svelte -->
<script lang="ts">
let { data } = $props()
const imageJsonLd = $derived(
JSON.stringify({
'@context': 'https://schema.org',
'@type': 'ImageObject',
contentUrl: `${data.product.image.baseUrl}-1600w.webp`,
thumbnailUrl: `${data.product.image.baseUrl}-400w.webp`,
caption: data.product.image.alt,
width: data.product.image.width,
height: data.product.image.height,
creator: { '@type': 'Organization', name: 'Your Brand' },
creditText: data.product.image.credit,
copyrightNotice: `© ${new Date().getFullYear()} Your Brand`,
license: 'https://example.com/licence'
})
)
</script>
<svelte:head>
{@html `<script type="application/ld+json">${imageJsonLd}</script>`}
</svelte:head> A few notes on the shape.
The caption field should match your alt text. Search engines cross-reference these and downgrade pages where the structured data appears to disagree with the on-page content. Keep them in sync by deriving both from the same record in your database.
The contentUrl should point to your highest-resolution variant. The thumbnailUrl should point to a small one. Search engines use these for different surfaces: contentUrl drives the full-size image preview when a result is clicked; thumbnailUrl is what appears in the search grid. Pointing both at the same URL works but wastes bandwidth on the thumbnail surface.
The license, creator, and copyrightNotice fields are increasingly important. Google has been rolling out “licensable” badges in image search results for sites that declare licence terms, and some publishers report a meaningful traffic uplift from being eligible for those badges. If your images are owned by your organisation, providing this metadata is essentially free signal.
For pages that present multiple images of equal importance ( a product gallery, an article with several supporting photos), emit one ImageObject per image inside an ItemList, or include them inline within the parent Product or Article schema. The principle is the same either way: every image that matters gets its own structured record.
The Alt-Text Enforcement Pattern
Now to the practical centrepiece of this lesson. Everything above is doctrine. None of it survives contact with a real upload flow unless you make alt text impossible to skip at the point of submission. The single most effective intervention any team can make for image accessibility is to treat alt text as a required field, not in a “soft warning” sense, but in the sense that the submit button is physically disabled until every image in the queue has a meaningful description.
Svelte 5 makes this elegant. A queue of pending uploads is $state, the readiness flag is $derived, and the disabled state of the upload button reads from the derived flag. There is no orchestration code, no separate validation pass, and no race condition between user input and submission.
<!-- src/lib/components/UploadQueue.svelte -->
<script lang="ts">
import { createOptimizer } from '$lib/optimizer.svelte'
interface QueueItem {
id: string
file: File
previewUrl: string
altText: string // user-editable
}
const optimizer = createOptimizer()
// The single source of truth for the queue. Each item carries its file,
// a preview URL for the thumbnail, and the alt text the user is editing.
let uploadQueue = $state<QueueItem[]>([])
// Minimum length is a deliberate choice. Five characters rules out "abc",
// "img", "photo", and similar throwaway placeholders without being so
// restrictive that legitimate short captions ("Logo", "Hero") fail.
const MIN_ALT_LENGTH = 5
// One derived expression captures the readiness gate. The button reads from
// it directly; no imperative validation pass anywhere in the component.
const isReadyToUpload = $derived(
uploadQueue.length > 0 &&
uploadQueue.every((item) => item.altText.trim().length >= MIN_ALT_LENGTH)
)
// Per-item validity is also derived, so each row can show its own indicator
// without a separate boolean per item. The Map is rebuilt on any queue change,
// but for typical queue sizes (under a few dozen) this is microseconds.
const itemValidity = $derived(
new Map(uploadQueue.map((item) => [item.id, item.altText.trim().length >= MIN_ALT_LENGTH]))
)
function handleFiles(files: FileList | null) {
if (!files) return
for (const file of files) {
uploadQueue.push({
id: crypto.randomUUID(),
file,
previewUrl: URL.createObjectURL(file),
altText: ''
})
}
}
function removeItem(id: string) {
const item = uploadQueue.find((i) => i.id === id)
if (item) URL.revokeObjectURL(item.previewUrl)
uploadQueue = uploadQueue.filter((i) => i.id !== id)
}
async function submitQueue() {
if (!isReadyToUpload) return // belt-and-braces; the button is also disabled
for (const item of uploadQueue) {
await optimizer.squash(item.file, { altText: item.altText.trim() })
}
// Clear the queue after successful submission.
for (const item of uploadQueue) URL.revokeObjectURL(item.previewUrl)
uploadQueue = []
}
</script>
<div class="upload-queue">
<input
type="file"
multiple
accept="image/*"
onchange={(e) => handleFiles((e.currentTarget as HTMLInputElement).files)}
/>
{#each uploadQueue as item (item.id)}
{@const isValid = itemValidity.get(item.id) ?? false}
<div class="upload-queue__item" class:upload-queue__item--invalid={!isValid}>
<img src={item.previewUrl} alt="Preview" width="80" height="80" />
<label class="upload-queue__alt-field">
<span class="upload-queue__alt-label">
Describe this image
<span class="upload-queue__alt-hint">
(min. {MIN_ALT_LENGTH} characters - what is in the picture?)
</span>
</span>
<textarea
bind:value={item.altText}
placeholder="e.g. A red enamel teapot on a cream linen background"
rows="2"
required
aria-invalid={!isValid}
></textarea>
</label>
<button type="button" onclick={() => removeItem(item.id)} aria-label="Remove"> × </button>
</div>
{/each}
<button
type="button"
class="upload-queue__submit"
disabled={!isReadyToUpload}
onclick={submitQueue}
>
{#if uploadQueue.length === 0}
Add images to upload
{:else if !isReadyToUpload}
Describe every image to continue
{:else}
Upload {uploadQueue.length} image{uploadQueue.length === 1 ? '' : 's'}
{/if}
</button>
</div> A few details that matter.
One $derived is the gate. isReadyToUpload is the only expression that controls the button. It reads from uploadQueue and recomputes whenever any item’s altText changes. There is no separate validate-on-submit handler, no imperative pass that walks the queue before allowing submission. Svelte’s reactivity makes the derived flag the single source of truth.
Five characters is deliberate, not arbitrary. Long enough to rule out junk inputs (“img”, “abc”, “1”), short enough to allow legitimate short captions (“Logo”, “Hero”). The exact number is a product decision; some teams require 20 or 30 characters and accept the friction. The number is a constant in one place, easy to tune.
The button label changes with state. “Add images to upload” → “Describe every image to continue” → “Upload N images”. The user always knows why the button is disabled. A disabled button with no explanation is a usability failure; a disabled button with a label that explains the gate is a guardrail.
Per-item validity uses a Map, not separate booleans. This keeps the per-item indicator (the aria-invalid attribute and the optional CSS class) in sync without proliferating reactive state. The Map is rebuilt cheaply on any queue change.
aria-invalid is on the textarea, not just CSS. Screen readers announce the validity state when the user focuses the field. A purely visual indicator (a red border) is invisible to half the users you are trying to help with this entire pattern in the first place.
The optimizer is called with the alt text already in scope. When submitQueue runs, every call to optimizer.squash already has the alt text the user entered. The slug derivation, the database row, the structured data emission, and the <img alt> rendering all originate from the same string the user typed.
This pattern is short, but the consequence is large. Every image that enters your CDN through this flow comes with a description. There is no backfill problem, no “we will get to alt text later” debt that builds up across releases, no audit that finds a thousand missing descriptions a year after launch. The constraint exists at the moment of creation, where the cost of meeting it is one sentence per upload.
Putting the Pieces Together at Render Time
The display side reads from whatever the upload side stored. If your upload flow recorded alt text, slugified file names, and width/height metadata, your render components have everything they need. The LazyImage, ResponsiveImage, and RemoteImage components from earlier lessons all already accept alt as a required prop, which means TypeScript catches the omission at every call site. Combine that with the upload-time enforcement and a missing alt becomes mathematically impossible to ship.
The +page.server.ts for a content page can also assemble the structured data alongside the props. The shape is:
// src/routes/products/[slug]/+page.server.ts
import type { PageServerLoad } from './$types'
export const load: PageServerLoad = async ({ params, locals }) => {
const product = await locals.db.product.findUnique({
where: { slug: params.slug },
include: { image: true, gallery: true }
})
if (!product) throw error(404, 'Not found')
// Build the JSON-LD on the server so the bot sees it in the initial HTML,
// not after a hydration round-trip.
const imageJsonLd = {
'@context': 'https://schema.org',
'@type': 'ImageObject',
contentUrl: `${product.image.baseUrl}-1600w.webp`,
thumbnailUrl: `${product.image.baseUrl}-400w.webp`,
caption: product.image.alt,
width: product.image.width,
height: product.image.height
}
return { product, imageJsonLd }
} The component then renders the <svelte:head> block with {@html JSON.stringify(...)} as shown earlier. Search engine crawlers receive the structured data in the initial HTML response, so the indexing is immediate even if the page is otherwise rendered client-side.
Common Mistakes and Anti-Patterns
Treating alt text as developer text
If a developer writes alt text inline in a Svelte component (alt="hero image"), the result is invariably bad. The developer is not the person who knows what is in the image; the content editor or the user uploading it is. Treat alt text as content data. Store it in the database alongside the image record, surface it in the upload UI, and let the people who own the content own the description.
Allowing empty alt on content images
Some upload forms make alt text optional, with the rationalisation that “decorative images can have empty alt anyway”. This is true in principle and a disaster in practice. In practice, every image becomes “decorative” because users learn that leaving the field empty is faster. The right division is to require alt text by default and provide a separate, explicit “this image is decorative” checkbox that, when ticked, both empties the alt field and tags the record as decorative. The default must be the safer one.
Stuffing keywords into alt text
Modern search algorithms detect keyword stuffing reliably and the screen reader experience is genuinely cruel. If you find yourself listing related products or repeating brand names in the alt, you have crossed the line. The alt is a description of what is in the image, not a search-ranking lever.
Skipping width and height on <img>
This is a CLS issue covered in Lesson 08, but it is also an SEO and accessibility issue. Search engines and screen readers both use intrinsic dimensions to make decisions about presentation. Always include the attributes; the upload pipeline already captures them, so it is a matter of plumbing them through.
Letting filenames stay as IMG_4023.jpg
Camera-default file names tell search engines nothing and add visual noise to your CDN logs and admin tooling. Slugify the alt text or the product title at upload time. The cost is a few lines of code in the upload handler, and the benefit compounds across the entire catalogue.
Forgetting structured data for image-heavy pages
Pages that exist primarily to showcase images like product catalogues, photo galleries or recipe pages, benefit substantially from ImageObject schema markup. Without it, search engines have to infer the relationship between text and image from layout heuristics, which they can do but do imperfectly. With it, the relationship is explicit. Eligibility for licensable badges and rich previews depends on it.
Disabling the submit button without explaining why
A greyed-out button with no tooltip or label is a dead end. Users press it once, nothing happens, and they assume the form is broken. The button label or an adjacent message must always explain what the user needs to do to enable submission. The pattern in this lesson uses a state-dependent label; an alternative is a separate inline message - either is fine, neither is “no explanation at all”.
A Brief Note on Decorative-Image Workflows
There is a real, recurring case that the enforcement pattern above does not handle gracefully out of the box: genuinely decorative images. A blog post might include an abstract divider that adds visual rhythm but conveys no information. Forcing the editor to write an alt for it would produce padding text like “Decorative line” that the screen reader user did not need to hear.
The clean solution is a separate flag on the queue item:
interface QueueItem {
id: string
file: File
previewUrl: string
altText: string
decorative: boolean // ← new
}
// The readiness gate now accepts either a meaningful alt OR an explicit
// decorative flag. Both branches require the user to make a deliberate choice.
const isReadyToUpload = $derived(
uploadQueue.length > 0 &&
uploadQueue.every((item) => item.decorative || item.altText.trim().length >= MIN_ALT_LENGTH)
) The UI adds a “This image is decorative” checkbox per row. When ticked, the alt field is greyed out and the readiness gate accepts the row regardless of alt length. The database stores alt: '' and a decorative: true flag, and the rendering component emits alt="" plus role="presentation".
The crucial property is that decorative is an explicit, deliberate choice the user makes per image, not the default. The default is always “describe this image”. The escape hatch exists for the small number of cases where description would actively harm the experience.
Conclusion
The image stack you have built across this track is fast. This lesson is about making it legible. Performance is a baseline; accessibility and SEO are how the work you have already done reaches every reader who matters.
Alt text is the single highest-leverage detail. Written well, it serves screen reader users, search crawlers, and resilience against load failure simultaneously, and the cost is a sentence per image. Written poorly or omitted, it sabotages all three. The enforcement pattern in this lesson - $state for the queue, $derived for the readiness gate, button disabled reading from the derived flag - costs perhaps thirty lines of Svelte and prevents an entire category of long-term debt from accumulating in your catalogue.
Filenames matter at the margin and are essentially free if you build the slug-from-alt habit early. Structured data with ImageObject makes the relationship between text and image explicit for search engines, and it is the price of admission for licensable badges and rich-preview eligibility on image search surfaces.
The decorative-image case is the one nuance worth being explicit about. The default must always be “describe this image”; the decorative checkbox is an opt-in escape hatch for the small number of cases where description would harm rather than help. Building the system this way means every image that enters production carries a description by default, and the audit you fear at launch turns out to be a non-event.
This is the eleventh lesson, with one to go: the capstone, where everything you have built such as the worker pool, the quality slider, the responsive variants, the LQIP, the priority hints, the build-time pipeline, the Sharp endpoint, and the alt-text-enforced upload queue, comes together as a single working application. The audit at the end of that lesson is the audit your future self will run on every site you build.
Key Takeaways
- Alt text serves screen reader users, search engines, and load-failure fallback at the same time. A good alt is a single sentence describing what is in the image, in plain language, in the same voice as the rest of the page.
alt=""is the correct value for genuinely decorative images. It is not the same as omitting the attribute, which causes screen readers to fall back to reading the file name.- File names are a weak SEO signal that survives every transformation in the pipeline. Slugify alt text at upload time so the filename pattern becomes
red-enamel-teapot-3f9a2b1c-800w.webpinstead ofIMG_4023-800w.webp. ImageObjectJSON-LD in<svelte:head>makes the image-to-content relationship explicit for search engines and unlocks rich-preview surfaces and licensable-image badges.- The alt-text enforcement pattern uses a single
$derivedflag (isReadyToUpload) that gates the submit button. The button is physically disabled until every queue item has at least five non-whitespace characters of alt text. - The button label changes with state, so the user always understands why the button is disabled. A disabled button with no explanation is a UX failure, not a guardrail.
- A separate “this image is decorative” checkbox is the right escape hatch for genuinely decorative content. The default must remain “describe this image”; decorative must be an explicit opt-in.
- Treat alt text as content data, not developer text. Store it in the database alongside the image record, derive the slug from it, embed it in the structured data, and render it from the same field everywhere.
Further Reading
- WCAG 2.2: Non-text Content (1.1.1)
- WAI alt text decision tree
- Schema.org ImageObject
- Google Image SEO best practices
- Licensable images in Google Search
See Also
- Lesson 5: Metadata - Save the Brain Before You Squash the Body - the EXIF half of image metadata; this lesson is the human-readable half.
- Lesson 10: Persisting the Pipeline - where the alt text and decorative flag from the upload-queue gate land in the database, and how the
+page.server.tsreads them back at render time. - Lesson 11: SvelteKit’s Built-In Image Tools - the previous lesson, covering the build-time and Sharp pipelines whose output this lesson decorates with alt text and schema markup.
- Lesson 15: The Proof of Work - Lighthouse Audits - the audit that demonstrates the accessibility-score impact of alt-text enforcement (typical jump: 82 → 100).