Fix INP in Drupal
Drupal's INP problem is the aggregated JavaScript bundle. Core plus a handful of contrib modules ship 220-350 KB of parsed JS on every page, Drupal.behaviors iterate the entire DOM on every AJAX response, core autocomplete fires a Views request per keystroke, and contrib GTM and chat libraries load without defer flags. On a stock Drupal 10 install the INP p75 on an interactive listing page routinely lands between 350 and 600 ms. The same site, with libraries split per twig template, non-critical JS deferred via hook_page_attachments_alter, Drupal.behaviors yielding to the main thread, autocomplete debounced at 500 ms with cached widget markup, and third-party tags behind facades, delivers INP p75 under 200 ms on 4G. This guide walks through the five moves that actually move the needle on Drupal INP in production.
Expected results
Before
450 ms
INP p75 (Needs Improvement) -- Drupal 10 with globally aggregated JS bundle, no defer on GTM or chat, Drupal.behaviors iterating full DOM on every AJAX response, core autocomplete debouncing at 300 ms
After
175 ms
INP p75 (Good) -- library-per-page splitting, hook_page_attachments_alter defer for analytics + chat, scheduler.yield() inside iteration behaviors, autocomplete debounce raised to 500 ms with in-memory cache, lite-youtube + Intercom facades
Step-by-step fix
Split the aggregated JavaScript bundle with library-per-page loading
Drupal aggregates every module's library into two or three large bundles that ship on every page. Declare libraries scoped per twig template and attach them from a preprocess hook or with attach_library() inside the template so a listing page does not download admin, form-editor, or slick-slider JS it will never use.
# themes/custom/mytheme/mytheme.libraries.yml
article-interactions:
version: 1.x
js:
js/article-interactions.js: { minified: false, attributes: { defer: true } }
dependencies:
- core/drupal
- core/once
listing-filters:
version: 1.x
js:
js/listing-filters.js: { minified: false, attributes: { defer: true } }
dependencies:
- core/drupal
- core/drupalSettings
# Attach ONLY on the article template.
# themes/custom/mytheme/templates/page--article.html.twig
#
# {{ attach_library('mytheme/article-interactions') }}
#
# On page--taxonomy-term--tag.html.twig attach 'mytheme/listing-filters' instead.
# Neither library ships on unrelated routes -- Drupal's aggregator produces a
# per-template bundle keyed by the attached libraries set.
# Compare aggregated JS payload across templates
$ curl -s https://example.com/article/hello | grep -oE 'js_[a-f0-9]+\.js' | sort -u
$ curl -s https://example.com/tags/drupal | grep -oE 'js_[a-f0-9]+\.js' | sort -u
# The article and taxonomy pages should reference DIFFERENT aggregated files.
# If both reference the same file, the library is still attached globally --
# grep the .theme and .module files for 'library_info_alter' overrides.
# Measure the delta in total parsed JS bytes
$ curl -s https://example.com/tags/drupal | \
grep -oE 'src="[^"]*\.js"' | \
awk -F'"' '{print $2}' | \
xargs -I{} curl -sI "https://example.com{}" | grep -i content-length
Defer non-critical JS with hook_page_attachments_alter
Google Tag Manager, chat widgets, hotjar, and Drupal's contextual toolbar frequently ship as render-blocking scripts because a contrib module attaches them with weight=0 and no defer flag. Iterate #attached['library'] in hook_page_attachments_alter, mark analytics and non-interactive libraries with defer=true, and push GTM into requestIdleCallback so it does not compete with the first user interaction.
<?php
// modules/custom/mymodule/mymodule.module
/**
* Implements hook_page_attachments_alter().
*
* Adds defer=true to analytics and third-party libraries so they do not
* block the interaction path, and swaps GTM to an idle-callback loader.
*/
function mymodule_page_attachments_alter(array &$attachments) {
$defer_libraries = [
'google_analytics/google_analytics',
'google_tag/gtag',
'hotjar/hotjar',
'contextual/drupal.contextual-links',
'toolbar/toolbar',
];
foreach ($attachments['#attached']['library'] ?? [] as $lib) {
// Nothing to do here -- library attributes come from the .libraries.yml
// declaration. Instead, override via hook_library_info_alter below.
}
// Idle-schedule GTM: replace the direct script tag with a loader stub.
if (!empty($attachments['#attached']['html_head'])) {
foreach ($attachments['#attached']['html_head'] as $key => &$item) {
if (isset($item[0]['#tag']) && $item[0]['#tag'] === 'script'
&& strpos($item[0]['#value'] ?? '', 'googletagmanager.com/gtm.js') !== FALSE) {
$item[0]['#value'] = <<<'JS'
(function(){
var load = function(){
var s = document.createElement('script');
s.async = true;
s.src = 'https://www.googletagmanager.com/gtm.js?id=GTM-XXXX';
document.head.appendChild(s);
};
if ('requestIdleCallback' in window) {
requestIdleCallback(load, { timeout: 4000 });
} else {
setTimeout(load, 3000);
}
})();
JS;
}
}
}
}
/**
* Implements hook_library_info_alter().
*
* Adds defer=true to the JS files of non-critical libraries.
*/
function mymodule_library_info_alter(array &$libraries, $extension) {
$targets = [
'google_analytics' => ['google_analytics'],
'google_tag' => ['gtag'],
'hotjar' => ['hotjar'],
'contextual' => ['drupal.contextual-links'],
];
if (!isset($targets[$extension])) {
return;
}
foreach ($targets[$extension] as $lib_name) {
if (!isset($libraries[$lib_name]['js'])) {
continue;
}
foreach ($libraries[$lib_name]['js'] as &$def) {
$def['attributes']['defer'] = TRUE;
}
}
}
Yield to the main thread inside Drupal.behaviors that iterate large DOM sets
Drupal.behaviors run on every AJAX response and on initial load. A behaviour that iterates 500+ elements executes as a single long task and blocks the next paint. Chunk the iteration with scheduler.yield() when available or a setTimeout(0) fallback so the browser can process input events between chunks.
// themes/custom/mytheme/js/article-interactions.js
(function (Drupal, once) {
'use strict';
// Prefer scheduler.yield when available; fall back to setTimeout(0).
const yieldToMain = () => {
if (typeof scheduler !== 'undefined' && scheduler.yield) {
return scheduler.yield();
}
return new Promise((resolve) => setTimeout(resolve, 0));
};
Drupal.behaviors.articleInteractions = {
attach: async function (context) {
const nodes = once('article-interactions', '.js-collapsible', context);
const CHUNK = 50;
for (let i = 0; i < nodes.length; i += CHUNK) {
const slice = nodes.slice(i, i + CHUNK);
slice.forEach((el) => {
const trigger = el.querySelector('.js-collapsible__trigger');
if (!trigger) return;
trigger.addEventListener('click', (e) => {
e.preventDefault();
el.classList.toggle('is-open');
el.setAttribute('aria-expanded',
el.classList.contains('is-open') ? 'true' : 'false');
});
});
// Give the browser a chance to process input events between chunks.
if (i + CHUNK < nodes.length) {
await yieldToMain();
}
}
},
};
})(Drupal, once);
# Run Lighthouse with the Interactions track on a 500-row Views page
$ npx lighthouse https://example.com/tags/drupal \
--only-categories=performance \
--form-factor=mobile \
--throttling.cpuSlowdownMultiplier=4 \
--output=json --output-path=./lh-inp-drupal.json
# Extract the longest task duration and INP contribution
$ jq '.audits["long-tasks"].details.items[0:5]' lh-inp-drupal.json
$ jq '.audits["interaction-to-next-paint"].numericValue' lh-inp-drupal.json
# expected: no single task > 100 ms; INP under 200 ms
Throttle Views AJAX exposed filters and core autocomplete
Drupal core autocomplete debounces at 300 ms and fires a Views AJAX request per keystroke. On a slow backend that adds 250-600 ms of interaction blocking while the response returns and the response callback re-renders the widget. Raise the debounce to 500 ms via an autocomplete.js override, cache widget markup in-memory keyed by the query string, and use a BigPipe placeholder for the refresh so the input's next paint is not blocked by the response.
// themes/custom/mytheme/js/autocomplete-throttle.js
//
// Overrides Drupal.autocomplete.options.autoFocus and the debounce interval,
// caches responses in a Map keyed by the query string, and re-uses cached
// markup on repeat keystrokes so the input paints without a network round trip.
(function (Drupal, $) {
'use strict';
if (!Drupal.autocomplete) return;
Drupal.autocomplete.options.autoFocus = false;
Drupal.autocomplete.options.minLength = 3; // was 1
Drupal.autocomplete.options.delay = 500; // was 300
const cache = new Map();
const originalSource = Drupal.autocomplete.options.source;
Drupal.autocomplete.options.source = function (request, response) {
const term = (request.term || '').trim().toLowerCase();
if (cache.has(term)) {
response(cache.get(term));
return;
}
originalSource.call(this, request, function (data) {
// Cap the cache at 100 entries to avoid unbounded growth.
if (cache.size > 100) {
const firstKey = cache.keys().next().value;
cache.delete(firstKey);
}
cache.set(term, data);
response(data);
});
};
})(Drupal, jQuery);
<?php
// modules/custom/mymodule/mymodule.module
//
// Wrap the Views exposed-form output in a BigPipe placeholder so the
// initial paint does not block on the widget query. The placeholder
// streams after the shell and re-attaches behaviors scoped to itself.
use Drupal\Core\Render\Element;
/**
* Implements hook_form_alter().
*/
function mymodule_form_alter(&$form, &$form_state, $form_id) {
if (strpos($form_id, 'views_exposed_form') !== 0) {
return;
}
// Mark the exposed form for BigPipe placeholder rendering.
$form['#lazy_builder'] = ['mymodule.exposed_filter:build', [$form_id]];
$form['#create_placeholder'] = TRUE;
}
Reduce third-party JS with facades and server-side alternatives
Lighthouse's third-party summary on a stock Drupal 10 site usually reveals 150-280 KB of tag-manager, chat, analytics, and social widget code loading on every page. Replace GTM with server-side GA4, load Intercom or Zendesk chat only after a click on a static facade button, and lazy-load YouTube via lite-youtube-embed. Each facade removes 40-90 KB of parse-and-compile from the interaction path and drops INP by 60-150 ms per interaction.
<!-- templates/block--chat.html.twig -->
<button
type="button"
class="chat-facade"
aria-label="Open chat"
data-chat-facade>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z"/>
</svg>
Chat with support
</button>
<script>
// themes/custom/mytheme/js/chat-facade.js -- 320 bytes gzipped
(function () {
var button = document.querySelector('[data-chat-facade]');
if (!button) return;
button.addEventListener('click', function loadChat() {
button.removeEventListener('click', loadChat);
button.textContent = 'Loading chat...';
var s = document.createElement('script');
s.async = true;
s.src = 'https://widget.intercom.io/widget/APP_ID';
s.onload = function () { window.Intercom && window.Intercom('show'); };
document.head.appendChild(s);
}, { once: true });
})();
</script>
# Compare third-party summary before and after
$ npx lighthouse https://example.com/tags/drupal \
--only-audits=third-party-summary,interaction-to-next-paint \
--form-factor=mobile \
--throttling.cpuSlowdownMultiplier=4 \
--output=json --output-path=./lh-tp.json
$ jq '.audits["third-party-summary"].details.summary.wastedBytes' lh-tp.json
$ jq '.audits["interaction-to-next-paint"].numericValue' lh-tp.json
# before: wastedBytes ~180000, INP ~450
# after: wastedBytes ~55000, INP ~175
# Confirm the chat script does not appear in the initial HTML
$ curl -s https://example.com/ | grep -c 'widget.intercom.io'
# expected: 0 (loads only on facade click)
Quick checklist
-
Libraries scoped per twig template (page--article, page--taxonomy-term, page--front) and attached via
attach_library() -
Non-critical libraries (GTM, analytics, hotjar, contextual, toolbar) marked
defer=trueviahook_library_info_alter -
GTM loaded inside
requestIdleCallbackso it never competes with the first interaction -
Drupal.behaviors iterate in chunks with
scheduler.yield()(orsetTimeout(0)) between chunks - Core autocomplete debounce raised to 500 ms with an in-memory response cache
- Chat and video embeds behind static-button facades; loaded only on click
- Lighthouse mobile INP under 200 ms and no single task above 100 ms on the after-state build
Frequently asked questions
Drupal aggregates libraries into two or three global bundles that ship on every page (220-350 KB parsed JS on a stock install), Drupal.behaviors iterate large DOM sets on every AJAX response, core autocomplete debounces at 300 ms while firing a Views AJAX request per keystroke, and contrib modules commonly attach GTM, chat, and analytics libraries with no defer flag. Fixing INP means splitting the bundle per template, yielding inside long-running behaviors, throttling exposed filters, and moving third-party tags behind facades.
Google rates INP as Good under 200 ms, Needs Improvement between 200 ms and 500 ms, and Poor above 500 ms. On a well-tuned Drupal 10 site with per-template libraries, deferred analytics, yielded behaviors, and lazy-loaded chat and video embeds, INP p75 typically lands between 130 and 190 ms. Authenticated INP is usually 30-60 ms higher because BigPipe streams personalized fragments after the shell and each fragment attaches its own libraries.
Drupal libraries are declared in MYMODULE.libraries.yml and attached via #attached['library'] in a preprocess hook or with {{ attach_library('mymodule/hero-slider') }} inside a twig template. Only pages whose twig template attaches the library download and parse it. Combine that with a specific route or content-type theme suggestion (page--article, page--taxonomy-term--tag) and the aggregator produces a per-template bundle that ships only the JS the page actually uses.
Most of the wins require small custom code because they depend on which libraries your site attaches. Contrib helpers include advagg (advanced aggregation, deprecated in D10 but replaced by core aggregation), gtm_lite (deferred GTM loader), and lite_youtube for video facades. But the biggest gains come from a custom .theme file that iterates #attached in hook_page_attachments_alter, adds defer flags, splits libraries per template, and yields inside custom Drupal.behaviors.
Install the web-vitals library from a local build (npm i web-vitals) and attach it via a Drupal library in local settings only. It logs INP, LCP, and CLS to the console on every interaction. Combine with the Chrome DevTools Performance panel's Interactions track (Chrome 121+) to see exactly which handler produces the long task. For production measurement, ship web-vitals via CrUX or an RUM endpoint (Cloudflare RUM, Vercel Speed Insights) and filter INP by route to identify the worst templates.
BigPipe helps LCP and TTFB by streaming the page shell early, but it can hurt INP if a placeholder resolves during the first 300 ms after a click and re-attaches libraries that re-run Drupal.behaviors on the whole document. Scope re-attach calls to the specific fragment (Drupal.attachBehaviors(context) where context is the fragment root, not document.body) so the second attach does not re-iterate the entire DOM. With scoped re-attach BigPipe is INP-neutral or slightly positive on interactive pages.
Related resources
Complete INP Guide
INP thresholds, long tasks, event delay, and cross-platform strategies.
FixFix TTFB in Drupal
Companion Drupal fix: Internal Page Cache, Redis backends, opcache tuning, Fastly Purge.
FixFix LCP in Drupal
Companion Drupal fix: Responsive Image module, fetchpriority preload, critical CSS, WebP pipeline.
Continue learning
Complete INP Guide
INP thresholds, sub-parts, and cross-platform strategies.
FixFix INP in WordPress
Same INP recipe on a different CMS: plugin defer, jQuery yield, chat facades.
FixJavaScript Bundle INP
Code-splitting, dynamic imports, and the yield primitives every framework can use.
ToolCWV Score Explainer
Enter your scores for personalized fix recommendations.