Low quality image placeholders

The guidance on this page was tested with an older version (0.9.1) of the Rust SDK. It may still work with the latest version (0.10.0), but the change log may help if you encounter any issues.

Your images are the single largest contributor to page size. You want to display something that's lower resolution in place of the actual image that’s intended to be displayed, while waiting for the full-resolution image to be downloaded.

Illustration of concept

So much of the internet is made up of pages containing lots of images. Downloading all of these images when the page loads is wasteful, both of the user's time and possibly metered data allowance, especially to download data that they may not see if they end up not viewing that part of the page. It can also make the page slow to interact with, due to the page layout constantly changing as new images load, causing the browser to reprocess the page.

You can easily reserve exactly the right space for the images, but not load them until the reader scrolls to that part of the page, using the loading=lazy attribute. The problem with this is that scrolling quickly through a page will display a lot of blank spaces where the images will be.

A nice way to counteract this drawback is to proactively load low quality image placeholders (LQIP), and then lazy load the full resolution images on demand. This gives the user a nice sense of the colours and shape of the image, and makes the page feel more complete, despite loading only a tiny fraction of the data.

This tutorial will show you how to generate these LQIP with the Compute platform.

Instructions

IMPORTANT: This solution assumes that you already have the Fastly CLI installed. If you are new to the platform, read our Getting Started guide.

Initialize a project

If you haven't already created a Rust-based Compute project, run fastly compute init in a new directory in your terminal and follow the prompts to provision a new service using the default Rust starter kit:

$ mkdir image-placeholders && cd image-placeholders
$ fastly compute init
Creating a new Compute project.
Press ^C at any time to quit.
Name: [low-quality-image-placeholders]
Description: A low quality image placeholder (LQIP) generator, at the edge.
Author: My Name
Language:
[1] Rust
[2] JavaScript
[4] Other ('bring your own' Wasm binary)
Choose option: [1]
Starter kit:
[1] Default starter for Rust
A basic starter kit that demonstrates routing, simple synthetic responses and
overriding caching rules.
https://github.com/fastly/compute-starter-kit-rust-default
[2] Beacon termination
Capture beacon data from the browser, divert beacon request payloads to a log
endpoint, and avoid putting load on your own infrastructure.
https://github.com/fastly/compute-starter-kit-rust-beacon-termination
[3] Static content
Apply performance, security and usability upgrades to static bucket services such as
Google Cloud Storage or AWS S3.
https://github.com/fastly/compute-starter-kit-rust-static-content
Choose option or paste git URL: [1]

Configure a backend

After init has completed, you'll have some new files and folders in the working directory.

Your Compute service needs a backend from which it can load your website. When testing the service on your local machine, the backends are defined in the fastly.toml file. For this tutorial, you just need a server that hosts some images. You can use any image source you like; this solution uses images.unsplash.com. Add the following lines to your fastly.toml file:

fastly.toml
TOML
[local_server]
[local_server.backends]
[local_server.backends.content_backend]
url = "https://images.unsplash.com"
override_host = "images.unsplash.com"

Separately, fastly.toml can store configuration for your production origin. This backend will be created later, on the first run of fastly compute deploy.

fastly.toml
TOML
[setup]
[backends]
[setup.backends.content_backend]
address = "images.unsplash.com"
port = 443

HINT: The fastly.toml manifest file specifies configuration metadata related to a variety of tasks, including configuring dictionaries and log endpoints. Head over to the fastly.toml reference page to learn more.

Install dependencies

Add the following dependencies to your Cargo.toml file:

  • The image crate, which provides native Rust implementations of image encoding and decoding as well as some basic image manipulation functions.
  • blurhash, which is an algorithm that encodes an image into a short (~20-30 byte) ASCII string. When you decode the string back into an image, you get a gradient of colors that represent the original image.
Cargo.toml
TOML
image = { version = "0.24.4", default-features = false, features = ["png", "jpeg"] }
blurhash = "0.1.1"

Routing

In the main function of your Compute program, begin by setting up a route for LQIP requests, and forwarding all other requests to the origin:

src/main.rs
Rust
const CONTENT_BACKEND: &str = "content_backend";
match (req.get_method(), req.get_path()) {
// If the request is a `GET` to the `/lqip/*` path, generate a LQIP.
(&Method::GET, path) if path.starts_with("/lqip/") => lqip_generator(req),
// Forward all other requests to the content backend.
_ => Ok(req.send(CONTENT_BACKEND)?),
}

Generating a LQIP from the original image

Now you can deal with any requests to the /lqip/* path in a separate handler.

src/main.rs
Rust
// Generate a LQIP (low-quality image placeholder) from a given image
fn lqip_generator(mut req: Request) -> Result<Response, Error> {
}

First, retrieve the original image bytes from the origin:

src/main.rs
Rust
// Strip /lqip from the path.
let image_path = &req.get_path()[5..].to_owned();
req.set_path(image_path);
// Apply any origin-specific transformations to the request.
req.set_query_str(format!("{}&q=1", &req.get_query_str().unwrap_or("?")));
// Retrieve the image bytes from the backend response.
let img_bytes = req.send(CONTENT_BACKEND)?.take_body_bytes();

To save bandwidth and processing time, add the q query parameter, which is specific to origin in this tutorial (images.unsplash.com) and determines the quality of the image returned. You don't need a high quality version of the image to generate a blurry placeholder!

Next, decode the image data from the raw byte slices, and determine the width and height of the original image:

src/main.rs
Rust
use image::io::Reader as ImageReader;
use image::{DynamicImage, GenericImageView, ImageBuffer};
use std::io::Cursor;
let img = ImageReader::new(Cursor::new(img_bytes))
.with_guessed_format()?
.decode()?;
// Get the image dimensions.
let (width, height) = &img.dimensions();

Now you can use this data to generate the blurhash for this image.

src/main.rs
Rust
use blurhash::{decode, encode};
let blurhash = encode(4, 3, *width, *height, &img.to_rgba8().into_vec());

The first two arguments of the encode function are integers from 1-9 and represent the number of horizontal and vertical components for the transformation function used in the blurhash algorithm. Lower numbers of components will result in a more blurred placeholder.

Decode the blurhash to generate the image data for the placeholder, using the original width and height:

src/main.rs
Rust
// Turn the blurhash into an array of pixels for our placeholder image.
let pixels = decode(&blurhash, *width, *height, 1.0);
// Generate the LQIP.
let placeholder =
DynamicImage::ImageRgba8(ImageBuffer::from_vec(*width, *height, pixels).unwrap());

Encode the resulting image data as a highly compressed JPEG (quality 30-50%). The result is a delightful, blurry placeholder of the same dimensions but with a significant size reduction over the original image.

src/main.rs
Rust
let mut bytes: Cursor<Vec<u8>> = Cursor::new(Vec::new());
placeholder.write_to(&mut bytes, image::ImageOutputFormat::Jpeg(30))?;

Finally, return a response to the client containing the LQIP, along with cache directives and any other data that might be useful (here, we've also included some debug response headers that set the blurhash and image dimensions):

src/main.rs
Rust
Ok(Response::from_body(bytes.into_inner())
.with_content_type(mime::IMAGE_JPEG)
.with_header(header::CACHE_CONTROL, "public, max-age=31536000, immutable")
.with_header("X-Blurhash", blurhash)
.with_header("X-Width", format!("{}", width))
.with_header("X-Height", format!("{}", height)))

Setting up the frontend

In order to see the benefit of generating LQIP images, you need a mechanism to load them in your HTML page, and swap them out for full-resolution images on demand.

In practical terms, your image tags will look like this:

<img class="lazy" src="/lqip/photo-cat.jpg" data-src="/photo-cat.jpg" />

The image path that normally goes in the src parameter has been moved to data-src (which can be accessed in JavaScript later), and the LQIP version is now in the src. This will result in the browser loading the small low-quality version of the image by default.

Use the Intersection Observer API to detect when an image enters the viewport. This way, only the images that are actually visible will be loaded over their placeholders.

index.html
JavaScript
document.addEventListener("DOMContentLoaded", () => {
// Set up an intersection observer to detect when images become visible on the viewport.
const imageObserver = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
// Replace the placeholder with the full quality image.
entry.target.src = entry.target.dataset.src
// We don't need to observe this element anymore.
imageObserver.unobserve(entry.target)
}
})
})
// Loop through all images and observe them.
document.querySelectorAll("img.lazy").forEach((img) => {
imageObserver.observe(img)
})
})

Typically, your origin would handle all requests from the frontend. This tutorial, however, uses images.unsplash.com as a backend – which only serves images. Fortunately, Compute can also serve assets directly from the edge, so you can use that to hack in a single HTML file for now.

Create a new file, src/index.html. After expanding the contents of the code-box above, copy and paste them into your newly created HTML page. Then add a special route inside the main entrypoint of your Compute program:

src/main.rs
Rust
// If the request is a `GET` to the `/` path, serve a HTML page.
(&Method::GET, "/") => Ok(Response::new().with_body_text_html(include_str!("index.html"))),

Now, for the exciting part!

Testing the Compute service

Congratulations! You should now have a working LQIP generator capable of running at the edge. You can test it locally, using fastly compute serve.

$ fastly compute serve
✓ Initializing...
✓ Verifying package manifest...
✓ Verifying local rust toolchain...
✓ Building package using rust toolchain...
✓ Creating package archive...
SUCCESS: Built package 'low-quality-image-placeholders' (pkg/low-quality-image-placeholders.tar.gz)
✓ Checking latest Viceroy release...
✓ Checking installed Viceroy version...
✓ Running local server...
Jan 26 12:59:20.627 INFO checking if backend 'content_backend' is up
Jan 26 12:59:20.669 INFO backend 'content_backend' is up
Jan 26 12:59:20.669 INFO Listening on http://127.0.0.1:7676

Visit the local server at http://127.0.0.1:7676 and see the LQIP generator in action. It should look similar to this:

When you're ready to deploy your new Compute service, run fastly compute deploy. This will create the content backend and publish the WebAssembly package to Fastly's platform.

IMPORTANT: Using LQIP reduces the total number of bytes to display something useful to the viewer, but it does increase the total page weight and doubles the number of image requests required. To minimize any effects on performance, both images should be optimized and served at the correct size for their containers.