Debouncing Bursts of CMS Webhooks into a Single Build
A focused technique within Webhook-Triggered Rebuilds: when editors publish many entries in quick succession, or a release publishes dozens at once, collapse the burst of webhooks into a single build that starts shortly after the last event, with a ceiling so a never-ending stream of publishes cannot postpone the build forever.
Each webhook on its own is correct: an entry changed, so the site should update. The problem is volume. A release of 40 entries sends 40 webhooks within a few seconds; an editor tidying up a page publishes it six times in two minutes. Starting a build for each one wastes compute and, worse, runs builds concurrently that can finish out of order, so the last one to finish may deploy an older state of the content.
The Problem
A news site on Hygraph rebuilds its static edition on every publish. During breaking news, editors publish updates to a live story every 20 to 40 seconds for hours. The team first added a fixed-window debounce: at most one build every five minutes. That solved the cost problem and created a freshness problem, because an update published one second after a build started waited nearly five minutes. They then tried a pure trailing debounce, “build ten seconds after the last event”. During the busiest hour, events never paused for ten seconds, and the site did not rebuild at all for 50 minutes.
The fix combines both ideas: a trailing quiet period to catch bursts, and a maximum wait measured from the first unbuilt event, so continuous activity still produces regular builds.
How the Debouncer Works
The debouncer keeps a small amount of state per site, or per section if you build sections independently:
firstPendingAt: when the oldest event not yet included in a build arrived.lastEventAt: when the newest event arrived.building: whether a build is currently running.
A scheduled check, running every second or two, decides what to do. If there are pending events and either now - lastEventAt >= quiet or now - firstPendingAt >= maxWait, it starts a build and clears the pending state. If a build is already running, it waits; events that arrive during the build stay pending and trigger the next build when it finishes. That last rule is what guarantees the final state is always built: there is never a moment where an event is received and forgotten.
Implementation
The implementation below stores state in Redis so it survives restarts and works across several instances of the webhook service. The webhook handler only records the event; a separate worker loop decides when to build.
// webhook-debounce.ts
import Redis from "ioredis";
const redis = new Redis(process.env.REDIS_URL ?? "redis://localhost:6379");
const QUIET_MS = 10_000;
const MAX_WAIT_MS = 60_000;
const KEY = (site: string) => `debounce:${site}`;
// Called by the webhook handler after signature verification and filtering.
export async function recordEvent(site: string, entryId: string): Promise<void> {
const now = Date.now();
await redis
.multi()
.hsetnx(KEY(site), "firstPendingAt", String(now)) // only set if not already pending
.hset(KEY(site), "lastEventAt", String(now))
.sadd(`${KEY(site)}:entries`, entryId)
.exec();
}
// Runs every 2 s in a single worker (or with a Redis lock if several workers run).
export async function tick(site: string, triggerBuild: (entries: string[]) => Promise<void>): Promise<void> {
const state = await redis.hgetall(KEY(site));
if (!state.firstPendingAt) return; // nothing pending
if (state.building === "1") return; // wait for the current build
const now = Date.now();
const quietFor = now - Number(state.lastEventAt);
const waitingFor = now - Number(state.firstPendingAt);
if (quietFor < QUIET_MS && waitingFor < MAX_WAIT_MS) return;
// Claim the pending batch atomically, then build.
const [[, entries]] = (await redis
.multi()
.smembers(`${KEY(site)}:entries`)
.del(`${KEY(site)}:entries`)
.hdel(KEY(site), "firstPendingAt", "lastEventAt")
.hset(KEY(site), "building", "1")
.exec()) as [[null, string[]], ...unknown[]];
try {
await triggerBuild(entries);
} finally {
// The build hook call returned; the platform's build-finished webhook clears this flag.
// As a safety net, expire it after the longest build duration.
await redis.expire(KEY(site), 15 * 60);
}
}
// Called by the hosting platform's deploy-succeeded or deploy-failed webhook.
export async function buildFinished(site: string): Promise<void> {
await redis.hdel(KEY(site), "building");
}
triggerBuild calls the platform’s build hook, or for ISR sites, revalidates the tags for the collected entries. Passing the set of entry ids along lets the build or revalidation be targeted when the platform supports it, and makes the build log say which content it contains, which helps enormously when editors ask whether their change is live yet.
Debouncing per section
Large sites often split builds by section, such as news, guides and the shop, each with its own build hook or its own set of tags. Debounce each section independently by using the section as the state key, so a burst of product updates does not delay a single correction to a news article. Map each event to its section in the webhook handler, using the same content-type table that drives invalidation. Global content, such as navigation, belongs to every section; record such events in every section’s state, or trigger a full build directly, depending on how expensive a full build is for your site.
Configuration Reference
| Setting | Typical value | Effect |
|---|---|---|
| Quiet period | 5 to 15 s | Longer catches more of a burst; shorter publishes faster. |
| Maximum wait | 60 to 180 s | Upper bound on staleness during continuous publishing. |
| Tick interval | 1 to 2 s | Resolution of the decision; cheap either way. |
| Building flag expiry | longest build duration | Recovers if the build-finished webhook is lost. |
| Scope | per site, or per section for split builds | Independent sections do not wait for each other. |
Gotchas & Edge Cases
- Fixed windows are not debouncing. Truncating timestamps into five-second buckets, a common shortcut, lets two events one second apart fall into different buckets and trigger two builds. Use a trailing timer as above.
- Concurrent builds. If the platform starts a new build while one is running, the older one may finish last and deploy stale content. The
buildingflag prevents that on your side; enable cancel-in-progress on the platform side too. - Lost build-finished webhooks. If the platform’s deploy notification never arrives, the flag would block builds forever. The expiry on the key is the safety net; alert when it fires, because it means notifications are failing.
- Several worker instances. Running
tickon every instance without a lock can start two builds for the same batch. Use a short Redis lock around the claim, or run the loop in one scheduled worker. - Urgent publishes. Some content, such as a correction to a legal statement, should not wait for the quiet period. Let the webhook handler bypass the debouncer for content types flagged as urgent.
Worked Example
The news site from the problem statement settled on a 10-second quiet period and a 90-second maximum wait. On a normal day, most publishes produced a build within about 12 seconds of the last save. During breaking news, the site rebuilt every 90 seconds while editors kept publishing, and never went stale for longer than that. Build count on the busiest day fell from 380 to 71, and because builds no longer overlapped, the out-of-order deploys that had occasionally shown an older version of the live story disappeared completely.
Rollout Checklist
- Record events in shared state from the webhook handler instead of triggering builds directly.
- Run the tick loop in one worker or behind a lock.
- Set the quiet period and maximum wait from your editors’ publishing patterns.
- Connect the platform’s deploy-finished notification to clear the building flag.
- Add an urgent-content bypass for content types that must not wait.
Frequently Asked Questions
Does debouncing matter for ISR sites that revalidate tags?
Less, because revalidation is cheap and does not overlap like builds do. It still helps during bulk publishes that would otherwise trigger hundreds of regenerations at once and hit CMS rate limits; batching tags for a few seconds smooths that spike.
Can the CMS debounce for me?
Some platforms batch events from releases or bulk actions, and some build platforms deduplicate queued builds. Neither handles an editor publishing repeatedly over two minutes, so a debouncer on your side is still worth having.
What quiet period should I start with?
Ten seconds works for most editorial teams: long enough to catch a burst of saves, short enough that editors barely notice. Watch the build log for a week and adjust.
How do I show editors when their change will be live?
Expose the debouncer state on a small status endpoint: pending entries, when the next build will start at the latest, and whether a build is running. A banner in the CMS or the preview that reads “live in about 40 seconds” removes most “is it published yet” questions.