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

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

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.
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.
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.
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

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.