Static site generators love to call APIs at build time. Fetch some data, render it into HTML, ship a fast static page. Clean — until the API you're calling is your own, every page makes a call, and a full rebuild fires all of them at once.
That's how our blog build started returning 429 Too Many Requests. From us. To us.
The Problem
Each post on our blog embeds a live stats widget and a share button, both wired to a short link for that post. The link is created at build time: the Eleventy plugin POSTs the post's URL to our create endpoint and gets back a short code.
One call per post. Fine at 5 posts. At ~90 posts, a full-site rebuild fired ~90 create calls in a few seconds — a burst big enough to trip our own per-account create-rate limiter. The build would get part-way through, start eating 429s, and ship pages whose share buttons pointed at nothing.
It got worse. The generator renders every post to run the plugin — including posts scheduled for the future. So we were also minting links for pages that didn't exist yet, which 404'd until their publish date. We were rate-limiting ourselves to create broken links. Impressive.
The Root Cause
A build-time API call is a side effect, and we'd treated it like a pure function. Two properties were missing:
- Idempotency — calling create twice for the same page should not do work twice.
- Memory — the build had no recollection that it already created a link last time, so every rebuild started from zero.
Add "does work that shouldn't happen yet" (future posts) and you've got a burst of redundant, partly-invalid writes on every single build.
The Fix
Three changes, each small.
1. Make create idempotent (server side)
The create endpoint now returns the existing link when the same account asks to shorten the same destination again, instead of minting a duplicate:
// Idempotent per (destination, owner): re-creating is a no-op that returns the original.
urlRepository.findFirstByLongUrlAndUserIdAndDeletedFalse(longUrl, userId)
.map(existing -> existing) // already have one → return it
.orElseGet(() -> createNew(longUrl, userId));
Now a retry — or a second build — can't create garbage. This is the foundation: everything downstream is an optimization on top of a call that's safe to repeat.
2. Persist the result in git
Idempotent isn't enough — a re-create is still a network call, and 90 of them still burst. So the build remembers. A tiny map, committed to the repo:
// .url-cache.json — one entry per published post
{
"https://blog.example.com/post-a/": "/a/x7Kf2",
"https://blog.example.com/post-b/": "/a/9QzLm"
}
The plugin checks the map first and only calls the API on a miss:
// eleventy plugin (sketch)
function shortLinkFor(pageUrl, cache) {
if (cache[pageUrl]) return cache[pageUrl]; // hit → zero network
const code = createShortLink(pageUrl); // miss → one call
cache[pageUrl] = code;
return code;
}
A normal rebuild is now 100% cache hits → zero create calls. Only a brand-new post is a miss.
3. Commit the cache back from CI (without an infinite loop)
The map only helps if it survives between builds, so CI commits it back after a build that added an entry. The obvious trap: a CI job that pushes a commit triggers CI, which pushes a commit, which triggers CI… Break the loop with [skip ci] in the commit message:
# GitHub Actions — persist the generated cache, skip re-triggering CI
- name: Persist short-link cache
run: |
if ! git diff --quiet -- .url-cache.json; then
git config user.name "ci-bot"
git config user.email "ci@example.com"
git add .url-cache.json
git commit -m "chore: persist short-link cache [skip ci]"
git push
fi
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
The git diff --quiet guard means we only commit when something actually changed, so quiet builds don't churn the repo.
4. Gate work that shouldn't happen yet
Finally, don't create links for unpublished posts. The generator still renders them (that's how scheduling works), but the plugin no-ops unless the post is live:
if (new Date(post.publishAfter) > new Date()) return null; // future post → skip
The Result
| Build scenario | Create calls (before) | Create calls (after) |
|---|---|---|
| Rebuild, no new posts | ~90 | 0 |
| One new post goes live | ~90 | 1 |
| Future posts rendered | several wasted | 0 |
A normal build now makes zero calls to our own API. The day a post crosses its publish date it makes exactly one, then persists it forever. No more self-inflicted 429s, no more share buttons pointing at links that don't exist.
Lessons Learned
- Build-time API calls are side effects, not data fetches. If a call writes something, treat re-running the build as re-running the write — and make sure that's safe.
- Idempotency first, caching second. The git cache is an optimization; server-side idempotency is what makes the whole thing correct when the cache misses or a retry fires.
- Committing generated state back from CI is fine — with
[skip ci]. Guard it with a real diff check so you don't churn the repo on every run. - Your SSG renders more than it ships. Anything with a side effect in a template runs for draft and future pages too. Gate it.
- The scariest DDoS is the one you point at yourself. Rate limiters don't check whether the caller is friendly.
Ever rate-limited yourself from your own build pipeline? Tell me how — misery loves company in the comments.
Building jo4.io — a URL shortener with analytics for developers.