I've been testing Cloudflare Workers for a bit and was immediately impressed by their performance, but one thing that really stood out for me was their extreme flexibility. Lately, i've realised that with Workers it's really easy to achieve Incremental Static Regeneration.

What is Incremental Static Regeneration?

Incremental Static Regeneration, or ISR for short, is a technique first introduced by Vercel to improve build times for large static websites with a lot of pages.
Instead of rendering every page upfront at build time, you render pages on demand when they are requested, and then persist the response along with the rest of the site assets, effectively serving static pages after the first render.

This is a really cool feature that allows Jamstack sites to scale indefinitely.

How do we achieve ISR on Cloudflare Workers?

The real star of the show are not Workers per-se, but rather Workers KV. Let's read their official description:

Workers KV is a global, low-latency, key-value data store. It supports exceptionally high read volumes with low-latency, making it possible to build highly dynamic APIs and websites which respond as quickly as a cached static file would.

To be honest, this description is not really helpful in describing how awesome KV is. To me, this seems just like a key-value store that applications and websites *may* need, but looks more like a niche tool.

In reality, KV is more like a distributed storage system like S3, but optimised to work at the edge. In fact, you can store any type of data in KV, and Workers actually use KV to store and serve static assets.

After figuring out how useful KV is, an idea popped into my head: this is a great way to achieve ISR!

A Workers example with ISR

After a bit of theory, lets see some code:

export default {
    async fetch(request, environment, context) {
        const url = new URL(request.url);

        // Remove leading slashes
        const key = url.pathname.replace(/^\/+/, '');

        // Try to serve a static asset from KV
        try {
            const asset = await environment.__STATIC_CONTENT.get(key)
            if (asset) {
                return new Response(asset)
            }
        } catch (err) {
            // ignore errors and fall back to app rendering
        }

        // Fall back to app rendering
        try {
            // This part is framework-specific.
            // Your favourite framework will render the page
            // based on the request path
            const rendered = await render(request);

            if (rendered) {
                // ISR is achieved here:
                // on successful renders we store the response in KV.
                // Subsequent requests will be served from the store
                if (rendered.status >= 200 && rendered.status < 300) {
                    context.waitUntil(
                        environment.__STATIC_CONTENT.put(key, rendered.body)
                    );
                }

                return new Response(rendered.body, {
                    status: rendered.status,
                    headers: rendered.headers,
                });
            }
        } catch (e) {
            return new Response('Error rendering route: ' + (e.message || e.toString()), {
                status: 500
            });
        }

        return new Response('Not found', {
            status: 404,
        });
    }
};

Hopefully the code is self-explanatory, but let's summarize the key points:

  1. We check if the asset is already in the KV store. If it is, we return it.
  2. If we did not find the asset in KV, we render the page and store it.
  3. On the next request for the same page, it will be served from KV.

As you can see, we achieved ISR with this really simple Workers code.

A few notes:

  • Please do not use this code for production. Serving static assets is a bit more complicated than this, as you have to carefully set response headers to obtain the best possible caching behaviour on browsers. To serve static assets properly i suggest using this library https://github.com/cloudflare/kv-asset-handler
  • You can pass a third argument to the KV put method to provide a TTL or expiration date to re-render static pages after a certain amount of time. I highly suggest giving your static pages a limited lifetime, otherwise if content changes you need to purge KV keys manually (either through the dashboard or via API)
  • __STATIC_CONTENT is a special KV namespace that is always available to all Workers and is used to store static assets served by your application. For Cloudflare Pages there is a similar namespace called ASSETS, although it is exposed with a different API and i'm still figuring out if it has a method for writing.

Beyond simple ISR

The code sample above only shows a simple version of ISR, but we can actually go further:

Selective ISR

We can store rendered pages selectively on KV, making our website fully hybrid. Think about an e-commerce website with a blog: we can apply ISR only to the blog pages since their content never changes, and use normal SSR for product pages since their content may change frequently ( stock quantity, prices )

Stale while revalidate

If our content changes frequently, but we still want the benefit of hitting a static page, we can apply a stale while revalidate (SWR) logic: before returning a page from the store, we can re-render that page and store the new content on KV. We still return the stale version, but the next request will find the updated version already stored.

When implementing this technique we can also use two micro-optimisations:

  • A TTL logic that re-renders a stale page only every X seconds, depending how often our content changes. We can use KV metadata to store when the page was rendered.
  • Avoid the longer response penalty of re-rendering by leveraging the waitUntil method.

Sample SWR implementation:

const SWR_TTL = 60 * 1000 // Our static pages will be re-rendered every 60 seconds

// This function renders a page and stores it to KV with additional metadata
const renderAndStorePage = async (request, environment, context, key) => {
    try {
        // This part is framework-specific.
        // Your favourite framework will render the page
        // based on the request path
        const rendered = await render(request);
        if (rendered) {
            if (rendered.status >= 200 && rendered.status < 300) {
                context.waitUntil(
                    environment.__STATIC_CONTENT.put(key, rendered.body, {
                        metadata: {
                            createdAt: Date.now()
                        }
                    })
                );
            }

            return new Response(rendered.body, {
                status: rendered.status,
                headers: rendered.headers,
            });
        }
    } catch (e) {
        return new Response('Error rendering route: ' + (e.message || e.toString()), {
            status: 500
        });
    }

    return null
}

export default {
    async fetch(request, environment, context) {
        const url = new URL(request.url);

        // Remove leading slashes
        const key = url.pathname.replace(/^\/+/, '');

        // Try to serve a static asset from KV
        try {
            const result = await environment.__STATIC_CONTENT.getWithMetadata(key)
            if (result && result.value) {
                if (
                    // TODO: Determine if request is for a page
                    (!result.metadata || Date.now() - result.metadata.createdAt >= SWR_TTL)
                ) {
                    context.waitUntil(
                        renderAndStorePage(request, environment, context, key)
                    )
                }
                return new Response(result.value)
            }
        } catch (err) {
            // ignore errors and fall back to app rendering
        }

        // Fall back to app rendering
        const response = await renderAndStorePage(request, environment, context, key)
        return response || new Response('Not found', { status: 404 })
    }
}

Demo

I've created a small example application that uses ISR on Cloudflare Workers.

https://sveltekit-isr.reego.workers.dev

It is made with Svelte Kit, you can find the source code here. Every page has a top red bar ( or bottom if you are reading from a smartphone) that shows when the page was rendered and for how long it is stored.

References & links