Network data rarely arrives in neat application-sized messages. A socket can deliver half a header, a complete frame, or several frames at once. A decoder must remember which bytes have arrived and which it has already consumed. Rebuilding a fresh array after every read makes that bookkeeping harder.
SwiftNIO provides ByteBuffer in NIOCore. Rather than treating allocated memory as readable data, the type tracks a reader index and a writer index. The bytes between them are available to read. We will examine what happens on a successful read, an incomplete integer, a length-prefixed message, and a copied buffer. These small examples reveal both the convenience and the limits of this design.
Written Is Not the Same as Readable
A new buffer can have allocated storage without containing any readable bytes. Writing moves the writer index; reading moves the reader index.
import NIOCore
var buffer = ByteBufferAllocator().buffer(capacity: 16)
print("start:", buffer.readerIndex, buffer.writerIndex, buffer.readableBytes)
buffer.writeString("HELLO")
print("written:", buffer.readerIndex, buffer.writerIndex, buffer.readableBytes)
print("read:", buffer.readString(length: 2)!)
print("remaining:", buffer.readerIndex, buffer.writerIndex, buffer.readableBytes)
print("next:", buffer.readString(length: 3)!)
start: 0 0 0
written: 0 5 5
read: HE
remaining: 2 5 3
next: LLO
The initial capacity request is 16, yet no bytes are readable. Writing the five ASCII bytes of HELLO moves the writer index to 5. Reading HE advances the reader index to 2, leaving LLO readable. The second read consumes those three bytes. Reading advances a position; it does not mean that the original allocation shrinks after each operation.
A decoder should check readableBytes, not allocated capacity, when deciding whether a complete field is present.
Incomplete Input Must Not Consume a Field
Consider a two-byte unsigned integer in network byte order. One byte alone is not enough to decode it.
import NIOCore
var header = ByteBufferAllocator().buffer(capacity: 8)
header.writeInteger(UInt8(0x01))
let incomplete: UInt16? = header.readInteger()
print("incomplete:", incomplete == nil, header.readerIndex, header.readableBytes)
header.writeInteger(UInt8(0x02))
let complete: UInt16? = header.readInteger()
print("complete:", complete!, header.readerIndex, header.readableBytes)
incomplete: true 0 1
complete: 258 2 0
The first read returns nil and leaves the reader index at 0. Once 0x02 arrives, the two bytes 0x01 0x02 decode as 258 using the API’s default big-endian ordering. Only the successful read advances the reader index to 2. This is a useful contract for incremental parsing: insufficient data is not silently discarded.
The example does not distinguish a malformed protocol field from a merely incomplete one. That distinction belongs to the application’s decoder.
Peeking Before Committing
A common wire format stores a payload length before the payload itself. A decoder may need to inspect that length without consuming it. getInteger(at:) reads at a specified index without moving the reader index; readInteger() consumes on success.
import NIOCore
var frame = ByteBufferAllocator().buffer(capacity: 8)
frame.writeInteger(UInt16(3))
frame.writeString("cat")
let length: UInt16? = frame.getInteger(at: frame.readerIndex)
print("peek:", length!, frame.readerIndex, frame.readableBytes)
let consumedLength: UInt16? = frame.readInteger()
let payload = frame.readString(length: Int(consumedLength!))
print("payload:", payload!, frame.readerIndex, frame.readableBytes)
peek: 3 0 5
payload: cat 5 0
Two bytes encode the length 3; the following three bytes encode cat. Peeking preserves all five readable bytes. Consuming the length advances the reader index to 2; consuming the payload advances it to 5.
This is deliberately a complete-frame example. In a real decoder, check that the length header and the entire declared payload are available before consuming, or explicitly preserve partial-decoding state. The force unwraps are safe for these fixed example inputs, not for untrusted socket data. A malicious length must also be bounded by the application’s protocol limits.
A Buffer Is a Value, but Storage Has a Cost
The implementation in ByteBuffer-core.swift separates visible indexes and slices from backing storage. ByteBuffer uses copy-on-write to provide value semantics without eagerly copying all bytes on every assignment.
import NIOCore
var original = ByteBufferAllocator().buffer(capacity: 8)
original.writeString("ok")
var changed = original
changed.writeString("!")
print("original:", original.getString(at: 0, length: 2)!)
print("changed:", changed.getString(at: 0, length: 3)!)
original: ok
changed: ok!
Both values initially represent ok. Appending ! to changed does not alter original. That demonstrates the observable value behavior, not the number of allocations performed. The default allocator currently delegates to libc allocation functions. Holding on to a large buffer or a slice can also retain more storage than a small payload suggests.
Where the Two-Cursor Model Helps
For length-prefixed network frames, the cat example demonstrates the basic structure. Production code must also handle multiple frames per read, incomplete frames across reads, and invalid lengths. The buffer provides byte operations, not a framing policy.
For binary protocol headers, the 0x01 0x02 example shows how a failed fixed-width read preserves the data for a later attempt. The default integer endianness is big-endian; a different wire format must explicitly request its required ordering.
For pipeline handoffs, the ok and ok! example shows why passing a buffer value between stages need not mean that every stage mutates the same logical value. This does not make retention of large payloads free.
When an application only needs a complete in-memory string, String or Data may be simpler. The benefit of ByteBuffer becomes clear when byte offsets and partial consumption are part of the problem.
The Source-Level Contract
The inspected default-branch Package.swift declares Swift tools 6.1 and a NIOCore library product. The package references swift-atomics, swift-collections, and swift-system, although individual targets have different dependencies. It does not declare minimum Apple platform versions through a platforms property; the tools version alone is not evidence of a particular deployment minimum.
The repository is licensed under Apache 2.0. ByteBuffer-int.swift contains the fixed-width integer operations, ByteBuffer-core.swift defines the central storage and index behavior, and ByteBufferTest.swift exercises incomplete reads, integer operations, slices, and copy-on-write. These implementation and test files explain the mechanism more precisely than a general feature list.
The source was inspected on November 5, 2026. No latest-release claim is made here. The displayed outputs are derived from the inspected API and tests, not from an executed full-package build: the local environment could not fetch the actual dependency graph. This distinction matters before treating the article as fully verified.
The central design decision is simple: allocated bytes, written bytes, and consumed bytes are different things. The two indexes let a decoder manage that distinction without rebuilding its entire input on every partial read. SwiftNIO handles the buffer mechanics; the application still defines what a valid message means.
