@nxtedition/slice
v1.1.3
Published
A high-performance buffer slice and pool allocator for Node.js.
Maintainers
Keywords
Readme
@nxtedition/slice
A high-performance buffer slice and pool allocator for Node.js.
Why
Node.js Buffer.subarray() is slow. Every call creates a new Buffer object — a typed array wrapper with prototype chain setup, internal slot initialization, and bounds validation. This overhead is negligible for occasional use, but becomes a bottleneck in hot paths — protocol parsers, binary codecs, streaming pipelines — where thousands of sub-views are created per second.
Buffer.allocUnsafe() is worse. Allocations above the pool size (Buffer.poolSize) threshold go through allocBuffer which crosses into C++ to create a new ArrayBuffer backing store. The pooled fast path still involves bookkeeping and pool management overhead, and every allocation produces a new Buffer object that the GC must eventually collect.
Slice avoids this entirely. It is a plain JavaScript object with buffer, byteOffset, and byteLength fields. Creating a slice is just setting three properties — no typed array wrapper creation, no GC pressure from short-lived Buffer objects. Operations like toString, copy, and compare delegate directly to the underlying buffer with the correct offsets.
PoolAllocator takes this further. Like Node's internal pool, it has management overhead — but it rarely (if ever) allocates new backing stores, and because Slice is a plain object rather than a typed array, resizing or freeing a slice doesn't produce garbage for V8 to collect. It pre-allocates a large contiguous buffer and hands out regions using power-of-2 bucketing. When a slice is freed, its slot is recycled. When a slice is resized within the same bucket, no data moves at all — just a field update. This gives you malloc/realloc/free semantics with near-zero overhead per operation. The trade-off is upfront memory allocation and internal fragmentation from power-of-2 rounding — a 10-byte allocation uses a 16-byte slot. Buckets are also independent: a freed 16-byte slot cannot satisfy a 32-byte request, so the pool can become fragmented if allocation sizes are uneven. Use stats to monitor pool utilization and tune the pool size for your workload.
Install
npm install @nxtedition/sliceUsage
import { Slice, PoolAllocator } from '@nxtedition/slice'
// Create a slice from an existing buffer
const buf = Buffer.from('hello world')
const slice = new Slice(buf, 6, 5)
slice.toString() // 'world'
// Use a pool allocator for high-throughput allocation
const pool = new PoolAllocator()
const s = new Slice()
pool.realloc(s, 64) // allocate 64 bytes from pool
s.write('hello')
pool.realloc(s, 128) // grow — may reuse same slot
pool.realloc(s, 0) // free — slot is recycledBenchmarks
Measured on Apple M3 Pro, Node.js v25.3.0:
Allocation
| Operation | Buffer.allocUnsafe | Buffer.allocUnsafeSlow | PoolAllocator | Speedup |
| ---------------- | -------------------: | -----------------------: | --------------: | ------: |
| alloc 64 bytes | 38.08 ns | 41.23 ns | 5.66 ns | 6.7x |
| alloc 256 bytes | 52.09 ns | 231.46 ns | 5.90 ns | 8.8x |
| alloc 1024 bytes | 91.24 ns | 340.75 ns | 5.83 ns | 15.6x |
| alloc 4096 bytes | 446.53 ns | 437.83 ns | 6.24 ns | 71.6x |
Allocation (GC)
| Operation | Buffer.allocUnsafe | Buffer.allocUnsafeSlow | PoolAllocator | Speedup |
| ---------------- | -------------------: | -----------------------: | --------------: | ------: |
| alloc 64 bytes | 400.46 ns | 167.94 ns | 6.33 ns | 63.3x |
| alloc 256 bytes | 309.57 ns | 500.58 ns | 6.35 ns | 48.7x |
| alloc 4096 bytes | 653.40 ns | 620.19 ns | 6.32 ns | 103.4x |
Under GC pressure, the advantage grows dramatically — up to 103x faster — because PoolAllocator reuses slots from a pre-allocated buffer and never creates objects for V8 to trace.
Slice creation vs Buffer.subarray
| Operation | Buffer.subarray | Slice | Speedup |
| ---------------------- | ----------------: | -----------: | ------: |
| subarray 64 bytes | 38.11 ns | 12.99 ns | 2.9x |
| subarray 1024 bytes | 36.87 ns | 13.26 ns | 2.8x |
| subarray 64 bytes (GC) | 127.30 ns | 81.60 ns | 1.6x |
Combined operations
| Operation | Buffer | PoolAllocator | Speedup |
| ------------------------------------- | --------: | --------------: | ------: |
| alloc/free 64 bytes | 32.72 ns | 30.80 ns | 1.1x |
| alloc/free 64 bytes (GC) | 273.35 ns | 73.34 ns | 3.7x |
| alloc/free 256 bytes | 58.88 ns | 29.38 ns | 2.0x |
| realloc churn (64 → 128 → 64) | 93.63 ns | 26.99 ns | 3.5x |
| realloc in-place (grow within bucket) | 60.16 ns | 11.06 ns | 5.4x |
| 10 concurrent allocs then free | 406.26 ns | 337.83 ns | 1.2x |
| 10 concurrent allocs then free (GC) | 647.95 ns | 649.73 ns | 1.0x |
API
Slice
A lightweight view over a Buffer with explicit offset and length tracking.
new Slice(buffer?: Buffer, byteOffset?: number, byteLength?: number, maxByteLength?: number)
Creates a new slice. All parameters are optional — defaults to an empty slice.
Properties
buffer: Buffer— The underlyingBufferbyteOffset: number— Start offset into the bufferbyteLength: number— Current length in bytesmaxByteLength: number— Maximum capacity in byteslength: number— Alias forbyteLength
Methods
reset(): void— Clear the slice back to empty state. Note: this does not return the slot to thePoolAllocator— you must callrealloc(slice, 0)to free pool memory.copy(target: Buffer | Slice, targetStart?: number, sourceStart?: number, sourceEnd?: number): number— Copy data to aBufferorSlice. Returns bytes copied.compare(target: Buffer | Slice, targetStart?: number, targetEnd?: number, sourceStart?: number, sourceEnd?: number): -1 | 0 | 1— Compare with aBufferorSlicewrite(string: string, offset?: number, length?: number, encoding?: BufferEncoding): number— Write a string into the slice. Returns bytes written.set(source: Buffer | Slice | null | undefined, offset?: number): void— Copy from aBufferorSliceinto this sliceat(index: number): number— Read byte at index (supports negative indexing)test(expr: { test(buffer: Buffer, byteOffset: number, byteLength: number): boolean }): boolean— Test the slice against an expression objecttoString(encoding?: BufferEncoding, start?: number, end?: number): string— Convert to stringtoBuffer(start?: number, end?: number): Buffer— Return aBufferview
Static
Slice.EMPTY_BUF: Buffer— Shared empty buffer singleton
PoolAllocator
Pre-allocates a contiguous memory pool and manages slices using power-of-2 bucketing.
new PoolAllocator(poolTotal?: number)
Creates a pool allocator. Default pool size is 128 MB.
Methods
realloc(slice: Slice, byteLength: number): Slice— Allocate, resize, or free a slice. Pass0to free.isFromPool(slice: Slice | null | undefined): boolean— Check if a slice was allocated from this pool
Properties
size: number— Total size of all active allocationsstats: { size: number, padding: number, ratio: number, poolTotal: number, poolUsed: number, poolSize: number, poolCount: number }— Detailed allocation statistics
License
MIT
