Handling Binary Data with Uint8Array

Binary data consists of raw bytes that represent content like images, audio, or network packets. In Deno TypeScript, Uint8Array provides a high-performance way to manipulate these bytes by directly accessing an underlying ArrayBuffer. This direct memory access is crucial for applications requiring speed, such as real-time data processing, file handling, or network servers.

Understanding Zero-Copy and Zero-Allocation

Zero-copy means multiple views reference the same memory without duplicating data, reducing memory usage. Zero-allocation reuses existing memory buffers, avoiding the creation of new ones, which saves CPU cycles and prevents garbage collection overhead.

flowchart TD A[ArrayBuffer
Raw Memory Block] --> B[Uint8Array
Full View] A --> C[Subarray
Partial View] B --> D[Accesses] C --> D D --> E[Same Underlying
Memory] style A fill:#e1f5fe style E fill:#f3e5f5

How Memory Sharing Works:

  1. ArrayBuffer allocates raw memory space
  2. Uint8Array creates a view over the entire buffer
  3. subarray() creates additional views pointing to the same memory
  4. All operations affect the shared underlying bytes

Performance Techniques with Code Examples

1. Zero-Copy Views with subarray()

Creates lightweight windows on existing memory without copying data.

// Initialize a Uint8Array with 5 bytes
const data = new Uint8Array([1, 2, 3, 4, 5]);
// Create a zero-copy view from index 1 to 3 (exclusive)
const slice = data.subarray(1, 4);
// Modify the first element of the slice
slice[0] = 99; // Affects original buffer

console.log("Slice:", slice);
console.log("Original:", data);

Output:

Slice: Uint8Array(3) [ 99, 3, 4 ]
Original: Uint8Array(5) [ 1, 99, 3, 4, 5 ]

Memory Visualization:

Initial State:
┌───┬───┬───┬───┬───┐
│ 12345 │ ← ArrayBuffer with 5 bytes
└───┴───┴───┴───┴───┘

After subarray(1, 4):
┌───┬───┬───┬───┬───┐
│ 12345 │ ← Same memory
└───┴───┴───┴───┴───┘
     ↑-------↑ ← slice references bytes 1-3

After slice[0] = 99:
┌───┬────┬───┬───┬───┐
│ 199345 │ ← Both views show the change
└───┴────┴───┴───┴───┘

2. In-Place Copying with copyWithin()

Copies bytes within the same buffer without allocating new memory.

// Create a Uint8Array with initial bytes
const data = new Uint8Array([1, 2, 3, 4, 5]);
// Copy bytes from index 3 to end, paste starting at index 0
data.copyWithin(0, 3);

console.log("After copyWithin:", data);

Output:

After copyWithin: Uint8Array(5) [ 4, 5, 3, 4, 5 ]

Step-by-Step Operation:

Step 1: Identify source range (index 3 to end)
Original: ┌───┬───┬───┬───┬───┐
          │ 12345 │
          └───┴───┴───┴───┴───┘
                   ↑-----↑ ← Bytes to copy: [4, 5]

Step 2: Copy to target (starting at index 0)
Result:   ┌───┬───┬───┬───┬───┐
          │ 45345 │ ← Original bytes 3-4 copied to 0-1
          └───┴───┴───┴───┴───┘

3. Typed Data Access with DataView

Provides flexible access to multi-byte data types in the same memory.

// Allocate a 4-byte ArrayBuffer
const buffer = new ArrayBuffer(4);
// Create a DataView for typed operations
const view = new DataView(buffer);
// Write a 16-bit integer (258 = 0x0102) in little-endian
view.setInt16(0, 258, true);

// View the buffer as a Uint8Array
const uint8View = new Uint8Array(buffer);
console.log("Bytes:", uint8View);

Output:

Bytes: Uint8Array(4) [ 2, 1, 0, 0 ]

Memory Representation:

Value 258 in binary: 00000001 00000010 (0x0102)

Little-Endian Storage (least significant byte first):
┌─────┬─────┬─────┬─────┐
│ 0x020x010x000x00│ ← Bytes [2, 1, 0, 0]
└─────┴─────┴─────┴─────┘
 ↑     ↑
LSB   MSB

DataView handles the byte ordering automatically
based on the littleEndian parameter (true).

4. Node.js Compatibility with Buffer

Deno's Buffer provides utility methods while maintaining zero-copy efficiency.

// Import Buffer from Deno's Node compatibility layer
import { Buffer } from "node:buffer";

// Create Buffer from initial bytes
const buf = Buffer.from([1, 2, 3]);
// Write ASCII 'AB' (65, 66) at index 1
buf.write("AB", 1, 2, "utf8");

console.log("Buffer:", buf);
console.log("As Uint8Array:", new Uint8Array(buf));

Output:

Buffer: <Buffer 01 41 42>
As Uint8Array: Uint8Array(3) [ 1, 65, 66 ]

What Happens:

Initial: ┌───┬───┬───┐
         │ 123 │
         └───┴───┴───┘

After write("AB", 1):
ASCII 'A' = 65, 'B' = 66
         ┌───┬────┬────┐
         │ 16566 │
         └───┴────┴────┘

5. Direct Memory Modification

Modify values directly without creating new instances.

// Create Uint8Array with initial bytes
const data = new Uint8Array([10, 20, 30]);
// Directly set index 1 to 99
data[1] = 99;

console.log("Modified array:", data);

Output:

Modified array: Uint8Array(3) [ 10, 99, 30 ]

Memory Change Process:

Step 1: Initial memory state
┌────┬────┬────┐
│ 102030 │
└────┴────┴────┘
     ↑
     Index 1 (value 20)

Step 2: Direct assignment data[1] = 99
┌────┬────┬────┐
│ 109930 │ ← Memory updated in-place
└────┴────┴────┘
     ↑
     Index 1 now 99

No new arrays created, no memory copied.

Performance Characteristics

classDiagram class ArrayBuffer { +byteLength +slice() } class Uint8Array { +length +subarray() +copyWithin() +byteAccess } class DataView { +getInt16() +setInt32() +typedAccess } class Buffer { +write() +toString() +nodeCompat } ArrayBuffer <|-- Uint8Array Uint8Array <|-- Buffer ArrayBuffer <|-- DataView

When to Use Each:

  • ArrayBuffer: Raw memory allocation
  • Uint8Array: General byte manipulation, maximum performance
  • DataView: Structured binary data with specific byte ordering
  • Buffer: Node.js compatibility and string operations

Real-World Example: Network Packet Processing

// Process network packets with zero-copy techniques
function processPacket(packet: Uint8Array): {
  header: Uint8Array;
  payload: Uint8Array;
  checksum: number;
} {
  // Zero-copy header extraction (first 4 bytes)
  const header = packet.subarray(0, 4);
  
  // Zero-copy payload extraction (bytes 4 to end-2)
  const payload = packet.subarray(4, packet.length - 2);
  
  // Read checksum using DataView (last 2 bytes)
  const checksumView = new DataView(
    packet.buffer,
    packet.byteOffset + packet.length - 2,
    2
  );
  const checksum = checksumView.getUint16(0, true);
  
  return { header, payload, checksum };
}

// Example usage
const networkPacket = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]);
const result = processPacket(networkPacket);

console.log("Header:", result.header);
console.log("Payload:", result.payload);
console.log("Checksum:", result.checksum);

Best Practices for Maximum Performance

  1. Use subarray() instead of slice() - avoids memory copying
  2. Reuse buffers - prevents allocation overhead
  3. Prefer direct index access - fastest for single byte changes
  4. Use DataView for structured data - handles byte ordering automatically
  5. Choose Uint8Array for pure performance - minimal overhead
  6. Use Buffer for I/O operations - better string handling utilities

Resources

Conclusion

Master these binary data techniques in Deno TypeScript for optimal performance:

  • subarray(): Create zero-copy views for memory efficiency
  • copyWithin(): Perform in-place operations without allocation
  • DataView: Access typed data with proper byte ordering
  • Direct manipulation: Achieve maximum speed for simple changes

Choose the right tool for your task: Uint8Array for raw performance in critical paths, and Buffer when you need Node.js compatibility or utility methods.

I hope this post was helpful to you.

Leave a reaction if you liked this post!