← Back

How Cloudflare KV cut our page loads from 300ms to 5ms

2/20/26

How Cloudflare KV cut our page loads from 300ms to 5ms

The problem

Every time a logged-in user loads any page, SvelteKit needs to know who they are during server-side rendering. The way we do auth is session cookies. User logs in, gets a cookie, and on every page load the server needs to validate that cookie and grab the user data (username, id, email, etc).

So here’s what was happening on every single page load:

  1. User’s browser hits Cloudflare Pages
  2. SvelteKit’s hooks.server.ts runs
  3. It reads the session cookie
  4. It makes a fetch to our Rust API on the VPS
  5. The API queries PostgreSQL to validate the session
  6. User data comes back
  7. Now the page can actually render

That fetch in step 4 was taking 150-300ms. Sometimes more. And this happens on every page load, every navigation. For a real-time betting site where people are clicking around fast, that’s not very good for user experience.

Skipping SSR?

At first this was my thought. But then you get a flash of unauthenticated content on every page load, so I would need to build a spinner or some kind of feature that will hide the page contents while it loads. The user sees the logged out state for a split second before the client side auth kicks in. And for SEO it matters too.

The idea.

Our frontend already runs on Cloudflare’s edge network, and I use many of their products. Cloudflare has this thing called KV (Key-Value) which is basically a global key-value store that lives on their edge nodes. Reads are like 1-5ms because the data is right there, same place your site is being served from.

How it works

There’s two sides to this, the write side (Rust api) and the read side (SvelteKit).

Write side: Rust API pushes sessions to KV

When a user logs in, our API does two things now. It creates the session in PostgreSQL like normal, and then it also writes a copy to Cloudflare KV using their REST API.

pub async fn put_session(&self, session_hash: &str, data: &KvSessionData, ttl_seconds: u64) {
    let key = format!("session:{}", session_hash);
    let url = format!(
        "{}?expiration_ttl={}",
        self.kv_url(&self.sessions_namespace_id, &key),
        ttl_seconds
    );

    self.client
        .put(&url)
        .header("Authorization", format!("Bearer {}", self.api_token))
        .header("Content-Type", "application/json")
        .body(serde_json::to_string(data).unwrap())
        .send()
        .await;
}

The key is session:{sha256_hash} and the value is just the user data we need for SSR. Username, user ID, email, preferred currency, expiration time. That’s it. Nothing sensitive. The session token itself is never stored in KV, only its hash.

We also set expiration_ttl so Cloudflare automatically cleans up expired sessions. No cron jobs, no cleanup scripts.

When a user logs out, we delete it from KV too.

Read side: SvelteKit checks KV first

In hooks.server.ts, before we try the API call, we check KV:

const handle: Handle = async ({ event, resolve }) => {
    const sessionCookie = event.cookies.get('__Secure-session') || event.cookies.get('session');

    if (sessionCookie) {
        // Try KV first (Cloudflare edge, ~1-5ms)
        const kvUser = await tryKvSessionLookup(event.platform, sessionCookie);
        if (kvUser) {
            event.locals.user = kvUser;
            return resolve(event);  // done, no API call needed
        }

        // Fallback to API (~150-300ms)
        const response = await fetch(`${apiBase}/v1/user`, {
            headers: { Cookie: `session=${sessionCookie}` }
        });
        // ...
    }
};

The KV lookup function hashes the session token with SHA-256 (same way we hash it in the database) and checks KV:

async function tryKvSessionLookup(platform, sessionToken) {
    const kv = platform?.env?.SESSIONS_KV;
    if (!kv) return null;

    const sessionHash = sha256(sessionToken);
    const data = await kv.get(`session:${sessionHash}`, 'json');

    if (!data) return null;
    if (new Date(data.expires_at) <= new Date()) return null;

    return {
        id: data.user_id,
        username: data.username,
        email: data.email,
        preferred_display_currency: data.preferred_display_currency,
        created_at: data.created_at
    };
}

If KV has it, we skip the API call entirely. If not (KV miss, first deploy, whatever), we fall back to the normal API call. The user never notices either way.

What about the game catalog?

We did the same thing for our game catalog, we have 5000+ slot games, and loading that catalog from the API on every homepage visit was slow.

Now the Rust API pushes the full catalog to a second KV namespace called CATALOG_KV whenever games sync. The SvelteKit frontend reads from KV first:

const kv = platform?.env?.CATALOG_KV;
if (kv) {
    const data = await kv.get('catalog:all', 'json');
    if (data) {
        return json({ games: data.slice(0, limit) });
    }
}

// Fallback to VPS API
const res = await fetch(`${apiBase}/v1/slots/games?limit=${limit}&sort=popular`);

Homepage went from loading 5000 games from the API to reading them from the edge. Huge difference.

Does it actually matter?

Yeah. Here’s what changed:

WhatBeforeAfter
SSR auth check150-300ms (VPS round trip)1-5ms (edge KV)
Homepage catalog200-500ms (5000+ games from API)1-5ms (edge KV)
User experienceNoticeable delay on navigationBasically instant

Users that want to play on the site could be connecting from anywhere around the world, so the round trip adds up. With KV its replicated globally so it’s always close to the user.

Things I’d do differently

The fallback is important. When I first set this up I almost made KV the only path, KV writes can take a few seconds to propagate globally (it’s eventually consistent). So right after login, the KV data might not be there yet at the user’s edge node. The API fallback covers this gap.

Don’t put anything sensitive in KV. We only store the minimum needed for SSR: user ID, username, display preferences. No balances, no email verification status, nothing that could be stale in a dangerous way. The actual session validation still happens server side on any API call.

TTL is your friend. Setting expiration_ttl means we don’t have to worry about orphaned sessions in KV. If someone’s session expires, KV cleans it up automatically.

Setup

The wrangler.toml config:

[[kv_namespaces]]
binding = "SESSIONS_KV"
id = "your-namespace-id"

[[kv_namespaces]]
binding = "CATALOG_KV"
id = "your-namespace-id"

On the Rust side you need a Cloudflare API token with KV write permissions, and the namespace IDs as env vars.

© 2026 Colton Spyker