Optimize page performance

Best practice By design
When a Docly page scores poorly on PageSpeed Insights (PSI) mobile, use Claude Code and the Claude Chrome plugin to diagnose which bottleneck actually applies before fixing anything — the cause varies per page and could be heavy end-of-body JavaScript, render-blocking Google Fonts, an oversized or late-painted LCP image, gtag.js, or decorative canvas effects. This article describes that diagnose-then-fix workflow, the candidate fixes to reach for (defer heavy JS to after load + idle, self-host the font, shrink/preload the LCP image, defer analytics and canvas effects, pin effect elements to avoid CLS), and four Docly-specific traps that generic web-perf guides never mention. On dops.no this workflow took mobile from 61 to 94-98 without removing the signature effects, but that is one worked example, not a fixed recipe.

What you'll see

A Docly page (or a display template) scores badly on PageSpeed Insights mobile — typically 60-70 — even though it looks fine to real users. The cause varies from page to page: it might be end-of-body scripts (jQuery/Bootstrap/jarallax/Typed) executing mid-render, render-blocking Google Fonts, an oversized or late-painted hero image, or decorative canvas animations competing for the main thread — often a combination. LCP, FCP and/or TBT suffer. The goal is to diagnose which of these actually applies and fix the score without stripping the site's signature visual effects. This article describes how to do that with Claude Code and the Claude Chrome plugin.

What's actually happening

The point of this article is the workflow, not a fixed list of causes. On a given page the bottleneck could be any of several things — heavy end-of-body JavaScript running mid-render, render-blocking Google Fonts, an oversized or late-painted LCP image, gtag.js, or decorative canvas effects competing for the main thread. Which one dominates varies from page to page, so you should diagnose first, then fix the measured bottleneck rather than assume. On dops.no, for example, deferring the heavy end-of-body JavaScript until after window load plus requestIdleCallback turned out to be the single biggest lever, but treat that as one worked example, not a rule.

Use Claude Code to drive the analysis and the edits, and the Claude Chrome plugin to inspect and verify the live page. A typical loop: (1) let Claude read the page source and template structure, (2) use the Chrome plugin to open the page, read the console and network tab, run JS on the page and identify the actual LCP element and what is blocking it, (3) run Lighthouse for the numeric diagnosis, (4) have Claude Code apply a targeted fix, (5) re-verify in the browser and re-measure. The possible levers below (defer heavy JS, self-host the font, shrink/preload the LCP image, defer gtag.js, defer canvas effects, pin effect elements to avoid layout shift) are the candidate fixes to reach for once the diagnosis points at them.

Verification matters as much as the edits. Source-code scanning alone does not catch broken layout, unloaded images or dead SVGs. Verify in a real browser with the Claude Chrome plugin — read the console and network tab, run JS on the page, confirm effects, images and layout actually work. For measurement, use local Lighthouse for the full JSON diagnosis (LCP element, phase breakdown, opportunities) and treat pagespeed.web.dev as the authoritative score:

npx --yes lighthouse "<url>" --only-categories=performance --output=json --chrome-flags="--headless=new" --quiet

The scores swing. PSI/Lighthouse can report 70-98 on identical code because canvas animations paint at different moments each run. Take the median of several runs and trust only large effects. Local Lighthouse is extra noisy because it shares CPU with the browser. A stable 100 on mobile is not achievable while canvas animations run — the animations are the variance. The choice is: keep the effects (swings 94-98, occasionally 100) or disable them on mobile (stable ~100). Do not remove signature effects unless the site owner agrees; real users are already fast and field data is often "No data".

What to do

{ "Content": "

Diagnose first with Claude Code + the Claude Chrome plugin (see Explanation), then apply only the fixes the diagnosis points at. The sections below are candidate fixes, not a mandatory sequence — each explains why, how, and includes a copyable snippet. Whatever you apply, get the Docly-specific traps right and always verify in a real browser.

Docly-specific traps you MUST get right

These apply when you touch templates, head elements, scripts or static assets on a Docly site — generic web-perf guides never mention them. Which ones bite you depends on how the page is built (e.g. the first only applies if the page uses a master template).

  1. If the page uses a master template, xdt:Transform=\"Insert\" is mandatory for new head elements. This only applies when the child page declares a master via the <!--#master file=\"…\"--> directive (see the Master directive docs). In that case, child pages merge into the master via xdt:Transform attributes (Insert/Replace/SetAttributes/Remove/etc.), and a child is located in the master by xdt:Locator=\"Match(attr)\", otherwise by its id, otherwise by tag-path hierarchy. A new element you add to <head> without xdt:Transform=\"Insert\" (and without matching an existing element) is not merged in — the preload/link \"disappears\" and you wrongly conclude it did not help. Note: a malformed directive produces a visible error in the output, but a simply-missing xdt:Transform does not. On a standalone page (no master directive) you add head elements normally and none of this applies.
  2. CSP blocks inline script. script-src has no 'unsafe-inline', so no inline <script> and no inline onload= handlers. Everything must live in external .js / loader files. (The font media-toggle trick and similar do not work here.)
  3. Docly minifies HTML and JS. Attribute quotes are dropped (id=ga-init) and JS function names are renamed (boott). Do not rely on inline function names when verifying.
  4. Static /assets/ files have no automatic version parameter. When you change a static .js/.css, you must manually bump ?v= in the file that references it, or returning visitors get a stale cached version (e.g. bundle.css?v=9, footer-init.js?v=2).

1. Defer heavy end-of-body JavaScript to after load + idle

If the diagnosis shows end-of-body scripts running mid-render (this was the biggest lever on dops.no, ~70→98), load heavy libraries later. Heavy libraries running mid-render delay LCP. Load them sequentially (to preserve dependency order: jQuery → Popper → Bootstrap, footer-init.js last) only after load + requestIdleCallback, from one small boot.js:

(function () {\n  var scripts = [ /* jQuery, jarallax, popper, bootstrap, swiper, typed,\n                     smooth-scroll, submitform, dopsfx.js, footer-init.js?v=2 */ ];\n  function next(i){ if(i>=scripts.length)return;\n    var s=document.createElement('script'); s.src=scripts[i];\n    s.onload=s.onerror=function(){next(i+1)}; document.body.appendChild(s); }\n  var idle = window.requestIdleCallback || function(cb){return setTimeout(cb,1)};\n  if(document.readyState==='complete') idle(function(){next(0)},{timeout:2000});\n  else window.addEventListener('load', function(){idle(function(){next(0)},{timeout:2000})}, {once:true});\n})();

Replace the entire old script block at the bottom of footer.hash with just reveal.js + boot.js.

2. Self-host the font, remove render-blocking Google Fonts

If Google Fonts shows up as render-blocking in the diagnosis (on dops.no this cut FCP from ~3.8s to ~1.9s):

  • Download the font as one variable woff2 (a latin subset covers æøå: unicode-range: U+0000-00FF, …, weight 200 900).
  • Put an @font-face with font-display:swap in its own CSS.
  • Remove the Google Fonts @import from the theme CSS (render-blocking, ~900ms) — the third-party call disappears too (GDPR win).
  • Preload in <head>. crossorigin is always required for font preloads. (The xdt:Transform=\"Insert\" attribute below is only needed if the page uses a master template — drop it on a standalone page.)
@font-face {\n  font-family: 'Inter';\n  font-style: normal;\n  font-weight: 200 900;\n  font-display: swap;\n  src: url('/assets/fonts/inter-latin.woff2') format('woff2');\n  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+2000-206F;\n}
<link rel=\"preload\" as=\"font\" type=\"font/woff2\"\n      href=\"/assets/fonts/inter-latin.woff2\" crossorigin=\"\"\n      xdt:Transform=\"Insert\" />

3. Optimise + preload the LCP (hero) image

If the LCP element identified in the browser is a large or late-painted hero image:

  • Shrink the hero image (dops.no: 1024px/112KB → 768px/30KB webp, built with sharp).
  • Responsive, media-scoped preload with high priority (add xdt:Transform=\"Insert\" only if the page uses a master template):
<link rel=\"preload\" as=\"image\" href=\"…-m.webp\"\n      fetchpriority=\"high\" media=\"(max-width: 991.98px)\"\n      xdt:Transform=\"Insert\" />\n<link rel=\"preload\" as=\"image\" href=\"…-d.webp\"\n      fetchpriority=\"high\" media=\"(min-width: 992px)\"\n      xdt:Transform=\"Insert\" />
  • Remove jarallax from the mobile hero — parallax paints the LCP image with JS (render delay ~3.5s → ~400ms). Keep parallax on desktop if desired.

4. Defer Google Analytics (gtag.js)

If gtag.js shows up as main-thread cost in the diagnosis: set Consent Mode v2 defaults early and synchronously (so they are queued before any cookie write), but load the gtag.js library itself only after load + idle. On dops.no this saved ~600ms of main thread. No lost measurements — just slightly later. Because CSP blocks inline script, both the consent defaults and the deferred loader live in external .js files.

5. Defer decorative canvas effects (dops-fx) to after load + idle

If decorative effects show up as main-thread contention during the LCP window (on dops.no they accounted for ~3.9s scripting): wrap all effect components in a boot() that runs after window load + requestIdleCallback, with a prefers-reduced-motion skip.

6. Kill CLS from effect elements

If CLS is elevated and effect elements jump from inline to absolute when upgraded, pushing layout: give them absolute positioning in CSS from the start (on dops.no CLS 0.032 → 0.005):

dops-fx, .dops-fx {\n  position: absolute;\n  top: 0;\n  left: 0;\n  width: 100%;\n  height: 100%;\n  pointer-events: none;\n}

Mistakes to avoid

  • Do not paint the effects early (parallel loading / rAF timing instead of idle). It puts main-thread work back into the LCP window and wrecked the score (98 → 70). Keep effects deferred to idle.
  • Do not trust a single measurement. Use the median of several runs and trust only large effects. Local Lighthouse is the noisiest.
  • A stable 100 on mobile is impossible while canvas animations run — they are the variance. Keep the effects (94-98) or disable them on mobile (~100). Do not remove signature effects without the site owner's sign-off.
  • Always verify in a real browser (Chrome plugin), not just in source.

Copyable checklist

  1. Measure the baseline (PSI mobile + local Lighthouse median). Note the LCP element and phases.
  2. Move all heavy end-of-body scripts into boot.js (sequential, after load + idle). Tidy footer.hash.
  3. Self-host the font, remove the Google Fonts @import, preload the woff2 with crossorigin.
  4. Shrink + preload the LCP image (fetchpriority=high, media-scoped; add xdt:Transform=\"Insert\" only on master-templated pages). Remove jarallax from the mobile hero.
  5. Defer gtag.js to after load + idle; keep consent defaults synchronous.
  6. Defer decorative effects to load + idle; prefers-reduced-motion skip.
  7. Give effect elements position:absolute in CSS to kill CLS.
  8. Bump ?v= on every changed static /assets/ file.
  9. Verify in a real browser: no broken images, effects run, jQuery/Swiper/Typed work, NOINDEX preserved where required.
  10. Measure again (PSI + median). Compare against the baseline.

See also Cache-busting bundles with assetUrl, Use AVIF and WebP image formats, Restrict and register image sizes, and Use master pages for shared layout.

Relevant documentation: docly.assetUrl() (cache-busting bundle URLs), docly.setHeader() (response headers / caching), and the Master directive (master templates and xdt:Transform).

" }