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 Transporter
s 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 aforementionedApp
object.
- Reconstruct the
- Reconstruct the
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.