Skip to main content
Multisynq automatically handles session state through snapshots, but what happens when you update your code? This tutorial covers explicit persistence - how to save and restore application data across different versions of your application.

Automatic vs. Explicit Persistence

  • ๐Ÿ”„ Automatic (Snapshots)
  • ๐Ÿ’พ Explicit (Persistence)
Built-in session persistence
  • Works automatically without any code
  • Preserves state when users leave and return
  • Lost when code changes (new session created)
  • Perfect for temporary game sessions
// This data persists automatically between sessions
class GameModel extends Multisynq.Model {
    init() {
        this.players = new Map();
        this.score = 0;
    }
}
Any code change creates a new session, losing all previous data.

Session Identity System

Understanding how Multisynq identifies sessions is crucial for persistence:

๐Ÿ†” sessionId

Code-dependent session identifier
  • Combination of appId, name, and code hash
  • Changes with any code modification
  • Used for snapshots and live sessions
// These create the same sessionId:
Session.join({ appId: "myapp", name: "room1" });
// With identical code

// This creates a different sessionId:
Session.join({ appId: "myapp", name: "room1" });
// With modified code (even tiny changes)

๐Ÿ”„ persistentId

Code-independent persistent identifier
  • Combination of appId and name only
  • Survives code changes
  • Used for persistence lookup
// These share the same persistentId:
Session.join({ appId: "myapp", name: "room1" });
// Regardless of code changes

// Different persistentId:
Session.join({ appId: "myapp", name: "room2" });

How Persistence Works

1

First Session Join

When joining a session with a never-seen-before sessionId:
  1. Multisynq looks up persistent data by persistentId
  2. If found, passes it to your modelโ€™s init() method
  3. Your model restores state from persistent data
class MyModel extends Multisynq.Model {
    init(options, persisted) {
        if (persisted) {
            console.log("Restoring from persistent data:", persisted);
            this.restoreState(persisted);
        } else {
            console.log("Fresh session - no persistent data");
            this.initializeDefaults();
        }
    }
}
2

Saving Persistent Data

Your application decides when to save using persistSession():
save() {
    this.persistSession(() => {
        return {
            version: 1,
            data: this.collectImportantData(),
            timestamp: Date.now()
        };
    });
}
3

Code Update

When you update your code:
  1. New sessionId is generated (code changed)
  2. Same persistentId is used (appId + name unchanged)
  3. Persistent data is loaded into new code version

Basic Implementation

  • Simple Pattern
  • Advanced Pattern
Most applications can use this straightforward approach:
class SimpleAppModel extends Multisynq.Model {
    init(options, persisted) {
        // Regular setup
        this.users = new Map();
        this.content = "";
        this.lastModified = null;
        
        if (persisted) {
            // Restore from persistent data
            this.content = persisted.content || "";
            this.lastModified = persisted.lastModified;
            
            // Restore users (handle Map serialization)
            if (persisted.users) {
                this.users = new Map(persisted.users);
            }
        }
    }
    
    save() {
        this.persistSession(() => {
            return {
                content: this.content,
                lastModified: this.lastModified,
                users: Array.from(this.users.entries()) // Convert Map to Array
            };
        });
    }
    
    updateContent(newContent) {
        this.content = newContent;
        this.lastModified = Date.now();
        
        // Save after important changes
        this.save();
    }
}
This pattern works well for simple applications with straightforward data structures.

Data Serialization Requirements

Critical: persistSession() uses stable JSON stringification. Non-JSON types require special handling.
  • โœ… JSON-Safe Types
  • โš ๏ธ Requires Conversion
These work automatically:
const safeData = {
    strings: "text",
    numbers: 42,
    booleans: true,
    arrays: [1, 2, 3],
    objects: { nested: "value" },
    nulls: null
};

this.persistSession(() => safeData);

When to Use Persistence

Applications that benefit from persistence:
  • Collaborative editors: Documents must survive code updates
  • Creative tools: User creations are valuable
  • Configuration apps: Settings should persist
  • Score tracking: High scores across game updates
  • Chat applications: Message history preservation
// Example: Collaborative whiteboard
class WhiteboardModel extends Multisynq.Model {
    init(options, persisted) {
        this.shapes = persisted?.shapes || [];
        this.users = new Map(persisted?.users || []);
    }
    
    addShape(shape) {
        this.shapes.push(shape);
        this.save(); // Save after important changes
    }
    
    save() {
        this.persistSession(() => ({
            shapes: this.shapes,
            users: Array.from(this.users.entries())
        }));
    }
}
Applications that donโ€™t need persistence:
  • Simple games: No long-term state to preserve
  • Temporary demonstrations: Short-lived sessions
  • Real-time only: No data worth preserving
  • Prototype applications: Data structure still changing
// Example: Simple multiplayer pong game
class PongModel extends Multisynq.Model {
    init() {
        // Game state that resets each session
        this.ball = { x: 50, y: 50, vx: 1, vy: 1 };
        this.players = new Map();
        this.score = { left: 0, right: 0 };
        
        // No persistence needed - games start fresh
    }
}
If you donโ€™t need to preserve data across code changes, snapshots are sufficient.

When to Save Data

Unlike automatic snapshots, you control when to save persistent data. Balance data safety with performance.
  • ๐Ÿ’ฅ Major Changes
  • โฐ Timer-Based
Save after significant data modifications:
class DocumentEditor extends Multisynq.Model {
    addDocument(doc) {
        this.documents.set(doc.id, doc);
        this.save(); // Major change - save immediately
    }
    
    deleteDocument(id) {
        this.documents.delete(id);
        this.save(); // Major change - save immediately  
    }
    
    updateCursor(position) {
        this.cursors.set(this.viewId, position);
        // Minor change - don't save for every cursor move
    }
}

Error Handling and Recovery

Critical: Implement error handling to prevent data corruption during development.
  • Safe Loading Pattern
  • Development Workflow
Protect against corrupted persistent data:
class SafeModel extends Multisynq.Model {
    init(options, persisted) {
        if (persisted) {
            delete this.loadingPersistentDataErrored;
            this.loadingPersistentData = true;
            
            try {
                this.fromSavedData(persisted);
                console.log("Successfully loaded persistent data");
            } catch (error) {
                console.error("Error loading persistent data:", error);
                this.loadingPersistentDataErrored = true;
                
                // Fallback to defaults
                this.initializeDefaults();
            } finally {
                delete this.loadingPersistentData;
            }
        } else {
            this.initializeDefaults();
        }
    }
    
    save() {
        // Don't save while loading (prevents corruption)
        if (this.loadingPersistentData) return;
        
        // Don't save if loading failed (prevents overwriting good data)
        if (this.loadingPersistentDataErrored) return;
        
        this.persistSession(() => this.toSaveData());
    }
    
    fromSavedData(persisted) {
        // Validate data structure
        if (!persisted.version) {
            throw new Error("Missing version in persistent data");
        }
        
        if (persisted.version > 2) {
            throw new Error(`Unsupported version: ${persisted.version}`);
        }
        
        // Restore data
        this.documents = new Map(persisted.documents || []);
        this.metadata = persisted.metadata || {};
    }
    
    toSaveData() {
        return {
            version: 2,
            documents: Array.from(this.documents.entries()),
            metadata: this.metadata
        };
    }
}

Version Management

Handle changing data formats over time:
class VersionedModel extends Multisynq.Model {
    fromSavedData(persisted) {
        switch (persisted.version) {
            case 1:
                this.loadVersion1(persisted);
                break;
            case 2:
                this.loadVersion2(persisted);
                break;
            case 3:
                this.loadVersion3(persisted);
                break;
            default:
                if (persisted.version > 3) {
                    throw new Error(`Future version not supported: ${persisted.version}`);
                } else {
                    // Legacy data without version
                    this.loadLegacy(persisted);
                }
        }
    }
    
    loadVersion1(data) {
        // Original format
        this.documents = new Map(data.documents || []);
    }
    
    loadVersion2(data) {
        // Added user permissions
        this.loadVersion1(data);
        this.permissions = new Map(data.permissions || []);
    }
    
    loadVersion3(data) {
        // Added collaboration features
        this.loadVersion2(data);
        this.collaborators = new Set(data.collaborators || []);
        this.lastActivity = data.lastActivity || Date.now();
    }
    
    toSaveData() {
        return {
            version: 3, // Always save in latest format
            documents: Array.from(this.documents.entries()),
            permissions: Array.from(this.permissions.entries()),
            collaborators: Array.from(this.collaborators),
            lastActivity: this.lastActivity
        };
    }
}
Handle breaking changes safely:
class MigrationModel extends Multisynq.Model {
    fromSavedData(persisted) {
        if (persisted.version === 1) {
            // Major breaking change: documents now have structure
            const oldDocuments = persisted.documents || [];
            this.documents = new Map();
            
            // Migrate old format to new format
            oldDocuments.forEach((oldDoc, index) => {
                const newDoc = {
                    id: `doc-${index}`,
                    title: oldDoc.name || "Untitled",
                    content: oldDoc.text || "",
                    created: oldDoc.timestamp || Date.now(),
                    modified: oldDoc.timestamp || Date.now()
                };
                this.documents.set(newDoc.id, newDoc);
            });
            
            console.log(`Migrated ${oldDocuments.length} documents from v1 to v2`);
        } else {
            // Handle current versions normally
            this.documents = new Map(persisted.documents || []);
        }
    }
}

Debugging Tools

  • Debug Options
  • Manual Inspection
Enable detailed logging:
// In Session.join()
Session.join({
    appId: "myapp",
    name: "session1",
    model: MyModel,
    view: MyView,
    debug: "session" // Enables persistence logging
});

// Or via URL parameter
// https://myapp.com/#session1&debug=session
Console output:
[Multisynq] Session loaded from persistent data (persistentId: myapp-session1)
[Multisynq] Restoring 1.2KB of persistent data
[Multisynq] Persistent data saved (2.3KB)

Security and Encryption

End-to-End Encryption: Persistent data inherits Multisynqโ€™s security model.

๐Ÿ” Secure by Default

Your data is protected:
  • All persistent data is encrypted
  • Only clients with session password can decrypt
  • Server cannot read your data
  • Suitable for sensitive information
// This data is automatically encrypted
this.persistSession(() => ({
    confidentialDocument: "sensitive content",
    userSecrets: privateData
}));

โš ๏ธ Password Management

Important considerations:
  • Lost password = lost data (unrecoverable)
  • Consider password storage strategy
  • Balance security vs. convenience
// Option 1: User-managed passwords (most secure)
Session.join({
    name: userProvidedSessionName,
    password: userProvidedPassword // User remembers
});

// Option 2: Server-stored passwords (convenient but less secure)
const sessionInfo = await getSessionFromServer(userId);
Session.join({
    name: sessionInfo.name,
    password: sessionInfo.password // Server provides
});

Best Practices Summary

๐Ÿ“ Planning

Think ahead about persistence:
  • Plan persistence from the start
  • Canโ€™t add persistence to existing sessions
  • Consider what data needs to survive updates
  • Design for data format evolution
// โœ… Good: Plan persistence early
class MyModel extends Multisynq.Model {
    init(options, persisted) {
        // Handle both cases from the start
        if (persisted) {
            this.restoreData(persisted);
        } else {
            this.initDefaults();
        }
    }
}

โšก Performance

Optimize save frequency:
  • Save after major changes only
  • Use timers for burst activity
  • Keep persistent data minimal
  • Clean up unnecessary data
// โœ… Good: Strategic saving
save() {
    this.persistSession(() => ({
        // Only essential data
        documents: this.getEssentialDocs(),
        metadata: this.coreMetadata,
        // Skip: temporary UI state, caches, etc.
    }));
}

๐Ÿ›ก๏ธ Safety

Handle errors gracefully:
  • Validate persistent data structure
  • Handle version mismatches
  • Donโ€™t save during loading
  • Test with corrupted data
// โœ… Good: Safe error handling
try {
    this.fromSavedData(persisted);
} catch (error) {
    console.error("Load failed:", error);
    this.initDefaults(); // Fallback
    this.loadingErrored = true;
}

๐Ÿ”„ Testing

Test thoroughly:
  • Test fresh sessions
  • Test loading from persistence
  • Test data migrations
  • Use separate deployments for testing
// Test both paths
console.log(persisted ? "Loading saved data" : "Fresh start");

Real-World Examples

class DocumentEditorModel extends Multisynq.Model {
    init(options, persisted) {
        if (persisted) {
            this.documents = new Map(persisted.documents || []);
            this.users = new Map(persisted.users || []);
            this.settings = persisted.settings || {};
        } else {
            this.documents = new Map();
            this.users = new Map();
            this.settings = { theme: "light", autosave: true };
        }
        
        this.setupAutosave();
    }
    
    setupAutosave() {
        if (this.settings.autosave) {
            this.future(30000).autosave();
        }
    }
    
    autosave() {
        if (this.hasUnsavedChanges) {
            this.save();
            this.hasUnsavedChanges = false;
        }
        this.setupAutosave(); // Schedule next autosave
    }
    
    createDocument(title, content) {
        const doc = {
            id: this.generateId(),
            title,
            content,
            created: Date.now(),
            modified: Date.now()
        };
        
        this.documents.set(doc.id, doc);
        this.hasUnsavedChanges = true;
        this.save(); // Save immediately for major changes
    }
    
    updateDocument(id, changes) {
        const doc = this.documents.get(id);
        if (doc) {
            Object.assign(doc, changes, { modified: Date.now() });
            this.hasUnsavedChanges = true;
            
            // Don't save immediately - let autosave handle it
        }
    }
    
    save() {
        this.persistSession(() => ({
            version: 1,
            documents: Array.from(this.documents.entries()),
            users: Array.from(this.users.entries()),
            settings: this.settings
        }));
    }
}
class CanvasModel extends Multisynq.Model {
    init(options, persisted) {
        this.loadingPersistentData = !!persisted;
        
        try {
            if (persisted) {
                this.fromSavedData(persisted);
            } else {
                this.shapes = [];
                this.layers = [{ id: 'default', name: 'Layer 1', visible: true }];
                this.canvasSize = { width: 800, height: 600 };
            }
        } catch (error) {
            console.error("Failed to load canvas data:", error);
            this.shapes = [];
            this.layers = [{ id: 'default', name: 'Layer 1', visible: true }];
            this.canvasSize = { width: 800, height: 600 };
            this.loadingErrored = true;
        } finally {
            this.loadingPersistentData = false;
        }
    }
    
    addShape(shape) {
        shape.id = this.generateId();
        shape.created = Date.now();
        this.shapes.push(shape);
        
        this.save(); // Save after adding shapes
    }
    
    deleteShape(shapeId) {
        const index = this.shapes.findIndex(s => s.id === shapeId);
        if (index >= 0) {
            this.shapes.splice(index, 1);
            this.save(); // Save after deleting shapes
        }
    }
    
    save() {
        if (this.loadingPersistentData || this.loadingErrored) return;
        
        this.persistSession(() => ({
            version: 1,
            shapes: this.shapes,
            layers: this.layers,
            canvasSize: this.canvasSize,
            metadata: {
                shapeCount: this.shapes.length,
                lastModified: Date.now()
            }
        }));
    }
    
    fromSavedData(persisted) {
        if (persisted.version !== 1) {
            throw new Error(`Unsupported version: ${persisted.version}`);
        }
        
        this.shapes = persisted.shapes || [];
        this.layers = persisted.layers || [];
        this.canvasSize = persisted.canvasSize || { width: 800, height: 600 };
        
        console.log(`Restored ${this.shapes.length} shapes`);
    }
}

Next Steps

Persistence is essential for applications where user data has long-term value. Plan for it early, implement it safely, and test thoroughly to ensure your users never lose their important work.
โŒ˜I