Skip to content
Grav 2.2 is out: 2x faster cold starts and 96% less memory on big sites. Read the announcement →
journal 15 mins

Grav 2.2 Released: The Fastest Flat-File CMS Is Now 2x Faster

2x faster cold starts, up to 3.8x faster warm starts, and 96% less memory on a 10,000-page site

kahunacart.com passed 1,000 pages this summer. Most of them are documentation and API reference for KahunaCart, and more keep arriving. Somewhere along the way I noticed the site had gotten slow in one very particular way. A normal page view was fine. But right after a cache clear, the first visitor waited more than a second. And because every page save in the new admin cleared the whole cache, I paid that second every time I fixed a typo.

On a 10,000-page copy of the same site, that second became four and a half.

So I stopped adding features for a few weeks and did a proper performance audit of Grav core, the API plugin and Admin 2. Grav 2.2 is the result. There are no big new features in this one. It's just faster, and in some places a lot faster.

Here's the short version. "Before" is Grav 2.1.10 with API 1.0.39 and Admin 2 2.1.22. "After" is Grav 2.2.0 with API 1.0.40 and Admin 2 2.1.23. The small site is kahunacart.com's real content, 1,081 pages. The big one is the same site grown to 10,321 pages. A cold start is the first page view after the cache has been cleared, when Grav has to rebuild everything. A warm start is every page view after that, with the cache already built.

faster cold start, the first page view after a cache clear 1,047 → 526 ms
4,633 → 2,326 ms at 10k
96% less memory per page view on a 10,000-page site 241.8 → 9.6 MB
9 → 2 API requests to start the admin, and just 1 on a return visit 734 → 339 ms after a clear
16× faster first page view after saving a page in the admin 1,075 → 67.9 ms

Cold Starts

When Grav rebuilt its pages cache, 2.1.10 wrote a compiled PHP file for every single page and then loaded each one into OPcache. On a site with a thousand pages that's a thousand files written and compiled before your visitor sees anything. On a big site those files also pushed the rest of Grav's compiled code out of OPcache's memory, so everything else got slower too. Grav 2.2 doesn't write them at all.

A few more things add up on that same request:

  • Translations are prepared one language at a time, when a request actually needs that language. Before, the first request after a clear read every language that core and every plugin ship with.
  • Configuration and translation caches are loaded into OPcache after the response has gone out, so the visitor doesn't wait for it.
  • When several visitors arrive right after a clear, one request rebuilds the cache and the others wait for its result instead of all doing the same work. With four visitors at once on the 10k site, the slowest waited 2,585 ms instead of 4,722 ms.

The result is the same at both sizes: half the time.

Cold start: first page view after clearing the cache 2.1.10 2.2
1,081 pages −49.8%
2.1.10 1,047 ms
2.2 526 ms
10,321 pages −49.8%
2.1.10 4,633 ms
2.2 2,326 ms
Median time to first byte for the home page right after bin/grav clear. Shorter is better.

Warm Starts, and Quiet Sites

On every request, Grav checks whether any page has changed since the cache was built. That's how an edit made over FTP or with a git pull shows up without you clearing anything. In 2.1.10 that check scanned the whole pages folder. Now it looks only at the folders and files Grav already knows about, and only one request at a time does the checking while the others use the last result. Plugin classes load faster and page ETags use a much quicker hash, too.

Back-to-back page views on the 10k site went from 69.9 ms to 18.2 ms. On the 1k site, from 20.8 ms to 16.5 ms.

But the number I care most about is the one after a few quiet seconds. Most Grav sites don't get a request every second. They get one, then nothing for a bit, then another. After a short pause Grav runs its change check again and PHP rechecks its own compiled files, so that "first request after a pause" is what a low-traffic site actually serves most of the time. It's also where 2.2 made the biggest difference.

A page view after a few quiet seconds 2.1.10 2.2
1,081 pages −66.8%
2.1.10 73.1 ms
2.2 24.3 ms
10,321 pages −85.0%
2.1.10 348 ms
2.2 52.3 ms
Median time to first byte for the home page after at least 3 seconds with no requests.

Editing and Saving

Two kinds of edits, two separate fixes.

If you edit page files directly, with a text editor, over FTP or with a git pull, Grav has to rebuild the pages cache. 2.1.10 re-read every page and folder to do that. 2.2 re-reads only what changed and reuses the page headers it read last time. On the 10k site the first request after an edit went from 856 ms to 510 ms.

Saving in the admin was the bigger problem, and it's the one that started all this. API 1.0.39 cleared Grav's entire cache on every page save, autosave included. So the next visitor after any save got a full cold start: 1,075 ms on the 1k site. Now a save just marks the pages as changed, and the next page view takes 67.9 ms. The save itself dropped from 160 ms to 29.4 ms too. Plugins that write pages can do the same thing by calling Pages::markChanged().

That old cache clear had a nasty side effect if you're on shared hosting. It also reset PHP's OPcache, and OPcache is shared by every site running on the same PHP pool. So every time you saved a page, every other site on that server lost its compiled code and had to start over. And every time your neighbours saved a page, so did yours. That's gone too.

The first page view after a change 2.1.10 2.2
Edit a page file, 1,081 pages −35.1%
2.1.10 145 ms
2.2 94.2 ms
Edit a page file, 10,321 pages −40.4%
2.1.10 856 ms
2.2 510 ms
Save in the admin, 1,081 pages −93.7%
2.1.10 1,075 ms
2.2 67.9 ms
Median time to first byte for the first request after changing a page's Markdown on disk, and for the first page view after saving a page through the API.

Big Sites and Memory

This is the one that surprised me. 2.1.10 loads one big cache holding every page, on every request. On the 10k site a single page view peaked at 241.8 MB of memory, and the test server's PHP limit was 256 MB. That's one request, not the whole server. It doesn't leave much room.

Grav 2.1 already had the fix for this sitting behind an experimental switch: the lazy page index. It keeps a small SQLite database with an entry for each page, and Grav loads only the pages a request needs instead of all of them. In 2.2 it's properly finished. Menus, taxonomy pages, collections and the sitemap load their pages in a few batches instead of one at a time, and writes to the index are batched too. Best of all, it's now on by default for any site with 1,000 pages or more. Smaller sites keep the classic pages cache they have today.

Same page, same HTML, byte for byte:

Peak memory for one page view, 10,321 pages 256 MB PHP limit
2.1.10 241.8 MB
2.2 9.6 MB
Each square is 1 MB, rounded, and the whole grid is the test server's 256 MB PHP memory limit. On the 1,081-page site the same page went from 26.3 MB to 7.6 MB.

The Admin

Admin 2 did a lot of small things on startup that added up. It made eight separate API calls for your preferences, profile, menus, plugin panels and languages. Then it downloaded the full translation dictionary, all 724 KB of it, on every single start, because API 1.0.39 didn't give the browser a way to know it hadn't changed.

Now the admin makes one /admin-next/boot request. The translations come with a checksum, so they're only downloaded when they've actually changed, and they're compressed when they are. On a return visit the whole startup is one request and 4.6 KB.

What the admin asks the API for when it starts 2.1.10 2.2
2.1.10 every visit
preferences me menubar sidebar floating-widgets context-panels custom-fields languages translations
728.8 KB
2.2 first visit
boot translations
220.8 KB
2.2 return visit
boot
4.6 KB
API requests and bytes over the wire when the admin starts, 1,081-page site. Right after a cache clear they took 734 ms on 2.1.10 and 339 ms on 2.2. Warm, on a return visit, 146 ms against 17.9 ms.

The admin's own code went on a diet as well. Form fields, editors and the uploader load only when a screen needs them, which takes the startup JavaScript from 688 KB to 172 KB compressed. The admin font is split by alphabet, so English and the other Latin-script languages download 66 KB instead of 1.43 MB. Big forms like the page editor and the system configuration build each tab when you open it, and the pages list only keeps the rows near what you're looking at, so scrolling through thousands of pages stays smooth.

A few API calls that were painfully slow after a cache clear aren't anymore. Opening a page full of photos used to wait while every thumbnail was resized. Now each thumbnail is made the first time it's shown, and the media list went from 1,107 ms to 473 ms. The dashboard used to stall while it downloaded the package list: 1,969 ms, now 487 ms. And translations, the sidebar and blueprint forms are all several times quicker on every visit.

All the Numbers

Every number is a median, and negative changes are faster or smaller. "1k" is the 1,081-page site and "10k" is the 10,321-page one.

WhatSite2.1.102.2Change
Page views
Cold start1k1,047 ms526 ms−49.8%
10k4,633 ms2,326 ms−49.8%
Cold start, four visitors at once (slowest)1k988 ms526 ms−46.7%
10k4,722 ms2,585 ms−45.2%
Warm start, home page1k20.8 ms16.5 ms−20.8%
10k69.9 ms18.2 ms−73.9%
Warm start, docs page1k22.1 ms17.2 ms−22.1%
10k71.0 ms18.3 ms−74.2%
Home page after 3 quiet seconds1k73.1 ms24.3 ms−66.8%
10k348 ms52.3 ms−85.0%
Editing and saving
First request after editing a page file1k145 ms94.2 ms−35.1%
10k856 ms510 ms−40.4%
Saving a page through the API1k160 ms29.4 ms−81.6%
Next page view after that save1k1,075 ms67.9 ms−93.7%
Memory (peak, one page view)
Home page1k26.3 MB7.6 MB−71.2%
10k241.8 MB9.6 MB−96.0%
Docs page1k26.3 MB8.8 MB−66.6%
10k241.8 MB10.8 MB−95.5%
Admin startup
Cold start1k734 ms339 ms−53.8%
Warm, first visit1k147 ms22.2 ms−84.9%
Warm, return visit1k146 ms17.9 ms−87.7%
API data, first visit1k728.8 KB220.8 KB−69.7%
API data, return visit1k728.8 KB4.6 KB−99.4%
Startup JavaScript, compressed1k688.0 KB172.0 KB−75.0%
Startup CSS, compressed1k50.9 KB51.9 KB+2.0%
Admin font, first visit1k1.43 MB66.4 KB−95.5%
API calls
Your profile (/me) after a clear1k581 ms264 ms−54.5%
Your profile, warm1k8.8 ms8.3 ms−5.7%
Translations, full download1k104 ms9.5 ms−90.9%
Translations, unchanged since last visit1k105 ms7.3 ms−93.0%
Translations, compressed size1k724.3 KB216.2 KB−70.1%
Sidebar1k54.7 ms8.0 ms−85.4%
System configuration form1k58.6 ms11.9 ms−79.7%
Page editor form1k62.5 ms10.2 ms−83.6%
Dashboard stats, warm1k32.2 ms17.9 ms−44.5%
Dashboard stats after a clear1k1,969 ms487 ms−75.3%
Page media list after a clear1k1,107 ms473 ms−57.3%
Media list plus its 4 thumbnails after a clear1k1,161 ms539 ms−53.6%
Page media list, warm1k14.0 ms8.1 ms−42.5%
Media list plus thumbnails, warm1k49.7 ms43.6 ms−12.2%
500 pages in one list1k20.5 ms19.4 ms−5.6%
500 pages in one list, compressed1k20.7 ms22.4 ms+7.9%
500 pages, compressed size1k645.3 KB98.9 KB−84.7%
500 pages, summary fields only, compressed1k20.3 ms19.9 ms−2.0%

The One Number That Went the Wrong Way

Asking the API for 500 pages at once, compressed, got 1.7 ms slower: 20.7 ms to 22.4 ms, or +7.9%. That's because 2.2 actually compresses it now. 2.1.10 ignored the request for compression and sent 645.3 KB. 2.2 sends 98.9 KB. The benchmark runs on one machine, where there's no network to save time on, so the compression is pure extra work for the CPU. Over any real connection, 546 KB less to download is worth far more than 1.7 ms. I'd take that trade every time.

Two sizes grew a little as well. The admin's startup CSS is 1 KB bigger compressed (+2.0%), and the uncompressed translation dictionary is 2.1 KB bigger because it carries newer strings.

New Settings

You don't need to touch any of these. But if you like to tune things:

  • pages.lazy_index now defaults to auto, which turns the lazy page index on at 1,000 pages. Set it to true or false to force it either way. If your server has no database engine for it, Grav falls back to the classic cache on its own.
  • cache_modules: true in a modular page's header caches the output of its modules. Modules with their own Twig or a form, logged-in visitors and form submissions are always rendered fresh.
  • session.lazy (off by default) only starts a session when a visitor actually needs one: a login, a form, a message. Anonymous page views then go out without a session cookie, so a proxy or CDN like Cloudflare can cache them. Login 3.9.11 and KahunaCart 1.2.15 were updated so visitors who are only browsing don't get a cookie from them either. Try it on a copy first: data a plugin writes straight to $_SESSION isn't kept for a visitor who has no session yet.
  • pages.frontmatter.native_yaml (off by default) reads page frontmatter with PHP's much faster yaml extension, if your server has it. It's opt-in because that extension reads some values differently: an unquoted date stays text, and yes and no become true and false. Admin 2 has a toggle for it in the system configuration.
  • The API has a new Response Compression setting that compresses large responses for clients that accept it. It's on by default.
YAML
# user/config/system.yaml
pages:
  lazy_index: auto        # true, false, or auto (on at 1,000+ pages)
  frontmatter:
    native_yaml: false    # true to read frontmatter with the yaml extension
session:
  lazy: false             # true to skip the session cookie for anonymous visitors

How We Measured

The short, honest version:

  • One machine: an Apple M4 Max running PHP 8.3 FPM with OPcache (JIT off) behind Apache 2.4, the same way a real host serves a site. The client (curl) ran on the same machine, so there's no network time in any of these numbers. That's why the savings in download size don't show up as time.
  • Times are the server's time to first byte, without the TLS handshake. Memory is PHP's peak for a single page view.
  • The 1k site is kahunacart.com's real content. The 10k site is the same site with its 770-page docs section copied twelve times, and every copy's title changed so nothing could be skipped as a duplicate. It's a size test, not a real 10,000-page site.
  • A cold start means the first request after bin/grav clear, which empties Grav's caches but leaves PHP's compiled code alone, just like it does on a real server.
  • Before and after took turns on every sample, and the order flipped each round, so any background noise hit both sides equally. Each number is the median of 5 to 15 runs.
  • Both sides ran the same plugins, content and PHP extensions.

Your numbers will be different, since your server, your content and your plugins aren't mine. Small sites will see the smallest gains on warm page views. The bigger the site, the bigger the difference.

Upgrading

Warning

Always backup your site, or better yet, create a copy of your site and test upgrades there first.

Grav 2.2 upgrades in place from any 2.0 or 2.1 release.

BASH
bin/gpm selfupgrade
bin/gpm update

Or use the Updates screen in the admin. Update the API plugin to 1.0.40 and Admin 2 to 2.1.23 at the same time. They still work with older versions of each other, but the one-request admin startup, the lighter page lists and saves that no longer clear the cache need all three. If you use Login or KahunaCart and want to try lazy sessions, update those too.

One thing changes on its own. If your site has 1,000 pages or more, the lazy page index switches on after the upgrade. If anything looks off, set pages.lazy_index: false and let us know.

Getting Help

If you hit anything, the Discord chat is the fastest place to get an answer.

And if you run a big Grav site, I'd really like to hear what 2.2 does for it. Send me your before and after numbers!

— Andy

Related posts

Keep reading