How to make your SvelteKit App faster
| Published: January 27, 2024
| Updated: January 27, 2024
Of all the popular modern web frameworks, Svelte is often cited as one of the fastest. Javascript takes a back seat unless you need it and SvelteKit has prerendering and SSR functionality to make page changes as snappy as possible.
With that said, the ’performance discussion’ is much more nuanced, and many developers say they have experienced longer load times since moving to SvelteKit. This article posits that slow performance with SvelteKit is primarily due to misunderstandings of how it works. We’ll also detail many of the reasons why you could have slow performance with SvelteKit, and how to fix them.
What makes SvelteKit so fast?
When you develop with Svelte, you firstly get the benefits of an extremely lightweight core framework. Regarding the Svelte language itself, these properties primarily allow it to be ultra fast:
- Most of the patterns are Javascript-free by default,
- The Svelte code is compiled into plain Javascript during the build stage, and
- You’re dealing directly with the DOM, rather than the Virtual DOM, which you’ll encounter in other frameworks (React) - something the Svelte founder Rich Harris describes as Pure Overhead.
All of this means that the Svelte performance is almost as fast as vanilla Javascript, but turbocharged with a better developer experience.
Beyond all of the inherent properties of the Svelte framework, the SvelteKit meta-framework has a many features which go even further to make the UX almost instant. These main properties are:
These features in SvelteKit are powerful, however they can actually actually have the inverse effect and slow your site down when not used properly.
So for the rest of this article, we’ll talk about:
- Where and how these features are best used,
- How they can slow down your app,
- How to fix the issues if they are slowing down your app.
If you are trying to get to the bottom of why your SvelteKit app is running slow, a prerequisite is that you have made sure your server and database are in the same region. If you haven’t made sure of this, then this is likely the problem. If you have, then let’s proceed with the SvelteKit specific performance features.
We’ll start with a simple win that is new to SvelteKit and can get you major wins for media-heavy apps.
SvelteKit Enhanced Images
Recently, the SvelteKit team released the @sveltejs/enhanced-img
package, which processes all of your static images at the build stage, and provides your users with optimally sized and formatted images.
This means that your users get tiny file sizes, as they are compressed and served in .avif
and .webp
formats.
The additional benefit is that it only serves the image with appropriate dimensions given their device and prevents layout shift.
It’s an upgrade for your users, and it’s an upgrade for your developer experience as you usually won’t have to worry about adding width
and height
attributes to your img
tags.
This only works for static images where the images are known at build time. If you want to use user generated images, or any images coming from an external source or CDN, continue using the regular
<img>
tag.
It’s currently experimental, so it’s not recommended if you’re running a mission-critical app with SvelteKit.
Given that it’s still technically experimental, the @sveltejs/enhanced-image
package not bundled into the core SvelteKit bundle when you create your SvelteKit project, so you’ll need to install it separately.
Adding Enhanced Images to your project
If you’re using npm, you can install it with this command in your terminal:
npm install --save-dev @sveltejs/enhanced-img
Or if you’re using yarn/pnpm, use this:
- yarn:
yarn add @sveltejs/enhanced-img
, or - pnpm:
pnpm add @sveltejs/enhanced-img
Once you’ve installed it, you’ll need to add the enhancedImages
plugin to your vite.config.js
before your sveltekit()
plugin. So your vite.config.js
file should look something like this:
import { sveltekit } from '@sveltejs/kit/vite';
import { enhancedImages } from '@sveltejs/enhanced-img';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [enhancedImages(), sveltekit()]
});
Then you can use the <enhanced:img>
tags in your project!
Simply replace all your <img ... />
tags (with a static src
) in your project with <enhanced:img ... />
.
And the plugin will do all the heavy lifting for you.
So instead of the user getting served this:
<img src="..." alt="..." height="..." width="..." />
They now get served this:
<picture>
<source srcset="... 1x ... 2x" type="image/avif" />
<source srcset="... 1x ... 2x" type="image/webp" />
<source srcset="... 1x ... 2x" type="image/jpeg" />
<img src="..." alt="..." height="..." width="..." />
</picture>
There’s a few caveats you’ll need to know about when using enhanced:img
. The main one is that if you wish to used these tags dynamically (no statically set src
), you’ll need to append the images import directory with with ?enhanced
at the end.
For example, if we want to loop over images with an {#each}
loop, we would extract the src
from the loop iteration, rather than from a static import. Therefore to make sure the image isn’t excluded from the app’s build, we have to make this edit to the imports:
<script lang="ts">
import samImg from '$lib/assets/sam?enhanced';
import taylorImg from '$lib/assets/taylor?enhanced';
import markImg from '$lib/assets/mark?enhanced';
const employees = [
{ name: 'Sam Burns', image: samImg },
{ name: 'Taylor Layley', image: taylorImg },
{ name: 'Mark Bronson', image: markImg }
];
</script>
{#each employees as { name, image }}
<a href={`/employees/${name}`}>
<enhanced:img src={image} alt="" /> <span>{name}</span>
</a>
{/each}
If you don’t add the ?enhanced
flag to the import, the image won’t be included, and this component will not work.
For more details and all the caveats you need to know about the enhanced:img
feature, visit the SvelteKit Images article.
This is the main win we can get inside of our .svelte
markup, so the rest of the article will focus on page loading. These will cover the various ways we can get big wins, and prevent shooting ourselves in the foot when trying to implement features incorrectly.
Prerendering
Prerendering with SvelteKit means that the computation necessary to render pages is allocated to the build phase, where the resulting HTML is saved so that is can be immediately displayed for when a user navigates to a page.
If you configure your prerendering properly, and your pages can be statically rendered as HTML at build time, your users will benefit greatly from it.
When you pair this with the feature that links to prerendered pages will also be speculatively loaded before the user clicks on them; the end result is instant page loads.
This is particularly helpful for static pages and blogs where the HTML can be served immediately.
Thankfully there’s not much you need to know about prerendering, and using it incorrectly shouldn’t slow down your app. However, it’s a good idea to use it when you can.
How to add Prerendering to your site
If you want to have prerendering for a page, you can add this line to the top of your to your page’s +page.ts
/+page.server.ts
:
export const prerender = true;
However, it’s usually a good idea to have this applied to your entire site, which you can achieve by adding it the same line of code the top of your root +layout.ts
or +layout.server.ts
. And then it will apply to all pages in your app, and you won’t need it on a page-by-page basis.
If you do this, you can still exclude prerendering for specific pages by adding the following line of code to the their +page.ts
/+page.server.ts
:
export const prerender = false;
Regardless of whether you apply prerendering to a page or not, it will only be applied to pages which satisfy some criteria at build time. So let’s talk about that criteria.
Which pages can be prerendered
If you’ve applied the code mentioned above to prerender a page, and it’s still not prerendering - it’s likely that the page is unable to be prerendered. There are 2 basic rules which allow pages to be prerendered:
- The page cannot contain form actions, and
- For any two users hitting a page, the content returned from the server must be the exact same,
- You must not access
url.searchParams
during prerendering, however if you use it in the browser (e.g. in anonMount
on the+page.svelte)
, then it can still be prerendered.
So, any pages which violate these rules at the build stage won’t be prerendered, regardless of whether you add whether you set export const prerender = true
or not.
Cons of prerendering
It’s possible that prerendering could cause hydration bugs when some part of your page is attempting to access the window
or document
global objects. This is rare, however it can happen when a library you use utilizes these browser globals, such as maps or 3D rendering libraries. This is because these globals already require the component have access to the browser - which it doesn’t when prerendering.
If there’s issues with prerendering any pages which have been configured to prerender, SvelteKit will inform you in the build stage.
Read more about prerendering
As a general rule, what we’ve discussed above is enough to get you by with prerendering 99% of the time, however the rabbit-hole can go deep with the internals of how prerendering works.
If you’re interested in exploring this further or want more information on:
- prerendering server routes,
- prerendering dynamic routes,
- route conflicts when prerendering, and
- prerendering troubleshooting, you can read about it on the SvelteKit official docs.
Now let’s talk about a similar feature called server-side rendering.
Server-Side Rendering (SSR)
SvelteKit has first-class support for SSR, which means that when a user navigates to a page, the server will render the page before delivering it to the client. When done right, this can boost your load speeds dramatically. SSR is also on by default with SvelteKit.
The other benefit is that if the client doesn’t have Javascript (or if Javascript fails to load), the user will still get the page which was rendered on the server.
Given that SSR is on by default, you don’t need to specifically do anything to get this benefit, however if you want to turn SSR off on your page, you can add this line of code to the top of your +page.ts
/+page.server.ts
:
export const ssr = false;
However, you should only do this if you believe SSR is causing a bug, which is possible. Let’s now look at the only occasions where you should be mindful of SSR.
Is SSR causing a bug in your SvelteKit app?
SSR also suffers from the same downsides as prerendering, where it may cause hydration bugs when attempting to render libraries which rely on the window
or document
global objects on the server. You can get around this with the SvelteKit $app/environment
store.
import { dev, browser, building } from '$app/environment';
This control allows you to initialize particular libraries only on the browser, and not in the SSR process - thereby skating around hydration bugs.
For example, the Leaflet map library relies on the window
global object under the hood. So when we initialize Leaflet, we would do something like this:
<script>
import { onMount, onDestroy } from 'svelte';
import { browser } from '$app/environment';
import { tileLayerString } from '$lib/tileLayer';
let mapElement;
let map;
onMount(async () => {
if (browser) {
const leaflet = await import('leaflet');
map = leaflet.map(mapElement).setView([51.505, -0.09], 13);
leaflet.tileLayer(tileLayerString).addTo(map);
}
});
onDestroy(async () => {
if (map) {
map.remove();
}
});
</script>
<main>
<div bind:this="{mapElement}"></div>
</main>
Note that when we mount the component, we have an if
statement which only triggers when the window
object is present, and therefore won’t process with SSR.
Now that we’ve looked at situations where SSR can cause hydration bugs, and how to fix them, let’s look at situations where SSR can cause slower loading experiences.
Blocking waterfalls in your load
function
Working with SSR can be a double-edged sword - when dealing with asynchronous function calls in your load
functions, waiting for the responses can be slow and if not handled correctly can make your app feel sluggish and unresponsive.
Since SSR requires all promises to be resolved in a load
function before sending the page to the client, you can start to introduce bottlenecks to your loading speeds.
Let’s take a very common example where to view a page:
- a user should be authorized, and
- data needs to be fetched from a database.
Let’s look at the flow of data from the point the user clicks the link, to when they receive the page.
If you’re experience slow wait times for your app, this is probably what’s happening, whether you realize it or not:
- Firstly you request the page from the server,
- The server then has to send and wait for a session token validation response from the database,
- If successful, the server then has to send a request back to the database for the page data and wait for the response,
- Then with the returned data, the server can render the page with the data, and then
- Send it to the client
The whole process looks like this:
And if you don’t have loading spinners, all of this is happening while the user wonders whether they clicked the link or not. 😆
This is the common data flow for every page request where developers complain of SvelteKit being slow. The auth process is usually out of sight for the common page request because it’s been abstracted into a hook, or they don’t quite understand how load
functions work in SvelteKit.
There are two ways to get around this:
- Perform all outbound requests from the client component, (better for some components)
- Stream your promises from the
load
function to the client (best usually)
How to Perform data fetches from the component
When you do this, your page will render server-side without the data, and then present the bare HTML page to the user. However, it’s only at this point which they then begin to request the data.
To improve UX, you’d usually show a loading spinner until the data is returned.
This is how it could be coded up in a .svelte
component.
<script>
import { onMount } from 'svelte';
let fetchedData = null;
onMount(async () => {
const response = await fetch('/api/endpoint', {
headers: {
'content-type': 'application/json'
}
});
fetchedData = await response.json();
});
</script>
{#if fetchedData}
<div>
<h1>Data:</h1>
<pre>{JSON.stringify(fetchedData)}</pre>
</div>
{:else}
<p>Loading...</p>
{/if}
In this case, you don’t need a load function and the page should load relatively instantly, whilst it shows “Loading…“.
- The benefit of this is that the user gets a snappy response with page navigation, and a clear understanding that more data is being loaded.
- The problem with this is that you only begin fetching the data after the page is loaded, rendered and sent to the client.
This is how it looks on a similar diagram.
This solution isn’t optimal as you still have dead airtime between the time where the page renders on the server, and the time it is sent to you and mounted on your DOM.
It is still popular, however, as this is how you would typically do it in vanilla Svelte, and you can technically get faster TTFB’s (time to first byte). Despite this, the end result is longer wait-times for your user, especially if it’s for important content above-the-fold.
When fetching data from the client is good
The instances where this is most useful is when you have a component at the bottom of your page (below the fold for users), and you don’t want it to become a bottleneck when loading ‘above-the-fold’ content.
For example, on this page you’re looking at now, I have a .svelte
component which shows the most recent articles.
When I first made this component, I was fetching this data in the page load
function, and feeding the data into the component. But the problem was, it would add extra time to the page load, because it was waiting for this data to be returned. And because it’s at the bottom of the article, the reader wouldn’t see it until quite a bit later.
So, in order to optimize perceived load time and get useful content to the user as fast as possible, I stripped this asynchronous data fetching from the page load
function and moved it to the component itself. Now the data begins fetching after the page has been delivered to you, and the component is mounted. By the time you see it, the data will have populated.
An even better way to improve your load speed is by streaming promises to your page.
In the final feature, the wait times discussed in this SSR section are fixed all together, and will deliver the best user experience and fastest load times.
Stream your promises from the load function
With Sveltekit, you can remove data-waiting bottlenecks during page-loads by initializing promises in your load
function, and allowing the result to be streamed to your user. With this method, you minimize blocking on the server, and the client.
In the last SSR section, we talked about having to wait for multiple promises to be resolved before the server can even begin to start rendering your page. However, with this method, you can initialize your request ASAP, then render the page and send it to the client immediately before the data has returned.
It’s a ’best of both worlds’ solution, and with SvelteKit v2, achieving this is very straightforward.
How to stream promises from your load function to a Svelte component
Let’s take the example I mention before about getting this blog post, and also the related posts shown at the bottom of the page.
I firstly want to wait for the post
data and server-side render that, but the relatedPosts
aren’t essential, so I would pass the async
function down to the component, rather than await
it in the load
function.
So in your +page.ts
/+page.server.ts
, you would do this:
export async function load({ params }) {
return {
relatedPosts: loadRelatedPosts(params.slug),
post: await loadPost(params.slug)
};
}
where the effective returned object type is this:
relatedPosts: Promise<Post[]>;
post: Post;
and then in your related +page.svelte
component, you’d can display the post
directly with data.post
, and for the relatedPosts
, you can make use of the {#await}
tags in Svelte markup.
<script lang="ts">
import type { PageData } from './$types';
export let data: PageData;
</script>
<h1>{data.post.title}</h1>
<div>{@html data.post.content}</div>
{#await data.relatedPosts}
Loading related posts...
{:then relatedPosts}
{#each relatedPosts as relatedPost}
<p>{relatedPost.title}</p>
{/each}
{:catch error}
<p>error getting related posts: {error.message}</p>
{/await}
As the relatedPosts
property sent from the load
function is a promise, we can wrap its contents in an {#await}
tag.
This has several benefits in that:
- You no longer have to fetch data from the client, and
- The data will be loaded faster and you don’t have the dead airtime between the point where the server sends the rendered page to the client, and the component begins fetching
So, get used to using this approach in your load functions - you’ll have unbeatable app speeds, and your SEO performance will also be improved as an emergent benefit.
Conclusion
You should now know the main features SvelteKit has specifically built-in to fine-tune your app’s performance. And as with any tool, it’s only great when applied wisely. This article has walked you through situations you will come across where blindly relying these tools can work against you, and how to massage them to create the best outcome.
You should be able to best pick and choose which tools to use when you’re running:
- Blogs and static content pages,
- Pages with purely user-generated content,
- Interactive pages with form actions
And if the details were overwhelming, here is the main (and incomplete) takeaway:
Blogs and static pages
- ✅ Enhanced Images,
- ✅ Prerendering,
- ✅ SSR,
Dynamic pages with user-generated content:
- ❌ Enhanced images,
- ✅ Promise streaming from
load
function
Now obviously this ^ is generalized, however if you stay within those guidelines, you’re going to have a good time 👍