How does Drupal do so well on Core Web Vitals?

I’ve been looking at the core web vitals report to see how Content Management Systems are performing as part of the WordPress Performance team. WordPress has a relatively poor pass rate, with only ~22% of sites “Having Good CWV” in the most recent dataset (on the bright side, that is up from ~14% a year ago).

I’ve also been looking at performance in Drupal which is a very popular, somewhat similar open source, PHP based CMS. Drupal has a much better CWV pass rate, with ~38% in the latest dataset - see Core Web Vitals Technology Report

I am curious if we can dig into the data further to discover what aspects of Drupal sites consistently outperform WordPress sites and hopefully bring back some learnings to inform our performance team work.

Breaking out all metrics, I can see the biggest difference is in LCP with 52% vs 30% passing. FCP and TTFB are also much better in Drupal:

page component weight and lighthouse scores are pretty similar:

I’m not exactly sure how to proceed with data analysis so I have come here for help :slight_smile:

Some questions I have:

  • My assumption is the LCP element is a large image. What is the difference is size between the LCP element on WordPress vs Drupal sites?
  • My intuition tells me WordPress is serving the wrong sized image, ie. an image with dimensions not matching the html width and height dimensions (probably much larger). Can we tell how image dimensions compare to their html dimensions (and how far they are off)? and specifically for the LCP element?
  • What else in the data could inform what we can do to improve WordPress performance

Thanks in advance for any help or suggestions!

1 Like

I’d recommend looking at the TTFB differences between the platforms to see how much of it is a CMS problem vs a hosting/ecosystem problem (there are a LOT of inexpensive WordPress hosting providers with really bad back-end performance).

Maybe subtract out the TTFB from the LCP and see what the distribution of times looks like then.

1 Like

Found a good excuse to run some queries before I forget everything…

Date: 2021-12-01

client cms origins pct_good_ttfb pct_good_lcp
mobile WordPress 2022166 5.71% 30.64%
mobile Drupal 110077 12.41% 53.12%

Looking at the above table, apart from the big difference in number origins, the percentage of origins with a good TTFB on Drupal is more than double that on WP, so TTFB definitely plays a role.

client cms percentile total TTFB
mobile Drupal 50 133035 2173
mobile Drupal 75 133035 2728
mobile WordPress 50 2659697 2469
mobile WordPress 75 2659697 3360

When checking the data from WebPageTest (CWV come from CrUX), the difference in TTFB isn’t disproportionately large at the 75th percentile. Not sure how you could make better use this TTFB data though.

client cms percentile size
mobile Drupal 50 63037
mobile Drupal 75 97200
mobile WordPress 50 58200
mobile WordPress 75 81840

If my query is correct, when comparing the intrinsic size of the LCP elements (only taking <img> elements into consideration), the median size on Drupal and WordPress is similar (slightly larger on Drupal actually). I haven’t checked download size in KBs (query is a bit tedious) so perhaps WP images are not compressed.

My guess is similar to patmeenan’s and the issue stems from TTFB.


Generated using the below queries

CREATE TEMP FUNCTION IS_GOOD (good FLOAT64, needs_improvement FLOAT64, poor FLOAT64) RETURNS BOOL AS (
  good / (good + needs_improvement + poor) >= 0.75
);

CREATE TEMP FUNCTION IS_NON_ZERO (good FLOAT64, needs_improvement FLOAT64, poor FLOAT64) RETURNS BOOL AS (
  good + needs_improvement + poor > 0
);

SELECT
  client,
  cms,
  COUNT(DISTINCT origin) AS origins,
  # Origins with good TTFB divided by origins with any TTFB.
  SAFE_DIVIDE(
    COUNT(DISTINCT IF(IS_GOOD(fast_ttfb, avg_ttfb, slow_ttfb), origin, NULL)),
    COUNT(DISTINCT IF(IS_NON_ZERO(fast_ttfb, avg_ttfb, slow_ttfb), origin, NULL))) AS pct_good_ttfb,

  # Origins with good LCP divided by origins with any LCP.
  SAFE_DIVIDE(
    COUNT(DISTINCT IF(IS_GOOD(fast_lcp, avg_lcp, slow_lcp), origin, NULL)),
    COUNT(DISTINCT IF(IS_NON_ZERO(fast_lcp, avg_lcp, slow_lcp), origin, NULL))) AS pct_good_lcp,
FROM
  `chrome-ux-report.materialized.device_summary`
JOIN (
  SELECT
    _TABLE_SUFFIX AS client,
    url,
    app AS cms
  FROM
    `httparchive.technologies.2021_12_01_*`
  WHERE
    category = 'CMS'
  AND (
    app = 'Drupal' OR app = 'WordPress'
  )
)
ON
  CONCAT(origin, '/') = url AND
  IF(device = 'desktop', 'desktop', 'mobile') = client
WHERE
  date = '2021-12-01'
GROUP BY
  client,
  cms
ORDER BY
  origins DESC
WITH
lcp_stats AS (
  SELECT
    _TABLE_SUFFIX AS client,
    url,
    JSON_EXTRACT_SCALAR(payload, "$._performance.lcp_elem_stats[0].nodeName") AS nodeName,
    JSON_EXTRACT_SCALAR(payload, "$._performance.lcp_elem_stats[0].url") AS elementUrl,
    CAST(JSON_EXTRACT_SCALAR(payload, "$._performance.lcp_elem_stats[0].size") AS INT64) AS size,
    CAST(JSON_EXTRACT_SCALAR(payload, "$._performance.lcp_elem_stats[0].loadTime") AS FLOAT64) AS loadTime,
    CAST(JSON_EXTRACT_SCALAR(payload, "$._performance.lcp_elem_stats[0].startTime") AS FLOAT64) AS startTime,
    CAST(JSON_EXTRACT_SCALAR(payload, "$._performance.lcp_elem_stats[0].renderTime") AS FLOAT64) AS renderTime,
    JSON_EXTRACT(payload, "$._performance.lcp_elem_stats[0].attributes") AS attributes,
  FROM
    `httparchive.pages.2021_12_01_*`
)

SELECT
  client,
  nodeName,
  COUNT(DISTINCT url) AS pages,
  ANY_VALUE(total) AS total,
  COUNT(DISTINCT url) / ANY_VALUE(total) AS pct,
  COUNTIF(elementUrl != "") AS haveImages,
  COUNTIF(elementUrl != "") / COUNT(DISTINCT url) AS pct_haveImages  
FROM
  lcp_stats
JOIN (
SELECT
  _TABLE_SUFFIX AS client,
  cms,
  COUNT(0) AS total
FROM
  `httparchive.summary_pages.2021_12_01_*`
JOIN (
  SELECT
    _TABLE_SUFFIX AS _client,
    url AS _url,
    app AS cms
  FROM
    `httparchive.technologies.2021_12_01_*`
  WHERE
    category = 'CMS'
  AND (
    app = 'Drupal' OR app = 'WordPress'
  )
)
ON 
  (_url = url AND _client = _TABLE_SUFFIX)
GROUP BY 
  client,
  cms
)
USING
  (client)
GROUP BY 
  client,
  cms,
  nodeName
HAVING
  pages > 1000
ORDER BY
  client,
  pct DESC
WITH
lcp_stats AS (
  SELECT
    _TABLE_SUFFIX AS client,
    url,
    JSON_EXTRACT_SCALAR(payload, "$._performance.lcp_elem_stats[0].nodeName") AS nodeName,
    JSON_EXTRACT_SCALAR(payload, "$._performance.lcp_elem_stats[0].url") AS elementUrl,
    CAST(JSON_EXTRACT_SCALAR(payload, "$._performance.lcp_elem_stats[0].size") AS INT64) AS size,
    CAST(JSON_EXTRACT_SCALAR(payload, "$._performance.lcp_elem_stats[0].loadTime") AS FLOAT64) AS loadTime,
    CAST(JSON_EXTRACT_SCALAR(payload, "$._performance.lcp_elem_stats[0].startTime") AS FLOAT64) AS startTime,
    CAST(JSON_EXTRACT_SCALAR(payload, "$._performance.lcp_elem_stats[0].renderTime") AS FLOAT64) AS renderTime,
    JSON_EXTRACT(payload, "$._performance.lcp_elem_stats[0].attributes") AS attributes,
  FROM
    `httparchive.pages.2021_12_01_*`
)

SELECT
    _TABLE_SUFFIX AS client,
    app AS cms,
    percentile,
    APPROX_QUANTILES(size, 1000)[OFFSET(percentile * 10)] AS size,
FROM
    `httparchive.technologies.2021_12_01_*` X,
UNNEST (
    [10, 25, 50, 75, 90]
) AS percentile
JOIN
    lcp_stats
ON
    (lcp_stats.url = X.url AND lcp_stats.client = _TABLE_SUFFIX)
WHERE
    category = 'CMS'
AND (
    app = 'Drupal' OR app = 'WordPress'
)
AND 
    nodeName = "IMG"
GROUP BY 
  client,
  cms,  
  percentile
ORDER BY
  client,
  cms,
  percentile
2 Likes