How I Fixed the Order in SvelteKit to Improve My Performance Score
While working on my SvelteKit application, I noticed that the Performance score was lower than I wanted. It wasn’t that my SvelteKit code was slow; the problem was the order of the HTML tags in the <head>
section, which slowed down how the browser loaded and displayed the page.
In this post, I’ll show you how I found and fixed this issue. The fix improved my average Performance score from 77 to 80 and made my Largest Contentful Paint (LCP) 400ms faster.
Where I started: A Performance score of 77 and an LCP of 3.4 seconds.
Why the Order of Tags in <head>
Matters
The browser reads your HTML file from top to bottom. The order of tags in the <head>
section can really affect how fast your page loads. For example, when the browser sees a stylesheet tag like <link rel="stylesheet">
, it has to stop rendering the page until it downloads and understands that CSS file. These are called “render-blocking” resources.
On the other hand, a tag like link[rel="preconnect"]
should be near the top. It gives the browser a heads-up to start a network connection to another domain (like Google Fonts) early. Practically make DNS lookup, TCP handshake, and TLS negotiation. This saves time later when the browser actually needs to download a font from there. The goal is to arrange the tags to reduce this blocking and get content on the screen as fast as possible.
Finding the Cause: How <svelte:head>
Works
So, what was causing the bad order in my app? It turned out to be how SvelteKit handles the <svelte:head>
element.
SvelteKit builds pages from the main layout (src/routes/+layout.svelte
) down to the specific page (src/routes/.../+page.svelte
). When it does this, it takes the <svelte:head>
content from your page and adds it after the content from the layout.
This meant my page’s <title>
tag was ending up at the very bottom of the <head>
, after all the stylesheets and scripts from the main layout and other castling. That was the exact problem.
What I Tried First
My first thought was to just move tags around between my app.html
, +layout.svelte
, and +page.svelte
files. That didn’t work well and felt messy. I also looked into using a Vite plugin to change the HTML, but that wasn’t a reliable solution for a SvelteKit project.
Confirming the Problem with capo.js
To be sure I had found the right problem, I used a tool called capo.js
that checks the order of tags in the <head>
. The report it gave me showed the problem clearly:
Capo: Actual head order
███████████ 11 <meta charset="utf-8">
█ 1 <link rel="icon" href="./favicon.ico">
███████████ 11 <meta name="viewport" content="width=device-width, initial-scale=1">
█████ 5 <link href="./_app/immutable/assets/0.rkj8LxAo.css" rel="stylesheet">
█████ 5 <link href="./_app/immutable/assets/TrustedBy.DuuaL9Y1.css" rel="stylesheet">
█████ 5 <link href="./_app/immutable/assets/2.D-OnsEhB.css" rel="stylesheet">
████ 4 <link rel="modulepreload" href="./_app/immutable/entry/start.FggFOddC.js">
████ 4 <link rel="modulepreload" href="./_app/immutable/chunks/CmmFOFFc.js">
████ 4 <link rel="modulepreload" href="./_app/immutable/entry/app.VIXY-aEH.js">
█ 1 <meta name="description" content="The easiest way to bring the[...]">
█ 1 <meta name="keywords" content>
█ 1 <meta property="og:type" content="website">
█ 1 <meta property="og:url" content>
█ 1 <meta property="og:title" content="Nomodo AI Hub for SMBs">
█ 1 <meta property="og:description" content="The easiest way to bring the[...]">
█ 1 <meta property="og:image" content>
█ 1 <meta property="twitter:card" content="summary_large_image">
█ 1 <meta property="twitter:url" content>
█ 1 <meta property="twitter:title" content="Nomodo AI Hub for SMBs">
█ 1 <meta property="twitter:description" content="The easiest way to bring the[...]">
█ 1 <meta property="twitter:image" content>
██████████ 10 <title>Nomodo AI Hub for SMBs</title>
█ 1 <meta name="author" content="Nomodo">
█████████ 9 <link rel="preconnect" href="https://res.cloudinary.com" crossorigin>
█████████ 9 <link rel="preconnect" href="https://www.googletagmanager.com" crossorigin>
████████ 8 <script async src="https://www.googletagmanager.com/gtag/js?id=G-NKJ872J5DB"></script>
██████ 6 <script>…</script>
The important
<title>
tag (priority 10) was at the end, while the CSS files (priority 5) were blocking the page at the start.
The Solution That Worked: hooks.server.ts
and cheerio
The right place to make this kind of change in SvelteKit is a file called src/hooks.server.ts
. The code in this file runs on the server before the final HTML page is sent to the user. This means we can change the HTML on the fly.
To actually reorder the tags, I used a library called cheerio
and set the correct order based on the rules from capo.js
.
Here’s the code I added to src/hooks.server.ts
:
import * as cheerio from "cheerio";
// The correct order based on capo.js weights (highest to lowest)
const correctOrder = [
// WEIGHT 10: Critical meta tags and base
"meta[charset]",
'meta[http-equiv="accept-ch"]',
'meta[http-equiv="content-security-policy"]',
'meta[name="viewport"]',
"base",
// WEIGHT 9: Title
"title",
// WEIGHT 8: Preconnect for early connection establishment
'link[rel="preconnect"]',
// WEIGHT 7: Asynchronous scripts that don't block the parser
"script[async]",
// WEIGHT 6: Styles imported via @import
// 'style:contains("@import")', // Example if needed
// WEIGHT 5: Synchronous scripts (inline and external)
'script:not([async]):not([defer]):not([type="module"])',
// WEIGHT 4: Synchronous, render-blocking styles
'link[rel="stylesheet"]',
"style:not(:empty)",
// WEIGHT 3: Preload for resources needed on the current page
'link[rel="preload"]',
'link[rel="modulepreload"]',
// WEIGHT 2: Deferred scripts (defer and modern modules)
"script[defer]",
'script[type="module"]',
// WEIGHT 1: Prefetch/prerender for future navigations
'link[rel="prefetch"]',
'link[rel="dns-prefetch"]',
'link[rel="prerender"]',
// WEIGHT 0: Everything else (SEO meta tags, icons, etc.)
'meta:not([charset]):not([name="viewport"]):not([http-equiv])',
'link[rel="icon"]',
'link[rel="apple-touch-icon"]'
];
export const handle = async ({ event, resolve }) => {
const response = await resolve(event, {
transformPageChunk: ({ html }) => {
const $ = cheerio.load(html);
const head = $("head");
const tags = correctOrder
.flatMap((selector) => head.find(selector).toArray())
.map((tag) => $.html(tag));
// Find all other tags that don't match any selector
const otherTags = head
.children()
.not(correctOrder.join(","))
.toArray()
.map((tag) => $.html(tag));
head.html([...tags, ...otherTags].join("\n"));
return $.html();
}
});
return response;
};
Checking the Results
After putting the code in place, I ran the tests again. To get fair results, I ran each Lighthouse audit (before and after) three times on my computer and took the average.
The result: A score of 80 and an LCP of 3.0 seconds.
The average Performance score went up from 77 to 80, and the LCP time dropped from 3.4s to 3.0s. The new capo.js
report also confirmed that the tags were now in the correct order.
Capo: Actual head order
███████████ 11 <meta charset="utf-8">
███████████ 11 <meta name="viewport" content="width=device-width, initial-scale=1">
██████████ 10 <title>Nomodo AI Hub for SMBs</title>
█████████ 9 <link rel="preconnect" href="https://res.cloudinary.com" crossorigin>
█████████ 9 <link rel="preconnect" href="https://www.googletagmanager.com" crossorigin>
████████ 8 <script async src="https://www.googletagmanager.com/gtag/js?id=G-NKJ872J5DB"></script>
██████ 6 <script>…</script>
█████ 5 <link href="./_app/immutable/assets/0.rkj8LxAo.css" rel="stylesheet">
█████ 5 <link href="./_app/immutable/assets/TrustedBy.DuuaL9Y1.css" rel="stylesheet">
█████ 5 <link href="./_app/immutable/assets/2.D-OnsEhB.css" rel="stylesheet">
████ 4 <link rel="modulepreload" href="./_app/immutable/entry/start.FggFOddC.js">
████ 4 <link rel="modulepreload" href="./_app/immutable/chunks/CmmFOFFc.js">
████ 4 <link rel="modulepreload" href="./_app/immutable/entry/app.VIXY-aEH.js">
█ 1 <meta name="description" content="The easiest way to bring the[...]">
█ 1 <meta name="keywords" content>
█ 1 <meta property="og:type" content="website">
█ 1 <meta property="og:url" content>
█ 1 <meta property="og:title" content="Nomodo AI Hub for SMBs">
█ 1 <meta property="og:description" content="The easiest way to bring the[...]">
█ 1 <meta property="og:image" content>
█ 1 <meta property="twitter:card" content="summary_large_image">
█ 1 <meta property="twitter:url" content>
█ 1 <meta property="twitter:title" content="Nomodo AI Hub for SMBs">
█ 1 <meta property="twitter:description" content="The easiest way to bring the[...]">
█ 1 <meta property="twitter:image" content>
After the fix, the tags are in the right order, with the most important ones first.
Final Thoughts
Having the wrong tag order in your <head>
can really slow down how fast your page shows up. In SvelteKit, this can happen because of the way <svelte:head>
puts content together from layouts and pages.
Using the handle
function in the server hooks file is a great way to fix this. Even though it means changing the HTML directly, it’s a simple fix that gives you real, measurable improvements. It’s a good example of how you can get a nice performance boost with just a little bit of work.