Estaba orgullosa de mi API. Hasta que hice un benchmark.

Tres años construyendo una arquitectura limpia, rutas bien nombradas y un manejo sólido de errores… y, mientras tanto, los tiempos de respuesta estaban perjudicando silenciosamente la experiencia de usuario.

No era por una mala lógica.

No era por una base de datos lenta.

Era por JSON.

Todo salió a la luz durante una reunión de seguimiento un lunes.

El problema estaba en mis propias suposiciones.

Ese fin de semana me costó varias horas de sueño. Pero también cambió para siempre mi forma de pensar sobre el rendimiento.

TL;DR — Qué encontrarás en este artículo

Usa JSON en los límites públicos de tu sistema.

Para todo lo demás, cuando son máquinas comunicándose con máquinas, considera utilizar estos formatos.

El problema del que casi nadie habla

JSON es cómodo.

Es legible.

A la consola de tu navegador le encanta.

Tus compañeras y compañeros pueden abrirlo en el Bloc de notas y entender perfectamente lo que están viendo.

Pero esa comodidad tiene un precio.

En producción puedes estar haciendo esto miles de veces por segundo y asumir que está bien.

Y no siempre está bien.

Aquí tienes un dato real de uno de mis servicios:

Un payload con 10.000 registros de usuarios en JSON pesaba 2,3 MB y tardaba aproximadamente 180 ms en serializarse.

¿Los mismos datos utilizando un formato binario?

620 KB y aproximadamente 34 ms.

Los mismos datos.

Casi 5 veces más rápido.

Déjame enseñarte qué empecé a utilizar.

1. Protocol Buffers (Protobuf)

Google utilizó Protobuf internamente durante años antes de convertirlo en código abierto.

La idea es sencilla: defines una vez el esquema de tus datos en un archivo .proto, y la biblioteca se encarga de codificar y decodificar la información utilizando un formato binario muy compacto.

// user.proto
syntax = "proto3";

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

Después puedes codificar y decodificar los datos en Node.js:

const protobuf = require('protobufjs');

async function run() {
  const root = await protobuf.load('user.proto');
  const User = root.lookupType('User');
  const msg = User.create({ id: 1, name: 'Arjun', email: 'a@dev.io' });
  const buf = User.encode(msg).finish();   // buffer binario
  const decoded = User.decode(buf);        // vuelve a convertirse en objeto
}

Los números de los campos —1, 2 y 3— son lo que realmente se transmite.

No se transmiten los nombres de los campos.

Por ejemplo, "name" como cadena de texto ocupa 4 bytes cada vez que aparece en JSON.

En Protobuf, como etiqueta de campo, puede ocupar solo 1 byte.

Ahora multiplica esa diferencia por un millón de solicitudes al día.

Esto ya no es una microoptimización.

Estamos hablando de una reducción real de los costes de infraestructura.

Ideal para: comunicación interna entre servicios, APIs gRPC y pipelines de datos con un gran volumen de tráfico.

2. MessagePack

Piensa en MessagePack como si JSON hubiera empezado a ir al gimnasio.

Sigue siendo un formato basado en pares clave-valor.

Admite prácticamente los mismos tipos de datos.

Pero, en lugar de utilizar texto, codifica todo en binario.

Y no necesita un esquema.

Eso significa que puedes tomar los objetos JSON que ya utilizas y empezar a serializarlos con MessagePack prácticamente hoy mismo, con un esfuerzo de migración mínimo.

De hecho, esta fue la primera opción que probé.

Y, sinceramente, lo hice porque no tenía paciencia para ponerme a escribir esquemas .proto.

En una sola tarde cambié los payloads de nuestros WebSockets de JSON a MessagePack.

El tamaño de los payloads se redujo un 38 %, sin modificar una sola línea de la lógica de negocio.

const msgpack = require('@msgpack/msgpack');

const data = { id: 1, name: 'Arjun', score: 98.5 };
const encoded = msgpack.encode(data);    // Uint8Array, ~20 bytes
const decoded = msgpack.decode(encoded); // vuelve a ser un objeto normal

Compáralo con:

JSON.stringify(data)

que genera:

{"id":1,"name":"Arjun","score":98.5}

Son aproximadamente 37 caracteres, 37 bytes en ASCII.

MessagePack gana en tamaño porque puede utilizar un solo byte para representar valores comunes como números enteros pequeños, booleanos o null, en lugar de utilizar las representaciones completas en texto que necesita JSON.

Pero hay una contrapartida.

Pierdes la facilidad de lectura para una persona.

Por eso, antes de hacer el cambio, asegúrate de que tus sistemas de logging y debugging pueden trabajar correctamente con datos binarios.

Ideal para: payloads de WebSocket, caché en Redis y, en general, cualquier escenario donde controles ambos extremos de la comunicación.

Arquitectura: dónde encajan estos formatos

Este fue el modelo mental que hizo que todo encajara para mí.

El formato que utilizas debería depender de quién va a leer esos datos:

¿una persona o una máquina?

Aplicación cliente
     |
     |  (REST/HTTP — JSON está bien aquí, las personas pueden depurarlo)
     v
 API Gateway
     |
     |  (MessagePack o Protobuf — solo máquinas)
     v
 Servicio A ------> Servicio B ------> Servicio C
     |                                    |
     |  (Avro — streaming de eventos)     |
     v                                    v
 Kafka / Redpanda               Caché (MessagePack)

El límite importa.

Las APIs públicas necesitan ser fáciles de leer.

Las comunicaciones internas necesitan velocidad.

Muchos equipos utilizan JSON absolutamente para todo y después se preguntan por qué determinados procesos son lentos.

En ocasiones, la respuesta está literalmente delante de nosotros:

en la cabecera Content-Type.

3. Apache Avro

Avro es prácticamente el formato que Kafka espera silenciosamente que utilices, aunque nadie te lo haya explicado cuando configuraste tu pipeline.

Al igual que Protobuf, está basado en esquemas.

Pero Avro permite trabajar con el esquema junto con los datos, lo que lo convierte en una opción especialmente interesante para sistemas de streaming de eventos donde el esquema va evolucionando con el tiempo.

Imagina que un consumidor necesita leer eventos generados hace seis meses.

La semana pasada añadiste tres campos nuevos.

Ese consumidor debería seguir funcionando.

Avro está diseñado precisamente para manejar este tipo de evolución de esquemas de forma elegante.

const avro = require('avsc');

const UserEvent = avro.Type.forSchema({
  type: 'record',
  name: 'UserEvent',
  fields: [
    { name: 'id',    type: 'int' },
    { name: 'event', type: 'string' },
    { name: 'ts',    type: 'long' }
  ]
});

const buf = UserEvent.toBuffer({
  id: 42,
  event: 'login',
  ts: Date.now()
});

const obj = UserEvent.fromBuffer(buf);

Después de migrar nuestro bus interno de eventos a Avro, el retraso de los consumidores de Kafka pasó de 40 segundos en momentos de carga máxima a menos de 4 segundos.

Mismo hardware.

Mismas particiones del topic.

Lo único que cambió fue el formato utilizado para transmitir los datos.

La integración con registros de esquemas —ya sea utilizando Confluent o AWS Glue— hace que Avro esté preparado para entornos de producción donde los equipos gestionan pipelines de datos serios y a gran escala.

Ideal para: streaming de eventos con Kafka, data lakes y cualquier entorno donde la evolución del esquema sea una preocupación real a lo largo del tiempo.

4. FlatBuffers

Este probablemente sea el formato más infravalorado de toda la lista.

Y también uno de los más malinterpretados.

FlatBuffers, también desarrollado por Google, utiliza un enfoque completamente diferente.

En lugar de codificar un objeto en bytes y después decodificarlo en el otro extremo, construye el buffer de bytes de manera que tu código pueda leer directamente los datos.

Sin necesidad de realizar un proceso tradicional de deserialización.

No estás abriendo una caja para sacar lo que hay dentro.

Estás leyendo directamente la caja.

// Después de generar el código JS desde tu esquema .fbs:
const flatbuffers = require('flatbuffers');
const { Monster }  = require('./monster_generated');

const builder = new flatbuffers.Builder(128);
const name    = builder.createString('Orc');

Monster.startMonster(builder);
Monster.addHp(builder, 300);
Monster.addName(builder, name);

const orc = Monster.endMonster(builder);

builder.finish(orc);

const buf = builder.asUint8Array();

const monster = Monster.getRootAsMonster(
  new flatbuffers.ByteBuffer(buf)
);

console.log(monster.name());  // 'Orc' - leído directamente del buffer, zero copy
console.log(monster.hp());    // 300

Zero copy.

Prácticamente ninguna asignación adicional de memoria durante la lectura.

Para sistemas donde la latencia es crítica —feeds en tiempo real, datos financieros de mercado, backends de videojuegos— estamos hablando de otra categoría de rendimiento.

El tiempo de deserialización que aparece en el benchmark siguiente no es un error.

El benchmark: números reales

Hice estas pruebas con un payload de 5.000 registros, cada uno con 8 campos, utilizando Node.js 20:

Formato       | Tamaño   | Serialización | Deserialización
------------- | -------- | ------------- | ----------------
JSON          | 1,8 MB   | 142 ms        | 98 ms
MessagePack   | 1,1 MB   | 61 ms         | 44 ms
Protobuf      | 680 KB   | 38 ms         | 29 ms
Avro          | 590 KB   | 35 ms         | 31 ms
FlatBuffers   | 720 KB   | 28 ms         | ~2 ms *

*El tiempo de deserialización de FlatBuffers es prácticamente cero porque no existe una deserialización tradicional: tu código lee directamente desde el buffer de memoria sin procesarlo previamente como un objeto completo.

Entonces, ¿deberías abandonar JSON por completo?

No.

Y esa sería precisamente la conclusión equivocada.

Lo digo después de haber pasado un fin de semana entero intentando convencerme de lo contrario.

JSON es excelente para APIs públicas, archivos de configuración y cualquier situación en la que una persona pueda necesitar leer los datos directamente.

La facilidad para depurar un sistema tiene un valor real.

El soporte de herramientas también tiene un valor real.

La primera vez que tengas un incidente en producción y descubras que tus logs son una colección de blobs binarios imposibles de leer a simple vista, entenderás perfectamente a qué me refiero.

El verdadero cambio consiste en aprender a elegir el formato de manera consciente.

JSON en los límites del sistema, donde las personas necesitan interactuar, inspeccionar o depurar la información.

Formatos binarios en las capas internas, donde las máquinas se comunican con otras máquinas y el volumen de datos realmente importa.

Gracias por leer Código en Casa.
Si esto te a ayudado y te sumo algo Dale un 👏 , compártelo con tu red o dejame un comentario para saber tu opinión.