The Vue Storefront Essentials Course is now available! Learn More
Caching

Caching

Caching is another powerful technique that can boost the performance and reliability of your VSF middleware application. In the following guide, we will cover the caching of responses from GET endpoints that can be cached.

What are the requirements to cache an endpoint's response?

If you've created or modified an endpoint and wish to cache its response, the endpoint must:

  • Use the GET HTTP method.
  • Be cookie-independent, meaning it shouldn't build the response based on cookie values.
  • Send variables as URL query parameters.

If the aforementioned requirements are met, you can safely cache the response.

What if I cache a response not fullfiling requirements?

There's a possibility that the CDN either won't cache the response or will cache a response intended for a specific user, causing other users to receive that same response.

You might wonder, how can we ensure the requirements are fulfilled? The answer is the caching extension!

Understanding the caching extension

Given the myriad of edge cases that different clients might have, we've chosen to offer middleware caching extensions for each eCommerce integration. These extensions are freely available and serve as a starting point in your application. With this approach, you aren't limited. You have the flexibility to develop custom rules for specific endpoints, or groups of endpoints, in a way that best suits your business needs.

In this section, we will build a sample caching extension from scratch and explain the thought process behind it.

First, we assign a name to the extension and register the afterCall hook. This hook is triggered after the endpoint's code has executed but before sending a response to the client. It's the ideal location to perform essential checks and set the Cache-Control response header. Inside the hook, we must return the original response. This step is necessary to maintain the hook's contract, even though it's not directly related to the caching aspect.

middleware.config.js
const cachingExtension = {
  name: "caching-extension",
  hooks(req, res) {
    return {
      afterCall({ response }) {
        // ...
        return response
      }
    }
  }
};

We start with a basic structure, and then we aim to filter out cases that aren't suitable for caching. To achieve this, we add a check for the HTTP method, since we only want to cache GET requests.

We've intentionally made the extension code straightforward and repetitive to make it easy to understand and serve as a solid starting point for various approaches. While we've used console.log for simplicity, don't hesitate to modify, refactor, or adapt the code to suit your vision and address your business-specific edge cases. For instance, you might want to remove the log statement or replace it with a debug function from a Logger library.

middleware.config.js
const cachingExtension = {
  name: "caching-extension",
  hooks(req, res) {
    return {
      afterCall({ response }) {
        if (req.method !== "GET") {
          console.log("[CACHING] It's not a GET request, skipping caching");
          return response;
        }

        return response
      }
    }
  }
};

We also need to account for another scenario: filtering out responses that contain the 'Set-Cookie' header.

middleware.config.js
const cachingExtension = {
  name: "caching-extension",
  hooks(req, res) {
    return {
      afterCall({ response }) {
        if (req.method !== "GET") {
          console.log("[CACHING] It's not a GET request, skipping caching");
          return response;
        }

        if (res.getHeader("set-cookie")) {
          console.log(
            "[CACHING] Response containing Set-Cookie header, skipping caching"
          );
          return response;
        }

        return response
      }
    }
  }
};

Then we prepare information needed to proceed to evaluate each endpoint. We create a constant and a variable containg name of the currently called API endpoint and it's params. As req.query.body might be missing or not contain correct JSON for some reason - we need to handle potential error. That's why we use try...catch block, so malicious requests won't break the application.

middleware.config.js
const cachingExtension = {
  name: "caching-extension",
  hooks(req, res) {
    return {
      afterCall({ response }) {
        if (req.method !== "GET") {
          console.log("[CACHING] It's not a GET request, skipping caching");
          return response;
        }

        if (res.getHeader("set-cookie")) {
          console.log(
            "[CACHING] Response containing Set-Cookie header, skipping caching"
          );
          return response;
        }

        const apiMethod = req.params.functionName;
        let params;
        try {
          params = JSON.parse((req.query.body as string) || "{}");
        } catch (error) {
          console.error(
            "[CACHING] Couldn't read stringified body from query params, skipping caching. Error:",
            error,
          );
          return response;
        }

        return response
      }
    }
  }
};

Once it's ready, we proceed to evaluate each endpoint. There are 3 cases we should consider. In the simplest one, which is for example getReview endpoint (we are basing on commercetools integration in this guide) - there weren't any cookie related operations so we could easily set Cache-Control headers if apiMethod equals getReview.

const cachingExtension = {
  name: "caching-extension",
  hooks(req, res) {
    return {
      afterCall({ response }) {
        if (req.method !== "GET") {
          console.log("[CACHING] It's not a GET request, skipping caching");
          return response;
        }

        if (res.getHeader("set-cookie")) {
          console.log(
            "[CACHING] Response containing Set-Cookie header, skipping caching"
          );
          return response;
        }

        const apiMethod = req.params.functionName;
        let params;
        try {
          params = JSON.parse((req.query.body as string) || "{}");
        } catch (error) {
          console.error(
            "[CACHING] Couldn't read stringified body from query params, skipping caching. Error:",
            error,
          );
          return response;
        }

        if (apiMethod === "getReview") {
          console.log(
            "[CACHING] It's a getReview request, caching requirements fulfilled!"
          );
          res.set("Cache-Control", "public, max-age=315576");
        }

        return response
      }
    }
  }
};

Calling this endpoint via the SDK doesn't require any additional steps to cache the response.

import { sdk } from '~/sdk.config.ts';

const { reviews } = await sdk.commerce.getProductReviews({
  productId: '891c95f8-7bf4-4945-9ab5-00906a5f76ba', // example id
  limit: 20 // example limit
});

The case for the getStores endpoint is a bit more complicated. It previously read the locale from the cookie, (which contradicts one of our requirements). To maintain backward compatibility, we've retained this mechanism. However, it won't be used if you provide the locale via query parameters. With this in mind, we can cache responses for this endpoint only when the request's URL includes the locale query parameter.

middleware.config.js
const cachingExtension = {
  name: "caching-extension",
  hooks(req, res) {
    return {
      afterCall({ response }) {
        if (req.method !== "GET") {
          console.log("[CACHING] It's not a GET request, skipping caching");
          return response;
        }

        if (res.getHeader("set-cookie")) {
          console.log(
            "[CACHING] Response containing Set-Cookie header, skipping caching"
          );
          return response;
        }

        const apiMethod = req.params.functionName;
        let params;
        try {
          params = JSON.parse((req.query.body as string) || "{}");
        } catch (error) {
          console.error(
            "[CACHING] Couldn't read stringified body from query params, skipping caching. Error:",
            error,
          );
          return response;
        }

        if (apiMethod === "getReview") {
          console.log(
            "[CACHING] It's a getReview request, caching requirements fulfilled!"
          );
          res.set("Cache-Control", "public, max-age=315576");
        } else if (apiMethod === "getStores") {
          // Checking if obligatory param - `locale` has been provided
          if (params.locale) {
            console.log(
              "[CACHING] It's a getStores request, caching requirements fulfilled!"
            );
            res.set("Cache-Control", "public, max-age=31557600");
          } else {
            console.log(
              "[CACHING] It's a getStores request, caching requirements not fulfilled!"
            );
          }
        }

        return response
      }
    }
  }
};

To pass the locale via query parameters using the SDK, call the method as follows:

import { sdk } from '~/sdk.config.ts';

const result = await sdk.commerce.getStores({
  locale: "en"
});

A case of getProduct endpoint is similiar but a tiny bit more complicated. It used to read a few cookies that are always available and a two that are optional. Knowing that, we have to check optional fields the following way: "channel" in params and send theirs value as { channel: null } in SDK method. Sending value equal null for optional fields prevents it from calling fallback mechanism using cookies, as the payload will be stringified and null value won't be lost in JSON standard.

The case for the getProduct endpoint is similar, though slightly more intricate. It used to read several always-available cookies as well as two that are optional. Given this, we need to check the optional fields in the following manner: "channel" in params. Then, send their values as { channel: null } in the SDK method. By sending a value of null for optional fields, it prevents the fallback mechanism from using cookies. This is because the payload will be stringified, and in the JSON standard, the null value is preserved.

middleware.config.js
const cachingExtension = {
  name: "caching-extension",
  hooks(req, res) {
    return {
      afterCall({ response }) {
        if (req.method !== "GET") {
          console.log("[CACHING] It's not a GET request, skipping caching");
          return response;
        }

        if (res.getHeader("set-cookie")) {
          console.log(
            "[CACHING] Response containing Set-Cookie header, skipping caching"
          );
          return response;
        }

        const apiMethod = req.params.functionName;
        let params;
        try {
          params = JSON.parse((req.query.body as string) || "{}");
        } catch (error) {
          console.error(
            "[CACHING] Couldn't read stringified body from query params, skipping caching. Error:",
            error,
          );
          return response;
        }

        if (apiMethod === "getReview") {
          console.log(
            "[CACHING] It's a getReview request, caching requirements fulfilled!"
          );
          res.set("Cache-Control", "public, max-age=315576");
        } else if (apiMethod === "getStores") {
          if (params.locale) {
            console.log(
              "[CACHING] It's a getStores request, caching requirements fulfilled!"
            );
            res.set("Cache-Control", "public, max-age=31557600");
          } else {
            console.log(
              "[CACHING] It's a getStores request, caching requirements not fulfilled!"
            );
          }
        } else if (apiMethod === "getProduct") {
          if (
            // Obligatory fields
            params.country &&
            params.currency &&
            params.locale &&
            // Optional fields
            "channel" in params &&
            "customerGroupId" in params
          ) {
            console.log(
              "[CACHING] It's a getProduct request, caching requirements fulfilled!"
            );
            res.set("Cache-Control", "public, max-age=31557600");
          } else {
            console.log(
              "[CACHING] It's a getProduct request, caching requirements not fulfilled!"
            );
          }
        }

        return response
      }
    }
  }
};

To pass the required values through SDK (optional and required), call the method as follows:

import { sdk } from '~/sdk.config.ts';

const { products } = await sdk.commerce.getProduct({
  country: "PL",
  currency: "USD",
  locale: "en",
  customerGroupId: null,
  channel: null,
});

Why do I need to pass null values?

When the object is passed to the SDK method, it will be stringified and set as the body query parameter in the request URL. In the JSON standard, a null value is retained. In contrast, an undefined value would be omitted, leading to the use of cookies as a fallback.

Can I set multiple response headers?

For example Cache-Control & CDN-Cache-Control? Yes, it's absolutely fine to do so.

How do we know which SDK method calls which endpoint?

Every SDK method comes with a detailed description and usage examples. To view this, you can either hover over the method in your IDE for a few seconds to see a tooltip or consult the API Reference of the SDK package in our documentation. There, you'll find information about the target URL associated with each SDK method.

Full implementation

A caching strategy in the middleware needs to be tailored for each integration. Below, we provide a basic extension that demonstrates how to approach this for each of the following eCommerce integrations: SAP Commerce Cloud, Commercetools and BigCommerce. Copy the code for your specific eCommerce platform and assign it to a constant inside middleware.config.js:

commercetools
// middleware.config.js
const cachingExtension = {
  name: "caching-extension",
  hooks(req, res) {
    return {
      afterCall({ response }) {
        if (req.method !== "GET") {
          console.log("[CACHING] It's not a GET request, skipping caching");
          return response;
        }

        if (res.getHeader("set-cookie")) {
          console.log(
            "[CACHING] Response containing Set-Cookie header, skipping caching"
          );
          return response;
        }

        const apiMethod = req.params.functionName;
        let params;
        try {
          params = JSON.parse((req.query.body as string) || "{}");
        } catch (error) {
          console.error(
            "[CACHING] Couldn't read stringified body from query params, skipping caching. Error:",
            error,
          );
          return response;
        }

        if (apiMethod === "getReview") {
          console.log(
            "[CACHING] It's a getReview request, caching requirements fulfilled!"
          );
          res.set("Cache-Control", "public, max-age=315576");
        } else if (apiMethod === "getStores") {
          if (params.locale) {
            console.log(
              "[CACHING] It's a getStores request, caching requirements fulfilled!"
            );
            res.set("Cache-Control", "public, max-age=31557600");
          } else {
            console.log(
              "[CACHING] It's a getStores request, caching requirements not fulfilled!"
            );
          }
        } else if (apiMethod === "getProduct") {
          if (
            params.country &&
            params.currency &&
            params.locale &&
            "channel" in params &&
            "customerGroupId" in params
          ) {
            console.log(
              "[CACHING] It's a getProduct request, caching requirements fulfilled!"
            );
            res.set("Cache-Control", "public, max-age=31557600");
          } else {
            console.log(
              "[CACHING] It's a getProduct request, caching requirements not fulfilled!"
            );
          }
        } else if (apiMethod === "getCategory") {
          console.log(
            "[CACHING] It's a getCategory request, caching requirements fulfilled!"
          );
          res.set("Cache-Control", "public, max-age=31557600");
        } else if (apiMethod === "getFacet") {
          if (
            params.country &&
            params.currency &&
            params.locale &&
            "customerGroupId" in params
          ) {
            console.log(
              "[CACHING] It's a getFacet request, caching requirements fulfilled!"
            );
            res.set("Cache-Control", "public, max-age=315576");
          } else {
            console.log(
              "[CACHING] It's a getFacet request, caching requirements not fulfilled!"
            );
          }
        }
        return response;
      },
    };
  },
};

Feel free to remove console.log or replace it with dedicated logger if you need.

Then add it to the extensions array:

commercetools
// middleware.config.js
const middlewareConfig = {
  integrations: {
    ct: {
      location: "@vsf-enterprise/commercetools-api/server",
      extensions: (extensions) => [...extensions, cachingExtension]
      configuration: {
        // ...
      },
    },
  },
};

From now on, if an endpoint is cookie-independent, the extension will set the Cache-Control response header, which should be respected by both your CDN and the user's browser.

The shared caching extension assumes you are using the newest version of the middleware's integration and that you haven't extended or overwritten the mentioned endpoints. If you have made such modifications, you must either exclude the endpoint from caching or take responsibility for customizing both the extension and the custom endpoint's code to ensure it's safe to cache.