Live for Craft CMS

Delivery and performance

A live blog is written by one person and read by everybody at once. Live is built around that: publishing costs one lightweight database write, and reading costs a static file.

What happens when you press ⌘↵

  1. The card appears in the composer straight away. The request goes in the background.
  2. One element save. An Update element, saved with revisions off, no provisional draft and the search index skipped. The parent entry is not touched.
  3. A sequence number is allocated under a row lock on the post's head row.
  4. Your Twig runs, once. The update is rendered through live/_update.twig — your copy, if you made one.
  5. Two files are written: an immutable u-<seq>.json, and a rewrite of one small head.json.
  6. Readers see the number move. Everyone polling head.json sees a new seq, fetches what they are missing, and inserts it. The web server handled all of that; PHP never ran.

Steps 1 to 5 are one HTTP request. Step 6 costs a static file per reader per poll.

The sequence number

Every update gets a number, allocated under a row lock on the post's head row. It is per post, it only goes up, and it is the plugin's real identity for an update. Two editors publishing in the same millisecond get 412 and 413, not a coin toss.

Ordering by timestamp fails on exactly this kind of content. Two press-box laptops, a load balancer and a database server don't agree on the time to the millisecond, and a live blog is written at that resolution. "Everything after 412" has one right answer, forever. "Everything since 20:41:07" depends on whose clock you ask, and gets an update missed or shown twice.

Timestamps are still what readers see. They are just not what the machinery runs on.

What a publish writes

web/live-feed/<siteId>/<postId>-<fieldId>/
    head.json      # rewritten on every publish
    u-411.json     # immutable
    u-412.json     # immutable
    u-413.json     # immutable

A per-update file is written once. An edit keeps the seq and bumps the update's rev, and the client asks for u-<seq>.json?r=<rev>, so the URL changes whenever the content does and anything between you and the reader can cache the file hard.

head.json is the only file that changes, and it is small:

{
  "seq": 413,
  "state": "live",
  "count": 218,
  "pinned": 8811,
  "updatedAt": 1786646371,
  "poll": 5,
  "updates": [ { "id": 8940, "seq": 413, "rev": 0 } ],
  "removed": [ 402 ]
}

It lists the most recent headWindow updates (20 by default) with their revisions, so one request tells a reader what is new and which updates it already holds have been edited. removed is a tombstone list carried forward from head to head, so a reader who missed the poll in which an update was deleted still takes it down on the next one. poll lets you change the interval without anybody reloading.

Every file is written to a temporary file and renamed into place, so a poll gets the old file or the new one, never half of one.

Only as public as the entry

The files sit in your web root at a guessable path, so they exist only while the entry is live on that site. Cover a match on an entry scheduled for tomorrow and nothing is written; when the entry goes live, the first save, publish or garbage-collection run writes everything. Disable, trash or expire the entry and its files are removed. The live/feed/* actions follow the same rule.

A scheduled entry going live, or one passing its expiry date, saves nothing in Craft — so for those time-based changes the files catch up on the next publish or garbage-collection run. If you schedule entries, run live/snapshots/collect-garbage on a cron.

A failed snapshot never fails a publish

If the disk is full, the path is wrong or the web root is read-only, the update is still in the database and the publish still succeeded. The error is logged (Live could not write a snapshot…), a reader that can't fetch an update's file falls back to the live/feed/since action, and live/snapshots/rebuild puts the files right once you have fixed the cause.

Snapshots are a cache of something durable, not the record. Losing the web root loses nothing.

The page cache is never invalidated

Publishing doesn't touch the entry and, by default, doesn't clear its caches. The HTML page can be cached for a day — by Craft's {% cache %}, by a static cache plugin, by Varnish or by a CDN — and updates arrive over the top of it.

On a busy live blog the page is requested thousands of times a minute and changes forty times an hour. A design in which every publish invalidates the page spends its life re-rendering it, most of all at the moment traffic is highest.

Polling and CDNs

Readers poll head.json every pollInterval seconds (5 by default) with a ?t= parameter that only changes once per interval. Every reader in the same window asks for the same URL, so a CDN answers all of them from one origin fetch. Set the CDN to cache head.json for up to pollInterval seconds and to honour the query string.

The client pauses while the tab is hidden, stops when the post ends, and backs off — up to a minute — when requests are failing, so an origin having a bad time isn't held down by every reader retrying at full speed. A tab left open through a whole match catches up in one request, not eighty.

If your snapshots are served from a CDN host, set snapshotUrl to it:

// config/live.php
return [
    'snapshotUrl' => 'https://cdn.example.com/live-feed',
];

Without snapshots

Set snapshotsEnabled to false and readers poll the live/feed/head action instead. That boots Craft on every poll from every reader. Its responses carry Cache-Control: public, max-age=<pollInterval> (plus stale-while-revalidate), so a CDN in front still helps — but it is a setting for a small internal feed or a host with a read-only web root, not for anything public and busy.

Server-sent events

SSE is a Pro feature, and it is off by default.

Every connected reader holds a PHP process open for as long as they are connected. On PHP-FPM your ceiling is pm.max_children, and you find out where that ceiling is at exactly the moment a live blog goes well — when the pool is exhausted, the whole site stops answering, not just the live blog. Polling static JSON has no such ceiling.

Turn SSE on for an internal dashboard, not for a cup final. When you do:

  • sseMaxClients caps concurrent streams; beyond it readers are turned away and poll instead
  • sseMaxDuration (30 seconds) closes each stream and asks the client to reconnect, so no process is held indefinitely
  • polling keeps running underneath as the safety net — a dropped EventSource is silent, and a live blog that has quietly stopped updating is worse than one that is five seconds late
  • the stream sends X-Accel-Buffering: no, so nginx doesn't buffer it; other proxies may need buffering turned off for live/feed/stream

CDN purging

Pro, and mostly unnecessary: the point of the design is that you don't have to purge anything. Purging matters for readers who never run the JavaScript — search crawlers, feed readers, locked-down browsers — so they see a reasonably current page.

Pick a driver in the settings: Cloudflare (zone ID and API token), Fastly (API token) or a Webhook that receives {"urls": [...]} as a POST, for anything else. Live purges the entry's URL and, when snapshotUrl is absolute, the head.json URL. A root-relative snapshot URL can't be purged, because a CDN needs a full URL.

Purges are throttled per post. The first publish queues a purge job delayed by purgeThrottle seconds (60 by default); anything published while it waits is covered by the same job. A busy match is three hundred publishes, and three hundred purges of the same URL would be three hundred cache fills of the same page. Purges run on Craft's queue, so the queue needs to be running.