CRUD Operations
The same six methods — get, set, update, increment, remove, query — work identically on both client and server. The only difference is how results are returned:
Sync vs Async:
- Client: Returns a
Promise. Useawaitor.then(). - Server: Returns the result directly. No
awaitneeded (the in-memory database is accessed without a network round-trip).
Server-side calls run as root. get, set, update, increment, remove and query called from app.js — inside a trigger, a callable, or a POST handler — default to admin = "root", which bypasses security rules entirely. That is what makes them useful for privileged work, and it means an unfiltered server-side write is not protected by the rules you wrote for clients. To have a call checked as a particular caller, pass their auth context as the third argument: set(path, value, admin).
Durable Writes
By default, writes (set, update, increment, remove) are applied to the in-memory database immediately and flushed to disk on a debounce timer (~5 seconds). If you need to guarantee that a write is persisted to disk before continuing, pass the durable option:
// Durable set — waits for fsync before resolving
await set(["users", "john", "email"], "john@example.com", "root", { durable: true });
// Durable update
await update(["users", "john"], { lastLogin: Date.now() }, "root", { durable: true });
// Durable remove
await remove(["users", "john", "tempData"], "root", { durable: true });
How durable writes work:
- The write is applied to the in-memory database immediately (subscribers and triggers fire as normal).
- The response is held until the data is flushed to disk via
fsync. - Multiple durable writes in the same event-loop tick are batched into a single
fsync(group commit), so there is no per-write I/O penalty. - If the flush fails, the response returns
status: "Failed"withmessage: "Durable flush failed", even though the in-memory state was updated. A retry timer will attempt to reconcile disk later.
When to use durable: Use { durable: true } for writes where data loss on crash is unacceptable — authentication tokens, financial transactions, user-created content. Skip it for ephemeral data like presence status or analytics counters where the debounce flush is sufficient.
Client-side durable writes: The durable flag also works from the client SDK over WebSocket. The server holds the WebSocket response until the fsync completes, so the client's await only resolves once the data is on disk.
// Client (async)
const user = await get(["users", "john"]);
console.log(user.data);
// Server (sync)
const user = get(["users", "john"]);
console.log(user.data);
Examples in this section use the client (async) form. To use any of these on the server, drop await / .then() — the method calls themselves are unchanged.
Path argument shape: path must be an array for all data operations (get, set, update, remove, query) and their subscription variants — even for a single segment, write ["users"], not "users". (Internally a string path is only meaningful as the name of a registered callable when invoking callableFunction; it is not a one-segment shorthand for data ops, and would be iterated character-by-character.)
Hard limits (enforced server-side — operations that exceed any of these are rejected):
- Path depth: ≤ 64 segments
- Path segment length: ≤ 256 characters per string segment
- Numeric segments: non-negative integers ≤ 1,000,000 (no
NaN,Infinity, negatives, or non-integers) - Forbidden segments:
"",".","..", anything containing/,\, or null bytes, or any prototype-pollution key (__proto__,constructor,prototype, etc.) - Value nesting depth: ≤ 64 levels. Applies to the value you write, on both transports and to
setas well asupdate— so the same payload is never accepted on a path that doesn't exist yet and rejected on one that does. - Update merge depth: ≤ 64 levels (deeper merges are rejected)
- Increment fields: ≤ 256 numeric fields touched by one
incrementcall - Query string length: ≤ 512 characters
- Subscriptions per WebSocket session: ≤ 256
- WebSocket message size: ≤ 16 MB
requestIdlength: ≤ 128 characters (echoed back in replies)- Forbidden values: the string
"$split"is reserved. On disk it is indistinguishable from the engine's own "this subtree lives in its own directory" marker, so storing it as a value is refused at the door.
These limits prevent individual clients from holding open too many subscriptions or amplifying server CPU/memory with adversarial payloads. Stay well under them in normal use.
Setting Data
The set() function creates or replaces data at a specific path:
Auto-creation: The set() function automatically creates any missing parent containers in the path. The type of each created container is chosen by the next segment:
- String segment → object (
{}) - Integer segment → array (
[])
Numeric path segments must be actual integers, not numeric strings — 0 and "0" behave differently. The path validator only accepts non-negative integers as numeric segments; numeric strings are treated as object keys.
// Integer segment → array container is auto-created
set(["messages", 0], "hi");
// Result: { messages: ["hi"] }
// String segment → object container is auto-created
set(["messages", "0"], "hi");
// Result: { messages: { "0": "hi" } }
// Mixed: a nested integer segment creates an array inside an object
set(["users", "matt", "scores", 0], 100);
// Result: { users: { matt: { scores: [100] } } }
// Set a complete object
set(["users", "john"], { name: "John Doe", age: 32 }).then(response => {
console.log("User created successfully");
});
// Set a single value
set(["users", "john", "email"], "john@example.com").then(response => {
console.log(response);
});
// Auto-creates parent objects - even if 'users' doesn't exist
set(["users", "alice", "profile", "preferences", "theme"], "dark").then(response => {
// Creates: { users: { alice: { profile: { preferences: { theme: "dark" } } } } }
console.log("Theme set with auto-created parent objects");
});
Getting Data
Retrieve data with the get() function:
// Get a single user
get(["users", "john"]).then(response => {
console.log(response.data); // User data
});
// Get entire collection
get(["users"]).then(response => {
const users = response.data;
// Process users...
});
Updating Data
Update existing data without replacing unspecified fields:
Auto-creation: Same behavior as set() — missing parent containers are created automatically, with the type chosen by the next segment (string → object, integer → array). See the array-vs-object example under Setting Data above.
// Update specific fields
update(["users", "john"], {
lastLogin: Date.now(),
loginCount: 42
}).then(response => {
console.log(response);
});
// Update a single property
update(["users", "john", "status"], "online").then(response => {
console.log(response);
});
// Auto-creates missing parent objects
update(["settings", "app", "notifications", "email"], true).then(response => {
// If 'settings' doesn't exist, creates the entire path
console.log("Setting created with auto-generated parents");
});
Incrementing Data
The increment() function applies numeric deltas atomically. Use it wherever you would otherwise read a number, add to it, and write it back — that read-modify-write is a race, and this isn't.
// Add 1 to a single counter
const r = await increment(["posts", "p1", "views"], 1);
console.log(r.data); // 43 — the RESULTING value, so no follow-up get() is needed
// Negative deltas subtract
await increment(["users", "john", "credits"], -10);
// A nested object of numbers is applied as ONE all-or-nothing write
const r2 = await increment(["stats", "2026-08"], {
views: 1,
bandwidth: 84213,
byCountry: { US: 1 }
});
console.log(r2.data);
// { views: 1291, bandwidth: 9930118, byCountry: { US: 402 } }
// Every field resolved to its new absolute value
// Missing values start at 0 — no need to seed a counter before using it
await increment(["stats", "brand-new-key"], 5); // → 5
// Durable, like the other writes
await increment(["ledger", "balance"], 250, "root", { durable: true });
How increment works: the deltas are resolved against the current values first, producing a plain object of absolute numbers. That resolved object is then what security rules see as newData, what gets written, and what comes back in response.data. So a validate rule like "newData <= 100" is checked against the result, not the delta — which is what you want for a cap.
Rules can still reach the delta, because they also receive the previous value as data: newData - data is the increment.
module.exports = {
"accounts": {
"$id": {
"write": "$id == admin.uid",
// Blocks any increment that would take the balance negative
"balance": { "validate": "newData >= 0" },
// Caps how much a single increment may add
"score": { "validate": "newData - data <= 10" }
}
}
};
Increment rejects (the whole call fails, nothing is written) when:
- A delta is not a finite number, or the payload is neither a number nor an object of numbers
- The existing value at a target field is present but not a finite number —
"Cannot increment non-numeric value at <field>". A missing ornullvalue is treated as0, so only a genuinely wrong type fails. - A result would exceed
Number.MAX_SAFE_INTEGERin either direction - The payload touches more than 256 fields, nests deeper than 64 levels, or an object in it is empty
Strict about the value, not about its parents. The type check applies to the number being incremented, not to the containers above it — increment inherits the same path behaviour as set and update, which replace a non-object intermediate rather than failing:
set(["a"], 5)thenincrement(["a","b"], 1)succeeds and leavesaas{ b: 1 }— the5is gone.- An object payload aimed at an array replaces the array wholesale:
increment(["arr"], { 0: 5 })turns[1, 2]into{ "0": 6 }— element1is lost, not just reshaped. Incrementing a single element by path (increment(["arr", 0], 5)) keeps the array intact and is the safe form.
Use validate rules if you need the shape of a subtree enforced.
Increments are ordinary JavaScript numbers. Repeated fractional increments accumulate the usual floating-point drift, and results beyond Number.MAX_SAFE_INTEGER are rejected outright because addition stops being exact there. For money, either round at the call site or store integer units (cents).
Increment fires as an update. Subscribers on update@ and triggers registered with addDbTrigger("update", …) both see it — there is no separate increment@ event to subscribe to.
Removing Data
Delete data at a specific path:
// Remove a user
remove(["users", "john"]).then(response => {
console.log("User deleted");
});
// Remove a specific field
remove(["users", "john", "temporaryToken"]).then(response => {
console.log(response);
});
Server-Side: Direct Access via data
On the server only, the data export gives you direct read access to the raw in-memory database object. This skips the overhead of get() for fast lookups inside triggers, callables, and middleware:
module.exports = ({ data, get, set, ... }) => {
// Direct read — access the raw database object
const userName = data.users?.john?.name; // "John"
const allUsers = data.users; // { john: {...}, alice: {...} }
// Compared to using get():
const user = get(["users", "john"]);
console.log(user.data.name); // "John"
};
Read-only. Always use set(), update(), and remove() to modify data — these run subscriptions, triggers, security rules, and persistence. Writing directly to data bypasses all of these.