Guides

Performance Optimization

Strategies and patterns for maximizing Sia's serialization throughput and minimizing memory usage.

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

  1. Minimize allocations: reuse buffers and Sia instances wherever possible.
  2. Choose the smallest type: fewer bytes written means faster serialization and smaller payloads.
  3. Use reference semantics: avoid copies when you will consume data immediately.
  4. Batch operations: serialize multiple items in a single pass rather than creating new instances.
  5. 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:

Before
// Allocates a new 1 KB buffer (zeros it out)
const sia = Sia.alloc(1024);
sia.addString8("hello").addUInt32(42);
const bytes = sia.toUint8Array();
After
// 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-level reuse
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();
  }
}
Module-level reuse
const sharedSia = new Sia();

function serializeEvent(type: number, payload: string): Uint8Array {
  sharedSia.seek(0);
  sharedSia.addUInt8(type).addString16(payload);
  return sharedSia.toUint8Array();
}
Reused instances share the same underlying buffer. Always call 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.

Before
// 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
After
// 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.

Before
// Copies the byte array out of the buffer
const data = sia.readByteArray32();
processData(data);
// 'data' is an independent copy — safe but slower
After
// 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:

Copy (safe)
const bytes = sia.toUint8Array(); // allocates a new Uint8Array
socket.send(bytes);
Reference (fast)
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.

Before
// 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();
});
After
// 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.

Before
// Uses the full 32 MB shared buffer
const sia = new Sia();
sia.addString8(name); // only writing ~20 bytes
After
// 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.

Before
// UTF-8 encoding for ASCII-only content
sia.addString8("user_id");
sia.addString8("status_active");
sia.addString8("GET");
After
// 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() or Sia.allocUnsafe() instead of Sia.alloc()
  • Reuse Sia instances with seek(0) instead of creating new ones
  • Use addAscii8 for 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 String8 for strings under 255 bytes
  • Use ByteArrayN for fixed-size fields (no length prefix)
  • Pass asReference = true to readByteArray* 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 CaseKey Optimizations
WebSocket messagesReuse instances, allocUnsafe, toUint8ArrayReference
File format writingSia.alloc with estimated size, batch writes
Game state syncallocUnsafe, reference reads, smallest types
API serializationaddAscii8 for headers, String8 for fields
Log encodingModule-level shared instance, seek(0) between entries
Database storagetoUint8Array for persistence, right-sized types