December 1, 2025

Better than JSON

Better than JSON

Or why I stopped using JSON for my APIs

If you develop or use an API, there’s a 99% chance it exchanges data encoded in JSON. It has become the de facto standard for the modern web. And yet, for almost ten years, whenever I develop servers—whether for personal or professional projects—I do not use JSON.

And I find it surprising that JSON is so omnipresent when there are far more efficient alternatives, sometimes better suited to a truly modern development experience. Among them: Protocol Buffers, or Protobuf.

In this article, I’d like to explain why.

Serialization

Before going any further, let’s put the topic back into context.

An API (Application Programming Interface) is a set of rules that allow two systems to communicate. In the web world, REST APIs—those using the HTTP protocol and its methods (GET, POST, PUT, DELETE…)—are by far the most widespread.

When a client sends a request to a server, it transmits a message containing:

  • headers, including the well-known Content-Type, which indicates the message format (JSON, XML, Protobuf, etc.);
  • a body (payload), which contains the data itself;
  • a response status.

Serialization is the process of turning a data structure into a sequence of bytes that can be transmitted. JSON, for example, serializes data as human-readable text.

Why is JSON so common?

There are many reasons for its popularity:

Human-readable

JSON is easy to understand, even for non-developers. A simple console.log() is often enough to inspect most data.

Perfectly integrated into the web

It was propelled by JavaScript, then massively adopted by backend frameworks.

Flexible

You can add a field, remove one, or change a type “on the fly.” Useful… sometimes too much.

Tools everywhere

Need to inspect JSON? Any text editor will do. Need to send a request? Curl is enough. Result: massive adoption, rich ecosystem.


However, despite these advantages, another format offers me better efficiency—for both developers and end users.

Protobuf: ever heard of it?

There’s a strong chance you’ve never really worked with Protobuf. Yet this format was created as early as 2001 at Google and made public in 2008.

It’s heavily used inside Google and in many modern infrastructures—especially for inter-service communication in microservice architectures.

So why is it so discreet in public API development?

Perhaps because Protobuf is often associated with gRPC, and developers think they must use both together (which is false). Maybe also because it’s a binary format, making it feel less “comfortable” at first glance.

But here’s why I personally use it almost everywhere.

Proto — Strong typing and modern tooling

With JSON, you often send ambiguous or non-guaranteed data. You may encounter:

  • a missing field,
  • an incorrect type,
  • a typo in a key,
  • or simply an undocumented structure.

With Protobuf, that’s impossible. Everything starts with a .proto file that defines the structure of messages precisely.

Example of a Proto3 file

syntax = "proto3";

message User {
  int32 id = 1;
  string name = 2;
  string email = 3;
  bool isActive = 4;
}

Each field has:

  • a strict type (string, int32, bool…)
  • a numeric identifier (1, 2, 3…)
  • a stable name (name, email…)

This file is then used to automatically generate code in your preferred language.

Code generation

You use protoc:

protoc --dart_out=lib user.proto

and you automatically get the following in your Dart code:

final user = User()
  ..id = 42
  ..name = "Alice"
  ..email = "alice@example.com"
  ..isActive = true;

final bytes = user.writeToBuffer();       // Binary serialization
final sameUser = User.fromBuffer(bytes);  // Deserialization

No manual validation. No JSON parsing. No risk of type errors.

And this mechanism works with:

  • Dart
  • TypeScript
  • Kotlin
  • Swift
  • C#
  • Go
  • Rust
  • and many more…

It represents a huge time saver and brings exceptional maintainability comfort.

Buffer — Ultra-efficient binary serialization

Another major strength of Protobuf: it’s a binary format, designed to be compact and fast.

Let’s compare with JSON.

Example JSON message

{
  "id": 42,
  "name": "Alice",
  "email": "alice@example.com",
  "isActive": true
}

Size: 78 bytes (depending on whitespace).

The same message in Protobuf binary

→ About 23 bytes. Roughly 3× more compact, and often much more depending on structure.

Why? Because Protobuf uses:

  • compact “varint” encoding for numbers
  • no textual keys (they’re replaced by numeric tags)
  • no spaces, no JSON overhead
  • optimized optional fields
  • a very efficient internal structure

Results:

  • less bandwidth
  • faster response times
  • savings on mobile data
  • direct impact on user experience

Example: a tiny Dart server using Shelf that returns Protobuf

To make things more concrete, let’s build a minimal HTTP server in Dart using the shelf package, and return our User object serialized as Protobuf, with the correct Content-Type.

We’ll assume you already have the previously generated code for the User type.

Create a simple Shelf server

Create a file bin/server.dart:

import 'dart:io';

import 'package:shelf/shelf.dart';
import 'package:shelf/shelf_io.dart' as shelf_io;
import 'package:shelf_router/shelf_router.dart';

import 'package:your_package_name/user.pb.dart'; // Adjust the path to your generated file

void main(List<String> args) async {
  final router = Router()
    ..get('/user', _getUserHandler);

  final handler = const Pipeline()
      .addMiddleware(logRequests())
      .addHandler(router);

  final server = await shelf_io.serve(handler, InternetAddress.anyIPv4, 8080);
  print('Server listening on http://${server.address.host}:${server.port}');
}

Response _getUserHandler(Request request) {
  final user = User()
    ..id = 42
    ..name = 'Alice'
    ..email = 'alice@example.com'
    ..isActive = true;

  final bytes = user.writeToBuffer();

  return Response.ok(
    bytes,
    headers: {
      'content-type': 'application/protobuf',
    },
  );
}

Key points:

  • User() comes from the generated Protobuf code.
  • writeToBuffer() serializes the object into Protobuf binary.
  • The Content-Type header is set to application/protobuf, allowing clients to know they must decode Protobuf instead of JSON.

Calling the Protobuf API from Dart (using http)

Once your server returns a Protobuf-encoded User, you can retrieve and decode it directly from Dart. All you need is:

  • the http package
  • the generated Protobuf classes (user.pb.dart)

Create a Dart file (e.g. bin/client.dart):

import 'package:http/http.dart' as http;

import 'package:your_package_name/user.pb.dart'; // Adjust path

Future<void> main() async {
  final uri = Uri.parse('http://localhost:8080/user');

  final response = await http.get(
    uri,
    headers: {
      'Accept': 'application/protobuf',
    },
  );

  if (response.statusCode == 200) {
    // Decode the Protobuf bytes
    final user = User.fromBuffer(response.bodyBytes);

    print('User received:');
    print('  id       : ${user.id}');
    print('  name     : ${user.name}');
    print('  email    : ${user.email}');
    print('  isActive : ${user.isActive}');
  } else {
    print('Request failed: ${response.statusCode}');
  }
}

With this setup, both the server and the client rely on the same Protobuf definition, ensuring that data structures stay perfectly aligned without manual validation or JSON parsing. The same .proto file generates strongly typed code on both sides, making it impossible for the client and server to “disagree” about the shape or type of the data.

And this is not limited to Dart: the exact same approach works seamlessly if your server is written in Go, Rust, Kotlin, Swift, C#, TypeScript, or any language supported by the Protobuf compiler. Protobuf acts as a shared contract, giving you end-to-end type safety and consistent, compact data serialization across your entire stack.

However… JSON still keeps one important advantage

We shouldn’t ignore one of JSON’s key strengths:

Human debugging.

A JSON message can be read instantly. A Protobuf binary message can’t.

To decode a Protobuf message, you must know the .proto schema used to encode it. Without it, the bytes have no meaning.

It’s not a blocker, but it adds complexity:

  • needed tooling
  • schemas to version
  • mandatory decoding tools

For me, it’s a trade-off I gladly accept given the benefits.

Conclusion

I hope this article makes you want to try Protobuf. It’s an incredibly mature, extremely performant tool, but still too invisible in the world of public APIs.

And even though Protobuf is often associated with gRPC, nothing forces you to use both. Protobuf can work independently, on any traditional HTTP API.

If you’re looking for:

  • more performance,
  • more robustness,
  • fewer errors,
  • and a genuinely enjoyable development experience,

then I strongly encourage you to try Protobuf on your next project.