Skip to content

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.

Add kv, queue or both to the app’s forculus.json and deploy:

{
"version": 1,
"kv": true,
"queue": { "path": "/jobs/queue" }
}

The next deploy makes them, and every deploy and rollback after keeps them. forculus detect and the start of a deploy list them. For a Worker that’s kv: key-value storage, bound as KV and queue: bound as QUEUE, its messages sent to POST /jobs/queue. A container has no bindings, so it says reached at http://storage.forculus.internal instead. Once the version is up, the deploy ends with what it set up:

Key-value storage is set up, bound as KV, and a queue, bound as QUEUE. The queue's messages are sent to POST /jobs/queue.

Uploading a built Worker from the dashboard, pick forculus.json with its modules. With the API clients, pass kv and queue with the deploy. Taking them out of forculus.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 /.forculus. 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 forculus deploy or an agent. Until then its messages wait, and the run’s deploy says so.

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. KV is refused once the app has key-value storage, and QUEUE once it has a queue. forculus env set refuses one, and saves nothing:

error: KV is the name of this app's key-value storage. Pick another name.

A Worker’s deploy that asks for one while the app has such a variable is refused, and says to remove it with forculus env remove. A container app isn’t refused: its variables go to the container, not its bindings. A Worker that has such a variable from before can’t change its variables until it’s gone. Each change is refused, and nothing is saved:

error: This app has a variable named KV, which is the name of its key-value storage. Nothing was changed. Remove it with `forculus env remove acme/hello KV`, then try again.

While a legal hold keeps the workspace’s variables, the variable can’t be removed either, so the refusal asks you to contact support instead.

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;
}

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.

To send bytes, send a Uint8Array with { contentType: "bytes" }. It reaches the app as base64. Without a contentType, a message is sent as JSON, and bytes don’t survive that: an ArrayBuffer arrives as {}, and a Uint8Array as an object of numbered bytes. A BigInt can’t be sent as JSON at all, so send() throws.

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 sent with { contentType: "v8" } holding something JSON can’t, like a BigInt, arrives with a null body and "encoding": "unreadable", so it can’t hold up the rest.

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, forculus } from "@forculus/sdk/hono";
app.use("*", forculus());
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");
});

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.

Messages wait while:

  • whoever set the queue up can no longer edit the app. Deploy again, with queue in forculus.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.

A container reaches its keys and queue at http://storage.forculus.internal, with X-Forculus-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. It can’t be . or have a .. part between slashes, like a/... A key that breaks these rules is refused with 400 bad_key. A key with a .. part that the app wrote before this rule can still be read and deleted here. It can’t be written again.

From Python:

import json
import urllib.parse
import urllib.request
STORAGE = "http://storage.forculus.internal"
MARK = {"x-forculus-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.

Terminal window
forculus kv status acme/hello
forculus kv ls acme/hello user:
forculus kv get acme/hello settings
forculus kv put acme/hello settings '{"theme": "dark"}'
forculus kv put acme/hello report.csv --file report.csv
forculus kv rm acme/hello settings
forculus queue status acme/hello
forculus queue send acme/hello '{"job": "report"}' --delay 60

forculus kv get prints the value, or saves it with --out. forculus queue status says where messages go, how the last batch went and how many are waiting.

A message sent with forculus queue send, from the dashboard or by an agent is a JSON object. Anything else is refused:

error: A message sent from here is a JSON object, like {"job": "report"}. Put a string, number or list inside one. The app's own code can send any JSON value with env.QUEUE.send.

A write or a delete from here can take up to a minute to show everywhere, as one from the app can. Until then forculus kv get may read the old value.

forculus 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 forculus 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
Give an app key-value storage or a queue, or change where messages go Anyone who can deploy the app
See whether the app has them, where messages go and how the last batch went Anyone who can see the app
See how many messages wait, 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 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, forculus 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 forculus 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.

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 forculus 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 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 forculus kv get.

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, not ., with no .. part between slashes
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.