I run a site with practice exams for Dutch certification exams. Next to the exams there are guides: how buoys work for the small boating licence, which sound signals you need to know, how to book the exam. On 24 October a deploy failed, and the cause was a function that every one of those pages called without anyone thinking about it.
Here's what the build log said, what was actually slow, and the rule we wrote down afterwards.
What the build log said
At the time we were on Vercel's Hobby plan. The log listed the build machine as "2 cores, 8 GB", and Next.js 16.4.2 generated the static pages with a single worker:
Generating static pages using 1 worker (0/187) ...
Failed to build /(nl)/(site)/klein-vaarbewijs-1/betonning/page: /klein-vaarbewijs-1/betonning (attempt 1 of 3) because it took more than 60 seconds. Retrying again shortly.
...
Failed to build /(nl)/(site)/klein-vaarbewijs-1/bpr-en-rpr/page: /klein-vaarbewijs-1/bpr-en-rpr after 3 attempts.
Export encountered an error on /(nl)/(site)/klein-vaarbewijs-1/bpr-en-rpr/page: /klein-vaarbewijs-1/bpr-en-rpr, exiting the build.
Next.js gives each page 60 seconds to render during static generation. That's the default of staticPageGenerationTimeout. A page that takes longer is retried, up to three attempts, and if it still doesn't finish, the whole build fails.
Eight guides for the Klein Vaarbewijs 1 (the small boating licence) ran past the limit. One of them, the guide on BPR and RPR (the inland shipping rules), failed all three times. The deploy stopped after about 5 minutes 40 seconds, and production kept serving the previous build.
These are not heavy pages. The guide on buoys is text, some images and links to related guides. None of that should take a minute.
It had been close for a while
When I compared the logs of the deploys before it, the problem wasn't new:
- An earlier deploy already hit "took more than 60 seconds" on four reference pages about navigation lights, such as the lights of a large motor vessel. They passed on a retry, and that build took about 18 minutes.
- A redeploy of the same code built 184 pages with no timeouts, in 13 minutes.
- The failing deploy had 187 pages, and this time the retries didn't save it.
So the build had been sitting right at the limit, passing or failing by luck. A plain redeploy might have passed, and that would have hidden the problem until the next batch of pages.
The cause: every page re-read the whole content tree
Every question on the site comes with JSON files: a draft, a check by a second model and a verification by a third. They ship with the deployment, in content/checks/<exam>/ and content/verifications/<exam>/. A guide only goes live once enough of its exam's questions have passed both checks, so deciding which guides exist means reading those files.
The function that decides is publishedTopics(). It's shared by the sitemap and the navigation, and every page renders the navigation. For each of about 100 topics, it evaluated the topic's state like this:
export function getTopicState(slug: string) {
const topic = getTopic(slug);
return evaluateTopic(topic, loadCheckFiles(topic.examId), loadVerificationFiles(topic.examId));
}
And this was the loader underneath:
function readDir<T>(dir: string, schema: { parse(data: unknown): T }): T[] {
if (!existsSync(dir)) return [];
return readdirSync(dir)
.filter((f) => f.endsWith(".json"))
.sort()
.map((f) => schema.parse(JSON.parse(readFileSync(join(dir, f), "utf8"))));
}
Nothing is wrong with either function on its own. The problem is how often they ran. An exam's check and verification files were read, parsed and validated once for every guide of that exam, just to render one navigation. Then again for the next page, and the next. With one worker, every page waits for the one before it.
Turbopack had even pointed at the file, in the same log:
./lib/content/load.ts:19:18
Warning: The file pattern (<dynamic> '/content/checks/' <dynamic> | …) matches … files
Overly broad patterns can lead to build performance issues and over bundling.
That warning is about bundling (it can't narrow down a dynamic fs path), not about render time. But it was the right file.
The fix: parse once per process
The content files can't change inside a deployment, so there's no reason to parse them more than once. lib/content/load.ts now keeps the parsed directories in a Map for the life of the process, in production only:
const parsed = new Map<string, unknown[]>();
function readDir<T>(dir: string, schema: { parse(data: unknown): T }): T[] {
const cache = process.env.NODE_ENV === "production";
const hit = cache ? parsed.get(dir) : undefined;
if (hit) return hit as T[];
const files = existsSync(dir)
? readdirSync(dir)
.filter((f) => f.endsWith(".json"))
.sort()
.map((f) => schema.parse(JSON.parse(readFileSync(join(dir, f), "utf8"))))
: [];
if (cache) parsed.set(dir, files);
return files;
}
Why this works in Next.js:
-
next buildruns withNODE_ENV=production, so the cache is on during static generation, not only on the live site. - Module state lives as long as the worker process. With one static worker, the first page pays for parsing and every page after it reuses the result.
- In development and tests the cache is off, because there the files do change. A test pins both behaviours (trimmed):
vi.stubEnv("NODE_ENV", "production");
const first = loadCheckFiles("nl-kvb1");
expect(loadCheckFiles("nl-kvb1")).toBe(first);
// after vi.unstubAllEnvs():
expect(loadCheckFiles("nl-kvb1")).not.toBe(loadCheckFiles("nl-kvb1"));
The numbers: ten calls to publishedTopics() went from 3,445 ms to 187 ms. Our local build rehearsal dropped from about 10 minutes to under 4.
We also raised the timeout, with a comment that says what it is:
// The real fix is the parsed-content cache in lib/content/load.ts; this is headroom.
staticPageGenerationTimeout: 180,
Raising the timeout alone would have made the build pass and stay slow. Every new guide would have pushed it a bit closer to 180 seconds instead of 60. I wanted the timeout as a seatbelt, not as the fix.
The same pattern at runtime
The build was just where it showed first. Later that day we found the same pattern on the dynamic pages: the exam start, the result page, checkout and the account page evaluated topic states on every request, about 0.7 seconds of work for all topics. A second cache, one level up, now keeps the evaluated state per topic in production:
export function getTopicState(slug: string) {
const cached = topicStateCache.get(slug);
if (cached) return cached;
const topic = getTopic(slug);
const state = evaluateTopic(topic, loadCheckFiles(topic.examId), loadVerificationFiles(topic.examId));
if (process.env.NODE_ENV === "production") topicStateCache.set(slug, state);
return state;
}
The rule we wrote down
Our decision log now says: nothing a page renders may re-read the whole content tree per call. Load once, then reuse.
That isn't specific to JSON files. Anything in a shared layout, header or navigation runs once for every page in the build. If it does I/O or parsing that grows with your content, build time grows with pages times content. The site has grown since that day (the sitemap lists 193 URLs now), and with the old loader every one of those pages would have paid for it again.
What I'd tell someone else on Next.js 16
- Read the "Generating static pages using N worker" line. On a small build machine N can be 1, and then every slow page adds straight to the build time.
- Treat "took more than 60 seconds … Retrying" as a failure, even when the retry passes. Ours passed on retry before the day it didn't.
- Look for work in shared components that grows with your content. Navigation and sitemaps are the usual suspects, because every page or every build touches them.
- Cache what can't change inside a deployment at module level, in production only, and test that development still reads fresh.
-
Raise
staticPageGenerationTimeoutas headroom, not as the fix, and say so in the comment next to it.
We've since moved to Vercel Pro, but the rule stays. More build power would have hidden the problem, not fixed it.
I wrote this post with help from an AI assistant. The log lines, code and numbers come from our own repository.
Top comments (0)