--- title: "BabelQueue vs CloudEvents: two envelopes, different problems" description: "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." source: https://babelqueue.com/blog/babelqueue-vs-cloudevents/ pubDate: 2026-08-18T00:00:00.000Z --- 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](/glossary/#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](https://cloudevents.io/) 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: ```json { "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](/docs/spec/1.x/envelope/)). On top of that sit the pieces a queue actually needs: a `.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::`](/docs/spec/1.x/urn/). 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](/docs/spec/1.x/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](/docs/spec/1.x/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; `.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 `.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](/blog/why-the-envelope-is-frozen/). ### Queue operations are in scope for one of them Dead-lettering to `.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: `type` ↔ `job`. 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](/docs/spec/1.x/envelope/) for exactly what travels on the queue, or start with [what a polyglot queue is](/blog/what-is-a-polyglot-queue/) and the [glossary](/glossary/).