Skip to main content
Transactions let you update data based on its current value without worrying about conflicts from other clients. They’re essential for counters, inventory systems, auctions, and any scenario where a write depends on the existing data. For a conceptual overview of how transactions work and why you need them, see Transactions.

Callback-style transactions

The most common pattern. Pass a function that receives the current value and returns the new value:
import { LarkDatabase } from "@lark-sh/client";

const db = new LarkDatabase("my-project/my-database");
await db.connect({ anonymous: true });

const result = await db.ref("counters/pageViews").transaction((currentValue) => {
  return (currentValue ?? 0) + 1;
});

console.log(result.committed); // true if the transaction succeeded
console.log(result.snapshot.val()); // The final committed value

Transaction results

Every transaction() call returns a result object:
PropertyTypeDescription
committedbooleantrue if the transaction wrote data, false if it was aborted.
snapshotDataSnapshotThe final value at the path after the transaction.

Aborting a transaction

Return undefined from your update function to abort without writing:
const result = await db.ref("inventory/item-1").transaction((current) => {
  if (current === null) {
    return undefined; // Item doesn't exist, abort
  }

  if (current.quantity <= 0) {
    return undefined; // Out of stock, abort
  }

  return {
    ...current,
    quantity: current.quantity - 1,
  };
});

if (!result.committed) {
  console.log("Transaction was aborted");
}

Retry limit

Transactions automatically retry up to 25 times. If they still can’t commit after 25 attempts (due to extremely high contention), the promise rejects with a max_retries_exceeded error.
try {
  await db.ref("hot-counter").transaction((val) => (val ?? 0) + 1);
} catch (error) {
  if (error.code === "max_retries_exceeded") {
    console.error("Too much contention on this path");
  }
}
Your update function may be called multiple times if there are concurrent writes. Make sure it has no side effects — don’t make network requests, modify external state, or log analytics inside it.

Multi-path transactions

When you need to update multiple paths atomically — either all the changes happen, or none of them do.

Object syntax

The simplest form. Pass an object where keys are paths and values are what to write. Use null to delete a path.
await db.transaction({
  "/players/alice/coins": 50,
  "/players/bob/coins": 150,
  "/trades/latest": { from: "alice", to: "bob", amount: 50 },
});
All three writes happen atomically. If any one fails, none of them are applied.

Array syntax with conditions

For more control, use the array syntax with explicit operations. This lets you add conditions that check the current value before proceeding.
await db.transaction([
  // Only proceed if Alice has exactly 100 coins
  { op: "condition", path: "/players/alice/coins", value: 100 },
  // Transfer 50 coins
  { op: "set", path: "/players/alice/coins", value: 50 },
  { op: "set", path: "/players/bob/coins", value: 150 },
]);
If any condition fails, the entire transaction is rejected and no writes are applied. For complex objects, Lark computes a SHA-256 hash for comparison. This lets you condition on “this object hasn’t changed” without specifying every field:
await db.transaction([
  { op: "condition", path: "/game/state", value: currentStateHash },
  { op: "set", path: "/game/state", value: newState },
]);

Examples

Increment a counter

await db.ref("stats/totalGames").transaction((current) => {
  return (current ?? 0) + 1;
});

Update a high score (only if higher)

const newScore = 250;

const result = await db.ref("players/alice/highScore").transaction((current) => {
  if (current !== null && current >= newScore) {
    return undefined; // Current high score is already higher, abort
  }
  return newScore;
});

if (result.committed) {
  console.log("New high score recorded:", result.snapshot.val());
}

Transfer currency between players

// Read current values first
const aliceSnapshot = await db.ref("players/alice/coins").once("value");
const bobSnapshot = await db.ref("players/bob/coins").once("value");
const aliceCoins = aliceSnapshot.val();
const bobCoins = bobSnapshot.val();

const transferAmount = 50;

// Use a conditional multi-path transaction
await db.transaction([
  // Ensure neither balance changed since we read it
  { op: "condition", path: "/players/alice/coins", value: aliceCoins },
  { op: "condition", path: "/players/bob/coins", value: bobCoins },
  // Apply the transfer
  { op: "set", path: "/players/alice/coins", value: aliceCoins - transferAmount },
  { op: "set", path: "/players/bob/coins", value: bobCoins + transferAmount },
]);

Claim a unique resource

Use a transaction to ensure only one client can claim something:
const result = await db.ref("game/crown").transaction((current) => {
  if (current !== null) {
    return undefined; // Someone already claimed it
  }
  return { claimedBy: "alice", claimedAt: Date.now() };
});

if (result.committed) {
  console.log("Crown claimed!");
} else {
  console.log("Someone else got it first");
}

Conditional multi-path update

Only claim a reward if it hasn’t been claimed yet:
await db.transaction([
  { op: "condition", path: "/rewards/reward1/claimed", value: false },
  { op: "set", path: "/rewards/reward1/claimed", value: true },
  { op: "set", path: "/rewards/reward1/claimedBy", value: "alice" },
]);
Avoid running transactions on paths with very high write contention from many clients simultaneously. If you’re hitting the retry limit frequently, consider restructuring your data to reduce contention — for example, by sharding counters across multiple paths.