The Weight of Images on the Modern Web

Creating content online is now the default for many businesses and individuals. As the volume of data grows, platforms like Google and Netlify push for better performance optimization to keep the web fast. This focus on speed has made web performance a critical factor for user retention and search rankings. Images are the primary culprit when it comes to page bloat, demanding a rigorous optimization strategy.

Optimizing images is necessary for any web application, but it holds special significance in the Jamstack ecosystem. Since one of Jamstack's main objectives is superior web performance, images can either support or undermine that goal. For a Jamstack site, the front end is decoupled from the backend and pre-built into static pages. While this creates a fast foundation, images often become the weak point. Data from the Web Almanac shows that images are the main bottleneck for good user experience on Jamstack sites, often because developers rely on older formats like PNG and JPEG instead of modern alternatives like WebP or AVIF. This reliance causes slower load times and poor scores on Core Web Vitals metrics.

Why Image Load Speed Affects Your UX Metrics

Web performance is a measure of how quickly a page downloads and how fast it renders in a user's browser. It determines both objective load time and perceived user experience. Faster sites lead to higher visitor retention and better search engine positioning. Notably, images are responsible for more bytes on a page than any other resource. While transfer sizes have decreased in recent years thanks to newer optimization techniques, there is still significant room for improvement.

Data from Core Web Vitals reinforces this fact. The Largest Contentful Paint (LCP) metric, which measures the rendering of the largest above-the-fold element, shows that the img tag accounts for 42% of LCP elements on websites. An image acts as an LCP element for 71-79% of pages, sometimes via CSS backgrounds. Simply put, a site cannot achieve good performance scores without optimized images. From a technical perspective, utilizing an Image Transformation API alongside a global CDN simplifies and scales the process of delivering high-quality assets in ideal conditions.

Practical Optimization Strategies

To address the performance gap, developers need to adopt several optimization techniques. These methods reduce page weight without compromising visual quality:

  • Adopt next-generation formats that provide better compression than legacy PNG. Upgrade to WebP and AVIF to significantly reduce file sizes while meeting visual needs.
  • Pair a headless CMS with an image CDN to automate transformation and delivery across global edge networks.
  • Regularly audit which element, such as an img tag or CSS background, is your LCP so you can prioritize its load path.
  • Utilize correct sizing attributes to maintain layout stability.

Common Image Performance Issues And How To Fix Them

Performance audits from tools like WebPageTest or PageSpeed Insights often flag image-related problems. These reports typically point to issues with file size, format, or encoding. Here are the most frequent problems and the practical fixes for each.

1. Use Compressed Files

Platforms like DEV.to allow hundreds of contributors to upload content without review, so it's common to encounter large, high-resolution images that slow down page loads and consume excessive bandwidth.

Solution

The answer is to compress images and reduce their size with minimal quality loss. Two main compression techniques exist:

  1. Lossy compression
    Algorithms eliminate less critical data to reduce file size. This negatively impacts image quality, and recompressing an already lossy image degrades quality further.
  2. Lossless compression
    This technique compresses data without affecting image quality. It is safe for repeated compressions but results in larger file sizes than lossy methods.

Choosing between them depends on your audience. Text-focused social networks can afford minor quality loss to cut file size by a fifth, boosting performance significantly. Image-centric platforms should prioritize quality with lossless compression.

Tip: Image CDN services usually include compression, but for development workflows, consider open-source tools like Calibre Image Actions, a GitHub Action that compresses JPEGs, PNGs, and WebPs in pull requests, or Imgbot, which automatically submits pull requests after lossless compression.

2. Serve Next-Generation Formats

Older formats like JPG and PNG offer poorer compression and larger file sizes. Beyond compression, next-gen formats also improve encoding/decoding speed and quality. Despite the buzz around WebP, AVIF, and JPEG XL, many sites still use legacy formats, resulting in bad UX and poor performance.

Solution

Switching to modern formats lets you significantly reduce image size for faster downloads and lower bandwidth consumption.

"Modern image formats (AVIF or WebP) can improve compression by up to 50% and deliver better quality per byte while still looking visually appealing."

— Addy Osmani (Image optimization expert)
  • WebP

WebP supports both lossy and lossless compression, cutting file size by 25-34% versus JPEG. It also supports animation and alpha transparency, with 26% smaller files than PNG. Its strengths are broad browser support, a lossless 8-bit transparency channel, lossy RGB transparency, and metadata support. It lacks HDR and wide-gamut support and does not support progressive decoding.

WEBP image format support in all browsers
WEBP image format support in all browsers. (Generated by Can I Use at 20th October 2022) (Large preview)

  • AVIF

AVIF is an open-source format based on AV1, offering better lossy and lossless compression—about 50% smaller than JPEG. It supports animations and graphic elements, improves on JPEG and WebP compression, handles 12-bit color depth for HDR and wide gamut, and supports alpha transparency. The major drawback is incomplete browser support and heavier encoding/decoding costs in time and CPU, which is why some image CDNs do not apply AVIF automatically.

AVIF image format support in all browsers
AVIF image format support in all browsers. (Generated by Can I Use at 20th October 2022) (Large preview)

Whichever format you choose, always generate compressed files from a master image of the highest possible quality.

Extra tip: To leverage formats with limited browser support, use the <picture> element so the browser selects the first supported format in order.

<picture>
    <!-- If AVIF is not supported, WebP will be rendered. -->
    <source type="image/avif">
    <!-- If WebP is not supported, JPG will be rendered -->
    <source type="image/webp">
    <img src="img/image.jpg" width="360" height="240" alt="The last format we want">
</picture>

3. Specify Width And Height

When width and height attributes are missing from <img> tags, browsers cannot determine the aspect ratio and therefore cannot reserve an appropriately sized placeholder. This causes layout shifts on load, creating performance and usability issues.

Img HTML tag without width and height attributes before and after rendering, showcasing the layout shift
`` HTML tag without width and height attributes before and after rendering, showcasing the layout shift. (Large preview)

Solution

Adding width and height attributes solves most of the problem.

Img tag with width and height attributes before and after rendering, showcasing the placeholder box
`` tag with width and height attributes before and after rendering, showcasing the placeholder box. (Large preview)

For responsive resizing: Keep images within container margins using CSS like:

img {
  max-width: 100%;
  height: auto;
}

Set both height and width attributes when you specify one dimension in CSS and set the other to auto. Without the height attribute, the CSS sets height to 0 initially, causing content shift once the image loads.

<img src="image.webp" width="700" height="500" alt="The perfect scenario">

<style>
img {
    max-width: 100%;
  height: auto;
}
</style>

For responsive images with different aspect ratios: Recent Chromium versions support width and height attributes on <source> elements inside <picture>, so the container has the correct height before load and layout shifts are avoided.

Source width attribute
Can I use results for the `` width attribute at 20th October 2022. (Large preview)
<picture>
  <source media="(max-width: 420px)" width="200" height="200">
  <img src="image.webp" width="700" height="500" alt="Responsive images with different aspect ratios.">
</picture>

4. Optimize Images For Every Device

Scaling an image via CSS without pre-optimizing it for the use case forces the browser to download an incorrectly sized file, worsening loading times. Three distinct problems arise:

  • Resolution change: Desktop-sized images display on mobile, wasting up to 4 times more data, or mobile images upscale poorly on desktop.
  • Pixel density change: Pixel-based resizing fails on high-density screens, degrading sharpness.
  • Design change: Images with critical details lose their impact when cropped incorrectly across screen sizes.

Solution

Responsive image technologies solve these by offering multiple versions based on size, resolution, and design. The browser selects based on the user's screen and device capabilities.

1. Fixing resolution changes: Use srcset and sizes attributes on <img> to provide multiple image versions.

A visual example of responsive images in 3 different viewports: desktop, tablet, and mobile
A visual example of responsive images in 3 different viewports: desktop, tablet, and mobile. (Large preview)

<img
    src="image-desktop.webp"
   
   
    alt="Image providing 3 different sizes for 3 viewports">

  • src: Always include as a fallback for browsers without srcset/sizes support. Use an image large enough for most devices.
  • srcset: Defines a set of candidate images with width descriptors (in w units). For example, 360w means the image is 360px wide.
  • sizes [Optional]: Lists media queries that specify the rendered image width under given conditions, ending with a default width. Units can be vw, em, rem, calc(), and px, but not percentages.

The browser decides using device width, the sizes attribute, and the srcset candidates. It picks the closest match, defaulting to the first image larger than the computed width on standard-density screens.

2. Fixing pixel density changes: Reuse srcset with density descriptors (e.g., 1x, 2x, 3x) to serve appropriate resolutions per display density. No sizes attribute is needed.

Device vs CSS Pixels360px width image by screen resolution
1 device pixel = 1 CSS pixel360px
2 device pixels = 1 CSS pixel720px
3 device pixels = 1 CSS pixel1440px

<img
    src="image-1440.webp"
   
    alt="Image providing 3 different resolutions for 3 device densities">

3. Fixing design changes: Apply art direction—offering different images with distinct ratios or focus points per viewport. This uses the <picture> element with multiple <source> tags and an <img> fallback.

Art direction is the practice of serving completely different looking images to different viewports sizes to improve visual presentation, rather than different size versions of the same image.

A visual example of art direction: 3 different images for 3 different viewports
A visual example of art direction: 3 different images for 3 different viewports. (Large preview)

<picture>
  <source media="(max-width: 420px)" width="360" height="280">
  <source media="(max-width: 960px)" width="760" height="600">
  <img src="image-desktop.webp" width="1024" height="820" alt="Image providing 3 different images for 3 displays">
</picture>

  • picture: Wraps <source> elements and the <img>.
  • source: Each specifies a media resource via srcset. Order matters: the browser evaluates the media attribute from top to bottom, displaying the first matching image and ignoring subsequent ones.
  • img: Acts as a fallback if <picture> or <source> is unsupported or no media query matches. Size it appropriately for general use.

Extra tip: Combine art direction with resolution switching to multiply your criteria for selecting an image source.

<picture>
  <source media="(max-width: 420px)" width="360" height="280">
  <source media="(max-width: 960px)" width="760" height="600">
  <img src="image-desktop.webp" width="1024" height="820" alt="Image providing 6 different images for 3 displays and 6 pixels density">
</picture>

5. Defer Non-Critical Image Loading

Without explicit priorities, browsers load images before critical resources, hurting the Time To Interactive (TTI).

Solution

Native lazy loading defers off-screen images, letting above-the-fold content load first.

Lazy loading attribute
Lazy loading for images support in all browsers. (Generated by Can I Use at 20th October 2022) (Large preview)

Add the loading attribute with the value lazy to your images.

<!-- Native lazy loading -->
<img src="image.webp" width="700" height="500" alt="Loaded by appearance">

  • lazy: Postpones loading until the image nears the viewport.
  • eager: Loads immediately regardless of position; useful when you apply lazy loading globally but want to prioritize certain images.

Do not lazy-load above-the-fold images. Instead, use loading="eager" with fetchpriority="high" to load them faster.

Extra tip: For <picture> elements, put the loading attribute on the fallback <img>.

<picture>
  <source media="(max-width: 420px)">
  <img src="image-desktop.webp">
</picture>

6. Cache Frequently Accessed Images

Without caching, repeated requests for already-loaded images degrade performance.

Solution

Store heavily accessed images in the user's browser cache and use a CDN to handle server-side caching.

Beyond caching, remember accessibility and SEO basics: the alt attribute, meaningful file names, and clean metadata all contribute to a well-optimized image strategy.

Image Service CDNs: Offloading Optimization To The Edge

Many image optimization challenges can be addressed with external build tools, but doing so adds complexity and infrastructure overhead. An Image Service CDN combines an image transformation API with a content delivery network, allowing dynamic image manipulation through URL parameters while serving results through fast, cache-optimized edge infrastructure.

These services handle a wide range of transformations: format conversion, focal point detection, cropping and resizing to specific dimensions, plus visual effects and enhancements. Crucially, they compress images to the smallest viable size without perceptible quality loss, reducing bandwidth consumption and improving page performance. Most modern headless CMS platforms integrate Image Service CDN functionality directly. This article uses Storyblok's service as a concrete example.

Compression As A Default

Simply appending /m/ to an image URL activates the CDN service, which by default re-encodes the image at 80% quality. For finer control, the quality filter accepts a value from 0 to 100 via URL parameters like /filters:quality(10).

Original JPEG Image VS Compressed WebP Image
Original JPEG Image VS Compressed WebP Image using the Image Service CDN. (Large preview)

For granular compression control, append a filter:

Default quality compressed image VS Quality 10% compressed image
Default quality compressed image VS Quality 10% compressed image. (Large preview)

Format And Encoding Control

The service offers two routes to next-generation formats. First, adding /m/ triggers automatic WebP delivery for browsers that support it, since WebP is the default format. Second, the format filter explicitly forces a specific type — webp, jpeg, or png — using a pattern like /m/200x0/filters:format(jpeg).

Sizing And Geometry

For responsive layouts, resizing dimensions are specified directly in the URL path after /m/. Setting either the width or height parameter to 0 preserves the original aspect ratio while scaling proportionally from the specified value:

  • /m/0x400 resizes proportionally based on a height of 400 pixels.
  • /m/700x0 resizes proportionally based on a width of 700 pixels.

When both width and height are provided, the image is cropped to those exact dimensions. This suits art direction needs and specific aspect ratio requirements. For instance, /m/700x200 generates a 700 by 200 pixel crop.

For automated subject-aware cropping, the /smart flag centers crops on the detected subject:

Cropped image of 700x200 with the smart feature in action centering the subject face
Cropped image of 700x200 with the smart feature in action centering the subject face. (Large preview)

When the subject isn't a person or smart detection falls short, the focal point can be defined manually. The focal filter takes coordinate bounds — like /filters:focal(450x500:550x600) — to designate the region that must remain visible within the crop. Storyblok's CMS API simplifies this further by returning a focus variable for each image automatically.

Feeding The Front-End Component

While the CDN handles transformations, HTML attributes for loading behavior remain a front-end concern. Building a single-option field in the headless CMS — showing eager and lazy choices — lets editors assign loading behavior per image. In projects where all images are above the fold, this field can be omitted entirely.

Connection speed improves with a preconnect hint for the CDN origin, in this case https://a.storyblok.com/. The browser interprets this as a signal to establish early connection to that origin, reducing latency when it must fetch resources from it.

<link rel="preconnect" href="[https://a.storyblok.com/](https://a.storyblok.com/)">

Caching And Final Integration

No server-side configuration is required for caching. Appending /m/ directs image requests to the CDN, which caches them on the first load and subsequently serves those cached copies. Transformed versions are cached individually, meaning each unique URL parameter combination delivers a cached result on repeat requests.

Pairing the CDN URL structure with a reusable image component inside Storyblok — one that consumes provided width, height, and other responsive attributes — standardizes optimization across the site. Preset definitions further streamline the content editing experience by pre-filling common image configurations.

Building an Optimized Image Component With Nuxt 3 and Storyblok

For this implementation, we'll use Nuxt 3 with Vue 3's script setup, Storyblok as the headless CMS, and Storyblok's Image Service CDN for delivery. The same patterns apply regardless of your specific stack.

Project Setup and Space Configuration

Start by creating a Storyblok account and a new space from scratch.

Screenshot of ‘Create your new space’ screen at Storyblok
Screenshot of ‘Create your new space’ screen at Storyblok. (Large preview)

Next, create the Nuxt 3 application and connect it to your space.

npx nuxi init 

Install dependencies with yarn and run yarn dev to verify the setup. The Storyblok Visual Editor requires an HTTPS preview URL, so configure SSL for localhost and add https://localhost:3000/ under Settings > Visual Editor in your space.

Screenshot default environment preview URL in the Storyblok Space
Screenshot default environment preview URL in the Storyblok Space. (Large preview)

Open the Home story under Content and set the real path to / in the Entry configuration. You should now see the Nuxt landing page inside the Visual Editor.

Setting up the Real path field inside the Home story ‘Entry configuration’ at the Storyblok space
Setting up the Real path field inside the Home story ‘Entry configuration’ at the Storyblok space. (Large preview)

Connecting Nuxt to Storyblok Content

Install the Storyblok SDK, then register it as a module in nuxt.config.js using the Preview API token from Settings > Access Tokens.

yarn add @storyblok/nuxt axios # npm install @storyblok/nuxt axios
export default defineNuxtConfig({
    modules: [
      [
        '@storyblok/nuxt',
        { accessToken: '' }
      ]
    ]
})

The default space includes sample blocks. Remove all nestable components and keep only the Page content type so we can define our own from scratch.

Defining Content Blocks in Storyblok

Create the following blocks in the space's Block Library. Required fields are marked with (*).

Design Image (design_image) supports art direction — different images per device. It's a nestable component with:

Screenshot of the Design Image component schema, with the list of fields mentioned below
Screenshot of the Design Image component schema, with the list of fields mentioned below. (Large preview)
  • image (*) (Asset > Images)
  • width (*) (Number)
  • height (*) (Number)
  • media_condition (*) (Single-Option > Source: Self) with options: mobile → (max-width: 640px) and tablet → (max-width: 1024px), defaulting to the mobile condition.
Screenshot of the Single-Option media_condition field of the Design Image nestable block
Screenshot of the Single-Option media_condition field of the Design Image nestable block. (Large preview)

Image collects all data needed for optimization, split across two tabs. The General tab contains:

Screenshot of the Image nestable component General tab schema
Screenshot of the Image nestable component General tab schema. (Large preview)
  • original_image (*) (Asset > Images)
  • Image size (Group)
    • width (*) (Number): maximum rendered width.
    • height (*) (Number): maximum rendered height.
  • Responsive image (Group)
    • responsive_widths (Text with regex validation (^$|^\d+(,\d+)*$)) — comma-separated widths for srcset, e.g., 400,760,960,1024.
    • responsive_conditions (Text) — comma-separated media queries with image slot sizes for the sizes attribute.
  • Supported densities (Group)
    • density_2x (Boolean)
    • density_3x (Boolean)
  • Art Direction (Group)
    • art_direction (Blocks > Allow only design_image components)
Screenshot of the **art_direction** field of the Image nestable component
Screenshot of the art_direction field of the Image nestable component. (Large preview)

The Style tab holds:

Screenshot of the Image nestable component Style tab schema
Screenshot of the Image nestable component Style tab schema. (Large preview)
  • loading (Single-Option > Source: Self) with options lazy → lazy and eager → eager.
  • rounded (Boolean).

Card is a nestable component with image (blocks, max one, only image type), title, subtitle, color, and button text fields.

Screenshot of the card nestable component schema.
Screenshot of the card nestable component schema. (Large preview)

The color field uses the native-color-picker custom type — you must install the Colorpicker app from the App Directory to see it.

Example screenshot of how a card component looks in the final site
Example screenshot of how a card component looks in the final site. (Large preview)

Album is a universal (nestable + content type) component containing only cards.

Screenshot of the album universal component schema
Screenshot of the album universal component schema. (Large preview)

Setting Up Pages, Layout, and Tailwind

Delete the root app.vue and create a pages folder with a dynamic [...slug].vue view that fetches content by slug from Storyblok.

<script setup>
const { slug } = useRoute().params;
const url = slug || 'home';
 
const story = await useAsyncStoryblok(url, { version: 'draft' });
</script>
 
<template>
    <div class="container">
      <StoryblokComponent v-if="story" :blok="story.content" />
  </div>
</template>

The template uses StoryblokComponent to render blocks received from the Content Delivery API. For static generation, the useAsyncStoryblok composable wraps useAsyncData.

Create a default layout with basic styles and metadata.

<template>
  <main class="min-h-screen bg-[#1A0F25] text-white">
    <slot />
  </main>
</template>

<script setup>
useHead({
  title: 'Pokemon cards album',
  meta: [
    { name: 'description', content: 'The Pokemon album you were looking for with optimized images.' }
  ],
  htmlAttrs: {
    lang: 'en'
  }
})
</script>

Install the Nuxt Tailwind module for styling.

yarn add -D @nuxtjs/tailwindcss # npm install -D @nuxtjs/tailwindcss

Register the module in nuxt.config.ts.

export default defineNuxtConfig({
  modules: [
        // ...
        '@nuxtjs/tailwindcss'
    ]
})

Generate tailwind.config.js with npx tailwindcss init and add your configuration.

module.exports = {
  content: [
    'storyblok/**/*.{vue,js}',
    'components/**/*.{vue,js}',
    'pages/**/*.vue'
  ],
  theme: {
    container: {
      center: true,
      padding: '1rem',
    },
  },
  plugins: [],
}

Create assets/css/tailwind.css for the module to pick up the Tailwind styles.

@tailwind base;
@tailwind components;
@tailwind utilities;

Creating Components for the Blocks

Create a storyblok folder at the project root — the SDK auto-imports components from here when they appear on a page. Each component expects a blok prop containing that block's field data.

  • Page.vue (storyblok/Page.vue)
<template>
  <StoryblokComponent v-for="item in blok.body" :key="item._uid" :blok="item" />
</template>
 
<script setup>
defineProps({ blok: Object })
</script>

Since page only has a body array, iterate with v-for and render each item through StoryblokComponent. Album.vue follows the same pattern over blok.cards.

<template>
  <div
    v-editable="blok"
    class="container grid grid-cols-[repeat(auto-fit,332px)] justify-center gap-10 py-12"
  >
    <StoryblokComponent v-for="card in blok.cards" :key="card._uid" :blok="card"
    />
  </div>
</template>

<script setup>
defineProps({ blok: Object })
</script>
  • Card.vue (storyblok/Card.vue)
<template>
  <article v-editable="blok" class="bg-[#271B46] rounded-xl p-4 pb-6">
    <StoryblokComponent v-if="blok.image[0]" :blok="blok.image[0]" />
    <header class="pt-4 flex gap-4 items-center">
      <div class="rounded-full w-8 h-8" :style="`background-color: ${blok.color.color}`"></div>
      <h3 class="flex flex-col">
        {{ blok.title }}
        <span class="font-sans font-thin text-xs">{{ blok.subtitle}}</span>
      </h3>
      <button class="ml-auto bg-purple-900 rounded-full px-4 py-1">{{ blok.button_text }}</button>
    </header>
  </article>
</template>

<script setup>
defineProps({ blok: Object })
</script>

Card is a leaf node — render its fields directly in HTML rather than iterating.

Building the Image Component Incrementally

Base Image Rendering

Start with the core: accept original_image, width, and height fields and add a createImage method that returns an optimized URL via the Image Service CDN.

<template>
  <picture v-editable="blok">
    <img
      :src="createImage(filename, width, height)"
      :width="width"
      :height="height"
      :alt="alt"
      class="shadow-lg w-full"
    />
  </picture>
</template>

<script setup>
const props = defineProps({ blok: Object })

const { width, height } = props.blok
const { filename, alt, focus } = props.blok.original_image

const createImage = (original, width, height, focal = focus) => {
  return `${original}/m/${width}x${height}/filters:focal(${focal})`
};
</script>

Loading Behavior

Pass the loading field through as an attribute on the img tag.

<template>
  <picture v-editable="blok">
    <img
      // all other attributes
      :loading="loading"
    />
  </picture>
</template>

<script setup>
const props = defineProps({ blok: Object })

const { /* all other properties */, loading } = props.blok
// ...
</script>

Width-Based Responsive Images

For different display sizes, populate srcset using responsive_widths and set the sizes attribute from responsive_conditions.

<template>
  <picture v-editable="blok">
    <img
      // all other attributes
      :srcset="srcset"
      :sizes="blok.responsive_conditions"
    />
  </picture>
</template>

<script setup>
const props = defineProps({ blok: Object })

// all other properties
let srcset = ref('')

if (props.blok.responsive_widths) {
  const aspectRatio = width / height
  const responsiveImages = props.blok.responsive_widths.split(',')

  let widthsSrcset = ''
  responsiveImages.map(imageWidth => {
    widthsSrcset += `${createImage(filename, imageWidth, Math.round(imageWidth / aspectRatio))} ${imageWidth}w,`
    return true
  })

  srcset.value = widthsSrcset
}
</script>

Density-Based Responsive Images

When density_2x or density_3x are enabled, generate additional URLs at double or triple resolution for srcset. The original asset must be at least three times larger than the viewport image.

<template>
  <picture v-editable="blok">
    <img
      // all other attributes
      :srcset="srcset"
    />
  </picture>
</template>

<script setup>
const props = defineProps({ blok: Object })

// all other properties
let srcset = ref('')

if (props.blok.density_2x || props.blok.density_3x) {
  let densitiesSrcset = `${createImage(filename, width, height)} 1x`
  densitiesSrcset += props.blok.density_2x ? `, ${createImage(filename, width * 2, height * 2)} 2x` : ''
  densitiesSrcset += props.blok.density_3x ? `, ${createImage(filename, width * 3, height * 3)} 3x` : ''

  srcset.value = densitiesSrcset
}
</script>

Art Direction

For different images per breakpoint, render a source tag for each item in the art_direction array, using its media_condition as the media query.

<template>
  <picture v-editable="blok">
    <template v-if="art_direction">
      <source
        v-for="{ image, media_condition, width, height } in art_direction"
        :media="media_condition"
        :srcset="createImage(image.filename, width, height, image.focus)"
        :width="width"
        :height="height"
      >
    </template>
    <!-- Base Image -->
  </picture>
</template>

<script setup>
const props = defineProps({ blok: Object })

// all other properties
const { art_direction } = props.blok
</script>

A complete implementation combining all techniques is available in the demo's Image.vue (storyblok/Image.vue) file.

Measuring the Impact

Running Lighthouse on the site with images served directly from the CMS — only width and height attributes set, no CDN optimization — already shows degraded performance with just five images.

Mobile performance scores using the image without optimizations but with the specified width and height
Mobile performance scores using the image without optimizations but with the specified width and height. (Large preview)
The opportunities mentioned by the Lighthouse report to improve the quality of our images
The opportunities mentioned by the Lighthouse report to improve the quality of our images. (Large preview)

Applying the custom image component with proper CMS values brings scores up significantly. The remaining work is coordinating editors, designers, and developers on required values, plus creating reusable presets in Storyblok to simplify their workflow.

Performance scores in mobile after using the new image optimization component: 100 in each score
Performance scores in mobile after using the new image optimization component: 100 in each score! (Large preview)

Using Framework Image Components Instead

Frameworks like Nuxt, Next, and Astro provide built-in image components — Nuxt Image, Next Image, and Astro Image respectively. They wrap the img tag with preset optimizations. For testing in the Nuxt project, install @nuxt/image-edge and configure Storyblok as the CDN provider.

Nuxt Image the component built to improve the image optimization for Nuxt apps
Nuxt Image the component built to improve the image optimization for Nuxt apps. (Large preview)
export default defineNuxtConfig({
  modules: [
    // ...
    '@nuxt/image-edge',
  ],
    image: {
    storyblok: {
      baseURL: 'https://a.storyblok.com'
    }
  }
})

Swapping the custom component for Nuxt Image yields equivalent behavior with less code to maintain:

<template>
  <picture v-editable="blok">
    <NuxtImg
      provider="storyblok"
      :src="filename"
      :width="width"
      :height="height"
      :[srcset]="densitiesSrcset"
      :sizes="widthsPerSize"
      :modifiers="{ filters: { focal: focus } }"
      :loading="loading"
      :alt="alt"
    />
  </picture>
</template>

<script setup>
const props = defineProps({ blok: Object })

const { width, height, loading, responsive_widths, density_2x, density_3x } = props.blok
const { filename, alt, focus } = props.blok.original_image

let srcset = responsive_widths ? '' : 'srcset'
let densitiesSrcset = ''
if (density_2x || density_3x) {
  densitiesSrcset = `${filename}/m/${width}x${height}/filters:focal(${focus}) 1x`
  densitiesSrcset += density_2x ? `, ${filename}/m/${width * 2}x${height * 2}/filters:focal(${focus}) 2x` : ''
  densitiesSrcset += density_3x ? `, ${filename}/m/${width * 3}x${height * 3}/filters:focal(${focus}) 3x` : ''
}

let widthsPerSize = ''
if (responsive_widths) {
  const sizes = ['sm', 'md', 'lg', 'xl']
  widthsPerSize = responsive_widths.split(',').map((w, i) => `${sizes[i]}:${w}px`).join(' ')
}
</script>

Nuxt Image still doesn't support per-device source tags for art direction, so that part must stay custom. The main benefit: if you switch image services or omit explicit dimensions, the framework handles the details automatically.

Image Optimization Is an Ongoing Process

Treating image optimization as a one-time fix is a mistake. Like web performance in general, it requires continuous attention and incremental improvement. Three habits keep the effort on track: staying informed, monitoring results, and coordinating with your team.

Keep Up With Current Practices

Image formats, compression techniques, and browser behavior evolve. Following contributors who focus on performance, such as Addy Osmani and Barry Pollard, helps surface new improvements early. Publications and reference sites including Smashing Magazine, web.dev, the Web Almanac, and MDN documentation are reliable sources for tracking the state of the web and emerging best practices.

Measure and Monitor Image Performance

Optimization is only as good as its verification. Use tools like Lighthouse and PageSpeed Insights to establish a baseline, but make measurement a recurring activity. As MDN frames it, web performance is not just measuring an app once but monitoring it over time so that what you optimized remains optimized.

Tools that automate reporting simplify this. For example, WebPerformance Report sends a weekly email summarizing a site’s performance status. That steady stream of data makes it easier to notice regressions or browser-related changes that affect image loading.

Quality control during compression also matters. The RGBA Structural Similarity tool, maintained by @kornelski, calculates visual differences between PNG and JPEG images using an algorithm that approximates human perception. Running it during compression tests helps confirm that quality loss stays acceptable and informs better parameter choices.

Standardize With Your Team

The techniques described throughout this guide are proposals, not fixed templates. Your team of content creators, designers, and developers should adapt them into workflows that fit the project. Aligning early on image sizes, resolutions, and upload presets reduces friction later and makes optimization a shared responsibility rather than a solo effort.

Clear communication also speeds up troubleshooting. If everyone understands the expected output format and performance criteria, problems are easier to isolate and resolve.

For reference, the demo project mentioned throughout this article is available at these locations:

Many thanks to Joan León (@nucliweb) and Vitaly Friedman (@vitalyf) for reviewing the article and providing valuable feedback.

Smashing Editorial