Serialization
September 8, 2026 ยท View on GitHub
seroval offers 3 modes of serialization: sync, async and streaming.
Basic serialization
serialize offers the basic form of serialization.
import { serialize } from 'seroval';
console.log(serialize({ foo: 'bar' })); // {foo:"bar"}
Async serialization
serializeAsync is similar to serialize except that it supports asynchronous values, such as Promise instances.
import { serializeAsync } from 'seroval';
console.log(await serializeAsync(Promise.resolve({ foo: 'bar'}))); // Promise.resolve({foo:"bar"})
Deduping references
If the serializer functions encounter multiple instances of the same reference, the reference will get deduped.
import { serialize } from 'seroval';
const parent = {};
const a = { parent };
const b = { parent };
const children = [a, b];
console.log(serialize(children)); // (h=>([{parent:h={}},{parent:h}]))()
Cyclic references
seroval also supports cyclic references.
import { serialize } from 'seroval';
const cyclic = {};
cyclic.self = cyclic;
console.log(serialize(cyclic)); // (h=>(h={},h.self=h,h))()
It also supports references that are mutually cyclic (e.g. they reference each other)
import { serialize } from 'seroval';
const nodeA = {};
const nodeB = {};
nodeA.next = nodeB;
nodeB.prev = nodeA;
console.log(serialize([nodeA, nodeB])); // ((h,j,k)=>(k=[h={next:j={}},j],j.prev=h,k))()
It can also detect potential temporal dead zone
import { serialize } from 'seroval';
const root = {};
const nodeA = { parent: root };
const nodeB = { parent: nodeA };
root.child = nodeA;
nodeA.child = nodeB;
console.log(serialize(root)); // ((h,j,k)=>(h={child:j={child:k={}}},j.parent=h,k.parent=j,h))()
Cross-reference serialization
serialize and serializeAsync can only dedupe references within its own script, but what if you want two or more scripts to share the same references?
crossSerialize and crossSerializeAsync provides the capability
import { crossSerialize } from 'seroval';
const nodeA = {};
const nodeB = {};
nodeA.next = nodeB;
nodeB.prev = nodeA;
// keeps track of the shared references
const refs = new Map();
console.log(crossSerialize(nodeA, { refs })); // ($R[0]={next:$R[1]={}},$R[1].prev=$R[0],$R[0])
console.log(crossSerialize(nodeB, { refs })); // $R[1]
Take note that cross-reference scripts relies on the global array variable $R, which you can declare either manually, or a script via getCrossReferenceHeader
import { getCrossReferenceHeader } from 'seroval';
console.log(getCrossReferenceHeader()) // self.$R=self.$R||[]
Re-isolating cross-reference
crossSerialize and crossSerializeAsync can accept a scopeId string which allows $R to be scoped based on the given scopeId.
import { crossSerialize } from 'seroval';
const nodeA = {};
const nodeB = {};
nodeA.next = nodeB;
nodeB.prev = nodeA;
// keeps track of the shared references
const refsA = new Map();
const refsB = new Map();
console.log(crossSerialize(nodeA, { refs: refsA, scopeId: 'A' })); // ($R=>$R[0]={next:$R[1]={}},$R[1].prev=$R[0],$R[0])($R["A"])
console.log(crossSerialize(nodeA, { refs: refsB, scopeId: 'B' })); // ($R=>$R[0]={next:$R[1]={}},$R[1].prev=$R[0],$R[0])($R["B"])
console.log(crossSerialize(nodeB, { refs: refsA, scopeId: 'A' })); // ($R=>$R[1])($R["A"])
console.log(crossSerialize(nodeB, { refs: refsB, scopeId: 'B' })); // ($R=>$R[1])($R["B"])
You can independently initialize the $R variable by doing
import { getCrossReferenceHeader } from 'seroval';
console.log(getCrossReferenceHeader('A')) // (self.$R=self.$R||{})["A"]=[]
console.log(getCrossReferenceHeader('B')) // (self.$R=self.$R||{})["B"]=[]
Streaming serialization
serialize doesn't support async values, but serializeAsync do. However, both methods are "blocking" in a sense that you need to wait the entire value to resolve before you can receive the serialized string.
With streaming serialization, you can receive the serialized string immediately for the synchronous part, while receiving the asynchronous part later on.
Streaming serialization relies on cross-referencing since you can think of it as multiple crossSerialize calls.
import { crossSerializeStream } from 'seroval';
crossSerializeStream(Promise.resolve({ foo: 'bar'}), {
onSerialize(data) {
console.log(data);
},
});
// Logs:
$R[0]=($R[2]=r=>(r.p=new Promise((s,f)=>{r.s=s,r.f=f})))($R[1]={p:0,s:0,f:0})
($R[4]=(r,d)=>{r.s(d),r.p.s=1,r.p.v=d})($R[1],$R[3]={foo:"bar"})
Note
Much like other cross-reference methods, you can pass a refs and scopeId option.
createStream
Streaming serialization allows pushing values through Promise instances. However, Promise instances only resolve to a single value, but what if you can resolve multiple values at different times?
ReadableStream is capable of doing so, however it's not a JS standard (seroval supports it through plugins). Observable could have been nice however it's not a JS standard yet
With two of the options not available, seroval provides a streaming primitive called createStream which is capable of buffering streaming data as well as emitting pushed data.
import { createStream } from 'seroval';
const stream = createStream();
// Push early
stream.next('foo');
stream.next('bar');
// Add a listener
stream.on({
next(data) {
console.log('NEXT', data);
},
throw(data) {
console.log('THROW', data);
},
return(data) {
console.log('RETURN', data);
},
});
// Immediately logs `NEXT foo` and `NEXT bar`
stream.return('baz'); // RETURN baz
createStream instances are also serializable for async serialization
console.log(await serializeAsync(stream));
// which logs
((h,j)=>((j=((b,a,s,l,p,f,e,n)=>(b=[],a=!0,s=!1,l=[],s=0,f=(v,m,x)=>{for(x=0;x<s;x++)l[x]&&l[x][m](v)},n=(o,x,z,c)=>{for(x=0,z=b.length;x<z;x++)(c=b[x],x===z-1?o[s?"return":"throw"](c):o.next(c))},e=(o,t)=>(a&&(l[t=p++]=o),n(o),()=>{a&&(l[t]=void 0)}),{__SEROVAL_STREAM__:!0,on:o=>e(o),next:v=>{a&&(b.push(v),f(v,"next"))},throw:v=>{a&&(b.push(v),f(v,"throw"),a=s=!1,l.length=0)},return:v=>{a&&(b.push(v),f(v,"return"),a=!1,s=!0,l.length=0)}}))(),j.next("foo"),j.next("bar"),j.return("baz"),j)))()
Streaming serialization is also supported
crossSerializeStream(stream, {
onSerialize(data) {
console.log(data);
},
});
// which logs
$R[0]=($R[1]=(b,a,s,l,p,f,e,n)=>(b=[],a=!0,s=!1,l=[],s=0,f=(v,m,x)=>{for(x=0;x<s;x++)l[x]&&l[x][m](v)},n=(o,x,z,c)=>{for(x=0,z=b.length;x<z;x++)(c=b[x],x===z-1?o[s?"return":"throw"](c):o.next(c))},e=(o,t)=>(a&&(l[t=p++]=o),n(o),()=>{a&&(l[t]=void 0)}),{__SEROVAL_STREAM__:!0,on:o=>e(o),next:v=>{a&&(b.push(v),f(v,"next"))},throw:v=>{a&&(b.push(v),f(v,"throw"),a=s=!1,l.length=0)},return:v=>{a&&(b.push(v),f(v,"return"),a=!1,s=!0,l.length=0)}}))()
$R[0].next("foo")
$R[0].next("bar")
$R[0].return("baz")
JSON serialization
The mentioned serialization methods are ideal for server-to-client communication, however, client-to-server communication requires a sanitized data, because the medium is prone to RCE. seroval offers JSON modes as an alternative.
| modes | JS | JSON |
|---|---|---|
| sync | serialize | toJSON |
| async | serializeAsync | toJSONAsync |
| cross-sync | crossSerialize | toCrossJSON |
| cross-async | crossSerializeAsync | toCrossJSONAsync |
| streaming | crossSerializeStream | toCrossJSONStream |
| deserialization | deserialize | fromJSON |
| cross-deserialization | deserialize | fromCrossJSON |
Binary values
ArrayBuffers are encoded as base64. Seroval uses native Node or browser encoding when available, with a fallback for runtimes without either API.
Compact typed arrays and DataViews
By default, Seroval preserves each view's full backing buffer, byte offset, and shared-buffer identity. A small view can therefore serialize a much larger buffer, including bytes outside the view.
Pass compactArrayBufferViews: true to serialize only each view's visible bytes:
import { toJSON, fromJSON } from 'seroval';
const view = new Uint8Array(new ArrayBuffer(512 * 1024), 128, 1024);
const result = fromJSON<Uint8Array>(
toJSON(view, { compactArrayBufferViews: true }),
);
result.byteOffset; // 0
result.buffer.byteLength; // 1024
This option applies to all serialization modes and Serializer. Each distinct
view gets its own copied buffer, even when views overlap or span the entire
original buffer. Repeated references to the same view still refer to the same
deserialized view. The original values are not changed.
Compaction intentionally breaks buffer sharing between distinct views and any separately serialized backing buffer. If the input also contains the backing ArrayBuffer itself, that buffer is still serialized in full. Use the default when buffer identity or offsets matter.
JSON decoding limits
Serialization can produce buffers larger than the default JSON decoding limit.
fromJSON and fromCrossJSON accept maxBase64Length to set a receiver-owned
limit, measured in encoded characters per ArrayBuffer. The default remains
1,000,000 characters (up to 750,000 decoded bytes).
const buffer = new ArrayBuffer(1024 * 1024);
const json = toJSON(buffer);
// A 1 MiB buffer requires 1,398,104 base64 characters.
const result = fromJSON<ArrayBuffer>(json, { maxBase64Length: 1_398_104 });
The limit must be a non-negative safe integer. Zero permits only empty buffers.
Oversized input is rejected before base64 decoding or allocating its output
buffer, with a RangeError cause inside SerovalDeserializationError. Invalid
option values throw RangeError when the deserializer is created. Set the limit
according to the receiving application's needs; it is a per-buffer limit, not a
total payload or memory budget. JavaScript evaluation through deserialize does
not use these JSON decoding limits.
Push-based streaming serialization
Note
Coming soon.
Plugins
All serialization methods can accept plugins. Plugins allows extending the serialization capabilities of seroval. You can visit such examples on seroval-plugins.
import { serializeAsync } from 'seroval';
import { BlobPlugin } from 'seroval-plugins/web';
const example = new Blob(['Hello, World!'], { type: 'text/plain '});
console.log(await serializeAsync(example, {
plugins: [
BlobPlugin,
],
})); // new Blob([new Uint8Array([72,101,108,108,111,44,32,87,111,114,108,100,33]).buffer],{type:"text/plain "})