Capellic and Echo&Co officially merged on June 1!
Read more
Frontend performance optimization for Drupal websites: Part 1
This is part 1 of a series of articles that defines our approach to frontend performance optimization. In this part we get into the details of an effective cache policy.
By:
Dustin LeBlanc
Capellic maintains a suite of nonprofit Drupal websites that have been built over the past several years, using a very familiar but steadily evolving toolset. We adopted a component-driven approach to design and development years ago, and have steadily kept up with the popular tools used in the Drupal community to produce themes that adhere to the pattern.
Websites that we currently support include older installs built using Pattern Lab, and more recent themes built on Four Kitchen’s Emulsify theme system using Storybook.
At the same time, Capellic has been steadily working to improve the performance of all the client websites we’re involved with, which includes a mix of projects that we’ve inherited and created over the years with a wide array of build philosophies and setups.
Over the past 18 months, we’ve developed a very thorough understanding and strategy for what essentially all of these websites need in terms of configuration, code, and theming to produce lightning-fast and engaging experiences for users across the board.
Along the way, we’ve developed a general framework for addressing site performance predictably and thoroughly for our clients. Almost all websites we’ve come across have similar issues that need to be addressed and they can generally be categorized as follows (in priority order):
Don’t boot the server if you don’t have to (scroll down)
I’ll be addressing the first item in this article. The rest will be published in the coming weeks.
After optimizing the experience on a plethora of websites, we’ve found quite predictably that attacking performance using this list, from top to bottom, in order, yields predictably spectacular results.
Don’t boot the server if you don’t have to
We’re talking about static page caching (or caching in general) here. A lot on the internet has been written about caching, especially in Drupal, but the bottom line is that a dynamic website is always slower than a static one and far less capable of serving massive traffic given the same server resources.
For non-interactive web experiences (serving pure content to a reader instead of requesting user input and updating web content to suit), you should strive to serve a fully statically rendered page as often as your content strategy will allow.
Common gotchas:
Bot-fighting modules like Honeypot and CAPTCHA can be configured in ways that break the website cache for any page on which they are used.
Personalized content, when done incorrectly (using session-style cookies for instance) can break the cache for your visitors.
Randomized content can break the cache (depending on configuration).
You can confirm the cacheability of your content in Chrome using the ‘network’ tab of the developer tools. Open them up (cmd+opt+i on a Mac, and then select the network tab) and reload the page. The first result on the left should be the actual page itself, and you will want to look at the ‘headers’ section for the following:
The “Cache-Control” header should be something other than “must-revalidate, no-cache, private”. Ideally, if all is configured correctly, the Age header should increase every time the page is reloaded. If this is not happening, you should start by looking at the gotchas list above for reasons your content is not cacheable.
A useful troubleshooting technique is to go through your modules and disable them, one at a time and recheck the page cache headers as per the above. Doing this in a non-production environment allows you to experiment pretty freely without concern for breaking the site.
If you disable a module and the cache starts working, you can narrow your investigation to the offending module. There are roughly three categories of actions you can take when you find a module is causing the problem:
Investigate the configuration. Taking the Honeypot module as an example: The ‘time restriction’ setting can cause the page cache to break. Setting this value to 0 should allow pages with honeypot protected forms to remain cached.
If configuration is not an option, looking for an alternative module may be appropriate. There are often multiple options available to achieve any goal in the world of Drupal modules. The Capellic team has recently begun to use the Antibot module to accomplish the same features that we previously relied on the CAPTCHA and Honeypot modules to accomplish (reduce form spam). This module does not break the page cache and is an overall more pleasant experience for visitors than a captcha.
When module configuration and module selection are not viable solutions to fix your page cache issues, you’re likely either looking at custom code you need to refactor, or potentially a site feature that absolutely needs to break the cache.
Some examples of cache-friendly refactors
Capellic maintains a product called Conversion Engine that aims to provide non-profits an optimized donor journey platform. One of the features of that platform is a signature/email collection system that provides a Webform for visitors to sign the petition and a progress meter towards the campaign goal. The progress meter is tied to the number of submissions of the webform, and so by default, every single time a visitor submitted the form, the cache for the page itself was purged as the component rendering the signature had a cache dependency on the form and its submissions.
At low traffic volumes, this is a delightful experience. The meter goes up as visitors are signing the form, and their impact is immediately visible. For high-traffic campaigns being sent to thousands of potential visitors via social media ads, email lists, and SMS, this immediate feedback becomes a liability in a hurry.
The obvious answer here is to remove the cache dependency on the page, but we wanted to update the signature count on a quicker time schedule than the site page cache lifetime. In refactoring this component, we decided to decouple the count from the page render completely, and fetch it dynamically after page load:
(function (once) {
Drupal.behaviors.quickActionProgressMeter = {
attach(context, settings) {
const elements = once('quick-action-progress-meter', '[data-progressMeter]', context);
elements
.forEach(function (element) {
const eacId = element.getAttribute('data-eac-id');
const goal = element.getAttribute('data-goal');
fetch(`/conversion_engine/api/eac/count/${eacId}`)
.then(response => response.json())
.then(data => {
const count = data.count;
const progress = Math.round((count / goal) * 100);
element.querySelector('.action-meter__progress-bar').style.width = `${progress}%`;
element.querySelector('.action-meter__submissions').dataset.count = count;
// set the inner html to a comma formatted number using Intl.NumberFormat e.g 1,000,000
element.querySelector('.action-meter__submissions').innerHTML = new Intl.NumberFormat().format(count);
});
});
}
};
}(once));
In this Javascript, you can see we’re making a request to a custom API endpoint, and then modifying the count in the dom after the page load. This means the page itself can be served from the cache, regardless of what is happening with form submissions. If we take a look at the controller behind that api endpoint, we can see that it also caches the responses, but on it’s own schedule which is controllable from an admin configuration form to tune the feedback cycle for immediacy or performance:
<?php
namespace Drupal\ce_email_acquisition\Controller;
use Drupal\ce_email_acquisition\EmailAcquisitionInterface;
use Drupal\Component\Datetime\TimeInterface;
use Drupal\Core\Cache\CacheableJsonResponse;
use Drupal\Core\Cache\CacheableMetadata;
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\Controller\ControllerBase;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Drupal\ce_email_acquisition\EmailAcquisitionCounterInterface;
/**
* Controller for the eac submission counter.
*/
final class EmailAcquisitionCounterController extends ControllerBase {
/**
* The eac counter service.
*
* @var \Drupal\ce_email_acquisition\EmailAcquisitionCounterInterface
*/
private EmailAcquisitionCounterInterface $eacCounter;
/**
* The time service.
*
* @var \Drupal\Component\Datetime\TimeInterface
*/
protected $time;
/**
* A controller to fetch the quick action submission count of a challenge.
*
* @param \Drupal\ce_email_acquisition\EmailAcquisitionCounterInterface $eacCounter
* The signature_counter service.
* @param \Drupal\Core\Config\ConfigFactoryInterface $configFactory
* The config factory.
* @param \Drupal\Component\Datetime\TimeInterface $time
* The time stuff.
*/
public function __construct(
EmailAcquisitionCounterInterface $eacCounter,
ConfigFactoryInterface $configFactory,
TimeInterface $time
) {
$this->eacCounter = $eacCounter;
$this->configFactory = $configFactory;
$this->time = $time;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container) {
return new self(
$container->get('ce_email_acquisition.eac_counter'),
$container->get('config.factory'),
$container->get('datetime.time')
);
}
/**
* Callback for the quick action submission counter.
*
* @param \Drupal\ce_email_acquisition\EmailAcquisitionInterface $challenge
* The challenge entity.
*/
public function __invoke(EmailAcquisitionInterface $challenge) {
$count = $this->eacCounter
->getCountForChallenge(
$challenge->field_email_acquisition_id->value
);
$data = [
'count' => $count,
];
$response = new CacheableJsonResponse($data);
$response->setMaxAge($this->getCachedMaxAge());
// Do this because
// https://drupal.stackexchange.com/questions/255579/unable-to-set-cache-max-age-on-resourceresponse
$cache_meta_data = new CacheableMetadata();
$cache_meta_data->setCacheMaxAge($this->getCachedMaxAge());
$response->addCacheableDependency($cache_meta_data);
// Set expires header for Drupal's cache.
$date = new \DateTime($this->getCachedMaxAge() . 'sec');
$response->setExpires($date);
// Allow downstream proxies to cache (e.g. Varnish).
$response->setPublic();
// If the challenge changes, we want to invalidate the cache.
$response->addCacheableDependency(CacheableMetadata::createFromObject($challenge));
return $response;
}
/**
* Get the cache max age.
*
* @return int
* The cache max age in seconds, defaulting to 300 (5 minutes).
*/
private function getCachedMaxAge() : int {
return $this->configFactory
->getEditable('ce_cp.controlpanel')
->get('eac_count_cache_max_age') ?? 300;
}
}
This approach allows us to fine-tune the cache behavior of this highly trafficked page to find the sweet spot between performance and engagement so that the client can serve the largest possible campaigns while also boosting engagement by helping visitors see their impact more immediately.
This is an example of balancing a custom site feature whose initial specification was cache-resistant, but with careful use of the available technology, we can provide most of the same benefits with a dramatic improvement in scalability and performance.
This is part 3 of a series of articles that defines our approach to frontend performance optimization. In this part we challenge the prevailing wisdom of the monolithic CSS file.
This is part 4 of a series of articles that defines our approach to frontend performance optimization. In this part we squeeze more performance out through delivery optimization.