Out of 300 global security teams, 297 see their growth stalled by security threats. See how the winning 3 break through barriers with the State of Security 2026.

Story Version Caching: CV Handling Best Practice

Storyblok is the first headless CMS that works for developers & marketers alike.

When building a website with Storyblok, one of the first performance optimization knobs to turn is caching. Part of Storyblok's built-in caching mechanisms is cv, short for “cache version”. The CMS automatically generates this parameter, a Unix timestamp, and updates its value every time editors publish new content. That sounds simple and efficient, but if misconfigured, it might cause unintended side effects.

TIP:

Learn more about Cache Invalidation.

This tutorial briefly explains how cv works and introduces Story Version Caching, an innovative strategy that mitigates these side effects. Explore ready-to-implement code snippets and learn how Story Version Caching can pave the way to robust caching that improves performance while reducing API calls.

TIP:

Already familiar with cv and want to discover the alternative? Skip this part and continue reading Introducing Story Version Caching.

What is cv, and how does it handle caching

Every Storyblok space has a cv parameter with a numerical value (a Unix timestamp). Whenever someone publishes content in that space—whether it’s a full page or a nested component—Storyblok increments that value.

This means that the latest value always reflects the most recent public version of a space.

To try, first make an API call to the Content Delivery API (CAPI) spaces endpoint:

https://api.storyblok.com/v2/cdn/spaces/me/?token=YOUR_TOKEN

The response should look something like this:

Space JSON
{
  "space": {
    "id": 123456,
    "name": "your-storyblok-website",
    "domain": "https://www.example.com/",
    "version": 1768709201,
  }
}

Next, make a change to a story, save it, and call the API again. Notice that the value in the latest response is different:

Space JSON after saving changes
{
  "space": {
    "id": 123456,
    "name": "your-storyblok-website",
    "domain": "https://www.example.com/",
    "version": 1768723421,
  }
}

The CAPI stories endpoint also exposes that same version but refers to it as cv. In a standard setup, the values of version and cv are always identical.

In practice, cv becomes part of the cache key stored in Storyblok’s CDN. To retrieve a particular version of a story from the CDN, pass the story ID and the desired cv. For example: https://api.storyblok.com/v2/cdn/stories/story_id?cv=1768709201.

The response represents the story as it was captured on the CDN at this particular point in time.

When the app makes a request like https://api.storyblok.com/v2/cdn/stories/home?token=...&version=published&cv=1768723421, it instructs the CDN to “serve the home story for version 1768723421.”

If the CDN has already cached that exact combination (home at cv=1768723421), it responds directly from the edge. Otherwise, it returns to Storyblok's API, fetches the latest data for that version, and then caches the result for future requests.

Cache 22: the price of simplicity

Most Storyblok integrations use cv in a straightforward way. The frontend retrieves the current cv for the space, either by directly querying it or by making an API call that misses the CDN. Once the value is stored, it's attached to every CDN request:

/v2/cdn/stories/home?token=…&version=published&cv=latestSpaceCv

/v2/cdn/stories/pricing?token=…&version=published&cv=latestSpaceCv

At a high level, this makes sense. When content changes, the cv changes. If the app always uses the latest cv, it always serves the latest content. Job done?

There are two subtle issues hiding under that simplicity, both originating from the fact that the cv is assigned per space, not per story. This means that changing a single story increments the cv of the entire space.

Single story, global consequences

Imagine a space with two stories—Home and Pricing—whose initial cv is 100

First, the backend requests /v2/cdn/stories/home?cv=100 from the CDN. Since the CDN hasn't cached this combination yet, it fetches the data from the API and caches it.

Then, an editor works on Pricing and hits publish. Storyblok increments the space cv to 101. The backend dutifully updates the cv and calls /v2/cdn/stories/home?cv=101.

From the CDN’s perspective, although Home hasn’t changed, the cached combination doesn't match the newly requested home?cv=101. So, the CDN must call the API again, fetch Home, and store home?cv=101 as a new cache entry.

If editors continue publishing throughout the day cv 102, 103, 104), and the app always chases the newest cv, this happens over and over. The CDN treats each new story and cv pair as a cache miss, and the API is hit far more often than necessary.

Treating everything as stale all the time

The other side of this issue is cache invalidation. When the code is wired to “always use the latest cv for all stories”, once the cv updates, all cached stories effectively become invalidated.

If an editor republishes Pricing, the cached version of Home immediately goes stale because each content change triggers an “invalidate the entire space” event.

In this unfortunate scenario, performance worsens as editorial activity increases, and the API is overused, potentially exceeding rate limits.

Introducing Story Version Caching

Story Version Caching overrides this pattern without adding heavy complexity. Instead of treating cv as one global value that everything must follow, Story Version Caching treats cv as a per-story property.

The core idea is to cache only the mapping from story to cv, and let the CDN continue to cache the content.

In practice, the backend maintains a dictionary that looks something like this:

storyCvDictionary = {
  "home":    100,
  "pricing": 102,
  "faq":      98
}

Each entry is a combination of a story ID and cache version. The key is the story’s identifier (UUID or full_slug); the value is the cv of the story’s last successful fetch. That’s all the app keeps in memory: identifiers and integers. No full JSON blobs, no large in-memory caches, just a lightweight index.

When the app needs the Home story, it looks up storyCvDictionary["home"]. If there’s a value, say 100, it calls the CDN with /v2/cdn/stories/home?token=...&cv=100

If the CDN cached this combination, it returns the story’s JSON without involving Storyblok’s API.

If there is no matching entry for home in the dictionary, the code falls back to a standard fetch (without cv, or with another “default”). When Storyblok responds, the app fetches the cv from the response, stores it as storyCvDictionary["home"] = returnedCv, and uses it for subsequent requests.

The crucial behavior that makes this work is that Storyblok’s CDN keeps versions of stories keyed by cv, even when newer cv values appear. You can still ask for home @ cv=100 after cv=101 and cv=102 exist, and the CDN serves it from cache. Story Version Caching takes advantage of this by deliberately continuing to use an older per-story cv until a newer one is needed.

From an architectural perspective, Story Version Caching is a versioned, per-object, cache-aside pattern:

  • It's versioned because each entry stores a cv that represents the version requested from the CDN.
  • It's per-object because each story has its own entry.
  • It's cache-aside because the app decides when to look up the entry, when to call the CDN, and when to update or remove it. Storyblok’s CDN continues to store the story data.

The benefits are immediate:

  • Stories that haven’t changed use the same cv, so the CDN serves them from cache regardless of other stories.
  • Stories that do change can be targeted individually by updating or removing a single entry from the dictionary, rather than forcing every story onto a new cv.
  • The app’s memory footprint is minimal because it stores only story UUIDs and version numbers, not full content objects.

How to implement Story Version Caching

There’s no need for complex infrastructure to try Story Version Caching. Add three pieces to the backend: a dictionary, a wrapper around the fetch logic, and a way to update entries when content is republished.


Hint:

You can find working code examples in this GitHub repository.


Maintain a per-story cv dictionary

First, store the mapping from story to cv. In the simplest form, this can be an in-memory dictionary in the application process:

storyCvDictionary: storyId → cv

The following pseudo-code shows what a key-value repository would look like:

Pseudo-code: KV cache for storing Storyblok cv per slug.
// Backing store can be anything (Redis/Postgres/Dynamo/etc).

class CvCacheStore {

  constructor(storage) {
    this.storage = storage;
    this.prefix = "storyblok:slug:";
  }

  createCacheKey(slug) {
    return this.prefix + String(slug).replace(/^\/+/, "");
  }

  async get(slug) {
    const key = this.createCacheKey(slug);
    const value = await this.storage.read(key); // returns null if missing
    return value == null ? null : Number(value);
  }

  async set(slug, cv) {
    const key = this.createCacheKey(slug);
    await this.storage.write(key, cv); // upsert/overwrite is fine
  }
}

Wrap the Storyblok fetch logic

Next, route all reads of individual stories through a small helper that understands Story Version Caching. In pseudo-code, the flow looks like this:

  1. The app requests a story ID (for example, 12345).
  2. It calls a helper like getStory(ID).
  3. Inside that helper, it checks if storyCvDictionary[ID] exists.

If it does, it calls the CDN with that cv: GET /v2/cdn/stories/{slug}?cv=storyCvDictionary[slug].

Otherwise, it makes a standard request (without cv or with the current space cv), checks the response for the cv value, stores storyCvDictionary[slug] = returnedCv, and then returns the story.

This keeps all version logic in one place. The rest of the code can continue to ask “please give me story ID 12345” without worrying about which cv to use.

The simplified JavaScript code snippet below shows how to orchestrate Story Version Caching with a Storyblok client and cache client:


The response is a plain object, like { story, cv }
const cache = createSupabaseClient(); // stores cv by cacheKey
const storyblok = createStoryblokClient(); // fetches { story, cv }
export async function fetchStoryBySlug(slug) {

  const cacheKey = cache.createCacheKey(slug);

  const cachedCv = await cache.get(cacheKey);

  let res = null;

  // try with cached cv
  if (cachedCv != null) {
    res = await storyblok.fetchBySlug(slug, cachedCv);
  }

  // fallback without cv if stale/miss
  if (!res?.story) {
    res = await storyblok.fetchBySlug(slug);
  }

  if (!res?.story) return null;

  // update cached cv for next request
  if (res.cv != null) {
    await cache.set(cacheKey, res.cv);
  }

  return res.story;
}

Use webhooks for invalidation

Finally, use Storyblok’s webhooks to keep the dictionary accurate when content changes.

When configured to send a webhook on publish events, Storyblok sends an HTTP request to a predefined endpoint (for example, /storyblok/webhook) whenever a story is published, unpublished, or deleted. The webhook payload includes enough information to identify which story changed.

The webhook handler can then update the dictionary in one of two methods:

  • Invalidate on publish: when a story is published, remove its entry from storyCvDictionary. The next time that story is requested, the helper treats it as a miss, fetches the latest version from Storyblok, and stores the new cv . All other stories keep their existing cv values and continue to hit the CDN cache.
  • Update on publish: the webhook payload includes the new cv, and can update storyCvDictionary[storyId] directly to that value. Subsequent requests immediately start using the new story-cv pair with the CDN.

In both cases, the app uses per-story invalidation instead of space-wide invalidation. Publishing changes to a single story only affects its cache status. Any unchanged stories continue to point to their last valid cv, and the CDN serves them from cache.

Closing thoughts

Story version caching doesn’t change how Storyblok or its CDN works. It takes advantage of it by introducing another layer. Treating cv as a per-story control instead of a space-wide cache-buster guarantees that

  • More traffic flows via the CDN, not the API
  • Frequent content updates don't lead to unnecessary cache misses
  • Cache invalidation stays at the level that matters, individual stories.

That, in essence, is Story Version Caching: a versioned, per-object, cache-aside pattern that gets the most out of Storyblok’s CDN. A new approach that can transform “the website slows down whenever content editors are busy” into “content updates never diminish website visitors' experience”.