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.
4,633 → 2,326 ms at 10k
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.
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.
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.
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:
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.
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.
| What | Site | 2.1.10 | 2.2 | Change |
|---|---|---|---|---|
| Page views | ||||
| Cold start | 1k | 1,047 ms | 526 ms | −49.8% |
| 10k | 4,633 ms | 2,326 ms | −49.8% | |
| Cold start, four visitors at once (slowest) | 1k | 988 ms | 526 ms | −46.7% |
| 10k | 4,722 ms | 2,585 ms | −45.2% | |
| Warm start, home page | 1k | 20.8 ms | 16.5 ms | −20.8% |
| 10k | 69.9 ms | 18.2 ms | −73.9% | |
| Warm start, docs page | 1k | 22.1 ms | 17.2 ms | −22.1% |
| 10k | 71.0 ms | 18.3 ms | −74.2% | |
| Home page after 3 quiet seconds | 1k | 73.1 ms | 24.3 ms | −66.8% |
| 10k | 348 ms | 52.3 ms | −85.0% | |
| Editing and saving | ||||
| First request after editing a page file | 1k | 145 ms | 94.2 ms | −35.1% |
| 10k | 856 ms | 510 ms | −40.4% | |
| Saving a page through the API | 1k | 160 ms | 29.4 ms | −81.6% |
| Next page view after that save | 1k | 1,075 ms | 67.9 ms | −93.7% |
| Memory (peak, one page view) | ||||
| Home page | 1k | 26.3 MB | 7.6 MB | −71.2% |
| 10k | 241.8 MB | 9.6 MB | −96.0% | |
| Docs page | 1k | 26.3 MB | 8.8 MB | −66.6% |
| 10k | 241.8 MB | 10.8 MB | −95.5% | |
| Admin startup | ||||
| Cold start | 1k | 734 ms | 339 ms | −53.8% |
| Warm, first visit | 1k | 147 ms | 22.2 ms | −84.9% |
| Warm, return visit | 1k | 146 ms | 17.9 ms | −87.7% |
| API data, first visit | 1k | 728.8 KB | 220.8 KB | −69.7% |
| API data, return visit | 1k | 728.8 KB | 4.6 KB | −99.4% |
| Startup JavaScript, compressed | 1k | 688.0 KB | 172.0 KB | −75.0% |
| Startup CSS, compressed | 1k | 50.9 KB | 51.9 KB | +2.0% |
| Admin font, first visit | 1k | 1.43 MB | 66.4 KB | −95.5% |
| API calls | ||||
Your profile (/me) after a clear | 1k | 581 ms | 264 ms | −54.5% |
| Your profile, warm | 1k | 8.8 ms | 8.3 ms | −5.7% |
| Translations, full download | 1k | 104 ms | 9.5 ms | −90.9% |
| Translations, unchanged since last visit | 1k | 105 ms | 7.3 ms | −93.0% |
| Translations, compressed size | 1k | 724.3 KB | 216.2 KB | −70.1% |
| Sidebar | 1k | 54.7 ms | 8.0 ms | −85.4% |
| System configuration form | 1k | 58.6 ms | 11.9 ms | −79.7% |
| Page editor form | 1k | 62.5 ms | 10.2 ms | −83.6% |
| Dashboard stats, warm | 1k | 32.2 ms | 17.9 ms | −44.5% |
| Dashboard stats after a clear | 1k | 1,969 ms | 487 ms | −75.3% |
| Page media list after a clear | 1k | 1,107 ms | 473 ms | −57.3% |
| Media list plus its 4 thumbnails after a clear | 1k | 1,161 ms | 539 ms | −53.6% |
| Page media list, warm | 1k | 14.0 ms | 8.1 ms | −42.5% |
| Media list plus thumbnails, warm | 1k | 49.7 ms | 43.6 ms | −12.2% |
| 500 pages in one list | 1k | 20.5 ms | 19.4 ms | −5.6% |
| 500 pages in one list, compressed | 1k | 20.7 ms | 22.4 ms | +7.9% |
| 500 pages, compressed size | 1k | 645.3 KB | 98.9 KB | −84.7% |
| 500 pages, summary fields only, compressed | 1k | 20.3 ms | 19.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_indexnow defaults toauto, which turns the lazy page index on at 1,000 pages. Set it totrueorfalseto 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: truein 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$_SESSIONisn't kept for a visitor who has no session yet.pages.frontmatter.native_yaml(off by default) reads page frontmatter with PHP's much fasteryamlextension, if your server has it. It's opt-in because that extension reads some values differently: an unquoted date stays text, andyesandnobecometrueandfalse. 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.
# 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.
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