Acquia Back End Specialist (D10) study materials
Acquia Back End Specialist (D10) study resources

A custom module's hook runs on every request and the site slows down

Acquia Back End Specialist (D10) · Put your knowledge to work in context.

All case studies

A custom module's hook runs on every request and the site slows down

A Drupal 10 site has a custom module, acme_utils, with a hook_entity_presave() that, for every saved entity, loads all terms of a 40,000-term vocabulary to check whether the entity's title matches a term name, and a hook_preprocess_html() that calls \Drupal::database()->query() to count unpublished nodes on every page render. The site has 30,000 nodes and 200 editors saving content throughout the day. After launch, page load times for anonymous users rose from 200ms to 1.8s, and editors report saves taking 5-8 seconds. The module has no tests. A developer suggests adding #cache['max-age'] = 0 to the html preprocess to "make sure the count is fresh."

1. Attribute the slowdown to specific code and explain why each is expensive at this scale.

Read the worked answer

hook_preprocess_html() counting unpublished nodes. This runs on every page render, including anonymous pages that should be fully cached. A COUNT(*) over 30,000 nodes is cheap in isolation, but it defeats the render cache: any output that depends on a query result must either declare a cache tag or be uncacheable. Because the preprocess doesn't declare tags, the page can't be safely cached with that value, so the query runs per request. That's the 200ms → 1.8s regression for anonymous users.

hook_entity_presave() loading 40,000 terms. This runs on every entity save - not just the content type that needs the check. Loading all 40,000 terms into memory to do a name match is O(n) per save and pulls the entire vocabulary into PHP on each of the 200 editors' saves. That's the 5-8s save time. It also runs for config entities, users, and taxonomy terms themselves, multiplying the cost.

The developer's max-age = 0 suggestion makes it worse: it explicitly marks the output uncacheable, guaranteeing the query runs on every request forever.

2. Rewrite both hooks so they do bounded work, and explain the cache metadata each needs.

Read the worked answer

Preprocess html: don't query in the preprocess. Either:

  • Compute the count in a block/controller that declares #cache['tags'] = ['node_list'] so it invalidates when nodes change, or
  • If it must be in the html preprocess, attach the value via a lazy builder or a #attached render array with #cache['tags'] = ['node_list'] and a sensible max-age. Never max-age = 0.

The count only changes on node save, so node_list is the correct tag - it invalidates exactly when needed and lets the page cache serve anonymous users between saves.

Presave: scope it and bound it.

function acme_utils_entity_presave(EntityInterface $entity) {
  if ($entity->getEntityTypeId() !== 'node' || $entity->bundle() !== 'article') {
    return;
  }
  $name = $entity->label();
  $ids = \Drupal::entityQuery('taxonomy_term')
    ->accessCheck(FALSE)
    ->condition('vid', 'topics')
    ->condition('name', $name)
    ->range(0, 1)
    ->execute();
  if ($ids) {
    $entity->set('field_topic', reset($ids));
  }
}

This does one indexed lookup instead of loading 40,000 terms, and only for the bundle that needs it. If the vocabulary has no index on name, add one - the entity query will use it.

3. The module has no tests. What is the minimum test coverage that would have caught these regressions?

Read the worked answer

Two tests, both cheap:

  1. A kernel test for the presave hook. Create an article node, save it, assert the term reference is set; create a node of a different bundle, save it, and assert the hook did nothing (this catches the missing bundle check). Assert the number of queries executed stays constant as the vocabulary grows - or simply assert the hook doesn't call loadMultiple on all terms, which is the actual defect.

  2. A functional test for the html preprocess. Request a page as anonymous, assert the response is cacheable (the X-Drupal-Cache header or the render array's #cache metadata contains node_list and a non-zero max-age). Then save a node and assert the page reflects the new count - this catches both the missing tag and the max-age = 0 regression.

Neither test needs a browser. Both would have failed on the original code and pass on the fix. The point is to assert cacheability metadata, because the performance bug was invisible in the rendered output.