devinside.Marcin Borecki Ideas. Systems. Evidence.
← The notebook

Engineering

Smaller bytes are only one cost.

An event contains an identifier, a duration and a name. JSON repeats the field names. A compact binary record does not. The binary version is smaller, so the team calls the change a telemetry optimization.

Editorial ink and watercolor illustration for Smaller bytes are only one cost.
Historical edition · Sources and examples scoped to 2025-09-18.

An event contains an identifier, a duration and a name. JSON repeats the field names. A compact binary record does not. The binary version is smaller, so the team calls the change a telemetry optimization.

That conclusion is only the beginning. What unit is the duration in? What happens when the producer adds a field? Can an older consumer reject an incompatible record instead of interpreting the bytes incorrectly? Does the small record require expensive work before serialization? Can an operator still inspect a failed message?

An event format is a contract between producers, consumers and the people debugging them. Reducing bytes is valuable, especially at high volume. It does not remove the need to define meaning, versioning, bounds and failure behavior. A compact ambiguity can travel through a system very efficiently.

We will use a deliberately small codec with an explicit version. The interactive example encodes the input using real browser byte operations, displays the resulting bytes and decodes them again. Its byte counts are calculated from those values. It makes no claim about .NET CPU time, serializer allocations or a production pipeline's throughput.

Change the name from ASCII to text containing a non-ASCII character. The string's character count is no longer a reliable payload-byte count. Change the schema version or truncate the final byte. A correct decoder should expose the disagreement rather than produce a convenient partial event.

Define the meaning before packing the bytes

Our event has four pieces of meaning: a schema version, an event identifier, a numeric value and a name. In version one, the numeric value has an explicitly fixed unit. In version two, a unit discriminator becomes part of the record.

The distinction is semantic, not decorative. If a consumer interprets microseconds as milliseconds, the record can be syntactically valid and still be wrong by a factor of one thousand. A successful parse is therefore not proof of a compatible event contract.

public readonly record struct SampleEvent(
    uint Id,
    int Value,
    string Name);

This type is intentionally smaller than a general observability envelope. It omits timestamp synchronization, trace context, resource attributes and exporter protocol framing. Those may be essential in a real pipeline, but including them without explaining them would obscure the particular encoding boundary we are investigating.

A smaller event still needs an unambiguous unit
Inspect the full diagram ↗Swipe the drawing to inspect it.

The value's signedness and range are also part of the contract. Writing an unsigned identifier and reading it as a signed integer can create a problem that stays hidden until a high bit becomes set. The decoder should not depend on the convenient values seen in the first test corpus.

A length prefix describes bytes, not characters

For the binary example, choose a fixed header followed by a UTF-8 name. A two-byte name-length field bounds the following byte range. Before allocating or slicing, the decoder must verify that the complete range exists and fits the application limit.

The name ABC occupies three UTF-8 bytes. A different string with the same apparent number of characters can occupy more. A C# string.Length reports UTF-16 code units, which is yet another quantity. These distinctions matter when a length prefix is used to advance through a stream.

int nameBytes = Encoding.UTF8.GetByteCount(value.Name);
if (nameBytes > MaxNameBytes)
    throw new InvalidDataException("Name is too long.");

Span<byte> destination = buffer.AsSpan(0, HeaderSize + nameBytes);
BinaryPrimitives.WriteUInt32BigEndian(destination.Slice(1, 4), value.Id);
BinaryPrimitives.WriteInt32BigEndian(destination.Slice(5, 4), value.Value);
BinaryPrimitives.WriteUInt16BigEndian(destination.Slice(9, 2), (ushort)nameBytes);
Encoding.UTF8.GetBytes(value.Name, destination.Slice(HeaderSize));

This is the field-writing portion of a version-one encoder. The surrounding implementation still has to write the version byte, establish capacity and return the exact written length. The snippet should not be copied into a service as though omitted framing and error handling had been verified automatically.

The historical .NET 9.0.4 binary primitive implementation and UTF-8 implementation provide versioned references for this edition. The wire layout and limits are explicit design choices in our example.

A version-one record makes its boundaries explicit
Inspect the full diagram ↗Swipe the drawing to inspect it.

A decoder is a boundary, not a formatting convenience

A robust decoder checks the version before interpreting version-dependent offsets. It checks that the fixed header exists, validates declared lengths, verifies the complete payload and decides whether trailing data is allowed. It also has a policy for malformed text encoding.

Each of those decisions affects recovery. If a stream decoder mistakes a truncated record for a complete event, it can lose synchronization with every following record. If it accepts unknown versions by guessing, it can produce plausible but incorrect values that are harder to detect than a clear refusal.

The interactive codec uses a deliberately strict reader. An incompatible version or a missing byte is an error. That is not the only valid evolution strategy, but it is a coherent one. A format designed for unknown-field skipping needs a layout that actually supports skipping, not just an optimistic comment in the parser.

JSON makes some structural evolution easier because properties carry names, but it does not solve semantics automatically. An omitted required field, a changed unit or a new interpretation of an old property can still break consumers. A deserializer returning a default value can be especially misleading when the default is also a plausible real measurement.

Compare formats at the same boundary

It is easy to compare a compact binary payload with an entire JSON transport envelope and attribute every difference to the serializer. It is equally easy to compare a pre-encoded binary array with JSON that still has to allocate and encode strings.

Choose the boundary before measuring. Are both paths receiving the same typed event? Do both validate the same ranges and text? Does each result include the same metadata? Are both outputs ready for the same downstream consumer, or does one still need framing, copying or compression?

Choose the boundary before measuring the format
Inspect the full diagram ↗Swipe the drawing to inspect it.

The browser view reports the exact byte lengths of its two constructed representations. It does not infer the time to produce them. A smaller output can still take more CPU to encode, and a larger representation can compress well or be easier to inspect operationally. The correct decision depends on the complete path.

For a .NET measurement, keep the event corpus, encoding policy and output ownership consistent. A serializer that writes into a reusable destination is a different allocation boundary from one returning a new string. If the consumer needs bytes, include the string-to-byte conversion rather than declaring the string result finished work.

Versioning needs an acceptance matrix

Write down which producer/consumer combinations must work. A version-one producer and version-one reader are the baseline. A version-two producer with a version-one reader may be rejected, accepted through a compatibility path or interpreted through an agreed subset. A version-one producer with a version-two reader needs an explicit default or legacy interpretation for any new fields.

The useful test is not merely that every producer can read its own output. Two implementations can share the same mistaken assumption and round-trip successfully. Keep golden records with independently specified expected fields, including boundary values, non-ASCII names, unknown versions and truncated input.

A golden record is evidence of a particular contract, not a reason to freeze a bug forever. If the meaning changes, version the fixture and explain which old consumers remain supported. Do not silently replace expected bytes and call the compatibility suite green.

Test the combinations that can actually coexist
Inspect the full diagram ↗Swipe the drawing to inspect it.

For telemetry, remember that producers and consumers may deploy independently. A temporary mixed-version period is not an exceptional situation if the deployment process makes it normal. Test the combinations that can actually coexist, including retained records replayed after an upgrade.

The most expensive field may be created before serialization

Suppose the event includes a large diagnostic string produced by walking an object graph. Switching JSON to binary does not undo that construction. Sampling the event after the string is created does not undo it either.

Place expensive optional work behind a relevant decision when the API permits it. That decision might be whether detail is requested, whether the event is enabled or whether the chosen tracing path records the data. The guard should protect the construction that matters, not merely the final write call.

Be precise about what the guard means. An activity existing does not necessarily mean every detailed tag will be recorded or exported. An exporter queue accepting an event does not mean the remote destination durably stored it. These are separate boundaries, and one successful Boolean should not stand in for all of them.

The event format participates in this wider system. Its size affects queue occupancy and transfer work. Its schema affects decoding and queryability. Its production path affects application CPU and allocation. Optimizing one of those dimensions can move the bottleneck to another.

Keep the failed record inspectable

Binary formats often need tooling to explain a record. If an operator cannot tell which version, length or field failed, a small payload can become an expensive incident. Build a bounded diagnostic representation that reports the failure without dumping arbitrary sensitive data.

The hex view in the demonstration is useful because the example is small and synthetic. A production tool needs additional decisions: how much input to retain, which values to redact, how to correlate the failure and how to prevent malformed input from creating unbounded diagnostic output.

That tooling is part of the format's cost. It is reasonable to accept it when the workload benefits, but it should not disappear from the comparison because it is not in the microbenchmark project.

My decision would start with meaning and compatibility, then measure equal work. If the compact form wins at a relevant boundary and remains inspectable, it is a useful optimization. If it only wins after excluding validation, conversion or operational tooling, the result needs a narrower claim.

Historical source boundary

This edition uses APIs present in the .NET 9.0.4 source tag, available before its September 2025 context. The byte layouts and worked records are constructed examples. Their lengths are computed from explicit inputs, not presented as historical runtime measurements. Contemporary CPU/allocation experiments and original artifacts remain in a separately identified companion.

GO FURTHER

Inspect the contemporary laboratory.

This is a newly reconstructed historical edition, not a claim of earlier authorship. Later executed experiments, their exact environments and original results are preserved in a separate notebook.

Open the current laboratory notebook ↗

Download the current reconstruction sources ↗

THE AUTHOR

Marcin Borecki

Developer, entrepreneur and business angel. Writing about AI, software, data and the evidence behind technical decisions.

About the author →