Telerik blogs

Learn a few approaches to caching in the client with SvelteKit and see an example of implementation.

Page listing three blog posts

Built-in Fetch

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.

Client Caching

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.

cacheFetch

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.

1. Create an Endpoint

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"
    }
];

2. Create the Templates

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.

3. Create the Caching Mechanisms

/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.

Final Result

First load is slower, but much faster on reload

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.

One Last Step

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


About the Author

Jonathan Gamble

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/.

 

 

Related Posts

Comments

Comments are disabled in preview mode.