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.
Getting Started
Section titled “Getting Started”To begin using KV Stores:
- Navigate to the “Compute > K/V Stores” section in the QuantCDN dashboard
- Click “Create New Store” to create a new KV store
- Give your store a name and description
- Once created, you’ll receive a unique identifier for your store
Managing Data
Section titled “Managing Data”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
Using KV Stores in Edge Functions
Section titled “Using KV Stores in Edge Functions”KV Stores can be easily integrated into your edge functions using the kvStore object.
Storing Data
Section titled “Storing Data”To store data in a KV store:
await kvStore.set(KV_STORE_ID, key, data);Retrieving Data
Section titled “Retrieving Data”To retrieve data from a KV store:
const data = await kvStore.get(KV_STORE_ID, key);Deleting Data
Section titled “Deleting Data”To delete data from a KV store:
await kvStore.delete(KV_STORE_ID, key);Listing and Searching
Section titled “Listing and Searching”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.
Pagination and Consistency
Section titled “Pagination and Consistency”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.
Values, Permissions and Failures
Section titled “Values, Permissions and Failures”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.”
Indexed Stores (Format 2)
Section titled “Indexed Stores (Format 2)”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.
Collections
Section titled “Collections”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.
Key Rules
Section titled “Key Rules”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.
Reading Entries
Section titled “Reading Entries”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 resultgetMany() 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.Errors
Section titled “Errors”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.
Expiring Data (TTL)
Section titled “Expiring Data (TTL)”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 writtenawait 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
ttlremoves its expiry. ttlcan be combined withsecret: truefor encrypted values.- The API returns the expiry as
expires_at, a Unix timestamp in seconds, on reads and listings.
Atomic Counters
Section titled “Atomic Counters”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.
Encrypting Data
Section titled “Encrypting Data”Pass secret: true to encrypt a value at rest. Reads decrypt automatically.
await kvStore.set(KV_STORE_ID, 'api-key', apiKey, { secret: true });JSON Data Handling
Section titled “JSON Data Handling”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 objectconst user = { name: 'John', age: 30 };await kvStore.set(USERS_KV_STORE, 'user123', user);
// Retrieving the objectconst userData = await kvStore.get(USERS_KV_STORE, 'user123');// userData is automatically parsed as { name: 'John', age: 30 }Clearing and Deleting Stores
Section titled “Clearing and Deleting Stores”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.
Example Use Cases
Section titled “Example Use Cases”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
ttlso it expires
