Is there an alternative to Astro/Image?

Originally published: 7 January 2024

prefetching images from Astro/Image is all but impossible

astro image?

preload is retired (not just deprecated) speculation rules is now what chrome will use, and maybe safari more info here: https://developer.chrome.com/docs/web-platform/prerender-pages

prefetch by default only pulls the html, not the assets (images, css etc) to prefetch images (which is ideally what you want) you need to have the URL of the image and prefetch it separately

this is very tricky with dynamic imports

Astro/Image produces new images at build time, which can snowball with many images, but generally less concern than bandwidth for +++ users Next/Image does it on the client side, which costs a lot of bandwidth (and vercel have a bad rep for this). Cloudflare doesn’t support Next/Image Cloudflare does support Astro/Image

problem: astro/image generates exactly the images you want e.g.<Picture formats={['avif', 'webp']} src="/cute-bunnies.jpg" width="300" height="200"> will generate an avif, and a webp, EXACTLY 300w x 200h, which is great if you have fixed width images to generate. Something that next/image seems to do badly (I always get oversized image warnings in lighthouse with next/image), BUT, the URLs are generated strings with no obvious way to determine the process for naming. There must be one because it generates the same string every time, but I don’t know what the cipher is, so impossible to determine exact URLs to prefetch.

This makes it very hard to prefetch any image that served by astro/image or astro/picture.

what is astro/image or astro/picture actually doing though? it’s pre-making optimised versions and serving them up, but this can be done by yourself with a script:

something like this:



import sharp from 'sharp'
import fs from 'fs'
import path from 'path'

const sizes = [25, 50, 100, 150, 200, 300, 450, 600, 900, 1200];
const formats = ['avif', 'webp'];
const sourceDirectory = 'src/images/'; // Update with your image directory
const outputDirectory = 'public/images/'; // Update with your desired output directory

fs.readdir(sourceDirectory, (err, files) => {
    if (err) {
        console.error("Error reading directory:", err);
        return;
    }

    files.forEach(file => {
        const sourceImagePath = path.join(sourceDirectory, file);

        if (fs.statSync(sourceImagePath).isFile()) {
            sizes.forEach(size => {
                formats.forEach(format => {
                    const outputFilePath = path.join(outputDirectory, `${path.parse(file).name}-${size}.${format}`);
                    sharp(sourceImagePath)
                        .resize(size)
                        .toFormat(format)
                        .toFile(outputFilePath)
                        .then(() => {
                            console.log(`Generated: ${outputFilePath}`);
                        })
                        .catch(err => {
                            console.error(`Error processing ${outputFilePath}: ${err}`);
                        });
                });
            });
        }
    });
});

and running npm install sharp of course to use it

then node script.js

depending on your directories then it will output your images with predetermined sizes.

but the script above will generate LOTS images for each image you have.

it may be better to use something like const sizes = [200, 300, 450, 600, 750, 900, 1200]; much more managable.

but this may still get overwhelming for your images folder. but this is what astro/image is doing anyway, just behind the scenes and you don’t see it.

the benefit of generating them yourself, and it can easily be tagged on to the build script e.g. astro build && node script.js, similar to what I have for my robots.txt "build": "astro check && astro build && node generateRobotsTxt.js"

script to add srcset:


const fs = require('fs');

// Function to generate srcset for different formats and sizes
function generateSrcset(src) {
    const base = src.slice(0, src.lastIndexOf('.'));
    const formats = ['jpg', 'webp', 'avif'];
    const sizes = ['300', '600'];

    let srcset = '';
    formats.forEach(format => {
        sizes.forEach(size => {
            srcset += `${base}-${size}.${format} ${size}w, `;
        });
    });

    // Remove the last comma and space
    return srcset.slice(0, -2);
}

// Function to modify the img tag
function addSrcsetToImgTag(imgTag) {
    const srcMatch = imgTag.match(/src="([^"]+)"/);
    if (srcMatch && srcMatch[1]) {
        const src = srcMatch[1];
        const srcset = `srcset="${generateSrcset(src)}"`;

        // Insert the srcset attribute into the img tag
        return imgTag.replace(/<img /, `<img ${srcset} `);
    }
    return imgTag;
}

// Read the HTML file
fs.readFile('path/to/your/htmlfile.html', 'utf8', (err, data) => {
    if (err) {
        console.error('Error reading the file:', err);
        return;
    }

    // Replace img tags using the function
    const modifiedHtml = data.replace(/<img [^>]+>/g, addSrcsetToImgTag);

    // Write the modified HTML back to a file
    fs.writeFile('path/to/your/modified-htmlfile.html', modifiedHtml, (err) => {
        if (err) {
            console.error('Error writing the file:', err);
        } else {
            console.log('Successfully modified and saved the HTML file.');
        }
    });
});

you would then need code like:

const fs = require('fs');

// Function to generate srcset for different formats and sizes
function generateSrcset(src) {
    const base = src.slice(0, src.lastIndexOf('.'));
    const formats = ['jpg', 'webp', 'avif'];
    const sizes = ['300', '600'];

    return formats.flatMap(format => 
        sizes.map(size => `${base}-${size}.${format} ${size}w`)
    ).join(', ');
}

// Function to modify the img tag
function addSrcsetToImgTag(imgTag) {
    const srcMatch = imgTag.match(/src="([^"]+)"/);
    if (srcMatch && srcMatch[1]) {
        const src = srcMatch[1];
        const srcset = `srcset="${generateSrcset(src)}"`;

        // Insert the srcset attribute into the img tag
        return imgTag.replace(/<img /, `<img ${srcset} `);
    }
    return imgTag;
}

// Function to process a HTML file
function processHtmlFile(filePath) {
    fs.readFile(filePath, 'utf8', (err, data) => {
        if (err) {
            console.error('Error reading the file:', err);
            return;
        }

        // Replace img tags using the function
        const modifiedHtml = data.replace(/<img [^>]+>/g, addSrcsetToImgTag);

        // Write the modified HTML back to a file
        fs.writeFile(filePath, modifiedHtml, (err) => {
            if (err) {
                console.error('Error writing the file:', err);
            } else {
                console.log(`Successfully modified and saved the HTML file: ${filePath}`);
            }
        });
    });
}

// Specify the path to the directory containing your generated HTML files
const htmlDirectory = 'path/to/your/generated/html/directory';

// Read all files in the specified directory and process them
fs.readdir(htmlDirectory, (err, files) => {
    if (err) {
        console.error('Error reading the directory:', err);
        return;
    }

    files.forEach(file => {
        if (file.endsWith('.html')) {
            processHtmlFile(`${htmlDirectory}/${file}`);
        }
    });
});

this would amend the HTML files after they are built by Astro so that you can input the srcset in to the <img> tags.

theoretically this should all work fine, and the browser should determine what image to use (size and type).

problems:

  1. it’s a lot of custom scripting, lots to go wrong and debug if not working right
  2. genereation of MANY images (though none more than astro would build anyway)
  3. not sure if build caching would work for this or not which could lead to blow outs in build times
  4. no other benefits of Astro/Image (e.g. responsiveness, though I’m not sure this is actually a thing with astro/image or not).
  5. potential increase in bandwidth from prefetching images that aren’t actually used

benefits:

  1. custom scripting means control and less to break
  2. generation of your own images means you have 100% control, including type and quality
  3. ability to add other types like heif (I don’t think sharp will make heif though at present)
  4. ability to preload or prefetch specific images based on the known width (i.e. if the width of the image holder is 300px, then you’d prefetch the URL with filename-300.avif etc)
  5. ability to build images prior to pushing to git if you want (just remove from build script)

is this all worth it though?

how much does it really save? what’s the real benefit to user experience? over astro/picture the ability to prefetch image URLs probably saves around 50kb (depending on size of your images, most of my images are displayed at 600x400, and avifs are around 30-70kb for this image size.) it doesnt save any bandwidth, and in fact may increase it because of prefetching unused images, but it would save maybe…I don’t know. not a lot. I asked ChatGPT to work it out, and after a bit of back and forth it gave me an answer of: “In practice, prefetching a 50KB image might save anywhere from a few milliseconds to a few tenths of a second.”

is it worth it for all this hassle?

I will try this at some point when I’ve got a lot more time!

Read Count: 0


⬅️ Previous Post

Sharp vs Squoosh: which to pick?

Next Post ➡️

Is there an alternative to Astro/Image?