How AI-ready is your team? Take our AI Readiness Assessment to find out how you score — now live.

From 27 to 19 Minutes: How Concurrency Improvements Powered Storyblok's SSG Migration

Developers
Daniel Mendoza

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

Did navigating to this page feel snappy? That’s because, at the start of 2026, we migrated the Storyblok website from server-side rendering (SSR) toward static site generation (SSG)…well, a hybrid approach of SSG and SSR, but still, we migrated to SSG.

What was the motivation?

Simple. Our SSR setup had a poor TTFB (Time To First Byte) Core Web Vital.

The Storyblok marketing website consumes content from a dedicated Storyblok space, Algolia , and Greenhouse. Migrating to SSG means rebuilding a site when a change is made, and implementing this with 300 editors all while supporting search and 3rd party content makes that difficult. This then leads to support via dynamic content (the continued but selective use of SSR and CSR).

The website is built on Astro, and in Astro, if your project is set to SSG, SSR is still supported at the page level. However, you cannot define files that support both SSG and SSR, meaning that the entire file will either need to be SSG or SSR. This demands a clear distinction between dynamic on server and statically generated pages. We can support a hybrid site, but not hybrid pages.

All of this lead to challenges that influenced both architecture and updates to how content is managed.

The challenges

1. Handling job pages with content from Greenhouse

There are two related jobs pages within the Storyblok marketing website. The first is the jobs listing page, which contains a list of all open jobs. The page is statically generated, but the job listings are rendered on the client. This still improves performance, as users will see a rendered page with static information and only have to wait for the listings themselves to load.

The second is the individual job page, which accepts a job ID via a query param, populates the job description, and embeds a Greenhouse iframe for submitting applications; however, query params aren't supported in SSG, since the full route needs to be known at build time. One fix could be moving the ID from a query param into the slug, but third-party tracking tools rely on query params for referral links and social tracking, so that wasn't an option. Job pages stayed SSR.

2. Algolia indices vs. static pages

Many indices exist for the content within the website: a general index for global search, specialized indices for case studies, and more. The actual pages, such as the case studies pages, are pre-rendered. However, a Netlify serverless function keeps the Algolia indices in sync with Storyblok data, meaning search results can reflect changes without requiring a full rebuild.

Since the website index takes 2 hours to regenerate, rebuilding the index at every build was not an option. That created a consistency problem. The Algolia index is always live, but the pre-rendered pages are frozen at build time. Without any guardrails, the listing component could surface a case study that doesn't have a pre-rendered page yet. To prevent this, Algolia records created after the build started are filtered out. Algolia records don't have built-in timestamps, so a first_published_at_timestamp field was added to all indices, populated by Storyblok's own first_published_at value, and filter against a PUBLIC_BUILD_STARTED_AT_TIMESTAMP_MS environment variable set at the start of each build.

This prevents broken links, but not all inconsistencies. If a case study is renamed mid-build, the listing will show the new title while the case study page still shows the old one, until the next rebuild.

3. Datasource consistency

Multiple components across different pages fetch the same datasources. If an editor published a datasource change mid-build, some pages would be built with the old values and others with the new ones. This was solved by using the CLI v4's ability to pull datasources into local JSON files and reading from those during the build instead of hitting the Content Delivery API. Write access to datasources for non-developers was removed, and fields that had to be updated by content authors frequently were updated to text fields.

4. Handling the global application layout

A single configuration story drives the header, footer, cookie banner, and more — think global page layout — is required to render every page. With a 30-minute build, anyone could republish it mid-way through, leaving some pages built with the old config and others with the new one. SSR was needed in order for pages to use the exact same config as the last build.

The first instinct may be to use the cv param to pin a cached version. However, that's not actually how cv works: if a story isn't cached for that value, the API just returns the current version. Instead, we download the config story to a local JSON file at the start of every build and read from that file in production, while the dev server and Visual Editor preview still fetch it live from the API.

Hint:

Learn more about caching best practices here.

5. Blog, events, and app listings with pagination

Listing pages for blog posts, events, and app descriptions are all paginated via query params, which don't work in SSG. We kept these pages in SSR rather than re-architecting URLs and routing all at once.

That introduced a new problem: the SSR listing page fetches currently published stories, but the individual entry pages are pre-rendered and frozen at build time. A blog post published after the last build would appear in the listing but lead to a 404. The fix mirrors the Algolia solution: filtering out stories first published after the build started using the same PUBLIC_BUILD_STARTED_AT_TIMESTAMP_MS env variable.

It has the same limitations, too. It doesn't cover relation resolution, so a featured blog entry added after the last build could still surface as a broken link. And content can still become out of sync: rename a post mid-build, and the listing shows the new title while the post page shows the old one.

Keeping these pages in SSR also required creating a dedicated BlogListingPage content type, since the BlogListing component reads query params and couldn't live on the generic EnterprisePage type, all of which are pre-rendered. We also had to make sure editors couldn't accidentally create a second story of that type, since it would fall through to the catch-all pre-rendered route instead of the dedicated SSR page file. Storyblok doesn't have a concept of singleton content types, so we enforced this in code.

6. Content personalization

A component that let editors serve different content based on a query param existed. It was useful for personalizing landing pages per target account. But again, query params don't work in SSG, and there was no clean way to replicate the behavior. It was decided to remove the component entirely. Personalization is now achieved by creating separate pages.

7. Multiple 404 experiences

The 404 page showed different content depending on which path you tried to visit: a contextual experience that relied on knowing the original slug. Under SSR, this was straightforward with an Astro rewrite that attached a header with the original path. Astro rewrites don't work on pre-rendered pages.

This was switched to Netlify redirects, which support attaching custom request headers. The 404 page stayed SSR so it could read those headers and serve the right content. The tradeoff? Non-forced Netlify redirects only fire when no static file exists for that path, which means any prefix we've wired up this way is off-limits for SSR routes in the future.

8. Triggering builds

Before the move to SSG, Netlify deployments were triggered automatically on every merge to main. With builds taking roughly 30 minutes and a high merge volume, that quickly became impractical. Development continued on main, but production deployments switched to a dedicated production branch. A script force-pushed main to production only when new commits existed, and when there were none, rebuilds were triggered via Netlify build hooks routed through existing serverless functions.

9. Infrastructure for cron jobs

Our setup didn't require permanent servers, so the infrastructure for scheduled jobs simply didn't exist. Without adding cost or delaying the project, GitHub Actions become the obvious choice. It works, but it comes with tradeoffs: jobs run 10–15 minutes late, and occasional internal server failures are unavoidable.

10. Supporting on-demand rebuilds

Storyblok has employees all over the world. 300 users with edit access, spread across timezones, means changes were happening every few minutes. With 30-minute builds, deploying on every merged commit was no longer viable. We redesigned how builds get triggered and made a deliberate call to remove the ability for editors to trigger rebuilds themselves.

The key takeaway is that SSG turned a runtime performance problem into a build-time throughput and concurrency problem.

The concurrency and DX fixes that made it possible

Once we accepted that builds were our new bottleneck, the next step was cutting build time down.

Here’s what made that possible:

Tier-based rate limits

The js-client had a rate limit defaulting to just 5 req/s, and was easy to miss, which meant users were unknowingly throttled far below what the API actually supports. Users along with Vercel, reported the js-client rate limiting as a bottleneck. The most recent release, js-client 7.2.0, introduces a ThrottleQueueManager with a dynamic, tiered system that automatically selects the right limit based on request type: 1000 req/s for published stories, 50 req/s for single stories or small listings, and so on, matching the real constraints of the Content Delivery API. The client now also parses X-RateLimit response headers to adjust limits in real time and bypasses rate limiting entirely for in-memory cached published responses. No manual configuration needed. This alone delivered roughly a 35% reduction in build time.

Exponential backoff with full jitter

Static builds ask for a lot of content in parallel. Scale that across multiple processes and machines, and things start to fail: bursts of traffic trip limits, then synchronized retries trip limits again.

Exponential backoff with full jitter, introduced in monoblok PR #358, breaks that cycle. Instead of every request retrying at the same intervals, retries spread out randomly, reducing contention and improving success rates under load. This PR also increased the retry ceiling from 3 to 12 attempts and reduced batch sizes in CLI migration workflows from 100 to 6, which lowers burst pressure on the API. The result is a client that degrades more gracefully under contention rather than failing fast and doesn't require any changes from the systems consuming it.

Learn:

What is Linear Backoff and Jitter?

Linear backoff increases the delay between retries by a fixed amount (e.g. 100ms, 200ms, 300ms) rather than exponentially, giving a predictable, controlled retry rate.

Jitter adds randomness to those delays so that clients don't all retry at the same intervals, which matters in distributed systems where synchronized retries just cause repeated spikes.

Node streams in the CLI

The CLI v4's stream pipeline keeps 12 concurrent requests in-flight continuously across all stages simultaneously. While one stage is waiting on HTTP, another is writing to disk, another is transforming. The result? Up to ~4x speedups.

Each processing stage uses a semaphore to cap concurrency at 12 parallel requests. The key detail here is that each stage signals when it is ready for the next item immediately, rather than waiting for its current work to finish, meaning that the pipeline keeps flowing and requests are always in-flight.

Built-in lifecycle hooks ensure nothing is dropped: before any stage closes, it waits for all in-flight operations to settle. Backpressure is handled automatically by the pipeline, so each stage only receives new items as fast as it can process them.

Page sizes also went up: 100 per page for stories and assets, and 500 for migrations, reducing the number of round trips needed to paginate through large datasets. All of these decisions shift the bottleneck from sequential HTTP latency and toward the actual throughput limits of the API.

The result: from 27 minutes to 19 minutes

With the migration constraints acknowledged and the concurrency behavior improved, we moved from ~27-minute builds down to ~19 minutes.

And it wasn't just us. Clients like NordVPN independently saw a 3–4x improvement after hitting the same bottleneck that Vercel had flagged.

What’s next?

Just like the story of any other web project, there are still many areas that can be improved. We're continuing to refine the hybrid approach, identifying which remaining SSR pages are good candidates for pre-rendering and which genuinely need to stay dynamic. Reliable cron jobs need to be implemented, and are potentially a good candidate for a scheduled trigger workflow in Storyblok’s very own Flowmotion. We can also benefit from more investigation on keeping dynamic page lists in sync with their static counterparts. With the biggest concurrency bottlenecks addressed, the focus shifts to further reducing build times.