Why Your Core Web Vitals Are Still Bad (Even After Doing Everything Right)
You've optimized images, deferred scripts, and switched to next-gen fonts. Your PageSpeed score is still tanking. The real culprit is probably apps — and intercepting ScriptTag injections at the DOM level is the fix.
Here's a scenario that plays out in Shopify agencies constantly: a merchant hires you for a performance audit. You implement all the standard recommendations — lazy-load product images, compress the hero video, defer non-critical CSS, upgrade to a faster CDN. You re-run PageSpeed and the score is still in the red. Total Blocking Time is still 1,200ms. INP is still failing.
The images weren't the problem. The CSS wasn't the problem. The problem is apps.
How Shopify Apps Break Core Web Vitals
Shopify apps that use the ScriptTag API inject their JavaScript through {{ content_for_header }} — which runs on every single page of the storefront, before the main thread is available for user interaction. This is the root cause of most mysterious performance regressions on high-app-count stores.
A single review app, a single loyalty widget, a single chat bubble — each one might add 50-300ms of main thread blocking time. Multiply that by the 10-20 apps a typical growing Shopify store installs, and you're looking at 1-4 seconds of thread contention before the user can interact with anything.
- Total Blocking Time (TBT): Long tasks from app JS parsing block the main thread
- Interaction to Next Paint (INP): Heavy app scripts compete with user input events
- Time to Interactive (TTI): Pages can't be marked interactive while synchronous app scripts execute
- Largest Contentful Paint (LCP): Scripts injected before the LCP element delay its render
You cannot control what app publishers inject via ScriptTag. What you CAN control is when and whether those scripts execute — and that's where almost all the performance wins come from.
The Strategy: Intercept and Defer ScriptTag Injections
The core technique is to intercept document.createElement() at the theme level — before apps have a chance to inject their scripts — and apply loading strategies based on page context and user behavior.
// Intercept ScriptTag injections in theme.liquid
// Place BEFORE {{ content_for_header }}
(function() {
const origCreateElement = document.createElement.bind(document);
document.createElement = function(tagName, options) {
const el = origCreateElement(tagName, options);
if (tagName.toLowerCase() === 'script') {
// Tag this element for monitoring
el._isScriptTagInjection = true;
}
return el;
};
})();Once you have visibility into which scripts are being injected, you can apply three strategies based on context:
Strategy 1: Block Scripts on Irrelevant Pages
A review app has no business loading on the cart page. A wishlists app should not execute on the blog. A loyalty widget is irrelevant on the 404 page. You can read the current page template from Liquid — {{ request.page_type }} — and conditionally prevent app scripts from loading where they provide no value.
// In your ScriptTag interceptor
const pageType = {{ request.page_type | json }};
const blockedAppPatterns = {
'pages': ['review-app.js', 'wishlist.js'],
'cart': ['loyalty-widget.js', 'review-app.js'],
'blog': ['loyalty-widget.js', 'chat.js'],
};
// Block scripts that don't belong on this page typeStrategy 2: Load on Scroll
For widgets that appear below the fold, defer their loading until the user starts scrolling. An IntersectionObserver watching a sentinel element near the bottom of the viewport is 4 lines of code, and it means the app's script doesn't execute until the user has already interacted with the above-fold content.
// Defer below-fold apps until scroll
const reviewsWidgetSrc = 'https://app-cdn.example.com/reviews.js';
const observer = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting) {
const script = document.createElement('script');
script.src = reviewsWidgetSrc;
document.body.appendChild(script);
observer.disconnect();
}
});
observer.observe(document.querySelector('#reviews-section'));Strategy 3: Load on Interaction
The highest-impact strategy for some apps: don't load the script until the user tries to use the feature. A search app doesn't need to load until the user focuses the search input. A chat widget doesn't need to load until the user clicks the chat button. A popup app doesn't need its scripts until the exit-intent trigger fires.
- Search app: load on focus of search input (pointerdown or focusin)
- Reviews widget: load on scroll past product description
- Chat/support: load on click of the chat icon (render a fake icon until then)
- Popup/overlay: load on exit-intent or after 30s idle
- Loyalty widget: load on click of the loyalty button in the header
Real-World Before/After Numbers
On a mid-sized fashion store running 14 apps (review app, loyalty program, live chat, email popup, wishlists, size guide, recently-viewed, social proof toasts, currency switcher, and a few others), applying this three-strategy approach produced:
- Total Blocking Time: 2,100ms → 340ms (84% reduction)
- Time to Interactive: 8.2s → 3.1s (62% reduction)
- PageSpeed Mobile Score: 31 → 74
- INP: 890ms → 180ms (pass threshold)
This doesn't require removing any apps or convincing clients to do so. The apps still load and function — they just load at the right moment instead of all competing for the main thread on first paint.
The Larger Point: App Performance Is a First-Party Responsibility
Merchants trust their theme developers to deliver performant storefronts. The fact that third-party apps exist doesn't absolve us of that responsibility — if anything, it makes the technical solution more important, not less. The ScriptTag interception pattern has been a best-practice in high-traffic Shopify stores for a couple of years now, but it's still underutilized across most agencies.
If you're doing performance work and haven't audited the app script load order on the target store, start there. It's almost always the biggest lever available.
Written by
Sultan Ahmad
Shopify Engineering, Artiple Web