Key-value storage and queues
An app can have a key-value store of its own, for settings, caches and anything else it reads often and writes now and then, and a queue, for work it does in the background: emails, imports, reports. Both belong to the app. No other app, in your workspace or anyone else’s, can reach them.
They’re on Team and Business. Team can have them on 5 apps and Business on 25, each counted on its own. Free has neither.
Ask for them
Section titled “Ask for them”Add kv, queue or both to the app’s jiayang.json and deploy:
{ "version": 1, "kv": true, "queue": { "path": "/jobs/queue" }}The next deploy makes them, and every deploy and rollback after keeps them. Uploading a built Worker from the dashboard, pick jiayang.json with its modules. With the API clients, pass kv and queue with the deploy. Taking them out of jiayang.json deletes nothing: they stay until the app is deleted. A preview shares the app’s own, and makes them if the app has none yet.
queue.path is the path on your app we send the queue’s messages to. It starts with /, has no query string, and isn’t under /.jiayang. Deploy again with another path to change it.
A deploy from GitHub Actions makes them too, but only a person’s deploy says where the queue’s messages go, since they go as whoever set that up. After adding queue to a project that deploys from GitHub, deploy it once yourself, with jiayang deploy or an agent. Until then its messages wait.
A Worker gets them as env.KV and env.QUEUE. A container reaches them on its storage host, as it does its database and files. A static site can have neither: it has no code of its own to use them. Nor, yet, can an app in a workspace that keeps its data in the EU. A deploy that asks there is refused with eu_no_stores, and nothing is made.
KV and QUEUE are the platform’s names, so an app with key-value storage or a queue can’t have a variable of either name. A deploy that asks for one while the app has such a variable is refused, and says to remove it with jiayang env remove.
Key-value storage from a Worker
Section titled “Key-value storage from a Worker”env.KV is a Cloudflare Workers KV namespace of the app’s own:
export default { async fetch(request, env) { const url = new URL(request.url); if (url.pathname === "/settings" && request.method === "PUT") { await env.KV.put("settings", await request.text()); return new Response(null, { status: 204 }); } const settings = await env.KV.get("settings", "json"); return Response.json(settings ?? {}); },};Give a key a time to live in seconds with env.KV.put(key, value, { expirationTtl: 3600 }), at least 60. List keys with env.KV.list({ prefix: "user:" }).
KV is fast to read from anywhere and slower to agree: a write can take up to a minute to show in other places your app runs. It’s for data read far more often than it’s written. Keep records that change together, or that must be exact, in the app’s database.
In TypeScript:
interface Env { KV: KVNamespace; QUEUE: Queue;}A queue from a Worker
Section titled “A queue from a Worker”Send a message from anywhere in the app:
await env.QUEUE.send({ job: "welcome-email", to: "ada@example.com" });await env.QUEUE.sendBatch([{ body: { job: "report", month: "2026-09" } }, { body: { job: "report", month: "2026-10" } }]);Add { delaySeconds: 600 } to send one later, up to a day. A message is any value JSON can hold, up to 120 KB. Bytes work too: they reach the app as base64.
We send the queue’s messages to queue.path as a POST, in batches of up to 10, through our sign-in like any request:
{ "messages": [ { "id": "9e7d1c2b5a4f3e2d", "timestamp": "2026-10-07T12:00:01.000Z", "attempts": 1, "body": { "job": "welcome-email", "to": "ada@example.com" } } ]}Each message has the body it was sent with. One sent as bytes has its body in base64 and "encoding": "base64". One holding something JSON can’t, like a BigInt, arrives with a null body and "encoding": "unreadable", so it can’t hold up the rest.
Check it’s the queue
Section titled “Check it’s the queue”A batch arrives with the same signed identity token as a scheduled job: its kind is service, its sub starts with scheduler:, and its role is viewer. Check both before doing the work, since anyone your app is shared with can send a request to the same path:
import { getUser, jiayang } from "@jiayang-cloud/sdk/hono";
app.use("*", jiayang());app.post("/jobs/queue", async (c) => { const caller = getUser(c); if (caller.kind !== "service" || !caller.sub.startsWith("scheduler:")) return c.text("forbidden", 403); const { messages } = await c.req.json(); for (const m of messages) await doJob(m.id, m.body); return c.text("done");});Take a batch, or have it sent again
Section titled “Take a batch, or have it sent again”Answer with a 2xx and the whole batch is done. Answer with anything else, or not within 4 minutes, and the whole batch is sent again: 30 seconds later the first time, then twice as long each time, up to an hour. A message is sent at most 100 times, and kept at most 4 days, after which it’s dropped.
So a message can arrive more than once, a whole batch at a time. Make doing a job twice harmless: keep each message’s id once it’s done, in the app’s KV or database, and skip one you’ve seen.
We send one batch at a time to each app. While one is with your app, the next waits.
When messages wait
Section titled “When messages wait”Messages wait while:
- whoever set the queue up can no longer edit the app. Deploy again, with
queuein jiayang.json, and they go as you from then on. - whoever set it up is held by your identity provider’s SCIM.
- the app is in Recently deleted, or it’s stopped for its plan or its limits.
While they wait, each batch is tried again, at most an hour apart, which counts toward its 100 tries and its 4 days. A message still waiting after 4 days is dropped, and so is one tried 100 times. So an app restored from Recently deleted after more than 4 days, or a workspace back on a paid plan after more than 4 days, finds its queue empty.
A legal hold on the workspace doesn’t keep messages either. Delivery goes on as usual under one, and a message is dropped after 4 days or 100 tries as above.
From a container
Section titled “From a container”A container reaches its keys and queue at http://storage.jiayang.internal, with X-Jiayang-Storage: 1 on every request, as it does its database and files:
| Request | What it does |
|---|---|
GET /kv?prefix=&cursor=&limit= |
Lists up to 1,000 keys, in order, each with its expiration or null, and a cursor for the next page, or null. |
GET /kv?key=<key> |
Returns the key’s value, or 404. |
PUT /kv?key=<key>&ttl=<seconds> |
Sets the key. Send Content-Length. Up to 25 MB. ttl is optional, at least 60. |
DELETE /kv?key=<key> |
Deletes the key. Deleting one that isn’t there is fine. |
POST /queue/send |
Sends one message. Send {"body": ..., "delay_seconds": 0}. |
POST /queue/batch |
Sends up to 100 at once, 250 KB in all. Send {"messages": [{"body": ...}]}. |
Encode the key as any query value is. A key is 1 to 512 bytes.
From Python:
import jsonimport urllib.parseimport urllib.request
STORAGE = "http://storage.jiayang.internal"MARK = {"x-jiayang-storage": "1"}
def kv_put(key, value: bytes): url = f"{STORAGE}/kv?key={urllib.parse.quote(key, safe='')}" urllib.request.urlopen(urllib.request.Request(url, data=value, method="PUT", headers=MARK))
def enqueue(body): data = json.dumps({"body": body}).encode() req = urllib.request.Request(f"{STORAGE}/queue/send", data=data, headers={**MARK, "content-type": "application/json"}) urllib.request.urlopen(req)
kv_put("settings", b'{"theme": "dark"}')enqueue({"job": "report", "month": "2026-10"})The queue’s batches reach a container app the same way they reach a Worker: a POST to queue.path.
From your computer
Section titled “From your computer”jiayang kv status acme/hellojiayang kv ls acme/hello user:jiayang kv get acme/hello settingsjiayang kv put acme/hello settings '{"theme": "dark"}'jiayang kv put acme/hello report.csv --file report.csvjiayang kv rm acme/hello settings
jiayang queue status acme/hellojiayang queue send acme/hello '{"job": "report"}' --delay 60jiayang kv get prints the value, or saves it with --out. jiayang queue status says where messages go, how the last batch went and how many are waiting.
jiayang dev doesn’t give your app either: env.KV and env.QUEUE are undefined there. Check for them before you use them, and try that code on a preview.
The app’s Keys and queue tab in the dashboard shows the same, sends a test message, and lists and deletes keys. It never shows a value: read one with jiayang kv get. The app_stores, list_kv_keys, get_kv_value, set_kv_value, delete_kv_key and send_to_queue tools do it for an agent.
Who can do what
Section titled “Who can do what”| Who | |
|---|---|
| Give an app key-value storage or a queue, or change where messages go | Anyone who can deploy the app |
| See how they stand, and send a message | Anyone who can deploy the app |
| List and delete keys | The workspace’s owners and admins |
| Read and set keys | The workspace’s owners |
From the app’s own code, everything: the bindings are the app’s.
The audit log records each change and each call from outside the app as app.kv or app.queue, with what was done and a keyed hash of the key or message, never the key or message itself.
What they count toward
Section titled “What they count toward”What KV holds, keys and values, counts toward the workspace’s storage, with its files and databases. We measure it every five minutes from Cloudflare’s analytics, which can take an hour or so to include a new key.
Key-value data can take up to 10 GB of that storage on Team, Business and Enterprise, the way databases take only their share. Past that share, or over storage, jiayang kv put is refused, and every app with key-value storage stops answering, with 507, for everyone, since nothing else would stop an app writing more. Over storage, nothing new deploys either, and apps with files stop too. Delete keys with jiayang kv rm, or move to a bigger plan. On a paid plan we never delete an app’s keys for being over. On a plan without key-value storage, we do after 30 days: see When the plan changes.
Using them counts toward the workspace’s database rows, at what each costs us:
- Each KV read counts as 500 rows read. Each write, delete or listing counts as 5 rows written.
- Each queue operation counts as 400 rows read. A message is three: sent, delivered, and done. Each 64 KB of a bigger one counts again.
So Team includes about 1,000,000 KV reads, 100,000 writes, or 400,000 queue messages a month, and Business ten times that. Past those, they’re charged as rows. Each batch we send your app is a request, and counts like any other.
When the plan changes
Section titled “When the plan changes”On a plan without them, an app that has key-value storage or a queue stops answering, with 402, until the workspace is on a plan that has them again. Its messages wait, as above, and any still waiting after 4 days are dropped. Its keys stay for 30 days, and still count toward the workspace’s storage. After 23 days on such a plan, the workspace’s owners get an email saying when the keys will be deleted. Then, 30 days after the move and at least a week after that email, we delete every app’s keys, and they can’t be restored. Read what you want to keep with jiayang kv get before then. A legal hold on the workspace keeps them, and so does a move back. Back on Team or Business, the app’s next deploy gives it new, empty key-value storage.
Moving to a plan with room for fewer keeps what each app has. No other app can have them until the workspace is under the new plan’s number.
Deleting
Section titled “Deleting”Deleting an app deletes its keys and its queue, with any messages still in it. While deleted apps are kept, its keys are kept with the app in Recently deleted and come back when it’s restored. Nothing is sent from the queue meanwhile, and messages still in it after 4 days are dropped.
The workspace export doesn’t include keys or messages. Copy a key with jiayang kv get.
Limits
Section titled “Limits”| Limit | |
|---|---|
| Apps with key-value storage | None on Free, 5 on Team, 25 on Business |
| Apps with a queue | None on Free, 5 on Team, 25 on Business |
| Key-value data, within storage | None on Free, 10 GB on Team and Business |
| A key | 1 to 512 bytes |
| A value | 25 MB |
| Writes to one key | About one a second |
| A message | 120 KB of JSON |
| A batch to your app | Up to 10 messages |
| Time your app has to answer a batch | 4 minutes |
| Tries for each message | 100 |
| How long a message is kept | 4 days |
| Delay before a message is sent | Up to a day |
The numbers that come from Cloudflare are in Cloudflare’s KV limits and Queues limits.