Ploy
Ploy
Features

State

Add durable key-value storage to your workers with a Cloudflare KV-compatible API.

State

Ploy State provides a durable key-value store backed by SQLite. The binding type is still StateBinding, but it now implements the Cloudflare KVNamespace API so existing Workers code can keep using get, put, delete, getWithMetadata, and list with the same binding names as before.

Configuration

Add a state binding in your ploy.yaml:

ploy.yaml
kind: dynamic
build: pnpm build
out: dist
state:
  STATE: default

The key (STATE) is the binding name available in your worker's env. The value (default) is the state store identifier.

Run ploy types to generate TypeScript types:

env.d.ts
import type { StateBinding } from "@meetploy/types";

export interface Env {
	STATE: StateBinding;
}

StateBinding is a KV superset: it supports the Cloudflare KV API plus Ploy-specific set() and atomic JSON update().

Basic Example

src/index.ts
export default {
	async fetch(request, env) {
		const url = new URL(request.url);

		if (url.pathname === "/put") {
			await env.STATE.put("greeting", JSON.stringify({ message: "hello" }), {
				metadata: { source: "example" },
			});
			return Response.json({ success: true });
		}

		if (url.pathname === "/get") {
			const value = await env.STATE.get("greeting", "json");
			return Response.json({ value });
		}

		if (url.pathname === "/get-with-metadata") {
			const result = await env.STATE.getWithMetadata("greeting", {
				type: "json",
			});
			return Response.json(result);
		}

		if (url.pathname === "/delete") {
			await env.STATE.delete("greeting");
			return Response.json({ success: true });
		}

		return new Response("State Worker");
	},
} satisfies Ploy;

API Methods

get(key) - Get a Value

Returns the stored value or null if the key doesn't exist.

const value = await env.STATE.get("my-key");
// value is string | null

Cloudflare-style typed reads are also supported:

const settings = await env.STATE.get("settings", {
	type: "json",
	cacheTtl: 60,
});
const blob = await env.STATE.get("avatar", "arrayBuffer");

get(keys) - Bulk Get

Pass an array of keys to receive a Map, just like Cloudflare KV.

const values = await env.STATE.get(["user:1", "user:2"], { type: "json" });
const user1 = values.get("user:1");

getWithMetadata(key) - Get Value and Metadata

const result = await env.STATE.getWithMetadata("settings", { type: "json" });

console.log(result.value);
console.log(result.metadata);

put(key, value) - Store a Value

put() matches the Cloudflare KV write API. It supports strings, JSON blobs, ArrayBuffer, typed arrays, and ReadableStream, plus metadata and expiration.

await env.STATE.put("username", "alice", {
	expirationTtl: 3600,
	metadata: { updatedBy: "admin" },
});

set(key, value) - Set a Value

set() remains available for existing Ploy code. It is equivalent to storing a plain string value without KV options.

await env.STATE.set("username", "alice");

delete(key) - Delete a Value

Removes a key from the state store.

await env.STATE.delete("username");

list() - List Keys

List keys with Cloudflare-compatible prefix, limit, and cursor options.

const page = await env.STATE.list({ prefix: "user:", limit: 100 });

console.log(page.keys);
console.log(page.cursor);

update(key, update) - Atomic JSON Updates

Applies a MongoDB-style update document to a JSON value in a single atomic write. No need to read the full value, modify it in memory, and write it back -- operations are applied directly in SQLite using JSON functions.

await env.STATE.update("user:123", {
	$set: { role: "admin", updatedAt: Date.now() },
});

Five operators are supported:

OperatorDescriptionExample
$setSet one or more fields{ $set: { name: "Alice" } }
$unsetRemove one or more fields{ $unset: { oldField: "" } }
$incIncrement numeric fields{ $inc: { visits: 1 } }
$pushAppend a value to an array{ $push: { tags: "new-tag" } }
$popRemove first (-1) or last (1) element from an array{ $pop: { queue: -1 } }

Fields use dot notation for nested paths: "nested.field", "array.0".

Use put() when you want Cloudflare KV compatibility, metadata, expiration, or binary payloads. Use update() when you want atomic partial updates to a JSON document without a read-modify-write cycle.

Common Patterns

Storing JSON Data

await env.STATE.put(
	"user:123",
	JSON.stringify({ name: "Alice", role: "admin" }),
);

const user = await env.STATE.get("user:123", "json");

Feature Flags

async function isFeatureEnabled(
	env: PloyEnv,
	feature: string,
): Promise<boolean> {
	const value = await env.STATE.get(`feature:${feature}`);
	return value === "true";
}

// Enable a feature
await env.STATE.put("feature:dark-mode", "true");

// Check the feature
const enabled = await isFeatureEnabled(env, "dark-mode");

Persistent Counters

// Atomic increment — no read-modify-write needed
await env.STATE.update("counters", {
	$inc: { pageViews: 1 },
});

User Preferences

async function getUserPreferences(env: PloyEnv, userId: string) {
	const raw = await env.STATE.get(`prefs:${userId}`);
	return raw ? JSON.parse(raw) : { theme: "light", language: "en" };
}

async function setUserPreferences(
	env: PloyEnv,
	userId: string,
	prefs: Record<string, string>,
) {
	await env.STATE.put(`prefs:${userId}`, JSON.stringify(prefs));
}

Atomic Partial Updates

When you store JSON objects and only need to change a few fields, use update() instead of reading the whole object, modifying it, and writing it back:

// Instead of this (read-modify-write):
const raw = await env.STATE.get("user:123");
const user = JSON.parse(raw!);
user.lastSeen = Date.now();
user.visits += 1;
await env.STATE.put("user:123", JSON.stringify(user));

// Do this (atomic update):
await env.STATE.update("user:123", {
	$set: { lastSeen: Date.now() },
});

This avoids race conditions when multiple requests update the same key concurrently, and is more efficient since only the changed fields are sent over the wire.

Counters with $inc

// Increment a counter atomically (no read-modify-write needed)
await env.STATE.update("user:123", {
	$inc: { visits: 1, score: 10 },
});

Queue Management with update()

// Append to a list
await env.STATE.update("job-queue", {
	$push: { pending: { id: "job_1", task: "send-email" } },
});

// Remove the first item from a list
await env.STATE.update("job-queue", {
	$pop: { pending: -1 },
});

// Multiple operations in one atomic call
await env.STATE.update("job-queue", {
	$pop: { pending: -1 },
	$push: { completed: { id: "job_1", finishedAt: Date.now() } },
});

Multiple State Bindings

You can configure multiple state stores for different use cases:

ploy.yaml
state:
  STATE: default
  USER_STATE: users
// General state
await env.STATE.put("config", value);

// User-specific state
await env.USER_STATE.put("user:abc", userData);

Next Steps

  • Databases - Add persistent SQLite databases
  • Workers - Learn about Ploy workers

How is this guide?

Last updated on