Claude-skill-registry fvtt-performance-safe-updates

This skill should be used when adding features that update actors or items, implementing hook handlers, modifying update logic, or replacing embedded documents. Covers ownership guards, no-op checks, batched updates, queueUpdate wrapper, atomic document operations, and letting Foundry handle renders automatically for multi-client sync.

install
source · Clone the upstream repo
git clone https://github.com/majiayu000/claude-skill-registry
Claude Code · Install into ~/.claude/skills/
T=$(mktemp -d) && git clone --depth=1 https://github.com/majiayu000/claude-skill-registry "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/data/fvtt-performance-safe-updates" ~/.claude/skills/majiayu000-claude-skill-registry-fvtt-performance-safe-updates && rm -rf "$T"
manifest: skills/data/fvtt-performance-safe-updates/SKILL.md
source content

Foundry VTT Performance-Safe Updates

Ensure document updates in Foundry VTT modules don't cause multi-client update storms or render cascades.

When to Use This Skill

Invoke this skill when implementing ANY of the following in a Foundry VTT module:

  • Adding a new feature that updates actors or items
  • Modifying existing update logic
  • Adding UI elements that trigger document changes
  • Implementing hook handlers that respond to document changes
  • Replacing or swapping embedded documents (abilities, items, effects)

Core Problem

Foundry VTT runs in multi-client sessions where hooks fire on ALL connected clients. Without proper guards:

  • Every client triggers duplicate updates (2-10x redundant database writes)
  • Update storms occur when updates trigger more updates across clients
  • UI flickers when delete+create patterns cause "empty state" renders between operations
  • Performance degrades exponentially with number of connected clients

The Performance-Safe Pattern

Step 1: Ownership Guards

Before any document update, ask: "Should this run on every client?"

// ❌ BAD: Runs on every connected client
Hooks.on("deleteItem", (item, options, userId) => {
  item.parent.update({ "system.someField": newValue });
});

// ✅ GOOD: Only owner/GM performs the update
Hooks.on("deleteItem", (item, options, userId) => {
  if (!item.parent?.isOwner) return;
  item.parent.update({ "system.someField": newValue });
});

Common ownership checks:

  • item.isOwner
    - Current user owns this item
  • item.parent?.isOwner
    - Current user owns the parent (actor/container)
  • actor.isOwner
    - Current user owns this actor
  • game.user.isGM
    - Current user is the GM

Use GM-only guards for:

  • World-level changes
  • Compendium updates
  • Global settings modifications

Step 2: Skip No-Op Updates

Before calling update, check if the value actually changes:

// ❌ BAD: Always updates, even if value unchanged
await actor.update({ "system.selected_load_level": newLevel });

// ✅ GOOD: Skip if already set
if (actor.system.selected_load_level === newLevel) return;
await actor.update({ "system.selected_load_level": newLevel });

For flag-based updates:

// ✅ Skip if flag already matches target state
const currentProgress = actor.getFlag('bitd-alternate-sheets', 'abilityProgress') || {};
if (currentProgress[abilityId] === targetValue) return;

await actor.setFlag('bitd-alternate-sheets', 'abilityProgress', {
  ...currentProgress,
  [abilityId]: targetValue
});

Step 3: Batch Multiple Updates

Combine multiple field changes into a single update call:

// ❌ BAD: Three separate updates (3x hooks, 3x database writes)
await actor.update({ "system.harm.level1.value": "Bruised" });
await actor.update({ "system.stress.value": 5 });
await actor.update({ "system.xp.value": 3 });

// ✅ GOOD: Single batched update
await actor.update({
  "system.harm.level1.value": "Bruised",
  "system.stress.value": 5,
  "system.xp.value": 3
});

Step 4: Use queueUpdate Wrapper

Wrap ALL document updates in queueUpdate to prevent concurrent update collisions:

import { queueUpdate } from "./update-queue.js";

// ✅ Prevents race conditions in multi-client sessions
await queueUpdate(async () => {
  await this.actor.update(updates);
});

What queueUpdate does:

  • Ensures updates execute sequentially, not concurrently
  • Prevents "lost update" race conditions
  • Automatically handles update conflicts

When to use:

  • ANY actor.update() call
  • ANY updateEmbeddedDocuments() call
  • Batch operations that modify multiple documents

Step 5: Atomic Embedded Document Updates

When replacing embedded documents (items, effects), NEVER use delete+create:

// ❌ BAD: Delete + Create causes UI flicker and race conditions
await actor.deleteEmbeddedDocuments("Item", [oldItemId]);
await actor.createEmbeddedDocuments("Item", [newItemData]);
// UI renders "empty state" between these calls!

// ✅ GOOD: Update in place (atomic operation)
await actor.updateEmbeddedDocuments("Item", [{
  _id: oldItemId,
  name: newItemData.name,
  img: newItemData.img,
  system: newItemData.system
}]);

Use cases:

  • Swapping crew abilities
  • Changing hunting grounds
  • Replacing playbook items
  • Updating item references

Step 6: Guard Rerenders in Hooks

Only rerender sheets that are owned and currently visible:

// ❌ BAD: Rerenders ALL character sheets (including closed/unowned)
Hooks.on("renderBladesClockSheet", (sheet, html, data) => {
  game.actors.forEach(actor => {
    actor.sheet.render(false);
  });
});

// ✅ GOOD: Only rerender owned, open sheets
Hooks.on("renderBladesClockSheet", (sheet, html, data) => {
  game.actors.forEach(actor => {
    if (actor.isOwner && actor.sheet.rendered) {
      actor.sheet.render(false);
    }
  });
});

Step 7: Let Foundry Handle Renders (Avoid { render: false })

Default behavior: When

document.update()
is called, Foundry automatically re-renders all registered sheets on ALL connected clients. This is the correct behavior for multi-client synchronization.

Understanding Foundry's render flow:

When

document.update()
is called, Foundry:

  1. Sends update to server
  2. Broadcasts change to all clients
  3. Fires
    updateActor
    /
    updateItem
    hooks on each client
  4. Automatically calls
    render()
    on sheets registered in
    doc.apps

Critical: The

{ render: false }
option suppresses step 4 on ALL clients, not just the initiating client. This breaks multi-client synchronization.

// ❌ BAD: Suppresses render on ALL clients, breaking multi-client sync
await actor.update({ "system.value": newValue }, { render: false });
// Other players' sheets won't update!

// ✅ GOOD: Let Foundry handle renders automatically
await queueUpdate(async () => {
  await actor.update({ "system.value": newValue });
});
// All clients re-render automatically, staying in sync

Only exception - Data Migrations in getData():

When migrating data inside

getData()
, you must suppress render to prevent infinite loops:

// In getData() - migration MUST suppress render to avoid infinite loop
async getData() {
  // Detect old data format that needs migration
  if (this.actor.system.oldField !== undefined) {
    queueUpdate(() => this.actor.update({
      "system.newField": this.actor.system.oldField,
      "system.-=oldField": null
    }, { render: false }));
  }
  // ... rest of getData
}

With proper caching, Foundry sheet renders are fast (~2-3ms). There's no need for "optimistic UI" patterns that manipulate DOM before/after updates.

Step 8: Use the safeUpdate Helper

Combine all guards into a single helper:

/**
 * Safely updates a document with ownership and no-op guards.
 * Lets Foundry handle re-renders automatically for multi-client sync.
 */
export async function safeUpdate(doc, updateData, options = {}) {
  // 1. Ownership guard - only owner should update
  if (!doc?.isOwner) return false;

  // 2. Empty update guard
  const entries = Object.entries(updateData || {});
  if (entries.length === 0) return false;

  // 3. No-op detection - skip if values unchanged
  const hasChange = entries.some(([key, value]) => {
    // Objects always treated as changes (too complex to deep-compare)
    if (value !== null && typeof value === "object") return true;
    const currentValue = foundry.utils.getProperty(doc, key);
    return currentValue !== value;
  });
  if (!hasChange) return false;

  // 4. Queued update - let Foundry handle renders
  await queueUpdate(async () => {
    await doc.update(updateData, options);
  });
  return true;
}

Usage:

// Standard pattern: handles all guards, Foundry re-renders all clients
await safeUpdate(doc, { "system.value": newValue });

// Only use render: false for data migrations in getData()
await safeUpdate(doc, migrationData, { render: false });

Step 9: Debounce High-Frequency Handlers

For handlers that run frequently (keyup, mousemove), add debouncing:

import { debounce } from "./utils.js";

// ❌ BAD: Updates on every keystroke
html.find("input").on("keyup", async (ev) => {
  await actor.update({ "system.notes": ev.target.value });
});

// ✅ GOOD: Debounce to reduce update frequency
html.find("input").on("keyup", debounce(async (ev) => {
  await queueUpdate(async () => {
    await actor.update({ "system.notes": ev.target.value });
  });
}, 300));

Quick Checklist for New Code

Before submitting any code that updates documents, verify:

  • Ownership Guard: Added
    if (!item.parent?.isOwner) return;
    or
    if (!game.user.isGM) return;
    where appropriate
  • No-Op Check: Skip update if current value already matches target value
  • Batched: Multiple field changes combined into single update object
  • Queued: Update wrapped in
    queueUpdate(async () => { ... })
  • Atomic: Used
    updateEmbeddedDocuments()
    instead of delete+create for replacements
  • Rerender Guards: Only rerender owned and currently open sheets
  • No Render Suppression: NOT using
    { render: false }
    (breaks multi-client sync)
  • Debounced: High-frequency handlers (keyup, mousemove) use debouncing

Common Patterns by Feature Type

Adding a Toggle (checkbox, button)

html.find(".toggle-something").on("click", async (ev) => {
  const currentValue = this.actor.system.someFlag;
  const newValue = !currentValue;

  // Skip if unchanged
  if (currentValue === newValue) return;

  await queueUpdate(async () => {
    await this.actor.update({ "system.someFlag": newValue });
  });
  // No manual render - hook handles it
});

Implementing a Hook Handler

Hooks.on("deleteItem", (item, options, userId) => {
  // Guard: Only owner performs side effects
  if (!item.parent?.isOwner) return;

  // Check if update needed
  const needsUpdate = /* your logic */;
  if (!needsUpdate) return;

  // Perform update
  queueUpdate(async () => {
    await item.parent.update({ /* changes */ });
  });
});

Swapping Embedded Documents

async replaceAbility(oldAbilityId, newAbilityData) {
  const oldAbility = this.actor.items.get(oldAbilityId);
  if (!oldAbility) return;

  // Update in place (atomic)
  await queueUpdate(async () => {
    await this.actor.updateEmbeddedDocuments("Item", [{
      _id: oldAbilityId,
      name: newAbilityData.name,
      img: newAbilityData.img,
      system: newAbilityData.system
    }]);
  });
}

Anti-Patterns to Avoid

❌ Update Storms

// Every client updates, causing N × clients database writes
Hooks.on("deleteItem", (item) => {
  item.parent.update({ ... });  // Missing ownership guard!
});

❌ Render Cascades

// Rerenders ALL sheets, including unowned/closed
Hooks.on("updateActor", (actor) => {
  game.actors.forEach(a => a.sheet.render(false));
});

❌ Delete + Create Race Conditions

// UI flickers; race condition between delete and create
await actor.deleteEmbeddedDocuments("Item", [id]);
await actor.createEmbeddedDocuments("Item", [data]);

❌ Redundant No-Op Updates

// Updates even if value unchanged (wasted database writes)
await actor.update({ "system.xp": actor.system.xp });

❌ Render Suppression (Breaks Multi-Client Sync)

// Suppresses render on ALL connected clients, not just initiating client!
await actor.update({ "system.value": newValue }, { render: false });
// Other players' sheets won't update - they'll see stale data

❌ Optimistic UI DOM Manipulation

// DOM manipulation before persist causes desync if update fails
checkbox.checked = newValue;  // Update DOM first (optimistic)
await actor.update({ "system.equipped": newValue });  // Then persist
// If update fails, DOM shows wrong state; other clients may not sync

Testing Multi-Client Performance

After implementing updates, test with multiple clients:

  1. Open two browser windows (or use incognito mode)
  2. Log in as different users (or same user, different tabs)
  3. Perform the action (toggle, update, swap)
  4. Check browser console in both windows for:
    • Duplicate update logs
    • Error messages
    • Unexpected rerenders
  5. Verify in database that only one update occurred (not N × clients)

References

  • Foundry VTT API - Document#update - Official update options including
    render
  • dnd5e System - Uses
    { render: false }
    pattern extensively in migrations
  • Update queue pattern: prevents concurrent update collisions
  • Atomic updates:
    updateEmbeddedDocuments
    vs delete+create

Implementation notes:

  • The
    queueUpdate
    and
    safeUpdate
    helpers typically live in a utils module
  • Clock handlers and other UI interactions belong in dedicated feature modules
  • The exact file locations are project-specific; the patterns are what matter

Last Updated: 2026-01-14