·10 min read ·Muhammet Şafak

BabelQueue vs CloudEvents: two envelopes, different problems

CloudEvents standardizes how an event describes itself across protocols; BabelQueue freezes what a queue job looks like so six languages run it. An honest comparison — the real overlap, the real differences, and where CloudEvents is the better choice.

CloudEvents and BabelQueue both put a structured, language-neutral envelope around a message, and that is roughly where the resemblance ends. CloudEvents standardizes how an event describes itself so any producer, protocol and platform can agree on the metadata around an occurrence. BabelQueue freezes what a queue job looks like on the wire — the wire envelope — so six language SDKs execute the same work item off the broker you already run.

That difference in scope decides everything downstream: what each one pins down, what each one deliberately leaves open, and which of them you should actually adopt. This post compares them on the details, and says plainly where CloudEvents is the better answer — because for a large class of systems it is.

What each one standardizes

CloudEvents

CloudEvents is a CNCF specification (graduated in January 2024) for describing event data in a common way. Version 1.0 defines four required context attributes — id, source, specversion and type — and four optional ones: datacontenttype, dataschema, subject and time. An event MAY carry domain data in data, whose media type is declared by datacontenttype. Producers MUST ensure source + id is unique per distinct event, and type SHOULD be prefixed with a reverse-DNS name, e.g. com.github.pull_request.opened.

Around that core, the spec family defines event formats (JSON, Avro, Avro Compact, Protobuf, XML) and protocol bindings (HTTP, AMQP, Kafka, MQTT, NATS, WebSockets), each with a structured content mode (metadata and data together in the body, e.g. application/cloudevents+json), a binary mode (data in the body, attributes mapped to the protocol’s own metadata — ce--prefixed headers in the HTTP binding), and a batched mode. Any number of extension attributes may be added; the documented ones include Distributed Tracing, which carries W3C traceparent and tracestate as event attributes.

The scope line is drawn explicitly. “Inclusion of protocol-level routing information” is a stated non-goal, and the specifications “will not focus on the processing model of either the event producer or event consumer.” There is no attempt counter, no dead-letter concept and no redelivery rule anywhere in the format — that belongs to whatever platform is moving the event.

BabelQueue

BabelQueue defines exactly one thing — the bytes on the queue — and freezes it:

{
  "job": "urn:babel:orders:created",
  "trace_id": "7b3f9c2a-e41d-4f88-9b2a-1c0d5e6f7a8b",
  "data": { "order_id": 1042 },
  "meta": {
    "id": "f1e2d3c4-b5a6-4789-90ab-cdef01234567",
    "queue": "orders",
    "lang": "php",
    "schema_version": 1,
    "created_at": 1749132727000
  },
  "attempts": 0
}

All five top-level fields are required. Identity is the URN in job, never a class name. data is a pure JSON object — the cross-language rules for numbers, time and binary are part of the contract, not implementation notes. meta has five required keys; a producer may add its own, but consumers must ignore unknown ones and nothing extra may be required for routing. attempts is the single mutable field, kept deliberately outside the immutable meta block. There is a published list of forbidden fields, and meta.schema_version is frozen at 1 (the envelope).

On top of that sit the pieces a queue actually needs: a <queue>.dlq dead-letter convention with an additive dead_letter block, consumer dedupe keyed on meta.id, a transactional outbox, DLQ redrive — and framework adapters (a drop-in Laravel queue driver, a Symfony Messenger serializer, Spring AMQP, NestJS/BullMQ, MassTransit, Celery) so your existing worker keeps its retry and supervision and only the serialization changes.

Where they genuinely overlap

Three overlaps are real, not superficial.

A structured envelope with required metadata. Both refuse to let a message be an opaque blob, and both define where its identity, its message id and its timestamp live instead of leaving that to each producer.

A stable string type identifier. CloudEvents type and BabelQueue job solve the same problem: name the kind of message with a string the receiving code can match on, rather than a class or type shared between services. CloudEvents recommends reverse-DNS (com.github.pull_request.opened); BabelQueue recommends urn:babel:<context>:<event>. Neither is enforced by the libraries; both are conventions teams are expected to hold themselves to.

Bindings that project onto native protocol metadata. CloudEvents binary mode maps attributes onto the protocol’s own metadata — ce--prefixed headers in HTTP — so a router never parses the body. BabelQueue’s broker bindings do the analogous thing: the URN goes to AMQP type and trace_id to correlation_id, or to SQS MessageAttributes, or to an Azure Service Bus Subject.

There is an important asymmetry inside that last one. In CloudEvents binary mode the headers are the event metadata and the body holds only data. In BabelQueue the body is always the complete canonical envelope, and the native metadata is a redundant projection of it — never a replacement. If a broker, proxy or bridge drops headers, a binary-mode CloudEvent loses its identity while a BabelQueue message does not.

Correlation is a fourth, weaker overlap. CloudEvents can carry traceparent/tracestate via an optional extension; BabelQueue requires trace_id on every envelope and forwards it unchanged across every hop, with W3C traceparent riding out of band on the transport for exact span linkage (observability).

Side by side

CloudEvents 1.0 BabelQueue (schema_version: 1)
Standardizes how an event describes itself, across protocols what a queue job looks like on the wire, across languages
Envelope 4 required + 4 optional context attributes, plus extensions 5 required top-level fields; meta has 5 required keys
Identity type — SHOULD be reverse-DNS job — a URN, never a class name
Payload data, any media type via datacontenttype data, a pure JSON object, with cross-language encoding rules
Serializations JSON, Avro, Avro Compact, Protobuf, XML one: compact UTF-8 JSON
Where metadata lives body (structured) or protocol headers (binary) always the body; native metadata is a redundant projection
Version field specversion (1.0) meta.schema_version (1, frozen)
Extensibility any number of extension attributes additive optional keys; unknown keys ignored; forbidden-field list
Transports HTTP, AMQP, Kafka, MQTT, NATS, WebSockets Redis, RabbitMQ, SQS, Azure Service Bus, Pulsar, Kafka, Artemis
Retry / delivery not part of the event format attempts in the envelope; <queue>.dlq + dead_letter block
Correlation optional Distributed Tracing extension required trace_id on every message
Runtime SDKs in nine languages 6 SDK cores + framework adapters that reuse your worker
Governance CNCF graduated project, 40+ listed adopters ADR-governed, cross-SDK conformance suite in every SDK’s CI

One caveat on that transports row: per-SDK broker coverage is not uniform. PHP has no Azure Service Bus transport, and that is a documented deferral rather than a backlog item — Service Bus speaks AMQP 1.0 and has no modern official PHP client.

Where they genuinely differ

Describing a message versus running one

CloudEvents is deliberately neutral about what a message means: “the purpose, or semantic meaning, of an event is out of scope,” so a CloudEvent can perfectly well carry a command. What it does not carry is any of the bookkeeping a worker queue runs on. BabelQueue assumes from the first line that something will pick the message up, execute it, fail, retry and eventually give up — which is why attempts is a top-level mutable field, why <queue>.dlq is a convention rather than an afterthought, and why the dedupe key is fixed at meta.id.

That is a scope difference, not a defect on either side. A format that also has to describe an HTTP webhook cannot sensibly mandate a retry counter; a format for queue jobs cannot sensibly leave one out.

What is frozen versus what is left open

CloudEvents leaves the serialization format, the payload media type and the extension attribute set open, and that openness is exactly what lets it span HTTP webhooks, Kafka topics and MQTT devices. The cost is that “we both speak CloudEvents” does not by itself mean “I can read your messages”: a Protobuf-format event over Kafka and a JSON-format event over HTTP are both perfectly conformant, so two systems still have to agree on the event format, the content mode and the shape of data before anything interoperates.

BabelQueue takes the opposite trade. One serialization, one required shape for data, a fixed required meta key set, a forbidden-field list, and byte-comparable golden fixtures every SDK runs in CI — so two SDKs that pass conformance can read each other’s messages with no further agreement. That buys drop-in cross-language consumption and pays for it in flexibility. A wart in schema_version: 1 is a wart we live with, because the freeze is the product.

Queue operations are in scope for one of them

Dead-lettering to <queue>.dlq, redrive with dry-run and sandbox routing, dedupe on meta.id, a transactional outbox: all of that ships with the SDKs and none of it has a CloudEvents equivalent, by scope rather than by omission. The practical consequence is simply that if you standardize on CloudEvents for queue work, those parts are yours to build, buy or inherit from the platform underneath.

What you actually install

CloudEvents SDKs give you the event type and the protocol bindings; the dispatch loop is yours. BabelQueue ships framework adapters, so a Laravel, Symfony, Spring or NestJS worker keeps its retry, backoff and supervision and changes only its serializer. One honest caveat: the PHP reference transports are produce-side, and apart from the framework-less Kafka and Pulsar consumers, consuming in PHP goes through a framework worker.

Where CloudEvents is the better fit

Not hedging — these are cases where adopting BabelQueue would be the wrong call:

  • You publish beyond queues. HTTP webhooks, MQTT devices, NATS, WebSockets. CloudEvents has a binding for each; every BabelQueue binding is a message broker, and there is no HTTP binding.
  • Your platform already speaks it. Knative, Azure Event Grid, Google Eventarc, Dapr, Tekton, OpenFaaS and others emit and consume CloudEvents natively. Putting a BabelQueue envelope into that world means writing and maintaining the mapping yourself.
  • Your payload is not a JSON object. Avro, Protobuf, XML, a large binary blob, or any arbitrary media type. BabelQueue’s data is a pure JSON object, full stop — base64 and a documented field is the only escape hatch.
  • You want a description, not a job. If nothing retries and nothing dead-letters, and the consumers are unknown to the producer, CloudEvents’ smaller required attribute set is simply less to agree on.
  • Maturity and reach matter to you. CloudEvents is a CNCF graduated project with SDKs in nine languages and a long adopter list. BabelQueue is far younger and far smaller. That is a real risk factor and worth weighing honestly against the drop-in fit.

Can you use both?

Nothing in the contract stops you from carrying a JSON-format CloudEvent as the data object of a BabelQueue envelope — data must be a pure JSON object, and a structured-mode CloudEvent is one. The identity fields map mechanically: typejob.

But no SDK does that mapping for you today, so you would own it, and you would be carrying two identity fields — decide up front which one routes, or you will eventually have messages where they disagree. It is worth doing when a CloudEvents-native system genuinely sits on one side of the queue, and not otherwise.

Choosing

  • Cross-language queue jobs over the broker you already run, with a framework worker you want to keep. BabelQueue. That is the case it was designed for.
  • Event notifications fanning out across protocols, platforms and unknown consumers. CloudEvents, and it is not close.
  • A CloudEvents platform on one side and polyglot workers on the other. Both, with an explicit mapping you own at the boundary.

The two specs answer different questions, so “which is better” is the wrong frame. The useful question is whether your messages need delivery bookkeeping — retries, a dead-letter destination, dedupe — or a faithful description that can travel over anything. If it is the former, read the wire contract for exactly what travels on the queue, or start with what a polyglot queue is and the glossary.

Cross-language dead-letter queues

Route failed and unroutable messages to a dead-letter queue any language can read, with a structured reason — opt-in, additive, and still schema_version 1.

Read article →

What is a polyglot queue?

A polyglot queue is a message queue whose jobs are produced in one language and consumed natively in another. Why language-native serialization blocks that, the failure modes teams actually hit, and what a working solution has to provide.

Read article →

Why the envelope is frozen at schema_version 1

Freezing the wire envelope is the whole product. Here's what 'frozen' means, what can still change, and how versioning keeps a v1 producer readable by every v1 consumer.

Read article →