Caching with the KV store
Cache-aside, rate limits, feature flags and idempotency — with the TTL semantics that make them safe.
connect.kv is a small key/value store per workspace, backed by the Realtime
Database and reached only through Connect. Values are JSON up to 128 KB, keys
are [A-Za-z0-9_-] up to 64 characters.
Use it for what a document store is bad at: things you fetch rather than find.
The one semantic that matters
Expiry is evaluated on read. There is no sweep to wait for and no window where a stale value is still served — the moment a key's TTL passes, it reads as absent.
That is what makes cache-aside correct here without a version stamp or a background job.
Cache-aside
The pattern almost every use of this store reduces to:
import type { ConnectClient } from "@cosmoops/connect-sdk/server";
export async function cached<T>(
connect: ConnectClient,
workspaceId: string,
key: string,
ttlSeconds: number,
compute: () => Promise<T>,
): Promise<T> {
try {
const hit = await connect.kv.get({ workspaceId, namespace: "cache", key });
return hit.value as T;
} catch (error) {
// 404 is a miss, not a failure. Anything else is a real problem and
// should not be swallowed into a silent recompute on every request.
if (!isNotFound(error)) throw error;
}
const value = await compute();
await connect.kv.set({ workspaceId, namespace: "cache", key, value, ttlSeconds });
return value;
}
function isNotFound(error: unknown): boolean {
return typeof error === "object" && error !== null && (error as { status?: number }).status === 404;
}const report = await cached(connect, workspaceId, `report-${month}`, 3600, () =>
buildExpensiveReport(workspaceId, month),
);A miss is a 404, not an empty value
get throws on a missing or expired key. Catching everything turns a
Connect outage into a recompute on every request — exactly when you can least
afford it. Match on the status.
Invalidation
There is no wildcard delete, deliberately — a prefix delete over a shared store is an easy way to erase far more than intended.
Two patterns cover it:
Delete the keys you know. Correct when the set is small and enumerable.
await Promise.all(
["summary", "totals", "chart"].map((key) =>
connect.kv.delete({ workspaceId, namespace: "cache", key }),
),
);Put a version in the key. Correct when it is not. Bump the version and every old key ages out on its own TTL.
const version = await currentReportVersion(workspaceId);
const key = `report-v${version}-${month}`;The second scales and needs no cleanup. Prefer it once you have more than a handful of derived keys.
Rate limiting
export async function consumeRateLimit(
connect: ConnectClient,
workspaceId: string,
bucket: string,
limit: number,
windowSeconds: number,
): Promise<boolean> {
const key = `${bucket}-${Math.floor(Date.now() / 1000 / windowSeconds)}`;
let used = 0;
try {
used = (await connect.kv.get({ workspaceId, namespace: "limits", key })).value as number;
} catch {
// First request in this window.
}
if (used >= limit) return false;
await connect.kv.set({ workspaceId, namespace: "limits", key, value: used + 1, ttlSeconds: windowSeconds * 2 });
return true;
}The window is in the key, so a new window is a new key and the old one expires itself. The TTL is twice the window so a request landing on a boundary still finds its own counter.
This is approximate, on purpose
Read-then-write is not atomic, so two concurrent requests can both see the
same count. That is fine for protecting an expensive endpoint from a runaway
client, and wrong for anything you bill on — meter that through
billing.consumeAddon, which debits transactionally.
Feature flags as master data
A flag that is the same for every customer belongs in an application-scoped
namespace — one copy, owned by you, read by every tenant. Declare it that way
on the Store tab and omit workspaceId:
await connect.kv.set({
namespace: "flags",
key: "new-editor",
value: { enabled: true, rollout: 0.25 },
});
const flag = await connect.kv.get({ namespace: "flags", key: "new-editor" });Per-workspace overrides stay workspace-scoped, and the resolution order is yours:
async function flag(connect: ConnectClient, workspaceId: string, key: string) {
try {
return (await connect.kv.get({ workspaceId, namespace: "overrides", key })).value;
} catch {
return (await connect.kv.get({ namespace: "flags", key })).value;
}
}Master data is written with your API key only — never on behalf of a user. A write permission is something a workspace grants, so honouring one on shared data would let one tenant's admin edit what every other tenant reads.
Idempotency
const key = `webhook-${event.id}`;
try {
await connect.kv.get({ workspaceId, namespace: "processed", key });
return; // Already handled.
} catch {
// Not seen yet.
}
await handleEvent(event);
await connect.kv.set({ workspaceId, namespace: "processed", key, value: true, ttlSeconds: 7 * 24 * 3600 });Mark after the work, not before: marking first means a crash mid-handler loses the event permanently. Marking after means a crash replays it, which is what "at least once" already promised.
When not to use KV
| Want | Use |
|---|---|
| Query by a field | connect.data |
| More than 128 KB | connect.data, or Drive |
| Exact counters you bill on | billing.consumeAddon |
| Something surviving a cache wipe | connect.data |
The dividing line: KV is for values you can recompute. If losing it loses information, it belongs in the data store.
Listing
let cursor: string | null = null;
do {
const page = await connect.kv.list({ workspaceId, namespace: "cache", cursor });
for (const entry of page.items) console.log(entry.key, entry.expiresAt);
cursor = page.nextCursor;
} while (cursor);Stop on nextCursor, never on an empty page
Expired entries are filtered after paging, so a page can come back short — even empty — while more keys remain behind it.