Efficient Image Placeholders with Redis Caching

This post was published 2023/11/06

notebuildnextjsredis

My site has a lot of images. Blog headers, photography galleries, the occasional meme, optimizing these assets goes a long way. Let's dissect an image optimization approach implemented in NextJS 14. We'll focus on enhancing the user experience through the use of blur-up placeholders and caching with Redis.

Implementing Blur-Up Placeholders

Blur-up placeholders are great for providing a low-resolution preview while the full image loads. They're not just for aesthetics; they hold the user's attention and significantly improve perceived performance.

In the Next.js component RemoteImage, we determine whether the image has been cached. If not, we invoke getPlaceholder to fetch the image’s placeholder. Here's how it unfolds in the code:

if (shouldFetchPlaceholder) {
  const placeholderData = await getPlaceholder(src);
  base64 = placeholderData.base64;
  await kvSet(cacheKey, {
    base64,
    width,
    height,
  });
}

This code fetches and caches the placeholder, reducing load times on subsequent visits.

Redis Caching: The Silent Workhorse

Caching the base64 placeholder strings in Redis is a game-changer. It allows for immediate access to these strings, cutting down on unnecessary network requests.

The kvGet and kvSet functions are where the magic happens:

const cachedData: CachedData | null = await kvGet(cacheKey); //... await kvSet(cacheKey, { base64, width, height, });

This integration with Redis ensures that placeholders are stored and retrieved efficiently, optimizing server response times and conserving bandwidth.

The Server-Side Edge: Next.js 14 App Directory

The Next.js 14 app directory introduces a server-side component architecture, granting us the power to run server code alongside client components seamlessly. This means that our image optimization logic runs on the server before the page is even served to the user.

Overcoming the DynamicServerError in Next.js

When working on my site’s imagery, an unexpected roadblock appeared in the form of DynamicServerError. Specifically, the no-store error thrown by the vercel/kv module is well-documented: Next.js #46737, which indicates that the Vercel Edge Functions have limitations with KV (Key-Value) storage operations.

This no-store error manifests when trying to use kv.set within server components, leading to a frustrating interruption in the image optimization flow that breaks dynamic routes in production.

The Workaround: Vercel KV REST API

To circumvent this limitation, I turned to the Vercel KV REST API. It operates outside the constraints of server components, allowing us to perform cache operations without triggering the no-store error.

Here's a glimpse of how we communicate with the KV REST API:

export async function kvGet<T>(key: string): Promise<T | null> {
  const body = JSON.stringify(["GET", key]);

  const response = await fetch(`${process.env.KV_REST_API_URL}/`, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.KV_REST_API_TOKEN}`,
    },
    body,
  });

  if (!response.ok) {
    console.error(`Error fetching key ${key}: ${response.statusText}`);
    return null;
  }

  try {
    const wrapper = await response.json();
    const data = wrapper && wrapper.result ? JSON.parse(wrapper.result) : null;
    return data as T;
  } catch (error) {
    console.error(`Error parsing response for key ${key}:`, error);
    return null;
  }
}

export async function kvSet<T>(key: string, value: T): Promise<void> {
  const body = JSON.stringify([
    "SET",
    key,
    JSON.stringify(value),
    "EX",
    60 * 60 * 24,
  ]);

  const response = await fetch(`${process.env.KV_REST_API_URL}/`, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.KV_REST_API_TOKEN}`,
    },
    body: body,
  });

  if (!response.ok) {
    console.error(`Error setting key ${key}: ${response.statusText}`);
  }
}

By adopting the REST API, we ensure that our image optimization process is seamless and uninterrupted by internal server errors, maintaining a smooth user experience as a result.

Putting it all together

Here's how each component plays a crucial role in the grand scheme:

By synthesizing these elements, we've created an image optimization strategy that not only meets the demands of a high-traffic, image-rich site but does so with grace and efficiency. It's a testament to the power of modern web technologies and a strategic approach to performance and user experience optimization.

Here is the complete image component, hope it helps:

import Image from "next/image";
import { kvGet, kvSet } from "@/lib/redis-client";
import { getPlaceholder } from "@/lib/getPlaceholder";

interface CachedData {
  base64: string;
  height: number;
  width: number;
}

export default async function RemoteImage({
  src,
  alt,
  className,
}: {
  src: string;
  alt: string;
  className?: string;
}) {
  const cacheKey = `image-base64:${src}`;

  let base64: string, height: number, width: number;
  const cachedData: CachedData | null = await kvGet(cacheKey);

  let shouldFetchPlaceholder = !cachedData;

  if (cachedData) {
    try {
      const data = cachedData as CachedData;
      base64 = data.base64;
      width = data.width;
      height = data.height;

      if (typeof width === "undefined" || typeof height === "undefined") {
        shouldFetchPlaceholder = true;
      }
    } catch (error) {
      console.error("Error parsing cachedData:", error);
      shouldFetchPlaceholder = true;
    }
  }

  if (shouldFetchPlaceholder) {
    const placeholderData = await getPlaceholder(src);
    base64 = placeholderData.base64;
    width = placeholderData.metadata.width;
    height = placeholderData.metadata.height;

    await kvSet(cacheKey, {
      base64,
      width,
      height,
    });
  }

  return (
    <Image
      className={className}
      src={src}
      alt={alt}
      height={height!}
      width={width!}
      placeholder="blur"
      blurDataURL={base64!}
      priority={false}
    />
  );
}

Back