DEV Community

Cover image for Designing a Binary Protocol for Modern APIs
Derek Mwale
Derek Mwale

Posted on

Designing a Binary Protocol for Modern APIs

Designing a Binary Protocol for Modern APIs

There is something almost suspicious about the modern API.

We spend enormous amounts of time designing endpoints, authentication systems, database schemas, caching layers, observability pipelines, and distributed architectures.

Then, somewhere underneath all of that sophistication, two machines eventually have to do something incredibly simple:

send bytes to each other.

A developer writes:

POST /api/orders
Content-Type: application/json
Enter fullscreen mode Exit fullscreen mode

and thinks they are designing an API.

They are not.

They are designing the human-facing surface of a much deeper communication system.

Underneath the HTTP request is a TCP connection. Underneath TCP is a stream of bytes. Somewhere in that stream, a machine needs to determine where one message ends, what operation it represents, which fields exist, how large those fields are, whether the data is valid, whether the sender understands the protocol version, and what response should come next.

The JSON is merely one possible representation of that conversation.

And this raises an interesting question:

What if we designed the binary protocol underneath the API deliberately, rather than letting serialization happen almost accidentally?

That is the idea behind designing a binary protocol for modern APIs.

Not because JSON is bad.

JSON is excellent.

It is readable, ubiquitous, easy to debug, supported by almost everything, and incredibly productive.

But modern systems increasingly operate under conditions where every byte, allocation, round trip, CPU cycle, and microsecond can matter.

High-frequency trading systems.

IoT networks.

Mobile applications.

Game servers.

Distributed databases.

Edge computing.

Service-to-service communication.

Real-time collaboration.

AI inference infrastructure.

Financial systems.

Embedded devices.

Cloud services processing billions of requests.

In these environments, the protocol itself becomes architecture.

And designing one forces us to confront something software engineers sometimes avoid:

communication is a data structure.


1. An API Is Really a Conversation Between Machines

Let's simplify an API.

Imagine a client wants to retrieve a user.

At the application level:

GET /users/42
Enter fullscreen mode Exit fullscreen mode

At the conceptual level:

Client:
    Give me user 42.

Server:
    Here is user 42.
Enter fullscreen mode Exit fullscreen mode

At the protocol level, however, this becomes something closer to:

[length]
[version]
[message type]
[request ID]
[payload]
Enter fullscreen mode Exit fullscreen mode

The server needs to decode this structure.

For example:

+--------+---------+----------+------------+---------+
| Length | Version | Msg Type | Request ID | Payload |
+--------+---------+----------+------------+---------+
| 4 B    | 1 B     | 1 B      | 8 B        | N bytes |
+--------+---------+----------+------------+---------+
Enter fullscreen mode Exit fullscreen mode

Suddenly, API design becomes a binary data-layout problem.

And this is where things get interesting.

A protocol is not simply a format.

It is a shared agreement about meaning.

If one machine interprets byte 0x01 as "create user" while another interprets it as "delete user," you do not have an API.

You have a very expensive debugging session.


2. Why Build a Binary Protocol?

The obvious question is:

Why not just use JSON?

For many systems, the answer is:

You should.

A binary protocol introduces complexity.

You lose human readability.

You need encoding and decoding logic.

Debugging becomes harder.

Versioning requires more discipline.

You need tooling.

You may need language-specific libraries.

And if you design the protocol badly, you can create something worse than JSON.

So binary protocols should not be treated as an automatic performance upgrade.

The real question is:

What properties does the system need from communication?

Binary protocols can provide:

  • smaller messages
  • faster serialization
  • faster parsing
  • predictable memory usage
  • lower bandwidth consumption
  • fewer allocations
  • efficient numeric representation
  • explicit message framing
  • efficient streaming
  • better suitability for constrained devices
  • stronger control over wire compatibility

But the important insight is that binary is not synonymous with fast.

A badly designed binary protocol can be slower than JSON.

Imagine a protocol that requires:

allocate buffer
copy buffer
decode header
allocate object
decode field
allocate string
copy string
validate object
convert object
Enter fullscreen mode Exit fullscreen mode

You could end up spending more CPU and memory than a carefully implemented JSON parser.

The real advantage comes from controlling the entire pipeline.


3. Start With the Message, Not the Endpoint

One of the biggest mistakes in API design is beginning with URLs.

Developers think:

/users
/users/{id}
/orders
/orders/{id}
/payments
Enter fullscreen mode Exit fullscreen mode

But a binary protocol has no inherent concept of URLs.

It has messages.

So start there.

For example:

UserRequest
Enter fullscreen mode Exit fullscreen mode

could contain:

operation
user_id
request_id
Enter fullscreen mode Exit fullscreen mode

and a response could contain:

status
user_id
name
email
created_at
Enter fullscreen mode Exit fullscreen mode

Now define those fields precisely.

For example:

UserRequest

field 1:
    operation
    uint8

field 2:
    request_id
    uint64

field 3:
    user_id
    uint64
Enter fullscreen mode Exit fullscreen mode

The wire representation might look conceptually like:

01 00 00 00 00 00 00 00 7B 00 00 00 00 00 00 00 2A
Enter fullscreen mode Exit fullscreen mode

Humans hate this.

Computers love it.

The important part is not the hexadecimal.

The important part is that the meaning of every byte is defined.


4. Framing: Where Does a Message End?

This is one of the first genuinely difficult problems.

Suppose TCP gives you:

101100101011001010101010...
Enter fullscreen mode Exit fullscreen mode

TCP gives you a byte stream.

It does not inherently tell you:

"Here is packet number one."

If your protocol sends:

Message A
Message B
Message C
Enter fullscreen mode Exit fullscreen mode

the receiver might see:

Message A + first half of B
Enter fullscreen mode Exit fullscreen mode

or:

last half of A + Message B + Message C
Enter fullscreen mode Exit fullscreen mode

This is called TCP stream fragmentation/coalescing.

Therefore, your application protocol needs framing.

The simplest approach is a length-prefixed frame:

+------------+-------------------+
| Length     | Payload           |
| 4 bytes    | N bytes           |
+------------+-------------------+
Enter fullscreen mode Exit fullscreen mode

If length is:

00000042
Enter fullscreen mode Exit fullscreen mode

the receiver knows the next 42 bytes belong to the message.

Conceptually:

read 4 bytes
parse length
read N bytes
decode message
Enter fullscreen mode Exit fullscreen mode

This sounds trivial.

It isn't.

Because now you have to answer:

What happens if the client says the message is 10 GB?

You cannot simply allocate:

let buffer = vec![0; length];
Enter fullscreen mode Exit fullscreen mode

without validation.

A malicious or malfunctioning client could send:

FF FF FF FF
Enter fullscreen mode Exit fullscreen mode

and turn your protocol into a memory-exhaustion machine.

So framing must include resource limits.

For example:

MAX_FRAME_SIZE = 16 MB
Enter fullscreen mode Exit fullscreen mode

Then:

if length > MAX_FRAME_SIZE:
    reject connection
Enter fullscreen mode Exit fullscreen mode

This is one of the recurring themes of protocol design:

Every field is both data and an attack surface.


5. A Better Header

A modern binary protocol usually benefits from a structured header.

For example:

+---------+---------+----------+----------+-------------+
| Magic   | Version | Flags    | Type     | Request ID  |
+---------+---------+----------+----------+-------------+
| 2 B     | 1 B     | 1 B      | 2 B      | 8 B         |
+---------+---------+----------+----------+-------------+

+------------+------------------+
| Payload Len| Header Checksum  |
+------------+------------------+
| 4 B        | 4 B              |
+------------+------------------+
Enter fullscreen mode Exit fullscreen mode

Now every message begins with something predictable.

For example:

Magic       = 0xDA7A
Version     = 1
Flags       = 0
Type        = USER_GET
Request ID  = 92831
Payload Len = 84
Enter fullscreen mode Exit fullscreen mode

Why have a magic number?

Because it gives the receiver a recognizable signature.

If the server receives:

DA 7A
Enter fullscreen mode Exit fullscreen mode

it knows:

This looks like my protocol.

If it receives:

FF 42
Enter fullscreen mode Exit fullscreen mode

it can reject it.

This becomes particularly useful when debugging raw streams, detecting protocol mismatches, and preventing one protocol from accidentally being interpreted as another.


6. Versioning Is Not Optional

Here is where many protocol designs eventually become painful.

Version one says:

User:
    id
    name
Enter fullscreen mode Exit fullscreen mode

Then version two arrives:

User:
    id
    name
    email
Enter fullscreen mode Exit fullscreen mode

Then version three:

User:
    id
    name
    email
    phone
Enter fullscreen mode Exit fullscreen mode

How does an old client handle a new server?

This is the fundamental problem of protocol evolution.

The naive approach is:

version = 1
version = 2
version = 3
Enter fullscreen mode Exit fullscreen mode

and then writing completely different parsers.

That works for a while.

Then you end up with:

if version == 1:
    ...
elif version == 2:
    ...
elif version == 3:
    ...
elif version == 4:
    ...
elif version == 5:
    ...
Enter fullscreen mode Exit fullscreen mode

Congratulations.

You have created protocol archaeology.

A better approach is often forward-compatible field evolution.

For example:

field_id
field_type
field_length
field_value
Enter fullscreen mode Exit fullscreen mode

This lets an older client encounter:

field 7
Enter fullscreen mode Exit fullscreen mode

and simply say:

I don't know what field 7 means.

Skip it.
Enter fullscreen mode Exit fullscreen mode

That one capability can dramatically simplify protocol evolution.


7. TLV: The Beautifully Boring Idea

One useful pattern is Type-Length-Value, commonly abbreviated TLV.

The structure is:

+--------+--------+----------------+
| Type   | Length | Value          |
+--------+--------+----------------+
Enter fullscreen mode Exit fullscreen mode

For example:

Type = 1
Length = 8
Value = user ID
Enter fullscreen mode Exit fullscreen mode

Then:

Type = 2
Length = 13
Value = username
Enter fullscreen mode Exit fullscreen mode

A message might become:

01 08 [8 bytes]
02 0D [13 bytes]
Enter fullscreen mode Exit fullscreen mode

The advantage is flexibility.

Unknown fields can be skipped.

Fields can be reordered.

New fields can be introduced.

Optional fields do not necessarily require breaking the entire schema.

TLV is not magical.

It introduces overhead.

But sometimes a few bytes of metadata are an excellent trade for compatibility.

This is one of those places where protocol design becomes a negotiation between:

compactness and evolvability.


8. Don't Serialize Everything as Strings

JSON encourages a particular habit:

{
  "user_id": "92831",
  "active": "true",
  "balance": "1500.50"
}
Enter fullscreen mode Exit fullscreen mode

Humans can read it.

Computers now have to interpret strings.

A binary protocol can represent these values directly.

user_id:
    uint64

active:
    bool

balance:
    int64
Enter fullscreen mode Exit fullscreen mode

For money, you might store:

150050
Enter fullscreen mode Exit fullscreen mode

representing:

1500.50
Enter fullscreen mode Exit fullscreen mode

with an agreed scale.

That eliminates floating-point ambiguity and unnecessary string parsing.

Similarly, timestamps can be represented as:

int64
Enter fullscreen mode Exit fullscreen mode

rather than:

"2026-09-07T00:25:43Z"
Enter fullscreen mode Exit fullscreen mode

The latter is much nicer for humans.

The former is much cheaper for machines.

Again, the protocol's job is not to make bytes beautiful.

Its job is to make meaning unambiguous.


9. Endianness: The Tiny Detail That Can Ruin Your Weekend

Suppose you have:

uint32 = 1000
Enter fullscreen mode Exit fullscreen mode

How are those four bytes represented?

One common convention is network byte order, which is big-endian.

The value:

1000
Enter fullscreen mode Exit fullscreen mode

becomes:

00 00 03 E8
Enter fullscreen mode Exit fullscreen mode

If your protocol does not explicitly define byte order, different implementations can disagree.

Your Rust implementation works.

Your C implementation works.

Your JavaScript implementation appears to work.

Then your embedded device sends:

E8 03 00 00
Enter fullscreen mode Exit fullscreen mode

and your server thinks:

3892510720
Enter fullscreen mode Exit fullscreen mode

instead of:

1000
Enter fullscreen mode Exit fullscreen mode

This is why protocol specifications must be painfully explicit.

Define:

  • integer sizes
  • signedness
  • byte order
  • string encoding
  • floating-point representation
  • timestamp format
  • maximum lengths
  • null representation
  • optional field semantics

A protocol specification should leave very little room for interpretation.


10. Strings Are More Complicated Than They Look

Consider:

username = "Derek"
Enter fullscreen mode Exit fullscreen mode

How does the receiver know its length?

One option:

04 44 65 72 65
Enter fullscreen mode Exit fullscreen mode

But if you're using UTF-8, length means bytes, not necessarily characters.

For example:

hello
Enter fullscreen mode Exit fullscreen mode

has:

5 characters
5 bytes
Enter fullscreen mode Exit fullscreen mode

But another Unicode string might have:

5 characters
10 bytes
Enter fullscreen mode Exit fullscreen mode

So a protocol should generally define:

String length is measured in UTF-8 encoded bytes.

Then:

[length: uint32]
[UTF-8 bytes]
Enter fullscreen mode Exit fullscreen mode

The receiver can safely skip the correct number of bytes.

And again:

MAX_STRING_LENGTH
Enter fullscreen mode Exit fullscreen mode

must exist.

Never assume that the other side will send a reasonable amount of data.


11. Null, Empty, Missing, and Zero Are Different

This sounds philosophical.

It isn't.

Imagine a field:

email
Enter fullscreen mode Exit fullscreen mode

These could represent different states:

missing
null
""
"example@example.com"
Enter fullscreen mode Exit fullscreen mode

And:

age = 0
Enter fullscreen mode Exit fullscreen mode

is not necessarily the same as:

age = missing
Enter fullscreen mode Exit fullscreen mode

A binary protocol needs semantics for these states.

For optional fields, you might use a presence bitmap:

presence:
    10110100
Enter fullscreen mode Exit fullscreen mode

Each bit indicates whether a field exists.

Or TLV naturally allows absence:

field 1 exists
field 2 missing
field 3 exists
Enter fullscreen mode Exit fullscreen mode

This is especially important for PATCH-like operations.

For example:

UpdateUser
Enter fullscreen mode Exit fullscreen mode

could mean:

name = missing
email = null
phone = "+260..."
Enter fullscreen mode Exit fullscreen mode

Those three states can mean:

don't modify name
clear email
set phone
Enter fullscreen mode Exit fullscreen mode

A good protocol captures these semantics explicitly.


12. Request IDs Turn APIs Into Multiplexed Conversations

One of my favorite ideas in protocol design is the request ID.

Imagine sending:

Request 101
Request 102
Request 103
Enter fullscreen mode Exit fullscreen mode

without waiting for each response.

The server might respond:

Response 102
Response 101
Response 103
Enter fullscreen mode Exit fullscreen mode

This is completely fine if every request has an ID.

For example:

request_id = 101
Enter fullscreen mode Exit fullscreen mode

The client maintains:

101 -> waiting for user
102 -> waiting for order
103 -> waiting for payment
Enter fullscreen mode Exit fullscreen mode

When response 102 arrives:

resolve request 102
Enter fullscreen mode Exit fullscreen mode

Now your connection can support concurrent in-flight operations.

This is fundamentally different from:

request
wait
response
request
wait
response
Enter fullscreen mode Exit fullscreen mode

You have transformed the connection into a multiplexed communication channel.

That idea is incredibly powerful.

It reduces the need to open many connections and allows the network to carry multiple logical conversations simultaneously.


13. Streaming Changes Everything

Traditional APIs often assume:

request -> response
Enter fullscreen mode Exit fullscreen mode

But modern applications frequently need:

request -> response
response
response
response
response
Enter fullscreen mode Exit fullscreen mode

Think:

  • AI token streaming
  • database change feeds
  • logs
  • market data
  • multiplayer events
  • chat
  • telemetry
  • file transfers

Your protocol therefore needs to distinguish between:

single response
Enter fullscreen mode Exit fullscreen mode

and:

stream response
Enter fullscreen mode Exit fullscreen mode

You could have flags:

FINAL = 0x01
STREAM = 0x02
ERROR = 0x04
COMPRESSED = 0x08
Enter fullscreen mode Exit fullscreen mode

Then:

flags = STREAM
Enter fullscreen mode Exit fullscreen mode

means:

More messages are coming.

And:

flags = STREAM | FINAL
Enter fullscreen mode Exit fullscreen mode

means:

This is the final message.

Now the protocol itself understands streaming.


14. Compression Should Be Negotiated

Suppose a response is:

2 MB
Enter fullscreen mode Exit fullscreen mode

Compression might reduce it to:

200 KB
Enter fullscreen mode Exit fullscreen mode

Fantastic.

But compression costs CPU.

For a tiny message:

200 bytes
Enter fullscreen mode Exit fullscreen mode

compression may make things worse.

So compression should be negotiated.

During connection setup:

Client:
    supported compression:
        none
        gzip
        zstd
Enter fullscreen mode Exit fullscreen mode

Server:

Selected:
    zstd
Enter fullscreen mode Exit fullscreen mode

Then a flag indicates:

COMPRESSED
Enter fullscreen mode Exit fullscreen mode

This illustrates another important protocol principle:

Capabilities should be negotiated rather than assumed.

The client and server may have different capabilities.

A protocol should allow them to discover a common subset.


15. Authentication Belongs Above the Raw Bytes

A binary protocol does not automatically become secure because it is binary.

This:

01 02 03 04
Enter fullscreen mode Exit fullscreen mode

is not more secure than:

{"user":42}
Enter fullscreen mode Exit fullscreen mode

Security comes from cryptography, authentication, authorization, integrity protection, and correct implementation.

A modern binary API might run over:

TLS
Enter fullscreen mode Exit fullscreen mode

and then use its own protocol inside the encrypted connection.

For example:

TLS
  ↓
Binary API Protocol
  ↓
Application Message
Enter fullscreen mode Exit fullscreen mode

You might also have:

authentication token
Enter fullscreen mode Exit fullscreen mode

inside the protocol.

But be careful.

Do not invent your own cryptography.

Use established transport security mechanisms.

The protocol should focus on communication semantics, not pretending to become a cryptographic research project.


16. Error Handling Should Be a First-Class Message

One of the worst API designs is:

something went wrong
Enter fullscreen mode Exit fullscreen mode

followed by a random string.

A binary protocol should define errors explicitly.

For example:

ErrorResponse

code:
    uint16

request_id:
    uint64

message:
    string

details:
    optional bytes
Enter fullscreen mode Exit fullscreen mode

Possible codes:

1001 INVALID_REQUEST
1002 UNAUTHORIZED
1003 NOT_FOUND
1004 RATE_LIMITED
1005 INTERNAL_ERROR
1006 UNSUPPORTED_VERSION
Enter fullscreen mode Exit fullscreen mode

Now clients can program against stable error semantics.

Instead of:

if (error.message.includes("not found")) {
Enter fullscreen mode Exit fullscreen mode

you can write:

if (error.code === NOT_FOUND) {
Enter fullscreen mode Exit fullscreen mode

This distinction matters.

Messages are for humans.

Codes are for programs.


17. Error Messages Should Not Leak the System

Suppose the database throws:

PostgreSQL error:
duplicate key value violates unique constraint users_email_key
Enter fullscreen mode Exit fullscreen mode

You probably don't want to send that directly to a client.

Your protocol might expose:

code = EMAIL_ALREADY_EXISTS
Enter fullscreen mode Exit fullscreen mode

while the internal logs contain:

database constraint violation...
Enter fullscreen mode Exit fullscreen mode

This creates a clean separation:

internal implementation
        ↓
domain error
        ↓
protocol error
Enter fullscreen mode Exit fullscreen mode

That separation makes systems easier to evolve.


18. Checksums and Integrity

Should every binary protocol have a checksum?

Not necessarily.

If you're using a reliable and integrity-protected transport such as TLS, adding another checksum may be redundant for many API use cases.

But checksums can be useful in environments where corruption detection is important, particularly when operating closer to hardware, unreliable links, storage, or custom transports.

For example:

CRC32(payload)
Enter fullscreen mode Exit fullscreen mode

could be included in a frame.

The receiver calculates:

CRC32(received_payload)
Enter fullscreen mode Exit fullscreen mode

and compares it.

If they differ:

FRAME_CORRUPTED
Enter fullscreen mode Exit fullscreen mode

This is a design decision rather than a universal requirement.

Protocol engineering is full of these trade-offs.


19. The Most Important Field Might Be the Length

There is something wonderfully boring about a length field.

payload_length = 128
Enter fullscreen mode Exit fullscreen mode

It doesn't sound revolutionary.

But it enables:

  • framing
  • buffer management
  • streaming
  • skipping unknown fields
  • bounds checking
  • memory planning
  • efficient reads
  • partial decoding

Length is one of the fundamental pieces of structure in a byte stream.

But it must be validated.

Never trust it.

The decoder should enforce:

length <= maximum_frame_size
Enter fullscreen mode Exit fullscreen mode

and:

offset + length <= buffer_size
Enter fullscreen mode Exit fullscreen mode

Otherwise, your protocol parser can become a vulnerability.

And binary parsing vulnerabilities are particularly dangerous because the parser sits at the boundary between untrusted bytes and trusted program state.


20. Design the Decoder Before the Encoder

This is a surprisingly useful technique.

Developers often think:

How do I encode my object?

Instead ask:

What must the decoder safely understand?

Suppose you define:

encode(User)
Enter fullscreen mode Exit fullscreen mode

first.

You might create something beautiful.

Then six months later, your decoder receives:

malformed packet
truncated packet
unknown field
future field
huge length
duplicate field
invalid UTF-8
unexpected type
Enter fullscreen mode Exit fullscreen mode

Now the parser becomes a nightmare.

Design the decoding state machine first.

Something like:

READ_HEADER
    ↓
VALIDATE_MAGIC
    ↓
VALIDATE_VERSION
    ↓
VALIDATE_LENGTH
    ↓
READ_PAYLOAD
    ↓
DECODE_FIELDS
    ↓
VALIDATE_SEMANTICS
    ↓
DISPATCH_MESSAGE
Enter fullscreen mode Exit fullscreen mode

Then design encoding to produce messages that satisfy that grammar.

This mindset leads to more robust protocols.


21. The Decoder Is a Security Boundary

A binary decoder should assume the input is hostile.

Even if you control both client and server.

Because eventually someone will:

  • fuzz it
  • send corrupted packets
  • write another client
  • accidentally produce malformed messages
  • replay old requests
  • send enormous fields
  • exploit integer overflow
  • trigger parser edge cases

Consider:

length = 0xFFFFFFFF
Enter fullscreen mode Exit fullscreen mode

If you convert this incorrectly:

uint32 -> signed int
Enter fullscreen mode Exit fullscreen mode

you could get:

-1
Enter fullscreen mode Exit fullscreen mode

Then your code starts doing extremely interesting things.

Integer overflow.

Out-of-bounds reads.

Memory exhaustion.

Infinite loops.

Parser desynchronization.

Protocol implementations need defensive programming.


22. Protocol State Machines

A protocol is not only bytes.

It is also state.

For example:

CONNECTED
    ↓
HELLO
    ↓
AUTHENTICATING
    ↓
AUTHENTICATED
    ↓
READY
    ↓
CLOSING
Enter fullscreen mode Exit fullscreen mode

Maybe a client cannot send:

GET_USER
Enter fullscreen mode Exit fullscreen mode

before:

AUTH
Enter fullscreen mode Exit fullscreen mode

So:

state = CONNECTED
message = GET_USER
Enter fullscreen mode Exit fullscreen mode

should produce:

PROTOCOL_ERROR
Enter fullscreen mode Exit fullscreen mode

This is essentially a finite-state machine.

And thinking about protocols this way is incredibly useful.

You can ask:

What messages are legal in each state?

For example:

CONNECTED:
    HELLO
    CLOSE

AUTHENTICATING:
    AUTH
    CLOSE

READY:
    REQUEST
    PING
    CLOSE
Enter fullscreen mode Exit fullscreen mode

Now protocol correctness becomes something you can test.


23. Heartbeats and Dead Connections

Distributed systems have an annoying property:

A connection can disappear without telling you it disappeared.

The server may think:

client is alive
Enter fullscreen mode Exit fullscreen mode

while the client thinks:

server is dead
Enter fullscreen mode Exit fullscreen mode

Heartbeats help.

The protocol might define:

PING
PONG
Enter fullscreen mode Exit fullscreen mode

Every:

30 seconds
Enter fullscreen mode Exit fullscreen mode

the server sends:

PING
Enter fullscreen mode Exit fullscreen mode

and expects:

PONG
Enter fullscreen mode Exit fullscreen mode

within:

10 seconds
Enter fullscreen mode Exit fullscreen mode

If not:

connection = dead
Enter fullscreen mode Exit fullscreen mode

But heartbeats should be designed carefully.

A system with millions of clients sending heartbeats every second can create a surprisingly large amount of traffic.

Protocol design is often about avoiding small problems multiplied by millions.


24. Idempotency Belongs in the Protocol Conversation

Imagine a payment request:

request_id = 92831
Enter fullscreen mode Exit fullscreen mode

The client sends it.

The network fails.

The client doesn't know whether the server processed it.

So the client retries:

request_id = 92831
Enter fullscreen mode Exit fullscreen mode

The server receives it again.

What happens?

A protocol can use request IDs as part of idempotency semantics.

The server might maintain:

92831 -> completed
Enter fullscreen mode Exit fullscreen mode

and return the previous result rather than processing the operation again.

This is especially important for operations such as:

create payment
charge card
place order
transfer funds
Enter fullscreen mode Exit fullscreen mode

A protocol doesn't automatically solve distributed systems problems.

But a good protocol gives the application enough structure to solve them.


25. Protocol Design Is Distributed Systems Design

This is the deeper point.

Once you design a binary protocol, you're forced to think about:

  • ordering
  • retries
  • duplicates
  • timeouts
  • partial messages
  • connection failures
  • version compatibility
  • capabilities
  • streaming
  • backpressure
  • authentication
  • resource limits
  • concurrency
  • state machines

In other words:

binary protocol design is distributed systems design wearing a smaller hat.

Two machines are communicating across a network.

They don't share memory.

They don't share clocks.

They can fail independently.

Messages can be delayed.

Connections can disappear.

Processes can restart.

Versions can differ.

And therefore, your protocol becomes the contract that keeps two independently evolving systems synchronized.


26. Backpressure

Imagine a client requests a million records.

The server begins producing:

record 1
record 2
record 3
...
record 1,000,000
Enter fullscreen mode Exit fullscreen mode

What if the client can only process:

10,000 records/sec
Enter fullscreen mode Exit fullscreen mode

while the server produces:

100,000 records/sec
Enter fullscreen mode Exit fullscreen mode

You have a buffer problem.

A modern streaming protocol may therefore support flow control.

For example:

WINDOW_UPDATE
Enter fullscreen mode Exit fullscreen mode

The client says:

I can accept 1 MB more.
Enter fullscreen mode Exit fullscreen mode

The server sends until that window is consumed.

Then waits.

This prevents fast producers from overwhelming slow consumers.

And suddenly our little binary protocol starts looking suspiciously like a miniature transport protocol.

Because it is.


27. Keep the Protocol Small

There is a temptation when designing protocols to add everything.

You start with:

REQUEST
RESPONSE
Enter fullscreen mode Exit fullscreen mode

Then:

STREAM
PING
PONG
AUTH
COMPRESS
ENCRYPT
WINDOW_UPDATE
PRIORITY
MULTICAST
TRANSACTION
BATCH
CACHE_HINT
TRACE
DEBUG
...
Enter fullscreen mode Exit fullscreen mode

Eventually you have created:

HTTP 2: The Revenge.

Protocol complexity is dangerous.

Every feature adds:

  • implementation complexity
  • testing requirements
  • interoperability problems
  • documentation
  • security surface
  • future compatibility obligations

A protocol should have a small core.

Then optional capabilities can be negotiated.

Think:

Core protocol
+
extensions
Enter fullscreen mode Exit fullscreen mode

rather than:

everything protocol
Enter fullscreen mode Exit fullscreen mode

28. A Possible Modern Binary API

Let's put the ideas together.

Imagine a protocol called:

MAPI
Enter fullscreen mode Exit fullscreen mode

Modern API Protocol.

A frame might look like:

+--------+---------+-------+------+----------+--------+---------+
| Magic  | Version | Flags | Type | Request  | Length | Payload |
+--------+---------+-------+------+----------+--------+---------+
| 2 B    | 1 B     | 1 B   | 2 B  | 8 B      | 4 B    | N B     |
+--------+---------+-------+------+----------+--------+---------+
Enter fullscreen mode Exit fullscreen mode

Where:

Magic:
    0xDA7A

Version:
    1

Flags:
    STREAM
    FINAL
    COMPRESSED
    ERROR

Type:
    HELLO
    AUTH
    REQUEST
    RESPONSE
    ERROR
    PING
    PONG
    CLOSE

Request:
    uint64

Length:
    uint32
Enter fullscreen mode Exit fullscreen mode

Then a request payload might contain:

method
resource
parameters
Enter fullscreen mode Exit fullscreen mode

encoded with TLV.

Conceptually:

REQUEST
  |
  +-- field 1: resource = "users"
  |
  +-- field 2: operation = GET
  |
  +-- field 3: user_id = 42
Enter fullscreen mode Exit fullscreen mode

The response:

RESPONSE
  |
  +-- field 1: status = OK
  |
  +-- field 2: user_id = 42
  |
  +-- field 3: name = "Derek"
Enter fullscreen mode Exit fullscreen mode

Notice something interesting.

We haven't actually eliminated the API.

We've simply moved the API beneath the human-readable representation.


29. Binary Doesn't Mean You Should Abandon HTTP

This is another important distinction.

You can run a binary protocol:

directly over TCP
Enter fullscreen mode Exit fullscreen mode

But you don't necessarily need to.

You could have:

HTTP
  ↓
binary payload
Enter fullscreen mode Exit fullscreen mode

or:

HTTP/2
  ↓
binary frames
Enter fullscreen mode Exit fullscreen mode

or:

HTTP/3 / QUIC
  ↓
binary application protocol
Enter fullscreen mode Exit fullscreen mode

The transport and application protocol solve different problems.

The transport handles things like:

delivery
ordering
congestion
connections
Enter fullscreen mode Exit fullscreen mode

while the application protocol handles:

meaning
operations
schemas
requests
responses
Enter fullscreen mode Exit fullscreen mode

Separating these concerns gives you flexibility.


30. Observability Becomes More Important

One downside of binary protocols is that developers cannot simply:

curl
Enter fullscreen mode Exit fullscreen mode

and understand everything.

If your packet looks like:

DA 7A 01 04 00 02 ...
Enter fullscreen mode Exit fullscreen mode

good luck.

So tooling becomes part of the protocol ecosystem.

You want tools capable of:

capture packet
        ↓
decode frame
        ↓
decode message
        ↓
display semantic fields
Enter fullscreen mode Exit fullscreen mode

For example:

REQUEST
request_id: 98231
operation: GET_USER
user_id: 42
Enter fullscreen mode Exit fullscreen mode

This is one reason protocol design should include developer tooling from day one.

A protocol without debugging tools becomes tribal knowledge.

And tribal knowledge does not scale.


31. Make the Protocol Testable

A serious protocol needs more than unit tests.

You want:

Golden tests

Given:

object
Enter fullscreen mode Exit fullscreen mode

produce exactly:

bytes
Enter fullscreen mode Exit fullscreen mode

And vice versa.

Fuzz testing

Feed random bytes into the decoder.

The decoder should:

reject safely
Enter fullscreen mode Exit fullscreen mode

not:

crash
Enter fullscreen mode Exit fullscreen mode

Compatibility testing

Test:

old client ↔ new server
new client ↔ old server
Enter fullscreen mode Exit fullscreen mode

Property testing

Verify:

decode(encode(x)) == x
Enter fullscreen mode Exit fullscreen mode

for valid values.

Boundary testing

Try:

length = 0
length = 1
length = MAX
length = MAX + 1
Enter fullscreen mode Exit fullscreen mode

and:

empty string
maximum string
invalid UTF-8
unknown fields
duplicate fields
Enter fullscreen mode Exit fullscreen mode

Protocol correctness lives in the edge cases.


32. Documentation Should Describe Bytes and Meaning

A protocol specification should not simply say:

Send a user request.

It should say something closer to:

Offset  Size  Field
0       2     Magic
2       1     Version
3       1     Flags
4       2     Message Type
6       8     Request ID
14      4     Payload Length
18      N     Payload
Enter fullscreen mode Exit fullscreen mode

Then:

Message Type 0x0001 = GET_USER
Enter fullscreen mode Exit fullscreen mode

And:

Payload Field 0x01 = user_id
Type = UINT64
Required = yes
Enter fullscreen mode Exit fullscreen mode

This is the difference between:

documentation

and:

a protocol specification.

The second allows independent implementations.

That is the real test.

Could another developer, using only the specification, implement the protocol without reading your source code?

If yes, you have designed a protocol.


33. The Most Dangerous Protocol Is the One That "Just Works"

There is a strange phase in software development where a protocol feels perfect because it works.

The client sends bytes.

The server responds.

Everything is wonderful.

Then six months later:

We need another field.
Enter fullscreen mode Exit fullscreen mode

Then:

We need another client language.
Enter fullscreen mode Exit fullscreen mode

Then:

We need streaming.
Enter fullscreen mode Exit fullscreen mode

Then:

We need compression.
Enter fullscreen mode Exit fullscreen mode

Then:

We need retries.
Enter fullscreen mode Exit fullscreen mode

Then:

We need to support clients from two years ago.
Enter fullscreen mode Exit fullscreen mode

Then:

Why does version 4 interpret this field differently?
Enter fullscreen mode Exit fullscreen mode

The protocol wasn't designed to evolve.

It was designed to survive the demo.

These are very different goals.


34. Protocol Design Is About Future You

When designing a binary protocol, you're not only communicating with today's client.

You're communicating with:

future clients
future servers
future developers
future languages
future hardware
future network conditions
Enter fullscreen mode Exit fullscreen mode

You need to leave room.

Reserve message types.

Reserve flags.

Allow unknown fields.

Define compatibility rules.

Specify maximum values.

Separate transport from semantics.

Define errors.

Define state transitions.

Document everything.

Because the protocol you design today may outlive the software that created it.

That is both terrifying and beautiful.


35. The Byte Is the Foundation

We often talk about APIs at the level of:

GET /users
POST /orders
DELETE /payments
Enter fullscreen mode Exit fullscreen mode

But these abstractions hide something profound.

Every request eventually becomes bytes.

Every response eventually becomes bytes.

Every distributed system is, at some level, a collection of machines exchanging structured sequences of bytes.

The sophistication of the application does not change that.

AI models still receive bytes.

Databases still receive bytes.

Payment systems still receive bytes.

Game servers still receive bytes.

Cloud infrastructure still receives bytes.

The abstraction layer is useful because humans don't want to think about every byte.

But sometimes you need to go back down.

Not because abstraction is bad.

Because understanding the lower layer makes the higher layer better.


36. The Ideal Modern Binary Protocol

If I were designing a binary protocol for a modern API, I would want a few properties above everything else.

1. Explicit framing

The receiver should always know where a message begins and ends.

2. Strong bounds

Every length must have a maximum.

3. Explicit types

Don't encode everything as strings.

4. Stable identifiers

Operations and fields should have stable numeric IDs.

5. Forward compatibility

Unknown fields should be safely skippable where appropriate.

6. Request correlation

Every request should have a correlation mechanism.

7. Streaming support

The protocol should understand multi-message responses.

8. Capability negotiation

Compression, features, and optional behavior should be negotiated.

9. Explicit errors

Machines should receive error codes, humans can receive messages.

10. Secure transport

Do not confuse binary encoding with security.

11. Backpressure

Fast producers should not destroy slow consumers.

12. Excellent tooling

Developers need ways to inspect the bytes.

13. Fuzzability

Malformed input should be expected.

14. Documentation

The wire format should be implementable independently.

15. Small core

The protocol should not become a programming language.


37. Final Thought: APIs Are Contracts, Protocols Are Physics

An API says:

What are we allowed to ask each other?

A protocol says:

How do we physically express that conversation?

The API is the vocabulary.

The protocol is the grammar.

The network is the medium.

The bytes are the atoms.

And the distributed system emerges from the rules connecting them.

This is why binary protocol design is much more interesting than simply replacing JSON with some compact encoding.

You're designing a miniature communication system.

You're deciding how machines identify one another.

How they frame messages.

How they negotiate capabilities.

How they evolve.

How they recover.

How they stream.

How they fail.

How they handle unknown information.

How they protect themselves from malformed input.

How they remain compatible while the software around them changes.

And perhaps the most important lesson is this:

A protocol should not merely optimize today's messages. It should create a stable language for tomorrow's machines.

JSON made APIs accessible because humans could understand the messages.

Binary protocols can make APIs efficient because machines can understand the messages directly.

But the best protocol isn't necessarily the smallest.

It isn't necessarily the fastest.

It isn't necessarily the most clever.

The best protocol is the one that creates a clear, predictable, evolvable, and defensible contract between independent systems.

Because at the end of the day, modern APIs aren't really about URLs.

They aren't really about JSON.

They aren't even really about HTTP.

They are about communication.

And communication, when you strip away all the abstractions, is one machine looking at a sequence of bytes and saying:

"I know what you mean."

Top comments (0)