Asynchronous foundations

Define message and job contracts

Version small serializable payloads and keep large bodies in durable storage referenced by ID.

8 minute lesson

~~~

A queue message or workflow parameter crosses a durable serialization boundary. It gets serialized, stored, and deserialized 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, version, tenant, operation, and 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. The jobId makes duplicate deliveries recognizable. The version lets the consumer know which shape it’s reading. The tenantId scopes every downstream query. The traceId connects the eventual log lines back to the request that started everything, which is the difference between debugging and guessing.

What must stay out

Do not place a Request, Response, Error object, function, or huge file body in the payload. The 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 can exceed message size limits.

Store large content in R2 or relational state in D1, then pass the stable 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, and 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 can outlive the producer version that created a message. During a deploy, messages sent by old code are delivered to new code, and messages already queued wait out the transition. There is 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.

Design version 1 of an export-job payload, then write down how version 2 remains compatible during deployment. If your 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

Take this course offline

Get every free book and course as PDF and EPUB files.

Get the download library →