We invited an independent contractor to measure the performance of a Craft CMS demo site and the impact of using Blitz, paying for their time and agreeing to publish their words without further influence or agenda. This is what they wrote.

Load testing craft cms with blitz

You’re probably aware that Blitz can improve your Craft CMS site’s front-end performance. It says so in its marketing, and the plugin’s popularity suggests it’s doing something. In this post we’ll take an existing Craft site, see what Blitz can do with it, and see whatever difference it makes.

Crop from The Musuem for Euro Art homepage, with a giant title and two paintings

We’ll use the official Craft CMS Europa Museum demo to measure performance with and without Blitz. We’ll also look at what’s involved to get Blitz working and see if it’s worth the effort. This should be a well-rounded exploration of what a performant website is, with steps you can reproduce on your own.

What We’re Doing #

We’re going to fork a copy of the Europa demo, bring it up to date, and get it running on a Digital Ocean droplet provisioned with Ploi. We’ll look at how it performs, then move through a few rounds of optimization while testing again in each phase:

  1. Examine the initial base project.
  2. Implement native Craft {% cache %} tags.
  3. Switch from Craft’s cache tags to static caching with Blitz.
  4. Skip PHP altogether using Blitz server rewrites.

We deliberately won’t be using a CDN for most of our tests. While you’d want a CDN in production, services like Cloudflare and CloudFront come with features that limit excessive traffic and mess with our results. We want to keep our tests focused squarely on what our single web server can handle before we’ve layered services on top of it.

Setup #

Before we can get started, we’ll need to get our Craft site ready and run it on a web server. We’ll use Ploi to provision a Digital Ocean droplet and run our Europa fork.

We’ll also set up an instance of Locust on a separate server to do some basic load testing and see what happens when the web server gets busy.

These next sections walk through the test setup for anyone curious or wanting to do this themselves, but you can skip to Performance Testing for the results if you’d rather.

Base Project #

First we grab our own copy of the project to play with, and adjust dependencies so we can update it.

  1. Visit craftcms/europa-museum on GitHub and click Fork.
  2. Clone your forked copy locally:

     git clone https://github.com/your-username/europa-museum.git
  3. Replace DynamoDB with redis for cache, queue, and sessions:

     composer remove craftcms/yii2-dynamodb
     composer require yiisoft/yii2-redis
  4. Update Craft and its requirements:

     composer update
  5. While we’re here, open config/app.php to drop the Bugsnag component and support configuring redis with environment variables:

     use craft\\helpers\\App;
     use modules\\demos\\Module;
     $components = [];
     if (App::env('REDIS_HOSTNAME')) {
         $components = array_merge(
                 'redis' => [
                     'class' => yii\\redis\\Connection::class,
                     'hostname' => App::env('REDIS_HOSTNAME'),
                     'port' => App::env('REDIS_PORT') ?? 6379,
                     'database' => App::env('REDIS_DEFAULT_DB') ?? 0,
                 'cache' => [
                     'class' => yii\\redis\\Cache::class,
                     'redis' => [
                         'hostname' => App::env('REDIS_HOSTNAME'),
                         'port' => App::env('REDIS_PORT') ?? 6379,
                         'database' => App::env('REDIS_CRAFT_DB') ?? 1,
                 'session' => [
                     'class' => yii\\redis\\Session::class,
                     'as session' => [
                         'class' => \\craft\\behaviors\\SessionBehavior::class,
     return [
         'modules' => [
             'demos' => Module::class,
         'bootstrap' => ['demos'],
         'components' => $components
  6. Commit your changes and push them back to GitHub.

That will be enough to get an up-to-date version of the Europa site running. Now we can set up the variations of the site we intend to test.

Cache Tag Variant #

Our first variation of the Europa demo will use Craft’s native {% cache %} tags. These can be used to cache chunks of templates and reduce database queries on subsequent page requests. This built-in method of improving performance doesn’t require Blitz and it’s fairly straightforward to implement.

When using cache tags, you need to be careful with any dynamic output in your templates, like CSRF tokens for forms or conditional presentation changes that depend on the user or request.

I used a {% cache globally %} tag for the site’s unchanging header and footer templates, and a smattering of {% cache %} tags for most others — skipping the Matrix blocks on the contact page that currently include a contact form. One nice thing about the cache tag approach is that Craft’s still the one handling the request, so any part of a template not wrapped in {% cache %} tags will continue to work exactly the same.

I wrapped the header and footer pieces with a global cache tag like this:

{% cache globally %}
  {% include "_/partials/header" %}
{% endcache %}

I wrapped most other template innards with simple cache tags like this:

{% block content %}
  {% cache %}
    {# All the other stuff on the page #}
  {% endcache %}
{% endblock %}

This will probably take 15 – 30 minutes at most.

If you’re following along, commit these updates to a separate branch and push that to your GitHub repository for later.

Blitz Variant #

I switched back to my main branch to start the next variation, installing Blitz (at the latest version 4.2.3) to enable static caching.

Install Blitz like any other plugin:

composer require putyourlightson/craft-blitz
php craft plugin/install blitz

Blitz will now wait patiently until we tell it to do something. We’ll have it cache all pages by adding the following to a new config/blitz.php file:

return [
    'cachingEnabled' => true,
    'includedUriPatterns' => [
            'siteId' => 1,
            'uriPattern' => '.*',

(You could accomplish the same thing via the control panel just like the Blitz docs demonstrate.)

This tells Blitz to cache every URL (.*) for our primary site (1). At this point it should be, so if you happen to be running the site locally you can view the source of any front-end page, scroll to the bottom and see these comments after the closing </html> tag:

<!-- Cached by Blitz on 2022-12-06T13:33:09-08:00 -->
<!-- Served by Blitz on 2022-12-06T13:33:36-08:00 -->

Cached by is telling us Blitz did indeed generate a static cache for the current page and we’re looking at it. Served by is telling us that Blitz also delivered us the page (via Craft), which we’ll address in our final variation.

But first we need to un-mangle the contact form.

Contact Form

A tight crop of the Europa Museum’s contact form under a “How can we help?” heading with fields for First Name, Last Name, and Email

Unlike Craft’s cache tags, we can’t partially opt out of full-page caching. It’s an all-or-nothing kind of deal.

So the form on the Contact page will have its hidden CRAFT_CSRF_TOKEN and Freeform formHash values frozen in time when the cache is generated, meaning nobody will be able to submit the form anymore unless we disable CSRF protection.

Should we disable CSRF protection? If you grimaced a little and whispered of course not,” you are correct. Let’s fix it.

What we can do is let that static page do its thing and use a separate request to grab fresh form tokens. This is a common approach, demonstrated in the Freeform docs and for which Blitz provides options.

The front-end part of Freeform’s example requires jQuery, which we’re not using, so I loosely followed the instructions there for something more concise:

  1. Create a freeform-csrf.twig template that’ll be a JSON endpoint we can query:

     {% header "Content-Type: application/json" %}
     {% set form = craft.freeform.form(craft.app.request.get('form')) %}
     {{ {
       hash: form.hash,
       honeypot: craft.freeform.honeypot(form),
       csrf: {
         name: craft.app.config.general.csrfTokenName,
         value: craft.app.request.csrfToken,
     }|json_encode|raw }}
  2. Update the _/components/blocks/form.twig template so it uses an AJAX request to update the Freeform hash and CSRF token:

     {# Refresh form has and CSRF #}
     fetch('/freeform-csrf?form={{ theBlock.form.id }}')
       .then(response => response.json())
       .then(data => {
         const form = document.querySelector('#{{ freeformId }} form');
         form.querySelector('input[name=formHash]').value = data.hash;
         form.querySelector('input[name=' + data.csrf.name + ']').value = data.csrf.value;

Writing articles I can create an illusion of deftness and competence, but it took me about 45 minutes to get everything in place and whittled down to the point where forms kept working.

This brings us to the issue of the site’s landing page.

Landing Page

Tight crop of the Europa Museum’s Featured Exhibits section on the homepage, with a “Featured Exhibits” heading at center and four paintings with captions floated asymmetrically on top of and around it

The second major section of the homepage is a clump of four Featured Exhibits”, which are randomized as they’re queried in their Twig template. When we statically cache the page, that result is frozen in place and no longer random on each request.

My first stop was to move the exhibit Twig bits into their own template, and load that with {{ craft.blitz.getTemplate('my-template', { entry }) }}. But I don’t think Blitz was counting on me passing something as complex as an Entry element as a parameter, because the included template wasn’t convinced that entry.featuredExhibits existed.

It would have been possible to query the homepage entry directly in the new template instead of passing the entry parameter, except that it would have been loaded with AJAX and missed out on the page’s lazy loading and fancy scroll transitions that get initialized on page load.

Not willing to accept a tradeoff for visual presentation, I went where we all have to go sometimes: back to the front end.

You, dear reader, would have taken less time because you’re better at writing lovely and remarkably terse vanilla JavaScript. With hamfisted determination, I eventually figured out how to move the element-shuffling logic into JavaScript, reordering those exhibition items on the fly and making sure they had the right CSS classes and transition-speed markup.

Altogether I probably spent an hour and a half getting those two dynamic items — the contact form and the homepage-randomizing — ready for full-page caching.

Blitz Variant with nginx Rewrite #

With the Blitz branch ready, the final testing variation was quick.

In this one we use a server rewrite that lets us completely skip PHP when Blitz has a static file ready, having nginx send the file contents directly instead.

There’s nothing to change in your repository; this is handled completely on the server with an edit to the nginx configuration.

Just so you’re not in suspense, it means using Ploi’s control panel to visit Sitesyour siteManage, and click NGINX configuration. We’ll make two adjustments:

  1. Just above the existing location / { line, add this:

     set $cache_path false;
     if ($request_method = GET) {
         set $cache_path /cache/blitz/$host/$uri/$args/index.html;
     if ($args ~ "token=") { set $cache_path false; }
  2. Within that location block, add $cache_path just after try_files:

     # try_files $uri $uri/ /index.php?$query_string;
     try_files $cache_path $uri $uri/ /index.php?$query_string;

Click Save, then Test configuration to the right (safety step!), and finally Deploy to make the change live.

We’re telling nginx we’d like a $cache_path variable. For GET requests, this variable will point to where we’d expect a Blitz .html cache file to live by default. (A token parameter in the URL will disable this behavior.)

When nginx is trying to figure out where to grab a response, it’ll now check $cache_path before looking at the file system or passing off to PHP. If $cache_path is false, nginx will move on and try the next thing, but an appropriate HTML response will do the trick. If the cached .html file is there, nginx sends its contents back without needing to further check local files or hand off to PHP.

You can verify this is working by visiting a page on the front end and looking for the HTML comments we saw earlier. There should only be a Cached by line, and not a Served by one:

<!-- Cached by Blitz on 2022-12-06T13:33:09-08:00 -->

If you need more proof, dig into web/cache/blitz/site.tld/, pop open an HTML file, and add your own sassy comment. You should find your edit on the corresponding page in your browser, which may need a hard refresh.

Even though we need to do this on the web server, it should take less than five minutes.

Web Server #

Now our site’s ready to test in different configurations, so we can scoot it out onto the internet.

We’ll need two servers: one for the Europa site and another to run Locust for load testing, because we don’t want the site and the load-tester sharing resources and muddling our results.

In the course of writing this article, I used nine servers: a 2‑core NVMe VPS for running Locust, along with two from Katapult (pure curiosity), and six from Digital Ocean. I’ll share some of what I learned trying all these different servers, but we’ll stick to one test case to limit the word count here.

Every Digital Ocean server I used for the results below had the exact same specs. I learned not every server — despite identical location, specs, and time of day — performs identically, but we’ll come back to that later.

  • 2 CPUs
  • 2GB of memory
  • Ubuntu 20.04
  • San Francisco 3

When provisioning with Ploi, I picked modern options for that demo site:

  • Server type: Server
  • Webserver: NGINX
  • PHP version: 8.1
  • Database: PostgreSQL 14
Screenshot of settings for a new Ploi server as described above
Whipping up a new server with Ploi.

With the server ready, I configured a few common things to make Craft happy (green checks in UtilitiesSystem ReportRequirements) and production-ready:

  • Increased PHP’s memory limit to 256MB
  • Increased PHP’s timeout to 120 seconds
  • Enabled OPcache

Then it was a matter of getting the site set up with its supporting pieces in place:

  1. Add a database under Serversyour server nameDatabases. I called mine craft and left the other fields blank, using credentials from the server provision email.
  2. Create a site under Serversyour server nameSites. Use one of your spare domain names (I know you have some), setting Web directory to /web.
  3. Set a DNS A record to point your chosen domain name to the server’s IP address, easily copied from the top left of Ploi’s control panel.
  4. Add a site repository pointed to your fork.
  5. Run composer install.
  6. Set your environment variables. You’ll need to update the environment, database settings, and site URL, and specify redis details.

     # Change to production!
     # We’ll generate this in the next step
     # Database
     # Use your testing domain here
     # Use these verbatim for redis
  7. Optionally daemonize redis to handle the queue. Disable the AJAX queue with an environment variable:


    Create a daemon at the server level that runs as ploi with 1 process:

     /usr/bin/nice -n 10 /usr/bin/php8.1 /home/ploi/test-domain.tld/craft queue/listen --verbose
  8. Set up SSL under Sitesyour site nameSSL, by clicking Add certificate.

  9. SSH in to generate a security key, restore the included database dump, and create an admin user for yourself:

     php craft setup/security-key
     php craft db/restore seed.sql
     php craft users/create

If you can pop by your URL, you’ve got a site to test! You can create a test server for each phase, or use the same one to switch branches and redeploy.

I did all this eight times and got pretty efficient at it toward the end, but you might plan on it taking 30 minutes or so.

Load testing will just mean pointing at a URL we’ve prepared, so we’ll shuffle the site around for each configuration we test:

  1. The base setup we got ready.
  2. The native cache tag variation.
  3. The Blitz-installed statically-cached variation.
  4. That third item, plus the nginx rewrite we looked at above.

Load Testing #

Screenshot of Locust’s browser interface while running a test: host URL, “spawning” state, 559.1 RPS, and 84% failures, with running charts below for Total Requests per Second, Response Time (Ms), and Number of Users
A load test in progress.

Setting up a VPS to run Locust is almost painless.

The server should have Python installed, so you’ll need to grab a copy of Locust, increase the system’s max-open-file limit so Locust can do its thing, and write a brief script for your simulated user.

I used Locust 2.8.6 for everything you’ll see in the results. Following the Locust installation instructions, SSH into your droplet and install with pip3:

sudo apt-get install python3-pip
pip3 install locust

Locust’s executable is added to /home/ploi/.local/bin, so for convenience you’ll want to add that to your path or shell profile:

export PATH=/home/ploi/.local/bin:$PATH

You can run Locust now, but you’ll get a warning about your system open file limit”:

System open file limit '1024' is below minimum setting '10000'.

Locust needs to be able to open lots of files while it conducts its tests. You can see what the limit is on your machine like this:

ploi@steve:~$ cat /proc/sys/fs/file-max
ploi@steve:~$ ulimit -aS
core file size          (blocks, -c) 0
data seg size           (kbytes, -d) unlimited
scheduling priority             (-e) 0
file size               (blocks, -f) unlimited
pending signals                 (-i) 7883
max locked memory       (kbytes, -l) 65536
max memory size         (kbytes, -m) unlimited
open files                      (-n) 1024
pipe size            (512 bytes, -p) 8
POSIX message queues     (bytes, -q) 819200
real-time priority              (-r) 0
stack size              (kbytes, -s) 8192
cpu time               (seconds, -t) unlimited
max user processes              (-u) 7883
virtual memory          (kbytes, -v) unlimited
file locks                      (-x) unlimited

You can raise the limit as the root user by editing /etc/security/limits.conf and /etc/pam.d/common-session*, then restarting your shell session.

Lastly, visit Ploi’s Network section and allow Locust’s TCP port 8089 so we can use its browser GUI.

Tightly-cropped screenshot of Ploi’s server firewall, with an entry for Locust on TCP port 8089

Now you can SSH into the droplet and run locust with its default test script. I very simply edited that script to navigate to each major page/​layout on the Europa site:

from locust import HttpUser, task

class HelloWorldUser(HttpUser):
    def hello_world(self):

Press Control+C to stop the locust process and run it again. It’ll use your updated script for testing, which you can verify as the results accumulate.

Performance Testing #

Browser Metrics #

Most of us are used to thinking about a single user’s experience from their browser. I used KeyCDN’s Website Speed Test to score the homepage from San Francisco:

Testing ConfigurationScoreTTFBTime
Stock (unoptimized)77 – C1.05 s2.45 s
Cache Tags83 – B241.35 ms1.57 s
Blitz83 – B108.51 ms1.4 s
Blitz + Rewrite83 – B33.4 ms242.25 ms

The on-page score doesn’t change much because the assets and locations they’re served from are identical (with no CDN involved). What does change is how fast the web server is able to get to its initial response, which impacts the total page load time.

It’s immediately clear that any effort optimizing is worth it, with native cache tags significantly dropping the time to first byte and initial load time. Blitz’s static cache is zippier than native cache tags, but the rewrite pulls significantly ahead.

Things get more interesting with the load tests.

Load Tests #

Browser metrics give us a sense of performance for one site visitor, and load tests help us see how the server behaves with a bunch more visitors. You probably don’t expect one user at a time, but how much traffic can your infrastructure actually support?

To finally load test each configured site, I’d SSH into the VPS running Locust and run locust. This starts a server on port 8089 ready with the default test script, so you can visit https://my-locust-server.tld:8089 to use the web GUI.

For each test, I specified 1,000 users at a rate of 1 per second, giving it the URL for the Europa front end.

Tight crop of Locust’s browser interface with a “Start new load test” heading and three inputs with settings added: Number of users (1000), Spawn rate (1), and Host (https://europa.foo), followed with a button labeled “Start swarming”
Settings for another new load test.

They’re pretty interesting to watch.

First 30 seconds of a load test (sped up), with VPS vitals at right.

Control #

I set up a statically-generated Astro site and load-tested it as a control, assuming nothing would be faster or more straightforward than its plain HTML files. While I repeated this control test on different servers, each one looked like this:

Performance slowed and got a bit shaky about halfway through, but no failures and response times stayed under three seconds:

Stock (unoptimized) #

The stock configuration handles about 2.5 requests per second until things fall apart at 388 users. At 463 users the web server gets really efficient about serving (only) errors:

Cache Tags #

There’s a slow start as Craft is generating its template caches on demand, with requests-per-second similar to the unoptimized stock site.

The cache tags start paying off, though. We get a healthy boost that’s not obvious when visually compared to the previous chart: requests per second stay around 10 – 12 for a while, which is at least four times the unoptimized version. Initial bumps come at 396 users, with a smacking belly flop at 526:

Bear in mind that response times rapidly got uncomfortable, similarly to the unoptimized setup:

Blitz #

Like the cache tags, Blitz has a slow start as it generates its caches on demand. (I did this on purpose, even though Blitz gives you the option of pre-generating its caches.)

Once it’s warmed up, Blitz handles 16 – 20 requests per second until failures start creeping in at 402 users. We’re over a cliff again at 522 users.

Response times are slightly better but it’s not saying much:

Blitz + Rewrite #

Without pre-warming, the server-rewrite variant has a slow, bouncy start before vastly outperforming the previous configurations and holding steady. Initial requests per second are again at a PHP-ish 3 – 10, but at 68 users they soar to 709 and gradually shrink to a mere 606. Zero failures.

The response times are pretty wild while Blitz is generating its caches, but they stabilize and slow gently under the pressure of more users:

I didn’t think it was fair to compare warmed caches against Craft’s cache tags since Craft doesn’t have a native self-warming ability. But now that we’ve compared apples, here’s the (pre-warmed + load-tested) orange:

This looks roughly like the control test. In fact, to my lasting astonishment, Blitz + Rewrite (warmed) manages to outperform the Astro test by a healthy margin. Blitz peaks at 723 requests per second while a simpler, smaller site doesn’t break 521. I spun up more servers and re-ran tests only to keep finding the same thing. In both cases it’s down to nginx returning HTML, and yet performance is not the same. Surely there’s a reason, I just don’t know what that is yet.

While the difference in scale is alarming, it’s interesting seeing each test’s requests per second together:

The Astro result still confounds me, but it’s clear that nginx can sling static files more efficiently than PHP:

CDN Response Times #

Screenshot of a world map with dots scattered across every continent, each with a response time in milliseconds and “0% loss”—numbers generally grow from 10ms to 250ms the further you get from San Francisco in the United States
A bunny​.net Global Latency Test without a CDN. Use response times to find the origin!

Looking briefly at global response times will illustrate why using a CDN and static caching can be an important benefit for a global audience.

I used a nice tool from SpeedVitals to measure the test site’s response time from around the planet.

The test servers I used were all in San Francisco (United States). A global ping test reveals that the further the pinger, the longer the response. This is the Bare TTFB (time to first byte) below. That number is how long it takes for the first byte of content to come back to the browser. Behind the scenes, the request has to be routed through a network to whatever can give back a response. We have to wait while the call moves through the tubes to the origin server, the origin server comes up with a response, and the response makes its return trip back to our browser.

Turning on Cloudflare proxying is simple, and it improves things a little bit by replicating whatever parts of the site it can throughout a network of edge nodes. Whenever possible, each pinger gets a response from something closer and therefore faster. Less tube time. This is the Proxied TTFB below.

When we use a Page Rule to tell Cloudflare to cache everything, we get a more dramatic improvement. This means that when Cloudflare returns content it will honor its cache tag and keep a copy right at the edge, when appropriate, and only make the long trip back to the origin when necessary. Once we’ve told Cloudflare to cache everything and made sure our server sends appropriate cache headers, the San Francisco server starts seeing less traffic while the entire planet has a far better experience with the site. This is the Cached TTFB below.

Screenshot of Cloudflare’s Rules for your-domain.dev, with fields set for URL (https://your-domain.dev/) and Cache Level (Cache Everything) followed by buttons for Cancel and Save Page Rule
Adding a Cloudflare Page Rule to cache everything.

Once that cache gets warmed up by repeated visits, responses are more consistently cache hits from each nearby edge. The response times make it look like we have a server close to each pinger, which is basically what’s happening. This is the Cached + Warmed TTFB below: all cache hits from each Cloudflare edge.

LocationBare TTFBProxied TTFBCached TTFBCached + Warmed TTFB
London, UK4.4 s4.2 s618 ms71 ms
Paris, France4.2 s3.8 s619 ms73 ms
Sweden5 s3.5 s716 ms147 ms
Finland2.6 s1.8 s846 ms240 ms
Belgium2.2 s2.1 s678 ms75 ms
Madrid, Spain3.6 s3.2 s732 ms119 ms
Milan, Italy3.9 s3.5 s689 ms115 ms
Netherlands4.8 s4 s621 ms69 ms
Warsaw, Poland2.3 s2.9 s756 ms77 ms
Frankfurt, Germany4.7 s4.5 s657 ms92 ms
Zurich, Switzerland4.1 s3.7 s819 ms106 ms
Las Vegas, US3.9 s3.4 s336 ms90 ms
Los Angeles, US4 s3.7 s182 ms90 ms
Iowa, US3.2 s3.5 s386 ms114 ms
South Carolina, US3.8 s4.5 s485 ms150 ms
Northern Virginia, US4.5 s4 s469 ms67 ms
Oregon, US3.4 s3.7 s310 ms162 ms
Dallas, US3.6 s3.2 s349 ms157 ms
Montréal, Canada4.1 s2.6 s374 ms134 ms
Toronto, Canada4.3 s3.4 s360 ms89 ms
São Paulo, Brazil4.1 s4.9 s746 ms46 ms
Santiago, Chile4.4 s3.4 s692 ms55 ms
Asia Pacific & Africa
Mumbai, India4.1 s4.5 s988 ms328 ms
Delhi, India5 s4.2 s1.2 s267 ms
Taiwan4.5 s5.2 s846 ms100 ms
Hong Kong5.4 s3.8 s757 ms49 ms
Tokyo, Japan4.4 s3.8 s485 ms34 ms
Osaka, Japan4.8 s3.7 s511 ms56 ms
Seoul, South Korea4.4 s4 s656 ms202 ms
Singapore3.7 s3.7 s913 ms91 ms
Jakarta, Indonesia4.2 s4.1 s713 ms130 ms
Sydney, Australia2.3 s2.3 s627 ms47 ms
Melbourne, Australia2 s1.8 s702 ms104 ms
Tel Aviv, Israel1.6 s1.8 s776 ms356 ms
South Africa2.4 s2 s1.2 s214 ms

Time to first byte is only one aspect of web performance, and some argue it’s a worthless number. In this case, however, it illustrates the benefit of having a fast-responding web server and a warmed CDN.

Side Quests #

I ran into a few unexpected adventures while testing.

Server != Server #

I found that not every Digital Ocean droplet performs the same, even when it’s got the same specs and runs in the same region at the same time of day. I load-tested the statically-generated Astro site to make sure a VPS would behave as expected before running real” tests, which made it clear that one was just weirdly slow. Spun up another which was fine. 🤷‍♂️

Pool Tuning #

It’s clear from load-testing results that getting PHP involved is a slowdown.

So I spun up a separate, not-yet-mentioned VPS with 16 CPU cores and 48GB of memory in the name of responsible science. I ran a load test and watched the system’s CPU and memory usage. To my surprise, results looked similar to the other systems except that the CPU usage never got anywhere near 100%. PostgreSQL seemed to yawn. PHP struggled.

Side-by-side screenshots of server process lists, with very few `php-fpm` processes at left and almost exclusively `php-fpm` processes at right
Far fewer php-fpm processes ready for action with the default pool settings at left. At right, max_children increase readies a bunch more request-takers.

The takeaway is that PHP’s pool settings play an important role for the server’s throughput, and they’re easy to change. Follow these instructions with some simple math and you could get a healthy boost that better fits your server’s resources.

I modestly increased my max_children from 5 to 200, the results of which were clearly better despite being less dramatic than I’d hoped.

Upper Limit #

Anybody watching a site happily accommodate 1,000 pretend users without sweating naturally wonders what it’d take to melt the web server.

So I took the Blitz + Rewrite site variant and load-tested again with 10,000 concurrent users and a spawn rate of 10 per second rather than one.

The result? The server remains stable until 1,750 users, when response times start to grow. By 2,650 users responses take over 10 seconds. Approaching 5,000 users, we amazingly have a mere 2% failure rate, but response times were abhorrent and I ended the test.

If you can get a similarly-spec’d server to handle more than 2,500 users with okayish response times, I’d love to know how!

Things Not Done #

There’s still more that Blitz can do for performance we’ve not tested here. It sends appropriate cache headers automatically, and it has the ability to handle compression via gzip and Brotli so it’s not something the web server needs to waste time and resources handling on the fly. It can generate and deploy a static site for you, if that’s your thing.

Blitz 4.3 is bringing support for server side includes (SSI) and edge side includes (ESI), which are static-cache-friendly template partials that can reduce the surface area of what needs to be regenerated.

I completely ignored Blitz Hints — plugin feature and free standalone package—and getting into more careful optimization.

In a perfect world we’d continue on to eager-loading, tweaking your queue and warming settings for optimal performance, and load testing on different kinds of infrastructure — but the tests above took a significant amount of time and hopefully provide some insight and methods you might run with!

Blitz Impact #

You don’t need Blitz to have a performant website.

You can use a static site generator like Astro or Gatsby and deploy directly to a CDN, in exchange for either doing without Craft’s content modeling, or wrangling a headless Craft setup for live preview and any direct client-server interactions your site needs.

You can scale up your hosting infrastructure, in exchange for whatever it costs to configure and maintain. Tuning your web server can improve throughput for its resources. We’ve tested with a single server here, but you can run a bigger server or load-balance to spread traffic across several of them.

You can cache wildly at the edge! Cloudflare can cache your entire site right now with very little work and it’ll be screaming fast, but you’ll need to solve for dynamic content and find a way to purge relevant caches at the right times. You can use the Cloudflare plugin to help with static edge caching if you can live with imprecise invalidation and no multi-site and no ability to generate fresh local caches.

You can use web server features like nginx cache fragments as Andrew demonstrates, and figure out how to handle invalidation on your own.

Blitz doesn’t do anything that can’t be achieved in some other way, it just sits in a sweet spot that’s tightly integrated with Craft and able to support exactly the amount of caching you need in your project — without major changes, if any, to your underlying infrastructure. Every approach comes with tradeoffs, and it offers a careful balance of concerns with ongoing development and support from one of the Craft CMS landscape’s most thoughtful and reliable developers.

I used Blitz for some of my own client projects well before this testing adventure. I was happy to investigate what it would do with a project anyone can look at, and surprised by the magnitude of difference. It took me about an hour more to prep the site with Blitz (versus cache tags) and it would’ve additionally required a $99 license fee to take into production.

If you’re working on a Craft CMS project and you’re after the best possible performance, the numbers alone here should demonstrate that Blitz is worth considering.

You Try! #

Here’s a roundup of stuff we used in case you’d like to try anything yourself. Happy optimizing!

Setup #

Testing #