A copy of the live list page /Suche/de/berlin/berlin/wohnung-mieten (mobile HTML fetched 11 Nov 2026 21:53 UTC), on its current stack (server-rendered React + the same bundles, styles and third parties, loaded from the real hosts). One performance fix per iteration, each stacked on the one before, each scored by PageSpeed Insights.
Scoring: PageSpeed Insights API v5, mobile, n = 5 runs per iteration, 2026-11-11 22:12 - 22:50 UTC, Lighthouse 13.6.0. Cells show median (lowest - highest run). This is a demo copy, not production: what a real implementation needs is written under each step. Every response of this host carries X-Robots-Tag: noindex.
90+ is reached in the lab: first at /berlin/it8/ (median 91, runs 84 - 91), final state /berlin/it13/: median 95 (runs 94 - 95), LCP 2.86 s, TBT 0 ms, CLS 0.004. The faithful copy /berlin/it0/ scores 35, the live page 46 (same hour, same method).
Read before quoting the number:
?mui=now to an iteration URL, for example /berlin/it13/?mui=now.| Iteration | Change | Score | FCP | LCP | TBT | CLS | Speed Index | Requests | Transferred | Verdict |
|---|---|---|---|---|---|---|---|---|---|---|
| live | the real page /Suche/de/berlin/berlin/wohnung-mieten | 46 (38 - 55) | 2.85 s (2.85 s - 2.85 s) | 4.80 s (4.80 s - 11.30 s) | 1233 ms (621 ms - 2360 ms) | 0.000 (0.000 - 0.004) | 8.45 s (8.02 s - 10.81 s) | 325 | 3,330 KB | - |
| it0 | Copy the live page 1:1 (baseline) | 35 (33 - 37) | 3.31 s (3.30 s - 3.32 s) | 11.21 s (11.11 s - 11.85 s) | 1346 ms (1113 ms - 1673 ms) | 0.000 (0.000 - 0.000) | 9.94 s (9.38 s - 10.37 s) | 318 | 3,444 KB | baseline |
| it1 | Load the cookie banner (Usercentrics) after the first user interaction | 45 (39 - 49) | 3.32 s (3.30 s - 4.53 s) | 11.01 s (5.23 s - 12.49 s) | 828 ms (538 ms - 1062 ms) | 0.000 (0.000 - 0.004) | 7.96 s (6.24 s - 8.17 s) | 290 | 3,084 KB | kept (+10) |
| it2 | Load the tag manager (tr.js + Tealium utag.js) after the first user interaction | 44 (35 - 48) | 3.31 s (3.30 s - 3.50 s) | 10.64 s (5.18 s - 11.21 s) | 1011 ms (697 ms - 1361 ms) | 0.000 (0.000 - 0.004) | 7.96 s (5.87 s - 8.68 s) | 287 | 3,021 KB | no gain (-1), in chain |
| it3 | Load the ad scripts (OSA + Prebid) after the first user interaction | 47 (45 - 66) | 3.31 s (3.30 s - 3.32 s) | 9.56 s (4.37 s - 9.69 s) | 637 ms (414 ms - 904 ms) | 0.000 (0.000 - 0.004) | 7.50 s (4.74 s - 7.67 s) | 285 | 2,848 KB | kept (+3) |
| it4 | Load the AWS WAF challenge script after the first user interaction | 45 (43 - 60) | 3.31 s (3.30 s - 3.32 s) | 9.77 s (5.19 s - 9.81 s) | 771 ms (254 ms - 1187 ms) | 0.000 (0.000 - 0.004) | 7.15 s (5.38 s - 7.46 s) | 281 | 2,536 KB | no gain (-2), in chain |
| it5 | Load Google One Tap sign-in after the first user interaction | 46 (40 - 63) | 3.30 s (3.30 s - 3.32 s) | 9.52 s (5.04 s - 9.69 s) | 655 ms (380 ms - 1006 ms) | 0.000 (0.000 - 0.000) | 6.74 s (4.65 s - 7.02 s) | 275 | 2,426 KB | no gain (+1), in chain |
| it6 | Start the app JavaScript (React hydration) after the first user interaction | 71 (71 - 74) | 3.32 s (3.32 s - 3.33 s) | 5.43 s (5.12 s - 5.45 s) | 0 ms (0 ms - 0 ms) | 0.000 (0.000 - 0.000) | 4.61 s (3.32 s - 4.71 s) | 111 | 1,062 KB | kept (+25) |
| it7 | Take the 1.8 MB JSON state out of the HTML, load it with the app | 79 (79 - 85) | 2.56 s (2.56 s - 2.56 s) | 4.67 s (3.77 s - 4.69 s) | 0 ms (0 ms - 0 ms) | 0.004 (0.000 - 0.004) | 2.90 s (2.56 s - 3.06 s) | 111 | 894 KB | kept (+8) |
| it8 | Inline the CSS the first render uses, load the six stylesheets after the first user interaction | 91 (84 - 91) | 1.11 s (1.11 s - 1.14 s) | 3.47 s (3.47 s - 4.45 s) | 2 ms (0 ms - 6 ms) | 0.004 (0.004 - 0.004) | 1.68 s (1.11 s - 2.43 s) | 105 | 771 KB | kept (+12) |
| it9 | Lazy-load every image below the first result card | 93 (86 - 95) | 1.16 s (1.08 s - 1.84 s) | 3.01 s (2.86 s - 4.17 s) | 2 ms (0 ms - 30 ms) | 0.004 (0.000 - 0.004) | 1.84 s (1.08 s - 2.67 s) | 52 | 642 KB | no gain (+2), in chain |
| it10 | Insert the header's language-switcher stylesheet with createElement instead of an HTML string (hypothesis test) | 93 (85 - 95) | 1.13 s (1.11 s - 1.88 s) | 3.02 s (2.86 s - 4.15 s) | 0 ms (0 ms - 1 ms) | 0.004 (0.000 - 0.004) | 1.84 s (1.11 s - 2.71 s) | 52 | 642 KB | no gain (+0), not in chain |
| it11 | Preload the LCP image from the top of <head> | 95 (86 - 95) | 1.13 s (1.12 s - 1.13 s) | 2.86 s (2.86 s - 4.11 s) | 0 ms (0 ms - 0 ms) | 0.004 (0.004 - 0.004) | 1.13 s (1.12 s - 2.35 s) | 52 | 643 KB | no gain (+2), not in chain |
| it12 | Name the WebP image URLs in the server HTML | 95 (85 - 95) | 1.12 s (1.10 s - 1.87 s) | 2.86 s (2.86 s - 4.13 s) | 0 ms (0 ms - 0 ms) | 0.004 (0.004 - 0.004) | 1.12 s (1.10 s - 2.66 s) | 52 | 576 KB | no gain (+2), in chain |
| it13 | Skip rendering of result cards below the fold (content-visibility: auto) | 95 (94 - 95) | 1.10 s (1.07 s - 1.84 s) | 2.86 s (2.86 s - 2.86 s) | 0 ms (0 ms - 0 ms) | 0.004 (0.000 - 0.004) | 1.10 s (1.07 s - 1.84 s) | 33 | 451 KB | no gain (+0), in chain |
Verdict rule (written down at 22:28 UTC, when it0 - it2 and the first run of it6 were in; the thresholds for "lowers" were added at 22:50 UTC, before any verdict was computed): kept = median score at least 3 points above the state it was built on; worse = at least 3 below (dropped); no gain = in between (stays in the chain only if it lowers the median of requests by 1 or more, of transferred bytes by 1 % or more, or of TBT by 50 ms or more).
Blue bar = median of the 5 PSI mobile runs of an iteration that is part of the chain; light grey bar = iteration tested but not in the chain; dark grey bar = the live page (n = 5); black line with end caps = lowest to highest run; number above = the median score. Green dashed line = 90, the target.
Blue bar = median of the 5 PSI mobile runs of an iteration that is part of the chain; light grey bar = iteration tested but not in the chain; dark grey bar = the live page (n = 5); black line with end caps = lowest to highest run; number above = the median in seconds. Green dashed line = 2.5 s, Google's good threshold.
Blue bar = median of the 5 PSI mobile runs of an iteration that is part of the chain; light grey bar = iteration tested but not in the chain; dark grey bar = the live page (n = 5); black line with end caps = lowest to highest run; number above = the median in milliseconds. Green dashed line = 200 ms, Lighthouse's good threshold.
Blue bar = median of the 5 PSI mobile runs of an iteration that is part of the chain; light grey bar = iteration tested but not in the chain; dark grey bar = the live page (n = 5); black line with end caps = lowest to highest run; number above = the median. Green dashed line = 0.1, Google's good threshold (not visible when all values are far below it).
Nothing but what a copy on another host needs: a <base href> to the live URL so every relative URL (CSS, JS, links) still points to immobilienscout24.de, and robots noindex instead of index, follow.
Where: source/Suche_de_berlin_berlin_wohnung-mieten.html.gz (live HTML, fetched 2026-11-11T21:53:46Z), line 17 (<head>) and line 75 (meta robots)
IS:
<head> ... <meta name="robots" content="index, follow" />
SHOULD:
<head> <base href="https://www.immobilienscout24.de/Suche/de/berlin/berlin/wohnung-mieten"/> ... <meta name="robots" content="noindex" />
What a real implementation needs: Baseline only, nothing to ship. Differences to the live page are listed in proof/validation-it0-vs-live.md.
The consent manager script (app.usercentrics.eu/browser-ui/latest/loader.js, inserted by Tealium utag.js) is held until the first scroll / touchstart / mousemove / keydown (MUI). Adds the small MUI loader that all later steps use. Pattern: the two MUI decks (best-practices-mui-user-interaction-new.pdf, mui-minimal-user-interaction-cookie-banner.pdf).
Where: Request https://app.usercentrics.eu/browser-ui/latest/loader.js, created in https://tags-eu.tiqcdn.com/utag/immobilienscout/is24/prod/utag.js (first extension, about char 2,800: d.id='usercentrics-cmp'). In the demo: two inline scripts at the top of <head>.
IS:
// utag.js (Tealium), runs at page load
d=b.createElement('script');d.src='https://app.usercentrics.eu/browser-ui/latest/loader.js';d.id='usercentrics-cmp';...
a=b.getElementsByTagName(c)[0];a.parentNode.insertBefore(d,a);SHOULD:
// same extension, wrapped: the banner script is inserted on the first interaction
MUI.after(function () {
d=b.createElement('script');d.src='https://app.usercentrics.eu/browser-ui/latest/loader.js';d.id='usercentrics-cmp';...
a.parentNode.insertBefore(d,a);
});What a real implementation needs: Owner: whoever maintains the Tealium profile immobilienscout/is24 (the Usercentrics loader is a Tealium extension). The demo cannot edit utag.js, so it holds exactly this one script element from outside (Node.prototype.insertBefore gate); production wraps the loader in the extension instead. Consent law: MUI moves the dialog, never the consent duty - no tag may fire before the user said yes (Tealium is in opt-in mode here and waits for Usercentrics; check that this still holds). A returning visitor with a stored decision must get it applied without a dialog. utag.js already skips the banner for user agents containing "googlebot" (same file, same extension); Lighthouse / PSI and real users get it at load today.
The parser-blocking <script src="//tracking.immobilienscout24.de/tr.js?..."> (which loads tags-eu.tiqcdn.com/.../utag.js) is requested on MUI instead of during page load.
Where: source/Suche_de_berlin_berlin_wohnung-mieten.html.gz (live HTML, fetched 2026-11-11T21:53:46Z), line 629: <script src="//tracking.immobilienscout24.de/tr.js?cP--svc_module_name=resultlist&...">
IS:
<script src="//tracking.immobilienscout24.de/tr.js?cP--svc_module_name=resultlist&cP--countername=is24.de.finden.wohnen.wohnung_miete.result&addOnClick&reportEventsIndividually"></script>
SHOULD:
<script>MUI.after(function () { MUI.script("//tracking.immobilienscout24.de/tr.js?cP--svc_module_name=resultlist&cP--countername=is24.de.finden.wohnen.wohnung_miete.result&addOnClick&reportEventsIndividually"); });</script>What a real implementation needs: Owner: tracking / web analytics team (tr.js) and the search frontend (the tag in the page template). What could break: visits without any interaction are no longer counted (bounce and page-view numbers drop, this needs a sign-off from analytics); the page already carries a fallback (mockTrackingIfNotAvailable) for a missing IS24.TEALIUM, so the app does not crash, but page-view events the app reports before tr.js is there are lost in this step. From it6 on the tag manager is back in its original place in the script order (only later in time), so that gap closes.
The two <link rel="preload" as="script"> and the two async ad scripts (osa.s24-media.immobilienscout24.de: prebid-is24-resultlist-LATEST.js, osa-LATEST.js) are requested on MUI.
Where: source/Suche_de_berlin_berlin_wohnung-mieten.html.gz (live HTML, fetched 2026-11-11T21:53:46Z), lines 59 - 60 (preload) and 675 - 676 (script async)
IS:
<link rel="preload" href="https://osa.s24-media.immobilienscout24.de/prebid-is24-resultlist-LATEST.js" as="script" /> <link rel="preload" href="https://osa.s24-media.immobilienscout24.de/osa-LATEST.js" as="script" /> ... <script async src="https://osa.s24-media.immobilienscout24.de/prebid-is24-resultlist-LATEST.js"></script> <script async src="https://osa.s24-media.immobilienscout24.de/osa-LATEST.js"></script>
SHOULD:
<script>MUI.after(function () {
MUI.script("https://osa.s24-media.immobilienscout24.de/prebid-is24-resultlist-LATEST.js", { async: true });
MUI.script("https://osa.s24-media.immobilienscout24.de/osa-LATEST.js", { async: true });
});</script>What a real implementation needs: Owner: Scout24 media / ad tech (OSA). What could break: ad revenue - the first ad request starts at the first interaction, not at load, so viewable impressions for visitors who never interact go to zero and the first slot fills later. The page already lazy-loads ads (S24_OSA.config.lazyLoadAll = true), and without consent no personalised ad is allowed anyway; needs an A/B test on revenue. The S24_OSA.cmd queue keeps commands until the script is there.
The async bot-protection script (82d925f87a91.edge.sdk.awswaf.com/.../challenge.js) is requested on MUI.
Where: source/Suche_de_berlin_berlin_wohnung-mieten.html.gz (live HTML, fetched 2026-11-11T21:53:46Z), line 685
IS:
<script type="text/javascript" src="https://82d925f87a91.edge.sdk.awswaf.com/82d925f87a91/dc8cf73809de/challenge.js" async></script>
SHOULD:
<script>MUI.after(function () { MUI.script("https://82d925f87a91.edge.sdk.awswaf.com/82d925f87a91/dc8cf73809de/challenge.js", { async: true }); });</script>What a real implementation needs: Owner: platform / security (AWS WAF). What could break: the script fetches the aws-waf-token that later requests (search API, contact forms) may need; it must be there before the first protected request, which a first-interaction load normally satisfies, but security has to confirm that a page view without token does not raise the bot score. Not a decision for SEO alone.
The header inline script that appends sso-one-tap.js (which pulls accounts.google.com/gsi/client and starts FedCM) does so on MUI.
Where: source/Suche_de_berlin_berlin_wohnung-mieten.html.gz (live HTML, fetched 2026-11-11T21:53:46Z), lines 110 - 127 (first inline script in <body>, before <header id="is24-header">)
IS:
const script = document.createElement('script');
script.src = isSandboxOrDevelopment ? SANDBOX_URL : PROD_URL;
script.async = true;
...
document.head.appendChild(script);SHOULD:
const script = document.createElement('script');
script.src = isSandboxOrDevelopment ? SANDBOX_URL : PROD_URL;
script.async = true;
...
MUI.after(function () { document.head.appendChild(script); });What a real implementation needs: Owner: header / SSO team (the script sits in the shared header fragment). What could break: the One Tap prompt shows at the first interaction instead of at load, so fewer prompts are shown to visitors who never interact; login rate has to be watched.
The 13 parser-blocking bundle scripts at the end of <body> (jQuery, core, errorhandler, React, ReactDOM, MobX, jQuery UI, gac, vendor, scout24-libs, cxp-map, touchpoint, reactApp) plus the tag manager and the inline tracking fallback keep their place and order but run on MUI; the two i18n preloads go. Until then the visitor sees the server-rendered page.
Where: source/Suche_de_berlin_berlin_wohnung-mieten.html.gz (live HTML, fetched 2026-11-11T21:53:46Z), lines 618 - 672 (script tags), lines 54 - 55 (preload)
IS:
<script src="https://www.static-immobilienscout24.de/fro/jquery/3.6.2/jquery.min.js"></script> <script src="https://www.static-immobilienscout24.de/fro/core/9.4.0/js/core.min.js"></script> ... <script src="/Suche/resources/build/reactApp.js?v=c4859a1718d99c3c4701"></script>
SHOULD:
<script type="text/plain" data-mui src="https://www.static-immobilienscout24.de/fro/jquery/3.6.2/jquery.min.js"></script>
<script type="text/plain" data-mui src="https://www.static-immobilienscout24.de/fro/core/9.4.0/js/core.min.js"></script>
...
<script type="text/plain" data-mui src="/Suche/resources/build/reactApp.js?v=c4859a1718d99c3c4701"></script>
<!-- once, in <head> -->
<script>MUI.after(function () { /* turn every script[type="text/plain"][data-mui] into a real script, in document order */ });</script>What a real implementation needs: Owner: search frontend team (page template + webpack entry). This is the big one and the risky one. Until the first interaction nothing client-side exists: the search field and filter chips (rendered only by JavaScript today, the server sends an empty #reactCockpit), the map button, gallery swipe dots, save-search and the heart buttons do nothing until the bundles have loaded, so the first tap pays for download + hydration (INP of the first interaction gets worse; the links and images of the server-rendered list work without JavaScript). A production version would at least server-render the search header, preload the bundles at low priority during idle time and hydrate on interaction or visibility, not ship this text/plain switch. The live page throws React error #418 (hydration mismatch) in every PSI run; that re-render is part of the cost and should be fixed on its own.
The inline <script> that sets IS24.feature, IS24.resultList (resultListModel = the full search API response, 1,826,198 characters) and utag_data moves 1:1 into a file that is first in the MUI queue. The HTML shrinks from 2.19 MB to 0.36 MB. The ad scripts join the ordered queue because they read this state.
Where: source/Suche_de_berlin_berlin_wohnung-mieten.html.gz (live HTML, fetched 2026-11-11T21:53:46Z), lines 435 - 556 (inline script after the JSON-LD block); line 518 alone is 1.83 MB: resultListModel: {"searchResponseModel":...}
IS:
<script>
window.IS24 = window.IS24 || {};
IS24.ssoAppName = "search";
...
IS24.resultList = {
...
resultListModel: {"searchResponseModel":{ ... 1.83 MB ... }},
};
...
</script>SHOULD:
<script type="text/plain" data-mui src="https://is24-perf-demo.franzai.com/berlin/assets/state.ce9e81b630.js"></script>
What a real implementation needs: Owner: search frontend + search API team. In production this is not a static file: either the server sends only the fields the first render needs, or hydration fetches the state from the search API the page already names (lastSearchApiUrl: /region?realestatetype=apartmentrent&pagesize=20&geocodes=1276003001&pagenumber=1). What could break: hydration needs exactly the data the server rendered with (a second API call can return a different list, then React re-renders and the list jumps), so the state has to be cached per render or the page hydrated lazily per card. The 20 entries weigh 1.67 MB (83 KB each on average) - most of it is data no list card shows, so trimming the payload is worth it even without MUI.
The six render-blocking stylesheets (983,552 bytes uncompressed, 133,242 bytes on the wire, copies in source/css/) are replaced for the first paint by one inline <style> with only the rules whose classes, ids and tags exist in the server-rendered HTML (116,776 characters, about 14.8 KB gzipped: 676 of 6,982 rules); the full files load on MUI, before the app starts.
Where: source/Suche_de_berlin_berlin_wohnung-mieten.html.gz (live HTML, fetched 2026-11-11T21:53:46Z), lines 41 - 48: six <link rel="stylesheet"> (s24-icons.css, core.min.css, core-css-server-components.css, resultlist.css, slick-min.css, gac.min.css)
IS:
<link rel="stylesheet" href="https://www.static-immobilienscout24.de/fro/cosma-ui-icons/6.24.0/s24-icons.css"/> <link rel="stylesheet" href="https://www.static-immobilienscout24.de/fro/core/9.4.0/css/core.min.css"/> <link rel="stylesheet" href="/Suche/resources/build/core-css-server-components.css?v=7acdb14b831357c31fd3" /> <link rel="stylesheet" href="/Suche/resources/build/resultlist.css?v=4bfeebe65295092881d0" /> <link rel="stylesheet" href="https://www.static-immobilienscout24.de/fro/slick/1.8.1/slick-min.css" /> <link rel="stylesheet" href="https://www.static-immobilienscout24.de/fro/gac/5.0.7/gac.min.css"/>
SHOULD:
<style id="critical-css">/* used rules of the six files, in the same order */ ...</style>
<link rel="mui-stylesheet" href="https://www.static-immobilienscout24.de/fro/cosma-ui-icons/6.24.0/s24-icons.css"/>
... (five more, same change)
<script>MUI.after(function () { /* every link[rel="mui-stylesheet"] becomes rel="stylesheet" */ });</script>What a real implementation needs: Owner: search frontend (build pipeline) + design system team (core.min.css, s24-icons.css). The demo extracts the used rules statically (build/css_purge.py, no browser); production does this in the build (critical CSS per template, or simply a purged bundle per page type), and must keep it in sync with every release. What could break: a rule that only matches after JavaScript changed the DOM is not in the inline part - that is why the full files still load before the app starts; if a release adds a server-rendered class and the extraction is stale, that element is unstyled until the first interaction. The gac.min.css file has no rule that matches the server-rendered page at all.
All 119 <img> after the first card (provider logo + gallery image stay as they are) get loading="lazy" where it was missing (76 images, among them 70 project thumbnails of 44 x 44 px; 43 already had it) and lose fetchpriority="high" (5 images in cards 2 and 3 carried it).
Where: source/Suche_de_berlin_berlin_wohnung-mieten.html.gz (live HTML, fetched 2026-11-11T21:53:46Z), line 377 (the server-rendered list, one line): every <img> from <img class="block" src=".../usercontent/99fe19da-...JPG/ORIG/resize/240x60%3E/quality/90" fetchpriority="high"> onwards
IS:
<img class="gallery__image gallery__image-desk-zoom-in" src="https://pictures.immobilienscout24.de/listings/bcd6d029-f029-4709-807f-f8830fd2b1ab-1996566813.jpg/ORIG/legacy_thumbnail/350x350/format/jpg/quality/90" fetchpriority="high" alt="..."/> <img class="project-card-unit-carousel-card__image" src="https://pictures.immobilienscout24.de/listings/6ffc6a97-...jpg/ORIG/legacy_thumbnail/44x44/format/jpg/quality/90" alt=""/>
SHOULD:
<img loading="lazy" class="gallery__image gallery__image-desk-zoom-in" src="https://pictures.immobilienscout24.de/listings/bcd6d029-f029-4709-807f-f8830fd2b1ab-1996566813.jpg/ORIG/legacy_thumbnail/350x350/format/jpg/quality/90" alt="..."/> <img loading="lazy" class="project-card-unit-carousel-card__image" src="https://pictures.immobilienscout24.de/listings/6ffc6a97-...jpg/ORIG/legacy_thumbnail/44x44/format/jpg/quality/90" alt=""/>
What a real implementation needs: Owner: search frontend (list card + project card components). The app already sets loading="lazy" from card 4 on and fetchpriority="high" on cards 1 - 3; the rule has to follow the viewport (on a phone only card 1 is above the fold; on desktop the first row). What could break: nothing functional; Chrome loads lazy images about 1,250 px before they scroll into view, so fast scrolling can show an empty tile for a moment.
The shared header script adds the language switcher stylesheet (content.header.immobilienscout24.de/language-switcher/main/pro/style.css, a fourth host) to <head> with insertAdjacentHTML when DOMContentLoaded fires. Hypothesis: a stylesheet link created from an HTML string blocks the first paint if nothing was painted yet, and that is why the runs of it8 and it9 fall into two groups (first paint at 0.2 - 0.85 s or only at 1.26 - 1.49 s). The step creates the same link with document.createElement, which never blocks. Result: the two groups are still there (2 of 5 runs painted only at 1.32 - 1.34 s), so the hypothesis is refuted.
Where: source/Suche_de_berlin_berlin_wohnung-mieten.html.gz (live HTML, fetched 2026-11-11T21:53:46Z), line 340, inside initializeHeader() of the shared header fragment (its last inline script)
IS:
document.head.insertAdjacentHTML("beforeend", `<link rel="stylesheet" href="https://content.header.immobilienscout24.de/language-switcher/main/${deplymentEnv}/style.css" />`);SHOULD:
const languageSwitcherStyle = document.createElement("link");
languageSwitcherStyle.rel = "stylesheet";
languageSwitcherStyle.href = `https://content.header.immobilienscout24.de/language-switcher/main/${deplymentEnv}/style.css`;
document.head.appendChild(languageSwitcherStyle);What a real implementation needs: Nothing to ship from this step: tested, no gain, not part of the following states. It stays in the list because it rules out one explanation. What is still open: in 8 of 30 PSI runs of the states it8 - it13 Chrome's first paint comes only 1.26 - 1.49 s after navigation start although DOMContentLoaded fired at 0.2 - 0.7 s; Lighthouse then simulates an LCP about 1.0 - 1.3 s higher (4.1 - 4.5 s) and the score is 84 - 86 instead of 91 - 95. PSI returns no trace, so the cause cannot be read from these files; it needs a Chrome trace on a quiet machine (owner: whoever runs the measurement queue).
One <link rel="preload" as="image" fetchpriority="high"> for the first gallery image, directly after <base>. In the demo state the <img> itself sits about 290,000 characters into the document (after the inline CSS and the header markup).
Where: source/Suche_de_berlin_berlin_wohnung-mieten.html.gz (live HTML, fetched 2026-11-11T21:53:46Z), line 17 (<head>); the image is the first <img class="gallery__image"> in line 377, data-testid="171869029-slide-0"
IS:
<head> <meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1"/>
SHOULD:
<head> <link rel="preload" as="image" href="https://pictures.immobilienscout24.de/listings/f6fd9281-62cd-4a7f-9bcc-0e959e968589-2126911032.jpg/ORIG/legacy_thumbnail/350x350/format/jpg/quality/90" fetchpriority="high"/> <meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1"/>
What a real implementation needs: Owner: search frontend (server template: the first listing's first picture is known at render time). Better than a link tag: send it as an HTTP Link header or 103 Early Hints. What could break: the preloaded URL must be byte-identical to the <img src> (format, size, quality), otherwise the image is downloaded twice; on desktop the LCP element can be another one (first row has more cards).
The 82 listing image URLs of the form .../legacy_thumbnail/<size>/format/jpg/quality/<q> become format/webp. The app does this itself after hydration (the live PSI runs request .../350x350/format/webp/quality/90 and .../44x44/format/webp/quality/90); before hydration the browser has already downloaded the JPEGs.
Where: source/Suche_de_berlin_berlin_wohnung-mieten.html.gz (live HTML, fetched 2026-11-11T21:53:46Z), line 377: every <img src="https://pictures.immobilienscout24.de/listings/.../ORIG/legacy_thumbnail/.../format/jpg/quality/...">
IS:
src="https://pictures.immobilienscout24.de/listings/f6fd9281-62cd-4a7f-9bcc-0e959e968589-2126911032.jpg/ORIG/legacy_thumbnail/350x350/format/jpg/quality/90"
SHOULD:
src="https://pictures.immobilienscout24.de/listings/f6fd9281-62cd-4a7f-9bcc-0e959e968589-2126911032.jpg/ORIG/legacy_thumbnail/350x350/format/webp/quality/90"
What a real implementation needs: Owner: search frontend (image URL builder on the server; the client already has the WebP switch, body[data-usewebp]). For the LCP image this is 17,428 instead of 30,981 bytes (curl, 11 Nov 2026 22:29 UTC); quality 75 would be 8,688 bytes. What could break: browsers without WebP (none in current support matrices; a <picture> element covers them). The 350 x 350 px file is shown 362 px wide on a 412 px phone at DPR 1.75 and more, so it is also too small for a sharp picture - a srcset is the complete fix.
One CSS rule: result cards from the third on get content-visibility: auto with a reserved size (contain-intrinsic-size: auto 560px).
Where: CSS for .cardContainer > .listing-card (resultlist.css); in the demo one <style> at the end of <head>
IS:
/* no rule today: all 20 cards + ad placeholders are laid out and painted at load */
SHOULD:
.cardContainer>.listing-card:nth-child(n+3){content-visibility:auto;contain-intrinsic-size:auto 560px}What a real implementation needs: Owner: search frontend (list CSS). Measured side effect: images inside the skipped cards are not requested at load although Chrome would lazy-load them early (they are close to the viewport), so the page load drops from 52 to 33 requests and from 576 to 451 KB; the score does not move (the first card and its image are untouched). What could break: the reserved height must be close to the real card height per breakpoint, otherwise the scrollbar jumps while scrolling; in-page search and anchor links still work with content-visibility: auto. Google renders with a very tall viewport, so the cards are rendered there anyway.