Asynchronous foundations
Define message and job contracts
Version small serializable payloads and keep large bodies in durable storage referenced by ID.
A queue message or a workflow parameter gets serialized, stored, and read later by code that may not be the code that sent it. That makes the payload a contract, and contracts deserve design.
Send plain data with an ID, a version, a tenant, an operation, and a trace identifier:
await env.EXPORTS.send({
version: 1,
operation: 'export-invoices',
jobId: 'exp_01J1ZK3W9Q',
tenantId: 'tenant-123',
requestedBy: 'user-456',
traceId: request.headers.get('cf-ray'),
})
Each field earns its place. jobId makes duplicate deliveries recognizable. version tells the consumer which shape it’s reading. tenantId scopes every downstream query. traceId connects the eventual log lines back to the request that started everything. That last one is the difference between debugging and guessing.
What must stay out
Don’t put a Request, a Response, an Error object, a function, or a huge file body in the payload. Live objects don’t survive serialization. A Request is a stream tied to a connection that’s long gone by delivery time. And large bodies bloat every retry and run into the message size limit.
Store large content in R2, or relational state in D1, then pass the key:
await env.FILES.put(`exports/pending/${jobId}.json`, request.body)
await env.EXPORTS.send({ version: 1, jobId, tenantId, bodyKey: `exports/pending/${jobId}.json` })
The message stays a few hundred bytes. The data sits somewhere durable that both producer and consumer can reach.
Versions overlap during every deploy
Here’s the constraint people discover in production. Consumers outlive the producer version that created a message. During a deploy, messages sent by old code get delivered to new code, and messages already queued wait out the transition. There’s no moment when the queue is empty and both sides flip together.
So evolve the contract additively. New fields get defaults when missing. Old fields keep their meaning until no queued message can still carry them. A consumer that throws on an unfamiliar shape turns a routine deploy into a dead-letter flood.
Try this: design version 1 of an export-job payload, then write down how version 2 stays compatible during a deploy. If version 2 renames a field, describe what the consumer does with a version 1 message that arrives five minutes after the deploy. If the answer is “crashes”, the contract isn’t done.
Lesson completed