Hydration mismatch in <svelte:head> can remove unrelated stylesheet via skip_nodes()
Describe the bug
A hydration mismatch inside a conditional <svelte:head> fragment can cause Svelte's hydration recovery logic to remove an unrelated stylesheet <link> from <head>.
In my production application, this caused the entire site to become unstyled shortly after the initial page render.
The application is a prerendered SvelteKit site using @sveltejs/adapter-static.
The global stylesheet is imported normally from the root layout:
import '$lib/scss/style.scss';The initial prerendered HTML is correct and the stylesheet loads successfully. The page initially renders with all expected styling.
On one affected mobile device, shortly after JavaScript starts and client hydration runs, the generated global stylesheet link is removed from <head>.
The page then immediately falls back to completely unstyled/default browser HTML.
This included:
- default body margins
- serif font
- default blue links
- unstyled buttons and inputs
- navigation/sidebar content appearing in normal document flow
The same deployed site works correctly on another phone with the exact same model and the same browser/software versions.
I also tested multiple browsers on the affected phone:
- Chrome
- Brave
- Opera
- Yahoo browser
All showed the same behavior.
Isolation
JavaScript disabled
With JavaScript disabled on the affected device:
- prerendered HTML renders correctly
- global CSS loads correctly
- styling remains intact
- the issue does not occur
CSR disabled
I then temporarily added:
export const csr = false;to the root SvelteKit layout configuration.
With CSR disabled:
- JavaScript was still enabled in the browser
- prerendered HTML remained correct
- global CSS remained loaded
- the site stayed fully styled
This isolated the failure to client-side hydration rather than the static HTML, CSS asset, deployment, or browser cache.
JSON-LD diagnostic
Before isolating CSR, I also temporarily removed a JSON-LD {@html} block from <svelte:head>.
That did not resolve the issue.
JSON-LD itself was therefore ruled out as the original cause.
Runtime instrumentation
Because remote mobile DevTools was not practical on the affected device, I added temporary instrumentation that executed before the Svelte client runtime.
The diagnostic intercepted DOM removal operations, including Element.prototype.remove, and also observed <head> using a MutationObserver.
When the failure occurred, it captured removal of this node:
<link href="./_app/immutable/assets/0.vs2PQPtR.css" rel="stylesheet">The captured stack was:
Error
at removed (https://dnd-portal.com/?hydration-debug=1:38:14)
at prototype.<computed> [as remove] (https://dnd-portal.com/?hydration-debug=1:46:12)
at $e (https://dnd-portal.com/_app/immutable/chunks/cUcjPqaC.js:1:3653)
at o (https://dnd-portal.com/_app/immutable/chunks/cUcjPqaC.js:2:2662)
at https://dnd-portal.com/_app/immutable/chunks/cUcjPqaC.js:2:2765
at https://dnd-portal.com/_app/immutable/nodes/0.CMRnMLq6.js:8:13973
at https://dnd-portal.com/_app/immutable/chunks/cUcjPqaC.js:2:2748
at Tr (https://dnd-portal.com/_app/immutable/chunks/cUcjPqaC.js:1:25806)
at Or (https://dnd-portal.com/_app/immutable/chunks/cUcjPqaC.js:1:26924)
at z (https://dnd-portal.com/_app/immutable/chunks/cUcjPqaC.js:1:22309)Inspection of the generated client bundle mapped the relevant application frame to a conditional block in the root <svelte:head>.
The application contained:
{#if seo.imageWidth}
<meta
property="og:image:width"
content={String(seo.imageWidth)}
/>
{/if}
{#if seo.imageHeight}
<meta
property="og:image:height"
content={String(seo.imageHeight)}
/>
{/if}The Svelte runtime stack mapped to hydration handling for an if_block.
The observed recovery path was:
if_block hydration branch mismatch
→ skip_nodes()
→ node.remove()During this production failure, that recovery traversal reached the generated global stylesheet and removed it.
Important limitation
I have not proven why the SSR and client branch states differed on the affected device.
I am specifically not claiming that seo.imageWidth definitely had one specific value during SSR and another specific value on the client.
What is confirmed is:
- Svelte entered hydration mismatch recovery for this conditional
<svelte:head>structure. - That recovery executed node removal logic.
- The generated global stylesheet link was removed.
- The page became completely unstyled immediately afterward.
Workaround / production fix
I changed the head elements to have a stable structure instead of conditionally rendering the elements:
<meta
property="og:image:width"
content={seo.imageWidth == null ? undefined : String(seo.imageWidth)}
/>
<meta
property="og:image:height"
content={seo.imageHeight == null ? undefined : String(seo.imageHeight)}
/>After this change:
- CSR remained enabled
- hydration remained enabled
- the stylesheet remained present
- the affected device stayed fully styled
- the generated root client module no longer used
if_blockfor these two head nodes
The production issue disappeared completely.
I then removed the temporary debugging instrumentation and restored the rest of the SEO head content. The cleaned-up production build continues to work on the previously affected device.
Expected behavior
If a hydration mismatch occurs inside a conditional <svelte:head> fragment, recovery should be limited to the mismatching fragment or otherwise recover without deleting unrelated head resources.
In particular, a mismatch involving an Open Graph <meta> element should not remove the application's generated global stylesheet.
Actual behavior
Svelte entered hydration mismatch recovery, called skip_nodes(), and removed an unrelated generated stylesheet <link> from <head>.
This caused the entire application to lose all styling after hydration.
Reproduction
A minimal SvelteKit reproduction project is available here:
The reproduction uses the same Svelte/SvelteKit versions as the production application and intentionally creates an SSR/client branch difference inside <svelte:head>.
The relevant code is:
<script>
import { browser } from '$app/environment';
</script>
<svelte:head>
{#if browser}
<meta
name="head-hydration-repro"
content="client-only"
/>
{/if}
</svelte:head>The reproduction also:
- imports a global stylesheet
- uses SvelteKit
- uses
@sveltejs/adapter-static - prerenders successfully
- includes the generated stylesheet in the prerendered HTML
- includes the normal SvelteKit client runtime/modulepreloads
- contains reproduction-only
MutationObserverinstrumentation to detect stylesheet removal
pnpm check and pnpm build both succeed.
The reproduction branch was created to isolate the conditional <svelte:head> hydration structure from the much larger production application.
However, I was not able to establish deterministic stylesheet removal in this smaller reproduction in my local environment.
The original production failure was device-specific. The production runtime instrumentation did capture the actual stylesheet removal and Svelte hydration stack on the affected device.
So the current reproduction demonstrates the relevant conditional <svelte:head> hydration structure, but does not guarantee that the unrelated stylesheet deletion will happen on every machine/browser.
Production behavior was:
- prerendered HTML loads correctly
- stylesheet is present and applied
- Svelte client hydration begins
- hydration mismatch recovery runs
- the global stylesheet
<link>is removed - the page becomes completely unstyled
The production workaround was to replace the structural {#if} blocks with unconditional <meta> elements.
That prevents the issue while keeping normal CSR and hydration enabled.
Logs
Removed node:
<link href="./_app/immutable/assets/0.vs2PQPtR.css" rel="stylesheet">
Captured Element.remove stack:
Error
at removed (https://dnd-portal.com/?hydration-debug=1:38:14)
at prototype.<computed> [as remove] (https://dnd-portal.com/?hydration-debug=1:46:12)
at $e (https://dnd-portal.com/_app/immutable/chunks/cUcjPqaC.js:1:3653)
at o (https://dnd-portal.com/_app/immutable/chunks/cUcjPqaC.js:2:2662)
at https://dnd-portal.com/_app/immutable/chunks/cUcjPqaC.js:2:2765
at https://dnd-portal.com/_app/immutable/nodes/0.CMRnMLq6.js:8:13973
at https://dnd-portal.com/_app/immutable/chunks/cUcjPqaC.js:2:2748
at Tr (https://dnd-portal.com/_app/immutable/chunks/cUcjPqaC.js:1:25806)
at Or (https://dnd-portal.com/_app/immutable/chunks/cUcjPqaC.js:1:26924)
at z (https://dnd-portal.com/_app/immutable/chunks/cUcjPqaC.js:1:22309)
A MutationObserver independently observed the same stylesheet node being removed immediately afterward.
The relevant generated application frame was:
https://dnd-portal.com/_app/immutable/nodes/0.CMRnMLq6.js:8:13973
Inspection of the generated bundle mapped this to the conditional Open Graph image dimension block inside the root <svelte:head>.
Observed runtime sequence:
if_block hydration branch mismatch
→ skip_nodes()
→ node.remove()
→ generated global stylesheet removed
→ complete loss of page stylingSystem Info
System:
OS: Windows 11 10.0.26200
CPU: (16) x64 AMD Ryzen 9 8945H w/ Radeon 780M Graphics
Memory: 10.65 GB / 31.29 GB
Binaries:
Node: 25.9.0
npm: 11.12.1
pnpm: 11.10.0
Browsers:
Chrome: 152.0.7977.83
Edge: Chromium (146.0.3856.59)
npmPackages:
@sveltejs/adapter-static: ^3.0.10 => 3.0.10
@sveltejs/kit: ^2.63.0 => 2.70.1
@sveltejs/vite-plugin-svelte: ^7.1.2 => 7.2.0
svelte: ^5.56.1 => 5.56.7
vite: ^8.0.16 => 8.1.5
Production framework versions involved in the failure:
Svelte: 5.56.7
SvelteKit: 2.70.1
@sveltejs/vite-plugin-svelte: 7.2.0
Vite: 8.1.5
@sveltejs/adapter-static: 3.0.10Severity
annoyance
Source: sveltejs/svelte