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, render suppression with { render: false }, idempotency guards, and choosing between optimistic vs locking UI patterns.
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 itemitem.parent?.isOwner- Current user owns the parent (actor/container)actor.isOwner- Current user owns this actorgame.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: Render Suppression with { render: false }
Understanding Foundry's render flow:
When document.update() is called, Foundry:
- Sends update to server
- Broadcasts change to all clients
- Fires
updateActor/updateItemhooks on each client - Automatically calls
render()on sheets registered indoc.apps
The { render: false } option suppresses only step 4 - hooks still fire, but automatic sheet re-renders are skipped. This pattern is used by the official dnd5e system.
When using optimistic UI, suppress the automatic re-render:
// Pattern: Optimistic update + suppress re-render
function optimisticClockUpdate(clockEl, doc, newValue) {
// 1. Update DOM immediately (optimistic)
clockEl.style.backgroundImage = `url('clocks/clock_${newValue}.svg')`;
// 2. Persist with render suppressed - DOM already shows correct state
await doc.update({ "system.value": newValue }, { render: false });
}
Suppress render when:
- Optimistic UI already shows the correct final state
- Update is to a field not displayed in the current view
- Doing batch operations (render once at end)
Allow render when:
- Update affects computed/derived data the template needs
- Template needs to rebuild lists or complex structures
- You haven't done an optimistic update
// DON'T suppress render - sheet needs fresh data for ability list
// Removing an ability slot changes what abilities are shown
await actor.setFlag(MODULE_ID, "addedAbilitySlots", filteredSlots);
// Let Foundry re-render to rebuild the ability list from new data
Step 8: Use the safeUpdate Helper
Combine all guards into a single helper:
/**
* Safely updates a document with ownership, no-op, and render-suppression guards.
*/
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, render-suppressed update
await queueUpdate(async () => {
await doc.update(updateData, { render: false, ...options });
});
return true;
}
Usage:
// Simple: handles all guards automatically
clockEl.style.backgroundImage = `url('clocks/clock_${newValue}.svg')`;
await safeUpdate(doc, { "system.value": newValue });
// Override render suppression if needed
await safeUpdate(doc, { "system.abilitySlots": newSlots }, { render: true });
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));
Step 10: Idempotency Guard for Click Handlers
When using checkboxes with <label> elements, CSS interactions can cause duplicate click events:
html.find(".item-checkbox").on("click", async (ev) => {
const itemBlock = ev.currentTarget.closest(".item-block");
const desiredState = !isCurrentlyEquipped;
// IDEMPOTENCY GUARD: Prevent ghost clicks from CSS label overlaps
// Store pending state on DOM element itself
if (itemBlock.dataset.optimisticState === String(desiredState)) {
return; // Already processing this state change
}
itemBlock.dataset.optimisticState = String(desiredState);
// Proceed with optimistic update
requestAnimationFrame(() => {
checkbox.checked = desiredState;
});
await safeUpdate(doc, { "system.equipped": desiredState });
});
When to use idempotency guards:
- Checkbox/label combinations where CSS creates overlapping click targets
- Any UI where rapid duplicate events are possible
- Toggle buttons that might receive multiple events from same user action
Optimistic vs Locking Patterns
Not all UI interactions should use pure optimistic updates. Choose the pattern based on operation complexity.
Pure Optimistic Pattern
Use for simple flag or field updates where the final visual state is known immediately:
// Pure optimistic: clock increment
clockEl.style.backgroundImage = `url('clocks/clock_${newValue}.svg')`;
await safeUpdate(doc, { "system.value": newValue });
Locking Pattern
Use for operations that create/delete embedded documents or have cascading effects:
// Locking: ability toggle that may create/delete items
html.find(".ability-checkbox").change(async (ev) => {
const checkboxList = abilityBlock.querySelectorAll(".ability-checkbox");
// 1. LOCK: Disable inputs during operation
checkboxList.forEach(el => el.setAttribute("disabled", "disabled"));
try {
// 2. Perform complex operations (may create/delete items)
if (!hadProgress && willHaveProgress) {
await createOwnedAbility(actor, abilityId);
} else if (hadProgress && !willHaveProgress) {
await deleteOwnedAbility(actor, abilityId);
}
// 3. Update DOM AFTER operations complete (not before)
abilityBlock.dataset.abilityProgress = String(targetProgress);
checkboxList.forEach(el => {
el.checked = slotIndex <= targetProgress;
});
} finally {
// 4. UNLOCK: Re-enable inputs
checkboxList.forEach(el => el.removeAttribute("disabled"));
}
});
Choosing Between Patterns
| Aspect | Pure Optimistic | Locking Pattern |
|---|---|---|
| UI update timing | Before persist | After persist |
| User feedback | Immediate visual change | Disabled state during operation |
| Use case | Simple field updates | Complex multi-document operations |
| Failure handling | UI may show incorrect state | UI reflects actual final state |
Use Pure Optimistic when:
- Updating a single field or flag
- Final state is deterministic
- No side effects on other documents
Use Locking when:
- Creating or deleting embedded documents
- Ownership changes affect what items exist
- Multi-step operations where intermediate state could confuse users
Quick Checklist for New Code
Before submitting any code that updates documents, verify:
- Ownership Guard: Added
if (!item.parent?.isOwner) return;orif (!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
- Render Suppression: Using
{ render: false }with optimistic UI updates - Debounced: High-frequency handlers (keyup, mousemove) use debouncing
- Idempotency: Click handlers guard against duplicate events
- Pattern Choice: Using locking pattern for complex operations, optimistic for simple updates
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 });
Testing Multi-Client Performance
After implementing updates, test with multiple clients:
- Open two browser windows (or use incognito mode)
- Log in as different users (or same user, different tabs)
- Perform the action (toggle, update, swap)
- Check browser console in both windows for:
- Duplicate update logs
- Error messages
- Unexpected rerenders
- 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:
updateEmbeddedDocumentsvs delete+create
Implementation notes:
- The
queueUpdateandsafeUpdatehelpers 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-05