Performance Optimization
Overview
Sia is designed for high-throughput binary serialization. In our benchmarks, it is 2-4x faster than JSON, MessagePack, and Protobuf. There are several techniques you can apply to get even more out of it.
Five Key Principles
- Minimize allocations: reuse buffers and Sia instances wherever possible.
- Choose the smallest type: fewer bytes written means faster serialization and smaller payloads.
- Use reference semantics: avoid copies when you will consume data immediately.
- Batch operations: serialize multiple items in a single pass rather than creating new instances.
- Pre-size buffers: allocating the right size up front avoids unnecessary overhead.
Optimization Strategies
1. Use Unsafe Allocation
The default new Sia() constructor uses a shared 32 MB global buffer (lazy-allocated on first use), which avoids per-call allocation. For cases where you need a sized buffer without a fresh allocation, use allocUnsafe:
// Allocates a new 1 KB buffer (zeros it out)
const sia = Sia.alloc(1024);
sia.addString8("hello").addUInt32(42);
const bytes = sia.toUint8Array();
// Uses a slice of the shared buffer (no allocation, no zeroing)
const sia = Sia.allocUnsafe(1024);
sia.addString8("hello").addUInt32(42);
const bytes = sia.toUint8Array();
allocUnsafe is safe as long as you write all bytes before reading and
extract data before the shared buffer wraps around. See the Unsafe
Buffers guide for details.2. Reuse Sia Instances
Creating a new Sia instance per operation is wasteful. Reuse instances by calling seek(0) to reset the offset.
class UserSerializer {
private sia = new Sia();
serialize(user: User): Uint8Array {
this.sia.seek(0);
this.sia.addString8(user.name).addUInt8(user.age).addBool(user.active);
return this.sia.toUint8Array();
}
}
const sharedSia = new Sia();
function serializeEvent(type: number, payload: string): Uint8Array {
sharedSia.seek(0);
sharedSia.addUInt8(type).addString16(payload);
return sharedSia.toUint8Array();
}
toUint8Array() (which copies) before the next seek(0). Only use
toUint8ArrayReference() if you will consume the data synchronously before
any further writes.3. Choose Optimal Data Types
Use the smallest type that fits your data.
// Wasteful: using 32-bit integers for small values
sia.addUInt32(userAge); // 4 bytes for a value 0–150
sia.addString32(username); // 4-byte length prefix for a short string
sia.addUInt32(statusCode); // 4 bytes for a value 0–5
// Optimal: right-sized types
sia.addUInt8(userAge); // 1 byte — age fits in 0–255
sia.addString8(username); // 1-byte length prefix — names are < 255 bytes
sia.addUInt8(statusCode); // 1 byte — enum-like value
Savings: 8 bytes per record. At 100,000 records, that is 800 KB saved.
4. Use Reference Semantics
When you only need to read data temporarily (e.g., to process and discard), avoid copying by using zero-copy references.
// Copies the byte array out of the buffer
const data = sia.readByteArray32();
processData(data);
// 'data' is an independent copy — safe but slower
// Returns a subarray reference — no copy
const data = sia.readByteArray32(true);
processData(data);
// 'data' shares memory with the Sia buffer — faster
The same applies to output extraction:
const bytes = sia.toUint8Array(); // allocates a new Uint8Array
socket.send(bytes);
const bytes = sia.toUint8ArrayReference(); // subarray, no copy
socket.send(bytes); // send immediately before buffer reuse
5. Batch Operations
Serialize multiple items in a single Sia instance rather than creating one per item.
// One Sia instance per message — wasteful
const messages = events.map((event) => {
const sia = new Sia();
sia.addUInt8(event.type).addString8(event.data);
return sia.toUint8Array();
});
// Single Sia instance for all messages
const sia = new Sia();
sia.addArray16(events, (s, event) => {
s.addUInt8(event.type).addString8(event.data);
});
const batch = sia.toUint8Array();
6. Pre-size Buffers
If you know the approximate size of your serialized data, allocate the right amount up front to avoid relying on the shared buffer or over-allocating.
// Uses the full 32 MB shared buffer
const sia = new Sia();
sia.addString8(name); // only writing ~20 bytes
// Allocate just what you need
const sia = Sia.allocUnsafe(64); // small buffer for a small payload
sia.addString8(name);
7. Optimize String Encoding
Choose the right string encoding for your data profile.
// UTF-8 encoding for ASCII-only content
sia.addString8("user_id");
sia.addString8("status_active");
sia.addString8("GET");
// ASCII encoding is faster for ASCII-only strings
sia.addAscii8("user_id");
sia.addAscii8("status_active");
sia.addAscii8("GET");
addAscii8 performs a direct byte-per-character copy without the overhead of TextEncoder. For ASCII-only strings, it is measurably faster.
Advanced Techniques
Custom Serialization Functions
Pre-define serialization functions to avoid creating closures in hot paths:
// Define once at module level
const serializeUser = (s: Sia, user: User): void => {
s.addUInt32(user.id)
.addString8(user.name)
.addUInt8(user.age)
.addBool(user.active);
};
const deserializeUser = (s: Sia): User => ({
id: s.readUInt32(),
name: s.readString8(),
age: s.readUInt8(),
active: s.readBool(),
});
// Use in hot paths without allocating closures
sia.addArray16(users, serializeUser);
sia.seek(0);
const decoded = sia.readArray16(deserializeUser);
Memory Pooling
For concurrent workloads, maintain a pool of pre-allocated Sia instances:
class SiaPool {
private pool: Sia[] = [];
private size: number;
constructor(poolSize: number, bufferSize: number) {
this.size = poolSize;
for (let i = 0; i < poolSize; i++) {
this.pool.push(Sia.alloc(bufferSize));
}
}
acquire(): Sia | undefined {
const sia = this.pool.pop();
if (sia) sia.seek(0);
return sia;
}
release(sia: Sia): void {
if (this.pool.length < this.size) {
this.pool.push(sia);
}
}
}
const pool = new SiaPool(16, 4096);
Streaming Serialization
For large datasets, serialize in chunks to avoid memory pressure:
async function serializeStream(
items: AsyncIterable<User>,
send: (data: Uint8Array) => Promise<void>,
batchSize: number = 100,
): Promise<void> {
const sia = new Sia();
let batch: User[] = [];
for await (const item of items) {
batch.push(item);
if (batch.length >= batchSize) {
sia.seek(0);
sia.addArray16(batch, serializeUser);
await send(sia.toUint8Array());
batch = [];
}
}
// Flush remaining items
if (batch.length > 0) {
sia.seek(0);
sia.addArray16(batch, serializeUser);
await send(sia.toUint8Array());
}
}
Zero-Copy Deserialization
When reading data that will be processed immediately, use reference reads throughout:
function processMessage(data: Uint8Array): void {
const sia = new Sia(data);
const type = sia.readUInt8();
// Read payload as reference — no copy
const payload = sia.readByteArray32(true);
// Process payload in-place
handlePayload(type, payload);
// After this function returns, 'payload' is no longer valid
}
Real-World Example: WebSocket Server
import { Sia } from "@timeleap/sia";
interface Message {
type: number;
timestamp: number;
sender: string;
payload: string;
}
// Module-level reusable instances
const writer = new Sia();
const serializeMessage = (s: Sia, msg: Message): void => {
s.addUInt8(msg.type)
.addUInt64(msg.timestamp)
.addAscii8(msg.sender)
.addString16(msg.payload);
};
const deserializeMessage = (s: Sia): Message => ({
type: s.readUInt8(),
timestamp: s.readUInt64(),
sender: s.readAscii8(),
payload: s.readString16(),
});
function handleIncoming(data: Uint8Array): Message {
const reader = new Sia(data);
return deserializeMessage(reader);
}
function sendBatch(messages: Message[]): Uint8Array {
writer.seek(0);
writer.addArray16(messages, serializeMessage);
return writer.toUint8Array();
}
Performance Checklist
Maximum Throughput
- Use
new Sia()orSia.allocUnsafe()instead ofSia.alloc() - Reuse Sia instances with
seek(0)instead of creating new ones - Use
addAscii8for ASCII-only strings - Define serializer functions at module level (no inline closures)
- Use
addArray*for collections instead of manual loops - Use
toUint8ArrayReference()when consuming data immediately
Minimum Memory
- Use the smallest integer type that fits the data range
- Use
String8for strings under 255 bytes - Use
ByteArrayNfor fixed-size fields (no length prefix) - Pass
asReference = truetoreadByteArray*for temporary data - Batch serialize with
addArray*to avoid per-item overhead - Pre-size buffers with
Sia.allocUnsafe(estimatedSize)
Production
- Profile before optimizing: measure, don't guess
- Validate serialized sizes match expectations
- Test with realistic data volumes
- Monitor GC pauses if memory-sensitive
Common Anti-Patterns
Creating instances in loops
// Bad: allocates a new Sia per iteration
for (const item of items) {
const sia = new Sia();
sia.addUInt32(item.id).addString8(item.name);
results.push(sia.toUint8Array());
}
// Good: reuse a single instance
const sia = new Sia();
for (const item of items) {
sia.seek(0);
sia.addUInt32(item.id).addString8(item.name);
results.push(sia.toUint8Array());
}
Over-sized types
// Bad: UInt64 for a value that fits in UInt8
sia.addUInt64(userRole); // wastes 7 bytes
// Good: match the type to the data
sia.addUInt8(userRole); // 1 byte is enough
Ignoring reference semantics
// Bad: copying data you'll discard immediately
const temp = sia.readByteArray32(); // copies
const hash = computeHash(temp); // temp is never used again
// Good: read as reference when temporary
const temp = sia.readByteArray32(true); // zero-copy
const hash = computeHash(temp);
Performance Tips by Use Case
| Use Case | Key Optimizations |
|---|---|
| WebSocket messages | Reuse instances, allocUnsafe, toUint8ArrayReference |
| File format writing | Sia.alloc with estimated size, batch writes |
| Game state sync | allocUnsafe, reference reads, smallest types |
| API serialization | addAscii8 for headers, String8 for fields |
| Log encoding | Module-level shared instance, seek(0) between entries |
| Database storage | toUint8Array for persistence, right-sized types |