SvelteKit Transporters Are Underated

Cole
by Cole Crouter
Posted on 2025-05-06, 8:21 p.m.
Tags: sveltejswebfullstack

SvelteKit Transporters Are Underated

In a project I’ve been working on recently, I’ve been making ample use of this feature. Doing so, I have learned more and more about how it works, and discovered some deep… dark… and scary ways to use it. I made a post on Reddit sharing one of my earlier findings, but I’ve learned more since, and decided it deserved a writeup worthy of such a splendid feature.

Transporter? I Hardly Know Her!

The transport hook is a scarcely documented feature of SvelteKit, added in 2.11 (during 2024’s “Advent of Svelte”). Unlike other hooks, it does not handle Request or RequestEvent objects. In fact, it’s not even a function! Huh?!? It’s a record that holds Transporters Here’s an example straight from the docs:

import { Vector } from '$lib/math';
import type { Transport } from '@sveltejs/kit';

export const transport: Transport = {
    Vector: {
        encode: (value) => value instanceof Vector && [value.x, value.y],
        decode: ([x, y]) => new Vector(x, y)
    }
};

If you’ve used SvelteKit (or any fullstack framework), odds are you have an inkling of what’s going on here. You see, when passing data between the server and client, data needs to be serialized. Normally this is done through JSON encoding, but this has a few… limitations (not foreshadowing). Most commonly, you can’t pass functions, or even classes for that matter (pffft classes, who uses those?)

Previously, if you want to do this in SvelteKit, you would need an intermediate +page.ts file to “deserialize” the data. This works because +page.ts runs on both client & server. This works well if you want to do dynamic imports based on server output.

Now, the transport hooks allows you to provide encode and decode functions for classes, and SvelteKit will automatically call them when appropriate.

Yipee! Article Over, Right? Wrong! Go sit back down; I have more to say.

How Serialization Works

It really depends on the framework. In most cases, you’re using JSON in some capacity. I heard that LinkedIn uses Protobuf for their serialization. If I’m missing something obvious, feel free to imagine yourself telling me about it (if it makes you feel better).

devalue

Typically, SvelteKit will use a package called devalue (also written by Rich Harris) to serialize data. This is from the README:

  • cyclical references (obj.self = obj)
  • repeated references ([value, value])
  • undefined, Infinity, NaN, -0
  • regular expressions
  • dates
  • Map and Set
  • BigInt
  • ArrayBuffer and Typed Arrays
  • custom types via replacers, reducers and revivers

So yeah, devalue has probably saved your butt without you even knowing it.

Problem?

So, here’s the thing… I was trying to send a lot of data. I was hitting a server-side memory limit (128MB on Cloudflare Pages) while encoding data. That’s to say, the exception lied somewhere between after I had return-ed my final return, and before the client received anything.

Unfortunately, Cloudflare doesn’t make it easy to debug this. Their dashboard even incorrectly classified the error as an “internal error” rather than a memory limit error. This left me to my own devices. I pointed my fat finger at the custom transports, and naievely concluded that the problem lied in deserialization; specifically that the referenced objects would be duplicated (a real limitation with JSON).

For example, take code like this:

class App {
    stuff: "wooo";
}

class User {
    app: App;
    constructor(app: App) {
        this.app = app;
    }
}

// I am a loading function, beep boop

const app = new App();
const users = getUsers(app); // returns User[]

return {
    users
};

It became clear to me that the app object would be duplicated for each user, and decided that would be my focus. This was dumb for many reasons, including the fact that my bottleneck was clearly server-side (don’t judge).

I posted on Reddit about this theoretical problem. Specifically, what happens when devalue is not used? If you write your own transporters, you lose out on devalue’s features, right? (Wrong).

I proposed a solution here., I wrote a simple class that would dedupe objects based on their parameterized constructor. I was very proud of this solution. Did it make the page load faster? Yes! Did it deduplicate the classes? Also yes! Did it fix the memory usage? Well, no…

devalue’s Role

After the most basic digging imaginable, I learned that my previous assumption was incorrect. SvelteKit passes transporters to devalue, meaning that the standard optimizations are still applied. This meant that I was probably deduplicating already deduplicated objects. However, there are limitations with devalue.

My initial guess wasn’t far off the mark, though. After some testing, it seems to me like devalue washes its hands of anything a transporter touches. This means that no deduplication was being done. My solution succeeded in optimizing serialization and deserialization, but the serialized data still contained duplicate data. That is where the memory issue came from.

The Real Problem

Optimizing my deserialization process gave the client a noticeable boost, but my serialization game was weak. The generated JSON string was so large that it was eating up all of my precious memory. Ultimately, what I had was a cache, and what I needed was a lookup table. I needed to entirely omit the app property from each serialized User object.

I eventually did find a solution, and we’ll get to that in a minute. But first, there was something my OCD needed to figure out.

The Dark Side of Transporters

export const transport: Transport = {
    Vector: {
        encode: (value) => value instanceof Vector && [value.x, value.y],
        decode: ([x, y]) => new Vector(x, y)
    }
};

Something about this abstraction seems off. First we specify the name of the class, then we specify encode, which contains a type guard to check if the value is an instance of Vector. Does the name actually matter? Furthermore, what’s up with the typing of the encode function? Transporter is a generic type, allowing you to specify the type input & output types, so why do we need a type guard? (Default type is any, naughty-naughty!)

I’ll save you the exposition; the name does not matter. The type guard is required, because devalue recursively runs the encode function on every value & property. I figured this out earlier, as I was having issues with inherited classes being decoded as their parent class.

You can write a transporter for any type.

A quick look at the devalue readme reveals how this works.

const stringified = devalue.stringify(new Vector(30, 40), {
    Vector: (value) => value instanceof Vector && [value.x, value.y]
});

console.log(stringified); // [["Vector",1],[2,3],30,40]

const vector = devalue.parse(stringified, {
    Vector: ([x, y]) => new Vector(x, y)
});

devalue simply uses the “name” to determine which decode function to call, with the class instance being serialized as [[Name, Unique ID], [???], ...Constructor Args].

What I Did

Rather than using a transport for my App class, I wrote one for User[]. For encoding, I simply run my serilization for each User, but then call Object.groupBy to group the users by their app property. The last, most important step is to remove the app property from each “serialized” User entry, effectively decoupling the User from the App. For decoding, I do the following:

  • Call Object.entries on the object
  • Iterate over the pairs (e.g., [SerializedApp, [SerializedUser1, SerializedUser2, ...]][])
    • Reconstruct the App
    • Iterate over the serialized users (e.g., [SerializedUser1, SerializedUser2, ...])
      • Reconstruct the User using the aforementioned App object.

This worked great! Not only did it fix the memory issue, but it also sped up the LCP by (2-3x) overall. Unfortunately, I still have some other issues to work out, but let’s forget about that for now.

Cool Applications

OK here’s the fun stuff. I love this feature because it’s a non-leaky abstraction (I won’t say “zero cost”). Just write the transporter once, and SvelteKit will handle the rest. Since you can write a transporter for any type, you should be able to do some pretty neat things, too.

Disclaimer: I don’t know if any of this is a good idea. But if it turns out to be a good idea, I’ll be waiting for my check in the mail.

  • JSON obfuscation
  • Protocol Buffers (or other binary formats)
  • Info redaction (e.g. removing sensitive data)

Of course, these only apply to loading functions, and not regular fetch-ing. Maybe you could write separate server & client hooks for this? I don’t know, I’m not your mom.