Best practices for persisting assets across deployments on Cloudflare Pages?

I’m reaching out to the community for advice regarding an issue I’ve encountered with Cloudflare Pages. When I deploy a new version of my Single Page Application (SPA) that utilizes code splitting, users currently accessing the site experience loading errors because the assets from the old version return a 404 not found error.

I’ve attempted to mitigate this problem by enabling Cloudflare’s Tiered Cache and Cache Reserve features, hoping to persist the cache and thereby retain the old assets for a longer period. However, I’ve hit a roadblock because the assets served by Cloudflare Pages do not include a content-length response header, even when the accept-encoding is set to identity, which is a requirement for the Cache Reserve feature to function properly.

One solution I haven’t tried yet is creating a deployment script that would upload the resources to Cloudflare R2 storage. Before I proceed with this approach, I wanted to consult the community:

  1. Are there any best practices or recommended strategies for ensuring that old version assets remain available for a certain period after a new deployment on Cloudflare Pages?
  2. Has anyone successfully implemented a solution that effectively avoids the 404 errors on assets after a new deployment, without resorting to external storage options?
  3. Is there any way to configure Cloudflare Pages or the caching system to maintain old assets even after a new version goes live?

I would greatly appreciate any insights, experiences, or advice on this matter. Ensuring a seamless user experience during updates is crucial, and I’m hoping to find the most efficient and effective way to address this challenge.

Thanks in advance!

1 Like

+1. I have this exact same issue on Vercel (users using prior version of my SPA getting 404’s when navigating to routes that load JS that has been deleted since building my latest deployment).

Hoping that by switching to Cloudflare there’s more tools to deal with this. If R2 + worker is the way to do it (instead of Pages) that’s not ideal but I’ll do whatever I have to to fix this.
@ jiahao.lu if you’d solved this I’d love to hear how. Thanks!

Happy to find out other people are having the same issues. Not happy to not find any comment by Cloudflare about this :confused: IMO this should be an official supported option on Pages deployments.

My workaround is to point the assets on the page to the versioned domain {version}.example.pages.dev, which can be get from the CF_PAGES_URL environment variable at build time.

Gotcha, thanks for sharing your workaround! Not ideal for performance due to assets being cross-origin (extra DNS lookup and preflight requests), but glad that it gets the job done.

FWIW I ended up just refactoring my Preact app to refresh the page once if it ever gets a ChunkLoadError (unhandledrejection event) right after a route change. Kind of gross but gets the job done given that my routes never change and my service worker has already cached the latest assets.

This issue can be addressed by reverse proxying the assets with Functions. I created a function that rewrites https://www.example.com/_v/{version}/... to the versioned domain:

functions/_v/[version]/[[paths]].js

export const onRequestGet = async function (context) {
  const { version } = context.params;
  validateVersion(version);

  const path = `/${context.params.paths.join("/")}`;
  validatePath(path);

  const url = new URL(path, `https://${version}.your-project.pages.dev`);
  url.search = new URL(context.request.url).search;

  const response = await fetch(url.toString(), {
    redirect: "manual",
    // My domains are under the protection of Cloudflare Zero Trust
    // and you may not need to set the following headers
    headers: {
      "cf-access-client-id": context.env.CF_ACCESS_CLIENT_ID,
      "cf-access-client-secret": context.env.CF_ACCESS_CLIENT_SECRET,
    },
  });

  // avoid redirecting exposing the origin
  if (response.status >= 300 && response.status < 400) {
    throw new Error(`Unexpected redirect: ${response.status}`);
  }

  const modifiedResponse = new Response(response.body, response);
  modifiedResponse.headers.delete("set-cookie");
  modifiedResponse.headers.set(
    "cache-control",
    "immutable, max-age=31536000, public"
  );

  return modifiedResponse;
};

const validateVersion = (version) => {
  if (!/[0-9a-f]{8}/.test(version)) {
    throw new Error(`Invalid version: ${version}`);
  }
};

const validatePath = (path) => {
  if (path === "/favicon.svg") {
    return;
  }

  if (String(path).startsWith("/assets/")) {
    return;
  }

  throw new Error(`Invalid path: ${path}`);
};