Tech stack
·4 min read

4 Techniques for Balancing Performance and Server Costs with Next.js Cache Components

Next.js’ new caching strategy, Cache Components, allows for fine-grained caching of arbitrary functions and React components. Getting the most out of it requires deliberate choices, often depending on the specific use case. The following techniques help marketing websites balance fast load times with affordable server costs when using Cache Components.

This article assumes basic knowledge of Cache Components and its APIs.

4 Techniques for Balancing Performance and Server Costs with Cache Components

1. Use ID-based cache tags

To avoid unnecessary cache invalidations, use precise ID-based cache tags. ID-based cache tags can be easily invalidated as source content changes, unlike use-based cache tags.

For example, imagine a <Navigation> component that fetches a navigation document from a CMS. The document contains a list of content relationships to other documents, which are rendered in the component as the website’s main navigation links.

// components/Navigation.tsx

import { createClient } from "@/prismicio"

export async function Navigation() {
  "use cache"

  const client = createClient()
  const navigation = await client.getSingle("navigation")

  // render the navigation links
}

Compare how the following cache tags would be handled when source content changes:

// Use-based:
cacheTag("navigation")

// ID-based:
cacheTag(...navigation.data.links.map((link) => link.id))

The use-based tag declares the component’s intent: “navigation.” We can invalidate the cached function by calling revalidateTag("navigation") anytime we know the navigation document or any of its links have changed.

The ID-based tags declare what data the component uses. We can invalidate the cached function by calling revalidateTag with the document ID that changed.

Between the two, the use-based cache tag seems simpler, but hides complexity in the revalidation step. Assuming we receive a webhook when a document is updated and can call revalidateTag, we would need to determine whether the update affects navigation. This requires specialized checks and knowledge, and may even require additional network requests.

On the other hand, the ID-based cache tag requires no additional knowledge. We can call revalidateTag directly with the document ID, and Next.js will naturally find the <Navigation> component where it was declared.

Removing a level of abstraction (the use-based tag) by using low-level ID-based cache tags instead requires less domain knowledge; just tell Next.js where you use a specific piece of data and when it changes.

2. Use indefinite cache lifetimes with precise revalidation

Use cacheLife("max") whenever possible to prevent unnecessary cache invalidation. Marketing websites, in particular, can use an indefinite cache lifetime since their content is usually shared among visitors and changes relatively infrequently.

// app/page.tsx

import { cacheLife } from "next/cache"

export default async function Page() {
  "use cache"
  cacheLife("max")

  // ...
}

Using the "max" cache life profile requires a strategy to invalidate the cache. In most cases, that means having a system for tagging the cached function with cacheTag() and invalidating it with revalidateTag(). With a good cache tagging system, such as using ID-based tags, you can be more aggressive with cache lifetimes.

// app/page.tsx

import { cacheLife, cacheTag } from "next/cache"
import { createClient } from "@/prismicio"

export default async function Page() {
  "use cache"
  cacheLife("max")

  const client = createClient()
  const page = await client.getSingle("homepage")
  cacheTag(page.id)

  // ...
}
// app/api/revalidate/route.ts

import { NextResponse } from "next/server"
import { revalidateTag } from "next/cache"

export async function POST(request: Request) {
  const body = await request.json()

  for (const document of body.documents) {
    revalidateTag(document.id)
  }

  return NextResponse.json({ revalidated: true, now: Date.now() })
}

First time here? Discover what Prismic can do!

👋 Meet Prismic, your solution for creating performant websites! Developers, build with your preferred tech stack and deliver a visual page builder to marketers so they can quickly create on-brand pages independently!

3. Balance general and precise cache boundaries

Cache boundaries (where you write the use cache directive) can be as high or as low in the component tree as you want.

Setting a boundary high in the tree, such as at the page level, causes everything below it to be cached. All content is tied to the specific tag and lifetime set at the boundary. It guarantees most—if not all—elements in the tree are cached. It also means that the entire boundary must be invalidated if even a small piece of data low in the tree is updated.

// app/page.tsx

import { cacheLife, cacheTag } from "next/cache";
import { createClient } from "@/prismicio";

export default async function Page() {
  "use cache";
  cacheLife("max");

  const client = createClient();
  const page = await client.getSingle("homepage");
  cacheTag(page.id);

  // <Products> is cached with the page.
  // The whole page is invalided when any
  // referenced product is updated.
  return (
    <main>
      <Products products={page.data.products} />
    </main>
  );
}

Setting a boundary low in the tree, such as at a data-fetching function for a specific component, avoids that drawback since individual functions can be invalidated instead. However, each function call is a cache entry, requiring a cache lookup whenever the function is called; these lookups may be cheap, but they incur a network call and count toward your hosting quotas.

// components/Product.tsx

import { cacheTag } from "next/cache";

export async function Product({ id }: ProductProps) {
  const product = await getProduct(id);

  // ...
}

// Just the individual `getProduct` call for a specific
// product is invalidated when a product is updated.
async function getProduct(id: string) {
  "use cache";
  cacheTag(id);

  return await fetchProduct(id);
}

Marketing pages can usually set cache boundaries at the page level. Page-level boundaries lead to a simpler mental model, less cache usage, and simpler logs. However, if page content is composed of many different documents using content relationships, consider moving cache boundaries closer to where individual documents are fetched. Doing so will prevent small content updates from frequently invalidating entire pages, potentially slowing average page load times.

4. Prevent unnecessary prefetching

By default, next/link prefetches links once they enter the viewport. This aggressive prefetching leads to fast perceived load times but causes excess server load since each prefetched page costs the same as actually visiting the link. Visitors are only going to click on one link per page, making most prefetches unnecessary.

There is an upside, however: next/link stores prefetched pages in memory, so future link clicks may already be prefetched, essentially front-loading page loads. However, because the prefetched pages are in memory rather than in the browser's native cache, they are lost when the user leaves the site or closes the browser. This ensures that fresh content is served as often as possible, but it also increases server costs. Marketing sites don't always need that level of freshness.

Avoiding unnecessary prefetches reduces server and cache usage, saving money and resulting in cleaner logs that simplify debugging.

One solution is to disable prefetching altogether. However, this results in slow load times when clicking links. The delay is especially frustrating since clicking a next/link anchor does not activate the browser’s loading indicator.

<Link href="/products" prefetch={false}>Products</Link>

A better middle ground is prefetching on hover. Hovering a link signals an intent to click, thereby avoiding false-positive prefetches. It also avoids long load times by preempting clicks, even if just by 100ms. The following component can be used as a drop-in replacement for next/link:

// components/LazyLink.tsx

"use client"

import { useState, type ComponentProps } from "react"
import NextLink from "next/link"

type LazyLinkProps = ComponentProps<typeof NextLink>

export function LazyLink(props: LazyLinkProps) {
  const [active, setActive] = useState(false)

  const onPointerEnter: LazyLinkProps["onPointerEnter"] = (...args) => {
    props.onPointerEnter?.(...args)
    setActive(true)
  }
  const onFocus: LazyLinkProps["onFocus"] = (...args) => {
    props.onFocus?.(...args)
    setActive(true)
  }
  
  return (
    <NextLink
      prefetch={active ? null : false}
      {...props}
      onPointerEnter={onPointerEnter}
      onFocus={onFocus}
    />
  )
}

Conclusion

These techniques share a common theme: be intentional about what you cache and when you invalidate. They use the cache as aggressively as possible while using as few resources as necessary. Marketing websites have the unique benefit of being generally static and identical for all visitors, which can be exploited when creating cache strategies.

Cache Components is relatively new, and we’re still learning how to best use the feature. We’ll share further learnings as we gain more experience.

Article written by

Angelo Ashmore

Senior Developer Experience Engineer at Prismic focusing on Next.js, React, and TypeScript.

More posts

Join the discussion

Hit your website goals

Websites success stories from the Prismic Community

How Arcadia is Telling a Consistent Brand Story

Read Case Study

How Evri Cut their Time to Ship

Read Case Study

How Pallyy Grew Daily Visitors from 500 to 10,000

Read Case Study

From Powder to Pixels - Perfectly Planned Ski Vacations, Now Perfectly Digital

Read Case Study