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.
Scanned 2/12/2026
Install via CLI
openskills install majiayu000/claude-skill-registry---
name: fvtt-performance-safe-updates
description: 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.
---
# 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?"**
```javascript
// ❌ 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:**
```javascript
// ❌ 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:**
```javascript
// ✅ 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:**
```javascript
// ❌ 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:**
```javascript
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:**
```javascript
// ❌ 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:**
```javascript
// ❌ 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.
```javascript
// ❌ 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:
```javascript
// 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:**
```javascript
/**
* 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:**
```javascript
// 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:**
```javascript
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)
```javascript
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
```javascript
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
```javascript
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
```javascript
// Every client updates, causing N × clients database writes
Hooks.on("deleteItem", (item) => {
item.parent.update({ ... }); // Missing ownership guard!
});
```
### ❌ Render Cascades
```javascript
// Rerenders ALL sheets, including unowned/closed
Hooks.on("updateActor", (actor) => {
game.actors.forEach(a => a.sheet.render(false));
});
```
### ❌ Delete + Create Race Conditions
```javascript
// UI flickers; race condition between delete and create
await actor.deleteEmbeddedDocuments("Item", [id]);
await actor.createEmbeddedDocuments("Item", [data]);
```
### ❌ Redundant No-Op Updates
```javascript
// Updates even if value unchanged (wasted database writes)
await actor.update({ "system.xp": actor.system.xp });
```
### ❌ Render Suppression (Breaks Multi-Client Sync)
```javascript
// 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
```javascript
// 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](https://foundryvtt.com/api/classes/foundry.abstract.Document.html#update) - Official update options including `render`
- [dnd5e System](https://github.com/foundryvtt/dnd5e) - 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
No comments yet. Be the first to comment!