Skip to content

Key Value Stores

Key Value (KV) Stores provide a simple and efficient way to store and retrieve data at the edge. They can be used to store configuration data, user preferences, or any other data that needs to be quickly accessed by your edge functions.

To begin using KV Stores:

  1. Navigate to the “Compute > K/V Stores” section in the QuantCDN dashboard
  2. Click “Create New Store” to create a new KV store
  3. Give your store a name and description
  4. Once created, you’ll receive a unique identifier for your store

The QuantCDN dashboard provides a simple interface to:

  • Add new key-value pairs
  • Edit existing values
  • View all stored data
  • Search for specific keys
  • Delete key-value pairs
  • Clear a store, either every key or only the keys that match a prefix or an age
  • Delete a store once it is empty

KV Stores can be easily integrated into your edge functions using the kvStore object.

To store data in a KV store:

await kvStore.set(KV_STORE_ID, key, data);

To retrieve data from a KV store:

const data = await kvStore.get(KV_STORE_ID, key);

To delete data from a KV store:

await kvStore.delete(KV_STORE_ID, key);

Use list() to retrieve a page of keys, optionally restricted to a literal prefix. Set includeValues: true to retrieve values in the same request.

// customerId comes from your verified session, not an unchecked query parameter.
const page = await kvStore.list(WORLDS_STORE, {
prefix: `customer:${customerId}:world:`,
includeValues: true,
limit: 25,
});
// {
// items: [{ key, value, encrypted, createdAt?, updatedAt?, expiresAt? }],
// cursor: 'opaque-continuation-token' | null,
// hasMore: true | false,
// scannedCount: 25
// }

Use search() to combine key selection with exact-match filters on top-level fields of plaintext JSON objects:

const page = await kvStore.search(PROJECTS_STORE, {
prefix: `customer:${customerId}:project:`,
where: { customerId, status: 'ready' },
includeValues: true,
limit: 50,
cursor: requestCursor, // Omit on the first request; pass the returned cursor later.
});

All where conditions must match. Comparisons are case-sensitive and preserve JSON types: 0 differs from "0", and a missing field differs from null. The initial API supports equality only; it does not support nested paths, ranges, sorting, regular expressions, OR expressions or full-text search.

For wildcard key matching, use pattern instead of prefix:

const page = await kvStore.search(JOBS_STORE, {
pattern: 'customer:*:job:*',
where: { status: 'pending' },
includeValues: true,
});

Only * is a wildcard, matching zero or more characters. Other characters, including ? and ., are literal. A prefix is entirely literal, including any * it contains. prefix and pattern cannot be combined.

Option Methods Behavior
prefix Both Non-empty literal key prefix, at most 512 characters
pattern search Non-empty key pattern, at most 512 characters; only * is special
where search 1–8 top-level field equalities, combined with AND
type Both all (default), plaintext, or encrypted
includeValues Both Boolean, defaults to false
limit Both Integer 1–100, defaults to 25; bounds evaluated records before filtering
cursor Both Opaque continuation token from the preceding page, or null/omitted to start

where field names begin with a letter or underscore and contain only letters, digits and underscores, up to 64 characters. __proto__, constructor and prototype are reserved. Values must be a string of at most 512 characters, a finite number, a boolean or null. Unknown options and invalid filters reject rather than being ignored. search() requires at least one prefix, pattern, field or non-all type filter; use list() for an unfiltered page. KVStore is an alias for kvStore and supports the same methods.

Each request evaluates one backing-store scan page, at most limit records and at most 1 MiB of backing-store data before filtering. It returns only matching, unexpired records. An empty items array can therefore have hasMore: true; continue with its cursor instead of interpreting it as the end of the results. scannedCount is the number evaluated, not the total number of matches in the store.

Pass the same filters and includeValues setting on subsequent requests. You may change limit. Cursors are signed, bound to the store and query, and expire 24 hours after issuance. Restart without a cursor after expiry. Invalid, altered or mismatched cursors reject.

There is no guaranteed sort order or snapshot across pages. Concurrent writes can change results. On a format 1 store, listing is eventually consistent and is suitable for discovery; authorize individual records again before acting on them. Do not use a search result alone to reserve a credit, claim a job or establish unique ownership.

Key and JSON-field filters currently use bounded scans, not indexed queries. Prefixing customer records is a useful data model, but does not reduce the scan’s read cost. Keep frequently queried directories separate from large usage/event histories. Avoid looping through an entire store in one edge invocation; return the page/cursor to your client or advance a scheduled task in bounded steps.

Expired keys are omitted. expiresAt in the edge response is a Unix timestamp in seconds; createdAt and updatedAt use the stored timestamp strings. The underlying management API uses the corresponding snake-case names.

Encrypted values remain "[ENCRYPTED]" with encrypted: true when values are included. Search never decrypts secrets. Field filters exclude encrypted records, and combining where with type: 'encrypted' rejects. Use an authorized individual get() to retrieve a secret.

Both methods enforce the same project/organization store access rules as the existing KV methods. They do not implement per-customer authorization inside a shared store: derive customer IDs from verified sessions and enforce ownership in application code.

Backend failures and invalid queries reject the promise. They are distinct from a successful empty page. Edge queries have an 8-second request timeout and a 4 MiB response limit; oversized responses reject. Catch failures and return an appropriate retryable error rather than rendering “no projects.”

The scan-based listing above is the behaviour of a format 1 store. A format 2 store keeps its keys in an index, so listing the records of one customer reads only those records. Create a store with format: 2 to get this behaviour from the start. Existing stores can be upgraded to format 2 by contacting Quant support. During an upgrade, writes pause for at least 40 seconds plus the time it takes to copy the data. Reads, including getEntry() and getMany() on a store that is still format 1, continue. An upgrade is limited to stores of at most 100,000 items and 1 GiB. Before it starts, Quant checks the store for keys that format 2 would reject (see the key rules below) and for items that would exceed the 400 KB item size limit in format 2, and reports them to you first, with up to 20 example keys of each kind.

In a format 2 store, the text of a key before its last : is its collection, and the text after it is the id. The key customer:abc:world:w1 is the item w1 in the collection customer:abc:world. A key with no : belongs to the root collection, which you read with collection: ''. To read one collection, pass collection to list():

const page = await kvStore.list(WORLDS_STORE, {
collection: `customer:${customerId}:world`,
limit: 50,
});
// {
// items: [{ key, value?, encrypted, version?, createdAt?, updatedAt?, expiresAt? }],
// cursor: 'opaque-continuation-token' | null,
// hasMore: true | false,
// scannedCount: 50,
// indexed: true
// }

value is present only with includeValues: true, and version only on format 2 stores. Items come back in id order, which is the UTF-8 byte order of the id. A collection list contains only the items directly in that collection. The key customer:abc:world:w1:log is in the collection customer:abc:world:w1, so it does not appear when you list customer:abc:world.

Option Behavior
collection The collection to read. The text before the last : of the keys you want
startsWith Return only ids that start with this text
after Return only ids greater than this id
before Return only ids less than this id
reverse Boolean. Return the ids in descending order. Needs a format 2 store: on a format 1 store, reverse: true rejects with REQUIRES_FORMAT_2
where Exact-match field filters, as for search()
limit Integer 1–100, defaults to 25
cursor Continuation token from the preceding page
includeValues, type As for list() and search() above

The options follow these rules. startsWith cannot be combined with after or before, and after must sort before before. The id bounds (startsWith, after, before) cannot contain :, because they apply to the id only. collection cannot be combined with prefix or pattern. An option that breaks a rule rejects with a plain Error.

hasMore is exact on a format 2 store. The one exception is a request that uses up its read budget (2,000 rows, 20 backing requests or 4 seconds) before it fills a page, which can happen with where or type filters. That page can hold fewer than limit items while hasMore is true, so keep following cursor until hasMore is false. Cursors from before an upgrade are rejected on a format 2 store with CURSOR_INVALID, and you restart the listing without a cursor.

The indexed field in the response tells you how the call was served. A format 1 store answers a collection call with a bounded scan and reports indexed: false, so it keeps the cost and ordering of a scan. A format 1 store can still return a page with fewer items and hasMore: true for the same budget reason, and the same rule applies.

A key in a format 2 store must not start or end with :, and the id (the text after the last :) is at most 1,000 bytes. The whole key is limited to 2,048 bytes as before. A write that breaks these rules throws VALIDATION. The index orders items by id, so use time-ordered ids, such as a ULID, when you need a collection in time order.

getEntry() returns the value together with its timestamps, and on a format 2 store its version and encrypted flag. It returns null if the key is missing or expired. On a format 2 store the version changes on every write to that key. getMany() reads up to 100 keys in one request.

const entry = await kvStore.getEntry(WORLDS_STORE, `customer:${customerId}:world:w1`);
// { key, value, encrypted?, version?, createdAt, updatedAt, expiresAt? } | null
const entries = await kvStore.getMany(WORLDS_STORE, keys);
// [entry | null, ...] in the same order as keys, each entry shaped like getEntry()'s result

getMany() returns results in the order of the keys you passed, with null for a missing or expired key. Each entry has the same shape as the result of getEntry(), with the value already decoded. Passing no keys, more than 100 keys, or an empty or non-string key rejects with VALIDATION before any request is made. If the response would exceed 4 MiB, the call rejects with LIMIT_EXCEEDED. The response counts the values together with each key and its metadata, and a key you pass twice counts twice. Encrypted values are decrypted as they are for get(), so apply the same authorisation checks. Both methods also work on format 1 stores, where they return entries without version or encrypted and throw on any failure.

Reads on a format 2 store are strongly consistent by default, so a get() or a collection list sees a write that has already completed.

This example lists a customer’s worlds and then reads the settings stored under each one. The settings key customer:abc:world:w1:settings is in the collection customer:abc:world:w1, so it does not appear in the list of worlds:

const page = await kvStore.list(WORLDS_STORE, {
collection: `customer:${customerId}:world`,
limit: 50,
});
const settings = await kvStore.getMany(
WORLDS_STORE,
page.items.map((item) => `${item.key}:settings`),
);
// settings[i] belongs to page.items[i]; use settings[i]?.value, which is undefined if that world has no settings yet.

On a format 2 store, a backend or transport failure in get(), set(), delete(), list(), search(), getEntry() or getMany() throws an Error with name set to 'KVError' and these properties: code, retryable (boolean) and status (the HTTP status of the failure). status is undefined for a timeout and for a RESPONSE_INVALID error, which the runtime raises itself. A failure is never returned as an empty list or false. The only null results are a missing or expired key from get(), getEntry() and getMany(). Invalid options for list() and search() throw a plain Error without these properties.

Code Meaning Retry
VALIDATION The key, option or value is invalid No. Fix the request
LIMIT_EXCEEDED A size limit was exceeded, such as a getMany() response over 4 MiB or a value too large for one item (400 KB) No. Reduce the request
REQUIRES_FORMAT_2 The option needs a format 2 store, such as reverse on a format 1 store No. Upgrade the store or leave out the option
CURSOR_INVALID The cursor is altered, expired or from another query or store format No. Restart without a cursor
KEY_NOT_FOUND Management API only. The edge methods return null for a missing key No
STORE_NOT_FOUND The store does not exist No
UPGRADE_CONFLICT An upgrade is already running, or the store is not in a state that allows it No. Wait for the upgrade to finish
CONTENTION Concurrent writes to the same key conflicted Yes, with a short delay
STORE_MIGRATING The store is paused for the final step of an upgrade Yes, after a few seconds
UNAVAILABLE The backing service did not answer in time Yes, with a delay
RESPONSE_INVALID The service returned a response the runtime could not use No

increment() failures throw KVError on every store format, with the code the service reports (for example STORE_MIGRATING or CONTENTION). Invalid arguments throw VALIDATION before any request is made. A failure whose outcome is unknown (a timeout, a lost connection, or a service error without a code) has code UNAVAILABLE and retryable: false, and its message says not to retry automatically, because the write may already have been applied. See Atomic Counters below.

Format 1 stores keep their existing behaviour. get(), set() and delete() still return null or false on failure, with one exception: while a store is being upgraded, a write throws STORE_MIGRATING so that it cannot be lost without notice. getEntry() and getMany() throw on failure whatever the store format, and return null for a missing key.

Pass ttl in seconds to kvStore.set and the key expires on its own. The range is 60 seconds to 31,536,000 seconds (one year). Use this for sessions, OAuth state, nonces, rate-limit counters and any other short-lived value, so the store does not fill with stale keys.

// Expires 10 minutes after it is written
await kvStore.set(KV_STORE_ID, `oauth_state:${state}`, { returnTo: '/' }, { ttl: 600 });

How expiry behaves:

  • An expired key reads as null, even before the platform removes the row, which can take up to 48 hours.
  • Writing a key again without ttl removes its expiry.
  • ttl can be combined with secret: true for encrypted values.
  • The API returns the expiry as expires_at, a Unix timestamp in seconds, on reads and listings.

increment() atomically adds a signed integer to a numeric key and returns the new number. Concurrent calls from different edge executions or locations use the same regional KV service; successful calls do not lose each other’s increments.

const count = await kvStore.increment(COUNTERS_STORE, 'signup-attempts');
const updated = await kvStore.increment(COUNTERS_STORE, 'processed-items', 5);
const remaining = await kvStore.increment(COUNTERS_STORE, 'available-slots', -1);

The amount defaults to 1 and must be a nonzero JavaScript safe integer. The resulting value must also be a safe integer. Negative values are allowed; this method does not enforce a minimum, maximum or quota by itself. A missing or expired key starts at zero. Existing unencrypted integer values written with set() work without migration. Strings (including "1"), objects, fractional values and encrypted live values reject.

Supply ttl to expire a newly created counter:

const count = await kvStore.increment(COUNTERS_STORE, counterKey, 1, { ttl: 60 });

For increment(), TTL is set only when creating or restarting an expired key. Incrementing a live counter preserves its existing expiry, even when a different TTL is supplied. Incrementing a live key without TTL preserves its expiry too. This differs from set(), which replaces the expiry. TTL accepts the same 60-second to one-year range. No other increment options are supported.

Counters retain the existing JSON storage format and work with get(), list() and search(). The implementation uses a strongly consistent read followed by a conditional write, retrying only confirmed conflicts (up to eight attempts with jitter). It costs more than a native DynamoDB numeric ADD, but does not change the store schema or require migrating existing data. Heavy contention rejects instead of retrying indefinitely. Use the value returned by increment() for the immediate decision; on a format 1 store a separate normal get() may be eventually consistent (format 2 stores read with strong consistency by default). Avoid mixing unconditional set()/delete() calls with a live counter’s increment traffic.

Atomic does not mean exactly once. A timeout or lost response can occur after a successful write. The counter path disables automatic SDK write retries and the edge method makes one request, but a caller retry can still count twice. Treat an ambiguous failure explicitly; do not automatically retry it. Billing, credit settlement and other exact accounting require additional idempotency support. This method is a counter primitive, not a complete rate limiter, distributed lock or credit-reservation API.

If the edge request times out, increment() rejects with an error that says the outcome may be unknown, because the write may already have been applied. Both kvStore and its KVStore alias expose increment(). It uses the same store permissions as other KV methods and rejects unsupported backends or failed requests. The API has a six-second operation deadline; the edge request has an eight-second timeout. No region or replication change is needed.

Pass secret: true to encrypt a value at rest. Reads decrypt automatically.

await kvStore.set(KV_STORE_ID, 'api-key', apiKey, { secret: true });

When storing JSON data in a KV store, the data is automatically encoded when stored and decoded when retrieved. This means you can directly store and retrieve JavaScript objects without manual JSON parsing.

// Storing an object
const user = { name: 'John', age: 30 };
await kvStore.set(USERS_KV_STORE, 'user123', user);
// Retrieving the object
const userData = await kvStore.get(USERS_KV_STORE, 'user123');
// userData is automatically parsed as { name: 'John', age: 30 }

From the dashboard, Clear store deletes keys in place. You can clear every key, or only the keys that start with a prefix, or only the keys older than a chosen age. Small clears finish immediately; large ones continue in the background and the key count falls over the next few minutes. The action asks you to type the store name to confirm.

A store can only be deleted once it is empty. Deleting a store that still holds keys returns an error that tells you to clear it first. Through the API, DELETE .../kv/{store_id}?force=true deletes the store together with its data. Deleting a store also deletes any copy that an upgrade keeps, such as the format 1 table that is retained for 14 days after an upgrade. A store cannot be deleted while an upgrade is running.

KV Stores can be used for various purposes, such as:

  • Storing user preferences or settings
  • Caching API responses
  • Managing feature flags
  • Storing configuration data
  • Maintaining rate limiting counters
  • Storing temporary session data, with a ttl so it expires