Scheduled Publishing and Release Windows in Headless Frontends

This guide extends Draft State Management with time: how content scheduled to publish at 09:00, or a release of forty entries planned for a product launch, actually becomes visible on a cached headless frontend at 09:00, and not at 09:07 or, worse, at 08:59 on some pages and 09:12 on others.

A CMS can schedule a publish precisely. The frontend is where schedules drift. The CMS flips the entry’s state at the scheduled time and fires a webhook. Then every tier between the CMS and the reader has to notice, in the right order: the revalidation route, the application cache, the CDN, and any client caches in open tabs. Each step adds delay, and on some platforms the scheduled publish fires no webhook at all.

A 09:00 scheduled publish, uncoordinated versus coordinatedWith time-based caching alone, pages flip between 09:00 and 09:10 as windows expire; with the scheduled webhook plus a cron backstop and tag invalidation, every page flips within seconds of 09:00.Windows only: pages flipas each window expiresWebhook + tags: pages flipCron backstop run-60 s140 s340 s540 s09:00 publish
Relying on cache windows spreads a launch over ten minutes; wiring the schedule into invalidation makes it a single moment.

The Problem

A consumer electronics brand announces a product at 09:00 CET with a coordinated press embargo. The launch page, four product entries, a homepage hero swap and a navigation change are scheduled together in the CMS. On launch day, the homepage hero changes at 09:00:03, but the navigation still shows the old menu until 09:05 because its fetch had a five-minute window and no tag invalidation. Two of the four product pages return 404 until 09:10, because they had been requested before 09:00, when they did not exist yet, and ISR cached the 404s for ten minutes. A journalist refreshing at 09:01 sees the product mentioned on the homepage and a 404 behind the link.

The opposite failure is worse: content visible before the embargo. A preview link shared with a partner agency, a statically generated page that included the entry because the build ran with the preview token, or a CDN that cached a draft-mode response can all publish early.

How Scheduled Publishing Reaches the Frontend

The CMS side differs by platform. Contentful’s scheduled actions and releases publish entries at the scheduled time and emit the normal publish webhooks. Sanity schedules through its scheduled publishing feature, which also results in normal publish events. Storyblok and Strapi offer scheduled publishing through built-in features or plugins, and on some plans or versions, scheduled changes do not fire webhooks reliably, or fire them when the job runs rather than at the precise time. Whatever your platform does, design the frontend so that it does not depend on the webhook alone.

Three mechanisms together make launches punctual:

  1. Webhook-driven invalidation handles the normal case: each published entry invalidates its tags, and the next request renders fresh content.
  2. A cron backstop runs every minute around scheduled times, asks the CMS which entries were published since the last run, and invalidates their tags. It catches lost or late webhooks.
  3. Short negative caching keeps 404s for routes that will exist soon from lingering: pages that returned 404 are cached for seconds, not minutes, so a scheduled page appears promptly even if it was requested before launch.
Three mechanisms that make a launch punctualThe scheduled publish emits a webhook that invalidates tags; a cron backstop queries recently published entries and invalidates any that the webhook missed; short negative caching limits how long pre-launch 404s survive.CMS schedule09:00Publish webhookCron backstopevery minuteShort 404 cache10 srevalidateTag+ CDN purgemissed ids
Any one mechanism can fail; together they bound launch latency to about a minute in the worst case.

Implementation

The cron backstop is a small scheduled function. It stores the timestamp of its last run, asks the delivery API for entries published since then, and invalidates their tags through the same helper the webhook route uses. Because invalidation is idempotent, running it for entries the webhook already handled costs nothing but a cheap cache operation.

TypeScript
// app/api/cron/publish-backstop/route.ts (scheduled every minute)
import { revalidateTag } from "next/cache";
import { kv } from "@/lib/kv";
import { tagsForEntry } from "@/lib/cache-tags";

interface PublishedEntry {
  sys: { id: string; publishedAt: string; contentType: { sys: { id: string } } };
}

export async function GET(req: Request): Promise<Response> {
  if (req.headers.get("authorization") !== `Bearer ${process.env.CRON_SECRET}`) {
    return new Response("unauthorized", { status: 401 });
  }
  const since = (await kv.get<string>("backstop:last")) ?? new Date(Date.now() - 5 * 60_000).toISOString();
  const now = new Date().toISOString();

  const url = new URL(`https://cdn.contentful.com/spaces/${process.env.CONTENTFUL_SPACE}/environments/master/entries`);
  url.searchParams.set("sys.publishedAt[gte]", since);
  url.searchParams.set("select", "sys.id,sys.publishedAt,sys.contentType");
  url.searchParams.set("limit", "200");

  const res = await fetch(url, { headers: { Authorization: `Bearer ${process.env.CMS_DELIVERY_TOKEN}` }, cache: "no-store" });
  if (!res.ok) return new Response(`CMS ${res.status}`, { status: 502 });
  const { items } = (await res.json()) as { items: PublishedEntry[] };

  const tags = new Set<string>();
  for (const e of items) for (const t of tagsForEntry(e.sys.contentType.sys.id, e.sys.id)) tags.add(t);
  for (const t of tags) revalidateTag(t);

  await kv.set("backstop:last", now);
  return Response.json({ checkedSince: since, entries: items.length, tags: tags.size });
}

Short negative caching lives in the page. When an entry is not found, return the 404 with a much shorter window than found pages get. In the App Router, a route segment that calls notFound() inherits the segment’s revalidate window, so the trick is to fetch with a short window and a tag for the slug, and let the found case lengthen its own window through its own fetches:

TypeScript
// lib/get-entry.ts
export async function getEntryBySlug(type: string, slug: string) {
  const res = await fetch(`${process.env.CMS_API_URL}/entries?content_type=${type}&fields.slug=${encodeURIComponent(slug)}`, {
    headers: { Authorization: `Bearer ${process.env.CMS_DELIVERY_TOKEN}` },
    // Slug lookups stay short-lived so a not-yet-published slug appears within seconds of launch.
    next: { revalidate: 10, tags: [`slug:${type}:${slug}`] },
  });
  const { items } = (await res.json()) as { items: Array<{ sys: { id: string } }> };
  return items[0] ?? null;
}

The slug tag also lets the publish webhook invalidate the lookup directly, because the webhook payload contains the new entry’s slug.

Configuration Reference

Setting Value Why
Backstop schedule every minute Bounds a lost webhook to about a minute of delay.
Backstop overlap query from last run, not “now minus one minute” Clock drift and slow runs never create gaps.
Slug lookup window 10 s Pre-launch 404s expire almost immediately.
Entry window 3600 s with tags Normal freshness comes from invalidation.
Release publishing CMS releases or bundled actions Entries go live together, not one by one.
Time zone store and compare in UTC Editors see local time; systems never do.

Gotchas & Edge Cases

  • Builds that run with the preview token. A build started before the embargo that uses the preview token will bake the scheduled content into static pages. Builds must use the delivery token only.
  • Preview links shared outside. A preview link is a live window into drafts, including embargoed content. Use signed, expiring share links scoped to a single entry, and revoke them after the launch.
  • Navigation and homepage are separate entries. A launch usually changes shared content. Schedule those changes in the same release, and tag them so their invalidation is part of the same webhook burst.
  • Rate limits at launch time. A release of dozens of entries sends a burst of webhooks and, if traffic is high, a burst of regenerations. Invalidate by type-level tags for large releases and let regeneration happen lazily.
  • Unpublishing on schedule. Scheduled unpublishes, such as a sale ending at midnight, need the same backstop. Query entries that were unpublished or archived since the last run as well, or keep a list of scheduled end times and invalidate at those times.

Verifying the Result

Rehearse the launch in a staging environment with the same schedule shifted to the next hour. Request every affected URL before the scheduled time, which reproduces the cached-404 trap, then poll them from the scheduled time and record when each flips. All pages should flip within seconds of each other. Then disable the webhook in staging and repeat: the backstop should flip every page within about a minute. A timeline chart of the rehearsal is a useful artifact to share with the launch team.

Seconds until each launch URL flipped in the rehearsalTime from the scheduled publish until each of six launch URLs served the new content in a staging rehearsal, with webhooks enabled and with webhooks disabled so only the backstop ran.Homepage, webhook3 sLaunch page, webhook4 sProduct pages, webhook6 sHomepage, backstop only41 sLaunch page, backstop only43 sProduct pages, backstop only58 s
Staging rehearsal of the product launch; with webhooks every URL flipped within six seconds, and without them the backstop bounded the delay to one minute.

Rollout Checklist

  • Confirm whether your CMS fires webhooks for scheduled publishes and unpublishes, and at what time.
  • Add the cron backstop with overlap-safe timestamps.
  • Give slug lookups a short window and a slug tag, and keep entry fetches long-lived with tags.
  • Bundle related changes, such as pages, navigation and homepage modules, into one CMS release.
  • Replace shared preview secrets with expiring per-entry share links before any embargoed launch.
  • Rehearse the launch in staging, with and without webhooks.

Frequently Asked Questions

Can I schedule content in the frontend instead of the CMS?

You can gate rendering on a publishAt field, showing the content only after that time, but the content is already published in the CMS and visible through the API to anyone who looks. For embargoed content, schedule in the CMS so the delivery API does not return it early.

How precise can a headless launch be?

With webhook-driven invalidation, a few seconds after the CMS executes the schedule. The CMS’s own scheduler usually runs within a minute of the target time, so check its guarantees if second-level precision matters.

What about readers who already have the page open?

They see the change on their next navigation or reload. If the launch must appear in open tabs, for example a countdown page that reveals a product, push the change over a client channel as described in the SWR revalidation guide, or have the page poll a tiny status endpoint in the final minutes.

Does the backstop need its own monitoring?

Yes. Log each run’s entry count and alert if the job stops running. A backstop that silently stopped weeks ago only fails you on launch day, which is exactly when you need it.