← all posts
2026.10.23 · 7 min

Druxt 0.25.0; caching and performance buffs

DrupalDruxtPlanet Drupal

Druxt is the Fully Decoupled Drupal framework for Nuxt 2, and 0.25.0, the first Druxt release since 2023, makes it ask Drupal a lot less.

On druxtjs.org, the Druxt docs site, a page render made 17 requests to Drupal. On 0.25.0 it makes 11, and with content caching on it makes none, until Drupal's lifetime for the page runs out. The Nuxt server keeps what Drupal sends for as long as Drupal's Cache-Control allows, and a webhook clears it when content changes.

Separately, the first-load flicker is gone, and with schema.refresh on, a change in Drupal's Manage display shows up without a rebuild, which is the part I have wanted for years. The upgrade command is near the end.

Requests to Drupal per page over a 24-page warm crawl: 17 a page on Druxt 0.24.0, 11 on 0.25.0, and 0 on 0.25.0 with cache.content on. Counted at a proxy, Drupal answering max-age 300; each process also reads the index and the decoupled settings once at start.

Asking Drupal once

0.24.0 made 17 calls a page, and six of them were the JSON:API index and the menus, asked again on every page. The index is Drupal's map of its API, and Druxt reads it to find everything else. Two of the six were duplicates within one render, and they go the moment you upgrade: identical requests now share one fetch, and the menu cache keys on the query it sends.

The other four are kept on the Nuxt server between requests. Drupal's Cache-Control decides how long, ttl can only shorten it, and when in doubt nothing is kept. The caching guide has the full rules. Across a 24-page crawl, that is 144 index and menu requests down to none once the first page has been rendered.

A signed-in request never fills it: an Authorization header, basic auth or a Drupal session cookie skips it, and an analytics cookie doesn't count.

Turning it on

Index and menu caching is on by default in production builds. Drupal needs one setting, the page cache max-age, which ships at 0. It applies to anonymous responses across the site, so browsers and CDNs will hold pages for five minutes too. Check Drupal itself rather than a CDN in front of it:

Administration > Configuration > Development > Performance, with the page cache maximum age at 5 minutes.
Five minutes, then check the header
drush -y config:set system.performance cache.page.max_age 300
drush -y config:export
curl -sI https://cms.example.com/jsonapi | grep -i cache-control
# cache-control: max-age=300, public

If you have turned CORS on in Drupal's services.yml, allow one origin, or * without credentials. Two or more origins, or an origin pattern, add Vary: Origin to every response, and nothing gets cached. Credentials are fine with one origin, so a preview setup like Consumer Node Preview's keeps caching, and an editor's signed-in preview skips the cache anyway.

Caching content

The index and menus are cached by default. Set cache.content and the same cache keeps everything else Druxt fetches from Drupal: entities, collections, views and route lookups.

nuxt.config.js, with content caching
export default {
  druxt: {
    cache: { content: true, secret: process.env.DRUXT_CACHE_SECRET },
  },
}

On a local copy of druxtjs.org, a warm docs page went from 11 calls to Drupal to none. A cold crawl, every page visited once, went from 438 calls on 0.24.0 to 157, a third of them on the first page, where schema refresh reads Drupal's displays. Only successful anonymous responses are kept, so a signed-in visitor still reaches Drupal, and a page published after someone saw its 404 shows straight away.

Content changes far more often than menus, so pair it with the clear from Drupal, below. Each cache area holds at most 50 MB of stored JSON per consumer by default, set by cache.maxSize, and can take about three times that in memory, so size it against your Nuxt process.

Clearing it from Drupal

Five minutes is a long time to wait for a menu edit, so Drupal can clear the cache the moment content changes. Give Druxt a secret:

nuxt.config.js
export default {
  druxt: {
    cache: { secret: process.env.DRUXT_CACHE_SECRET },
  },
}

A server-rendered production build then has a clear endpoint, POST /_druxt/cache/clear, locked to the secret in an X-Druxt-Secret header. Each Nuxt process keeps its own cache, so a site running several clears each one. List Druxt, or a module that adds it such as druxt-site, under modules rather than buildModules, or the endpoint answers 404 under nuxt start.

On druxtjs.org, Drupal's Purge module sends that POST whenever a cache tag is invalidated, so a saved menu link reaches the frontend on the next request. It clears Druxt's cache only, so a CDN in front of Drupal needs its own purger, and a drush cr doesn't send it.

Purge, sending the clear to Nuxt
drush -y pm:install purge purge_ui purge_purger_http \
  purge_queuer_coretags purge_processor_lateruntime
drush p:purger-add httpbundled   # note the purger ID it prints
# At Configuration > Development > Performance > Purge, set the purger to
# Nuxt's host and port, path /_druxt/cache/clear, method POST,
# invalidation type tag, and a header X-Druxt-Secret.
settings.php, keeping the secret out of exported config
$config['purge_purger_http.settings.PURGER_ID']['headers'][0]['value'] = getenv('DRUXT_CACHE_SECRET');
Check the endpoint, expecting 204
curl -s -o /dev/null -w '%{http_code}\n' -X POST \
  -H "X-Druxt-Secret: your-secret" http://nuxt:3000/_druxt/cache/clear

Every tag invalidation clears the whole cache, which is blunt but cheap. The caching guide has the details.

Display changes without a rebuild

Druxt builds its schemas, which fields a component renders and in what order, from Manage display at build time, so moving a field meant a rebuild. With schema.refresh on, the server regenerates them after each clear: move a field, save, and the next page has it.

nuxt.config.js, with schema refresh
export default {
  modules: ['druxt-site'], // or druxt-schema, not buildModules
  druxt: {
    cache: { secret: process.env.DRUXT_CACHE_SECRET },
    schema: { refresh: true },
  },
}

The first page after a clear pays for it, with about two dozen configuration reads on druxtjs.org, and every page after that is back to normal. The server generates with the build's access to Drupal, so those credentials need to be in production too. A static site can run the same refresh from its own Node server with createSchemaRefresh, which the caching guide covers.

What it saves

Over a network that adds up. On a local copy of druxtjs.org, alternating the versions page by page, 0.25.0 rendered warm pages nearly a third faster than 0.24.0 in all 66 paired visits, and in about a sixth of 0.24.0's time with content caching on. Inside a hosting cluster, where Drupal is a couple of milliseconds away, the time saved is small, and the win is Drupal answering fewer requests. Put a proxy in front of your own Drupal and count: how many times does your site ask the same question?

Drupal is getting quicker too. ptmkenny's core patch in #3626255 reads JSON:API's cached normalizations in bulk. On the docs collection behind druxtjs.org's sitemap, with normalizations warm, it took 1301 database queries down to 77 with identical responses. It needs reviews, and the issue covers running it on 11.x.

Also in 0.25.0

  • No more first-load flicker. Druxt components no longer fetch their data again when a production build hydrates, so any components:extend hook you added to hide it can go.
  • Pages don't jump on a refetch, because components keep their content while they fetch.
  • Views follow the language you asked for, not the site default.
  • A 403 or 404 from a route renders through Nuxt's own error page.
  • Vue and Vuex are peer dependencies, so a site uses its own copy, and a mismatched second copy can't stop Nuxt at startup.

Upgrading

The packages version together, so move them in one go:

Upgrade the set together
# Drop any druxt package your site doesn't use
npm install druxt@^0.25.0 druxt-site@^0.15.0 druxt-router@^0.32.0 \
  druxt-entity@^0.29.0 druxt-menu@^0.22.0 druxt-blocks@^0.18.0 \
  druxt-views@^0.23.0 druxt-breadcrumb@^0.18.0 druxt-schema@^0.12.0 \
  axios@^0.34.0
# Still on Vue 2.6? Add vue@^2.7.14 vuex@^3.6.2
# Using druxt-auth? Take it to 0.5.1 in the same install.

The deprecated DruxtField components are off unless you set druxt.entity.components.fields: true, and a consola 2.15.3 pin can go. Wrapper components no longer render module data as HTML attributes, so entity="[object Object]" is gone from your markup. Wrappers still read it from $attrs. The rest is in the changelog on druxtjs.org.

Why 0.25.0, and not Nuxt 4?

Nuxt 2 is end of life, so it is a fair question. Druxt had years of improvements and fixes sitting unreleased, and I would rather finish them than carry them across half done. 0.25.0 gets that work out, and gives the 2.0.0 port a stable, fast baseline to start from. Get it right on the stack it runs on today, then upgrade, then add new features, then polish.

There is one more 0.x release planned before 1.0.0. 0.26.0 is mostly waiting on a decoupled router patch, so a site whose default language has no URL prefix stops redirecting its front page to /en, plus whatever other multilingual fixes turn up before then. Then 1.0.0, the last Druxt on Nuxt 2, with Druxt 2.0.0, the Nuxt 4 rewrite under @druxt, on track for a first beta alongside it, building on the caching model 0.25.0 settled on.

Experimenting with WebSockets

Something to play with while you wait. Umami Go is a multiplayer card game on the Umami demo, dealt from the magazine's recipes and rendered by Druxt. It is also where you can try the upcoming Druxt WebSockets module, still in development: edit a recipe in Drupal, and it changes in every hand holding it.

Umami Go, a card game dealt from Drupal's Umami recipes, with a live table over the upcoming Druxt WebSockets module.

Druxt 1.0.0 and 2.x move as fast as sponsorship lets them.

Discussion

via GitHub Discussions