Learn a few approaches to caching in the client with SvelteKit and see an example of implementation.
There are all sorts of ways to cache your data in a framework. The most common way in SvelteKit is to use Cache-Control headers with fetch
. You can also set the type of cache request using fetch on the frontend.
SvelteKit’s fetch is special and allows you to call these requests directly on the server without using a direct URL. It can call a function directly from the server, instead of fetching the data directly.
But what if you simply want to not read the same data twice? One way to do this would be to install a whole package like TanStack Query for Svelte, but then you have a bunch of overhead. Imagine you’re in a situation where Wi-Fi is slow, and you just want to view a page you have already navigated to. Imagine you already downloaded post information and you just want to click on the post detail page. Why would you need to re-query the database for data that you already viewed in your open app?
This method can also be useful with databases like Firebase where you get charged for every read! So, I decided to build a quick caching mechanism for SvelteKit to handle these use cases for faster loading.
I wanted something extremely easy to use, but that could be implemented in the page.ts
in the load
function. It needs to fetch the data in the background, and cache it by URL. Since fetching requires all this odd json
handling, we could put all that in there too. Again, using an external package like Axios would be overkill.
First we need to simulate a database by creating an endpoint.
/routes/data/+server.ts
import { error, json, type RequestHandler } from "@sveltejs/kit";
const posts = [ ... ];
export const GET = (async ({ url }) => {
const id = Number(url.searchParams.get('id'));
// simulate loading data slowly
await new Promise((res) => setTimeout(res, 2000));
if (id) {
const post = posts.find(post => post.id === id);
if (!post) {
throw error(404, 'Post not found!');
}
return json(post);
}
return json(posts);
}) satisfies RequestHandler;
All this does is get all posts if there is a direct request to /data
and get a specific post if there is a request to /data?id=x
. The extraneous promise here will simulate a slow database so you can see the cache in action.
I just had AI generate some dummy data, but you could use anything:
const posts = [
{
"id": 1,
"title": "The Joy of Gardening",
"content": "Discover the endless benefits of spending time in your garden. From growing your own food to the therapeutic aspects of gardening, this post explores why gardening is a hobby worth considering.",
"author": "Alex Smith",
"created_at": "2023-11-19T08:00:00Z"
},
{
"id": 2,
"title": "Tech Trends in 2023",
"content": "In this post, we dive into the latest technology trends of 2023. We cover everything from AI advancements to sustainable tech solutions that are shaping our world.",
"author": "Jordan Lee",
"created_at": "2023-11-18T15:30:00Z"
},
{
"id": 3,
"title": "Exploring World Cuisines",
"content": "Join us on a culinary journey as we explore different world cuisines. From the spicy dishes of Mexico to the intricate flavors of Japanese cuisine, get ready to expand your taste palette.",
"author": "Maria Gonzalez",
"created_at": "2023-11-17T19:45:00Z"
}
];
I created a basic layout post template to view posts. Makes sure to install Tailwind in your blank SvelteKit app first.
/routes/+layout.svelte
<script>
import { navigating } from '$app/stores';
import '../app.css';
</script>
<nav class="m-5">
<h1 class="text-xl underline hover:no-underline"><a href={`/`}>Home</a></h1>
</nav>
{#if $navigating}
<div
class="m-5 inline-block h-8 w-8 animate-spin rounded-full border-4 border-solid border-current border-r-transparent align-[-0.125em] motion-reduce:animate-[spin_1.5s_linear_infinite]"
role="status"
>
<span
class="!absolute !-m-px !h-px !w-px !overflow-hidden !whitespace-nowrap !border-0 !p-0 ![clip:rect(0,0,0,0)]"
>
Loading...
</span>
</div>
{:else}
<main class="m-5">
<slot />
</main>
{/if}
This will show a loading spinner when the page is navigating or fetching. The $navigating
is a Svelte mechanism trick to do this.
/routes/+page.svelte
<script lang="ts">
import type { PageData } from './$types';
import Post from '@components/post.svelte';
export let data: PageData;
</script>
<h1 class="text-3xl font-bold underline">Posts</h1>
{#each data.posts as post}
<Post {post} />
{/each}
This shows all posts once fetched.
/routes/post/[id]/+page.svelte
<script lang="ts">
import type { PageData } from './$types';
import Post from '@components/post.svelte';
export let data: PageData;
</script>
<Post post={data.post} />
This shows a single post with an ID input.
/components/post.svelte
<script lang="ts">
export let post: Post;
</script>
<div class="border border-1 p-3 my-3">
<h1 class="text-xl underline hover:no-underline">
<a href={`/post/${post.id}`}>{post.title}</a>
</h1>
<small>By {post.author} - {new Date(post.created_at).toDateString()}</small>
<article>
{post.content}
</article>
</div>
This displays the basic post information.
/app.d.ts
// See https://kit.svelte.dev/docs/types#app
// for information about these interfaces
declare global {
type Post = {
id: number;
title: string;
content: string;
author: string;
created_at: string;
};
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface Platform {}
}
}
export {};
Add a global type for the post.
/lib/cache.ts
import { browser } from "$app/environment";
import { error } from "@sveltejs/kit";
export const cache = new Map();
export const cacheFetch = async <T>(
key: string,
fetchCallback: () => ReturnType<typeof fetch>
) => {
if (browser && cache.has(key)) {
return cache.get(key) as T
}
const response = await fetchCallback();
if (!response.ok) {
const message = await response.json();
throw error(response.status, message);
}
const result = await response.json();
cache.set(key, result);
return result as T;
};
First, we only need to run the cache on the browser
. The cache itself is just a singleton Map object. Because you import this in your load functions, it will keep the data on the client. The file is only run once, keeping the map intact, while the function cacheFetch
can be run many times.
I also decided to simplify the fetch mechanism. Repeating the error-checking and json()
response techniques with fetch
can get cumbersome. All it does is return a cache if it exists, otherwise fetch the data, set the cache and return it.
Note: You could easily get rid of the fetch
boilerplate here if you are using another mechanism like Prisma, Firebase, Supabase, etc.
/routes/+page.ts
import type { PageLoad } from "./$types";
import { cacheFetch } from "$lib/cache";
export const load = (async ({ fetch, url }) => {
const { pathname } = url;
return {
posts: cacheFetch<Post[]>(pathname, () => fetch(`/data`))
};
}) satisfies PageLoad;
Look how clean this code is. The cacheFetch
function allows you to pass in the return type you want, and it just works. You can use fetch
in any way you like with any options, because the function itself is a callback function. pathname
is used as the key for the cache.
You also can just return the fetch
, or cacheFetch
in this case without await
, as SvelteKit will fetch
it for you with all other loading functions. Notice we are also using +page.ts
instead of +page.server.ts
, as this runs on both the client and server.
/routes/post/[id]/+page.ts
import type { PageLoad } from "./$types";
import { cacheFetch } from "$lib/cache";
export const load = (async ({ fetch, params, url }) => {
const { id } = params;
const { pathname } = url;
return {
post: cacheFetch<Post>(pathname, () => fetch(`/data?id=${id}`))
};
}) satisfies PageLoad;
This loads the data for the individual post. You get the id
from the file-based routing folder [id]
. If you enter a wrong id
, it will throw an error as expected.
The first time you load any page, you see a loading spinner. On subsequent requests, it just loads immediately. This is the power of caching. Now keep in mind it actually starts pre-fetching when you hover over the link itself, not when you click on it. This is one of the great things about SvelteKit.
Now, we could take this a step further by caching all posts on the initial fetch. Because we are actually getting all posts from the beginning, there is no reason to really fetch any of them ever again.
import type { PageLoad } from "./$types";
import { cache, cacheFetch } from "$lib/cache";
export const load = (async ({ fetch, url }) => {
const { pathname } = url;
const posts = await cacheFetch<Post[]>(pathname, () => fetch(`/data`));
posts.map(post => cache.set(`${pathname}post/${post.id}`, post));
return {
posts
};
}) satisfies PageLoad;
We can just loop through all the downloaded posts, and set them in the cache individually with map
and by importing the cache
from cache.ts
directly.
When you load your page once, you never have to fetch anything else! This can be useful to prevent N+1 Query Problem.
This is not always a solution. For example, if your post detail page has more information than the post list page. You have to decide if over-fetching the first time is worth not fetching at all on post detail pages.
Either way, now you have client-caching options with the understanding to implement your own!
Repo: GitHub
Jonathan Gamble has been an avid web programmer for more than 20 years. He has been building web applications as a hobby since he was 16 years old, and he received a post-bachelor’s in Computer Science from Oregon State. His real passions are language learning and playing rock piano, but he never gets away from coding. Read more from him at https://code.build/.