Skip to Content
Real-timeCollaboration

Build powerful collaborative applications with InstantDB’s real-time synchronization, presence system, and conflict resolution. Create experiences like shared whiteboards, collaborative editors, multiplayer games, and team workspaces.

Complete Collaborative Editor

Here’s a full example of a collaborative text editor with cursors, typing indicators, and presence:

class CollaborativeEditor extends StatefulWidget { @override State<CollaborativeEditor> createState() => _CollaborativeEditorState(); } class _CollaborativeEditorState extends State<CollaborativeEditor> { final TextEditingController _controller = TextEditingController(); final FocusNode _focusNode = FocusNode(); InstantRoom? _room; String? _documentId; Timer? _typingTimer; @override void initState() { super.initState(); _documentId = 'doc-${widget.documentId}'; _initializeCollaboration(); } void _initializeCollaboration() { final db = InstantProvider.of(context); final currentUser = db.auth.currentUser.value; // Join collaboration room _room = db.presence.joinRoom(_documentId!, initialPresence: { 'userName': currentUser?.email ?? 'Anonymous', 'status': 'editing', 'color': _generateUserColor(currentUser?.id ?? 'anonymous'), }); // Listen to document changes _subscribeToDocument(); // Handle text selection changes for cursor position _controller.addListener(_updateCursorPosition); } void _subscribeToDocument() { final db = InstantProvider.of(context); // Subscribe to document content changes db.subscribeQuery({ 'documents': { 'where': {'id': _documentId}, } }).stream.listen((result) { final documents = result.data?['documents'] as List? ?? []; if (documents.isNotEmpty) { final document = documents.first as Map<String, dynamic>; final content = document['content'] as String? ?? ''; // Update content if different (avoid cursor jumps) if (_controller.text != content) { final selection = _controller.selection; _controller.text = content; _controller.selection = selection; } } }); } void _updateCursorPosition() { final selection = _controller.selection; if (selection.isValid && _room != null) { _room!.updateCursor( x: selection.baseOffset.toDouble(), y: 0, // For text, we use line-based positioning ); } } void _handleTextChange(String text) { // Update document with debouncing _typingTimer?.cancel(); _room?.setTyping(true); _typingTimer = Timer(const Duration(milliseconds: 500), () async { await _saveDocument(text); _room?.setTyping(false); }); } Future<void> _saveDocument(String content) async { final db = InstantProvider.of(context); await db.transact([ db.update(_documentId!, { 'content': content, 'lastModified': DateTime.now().millisecondsSinceEpoch, }), ]); } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: const Text('Collaborative Editor'), actions: [ // Show connected users UserAvatars(room: _room!), const SizedBox(width: 16), ], ), body: Column( children: [ // Connection status ConnectionStatusBanner(), // Typing indicators TypingIndicatorBanner(room: _room!), // Editor with cursor overlay Expanded( child: Stack( children: [ // Main text editor Padding( padding: const EdgeInsets.all(16), child: TextField( controller: _controller, focusNode: _focusNode, maxLines: null, expands: true, onChanged: _handleTextChange, decoration: const InputDecoration( border: InputBorder.none, hintText: 'Start typing...', ), ), ), // Collaborative cursors overlay CursorOverlay(room: _room!), ], ), ), ], ), ); } }

Real-time Whiteboard

Create a collaborative drawing canvas:

class CollaborativeWhiteboard extends StatefulWidget { @override State<CollaborativeWhiteboard> createState() => _CollaborativeWhiteboardState(); } class _CollaborativeWhiteboardState extends State<CollaborativeWhiteboard> { InstantRoom? _room; final List<DrawingPoint> _points = []; String? _currentStroke; @override void initState() { super.initState(); _initializeWhiteboard(); } void _initializeWhiteboard() { final db = InstantProvider.of(context); _room = db.presence.joinRoom('whiteboard', initialPresence: { 'userName': 'Artist ${DateTime.now().millisecondsSinceEpoch % 1000}', 'tool': 'pen', 'color': '#000000', }); // Subscribe to drawing strokes db.subscribeQuery({ 'strokes': { 'where': {'whiteboardId': 'main'}, 'orderBy': {'createdAt': 'asc'}, } }).stream.listen((result) { final strokes = result.data?['strokes'] as List? ?? []; setState(() { _points.clear(); _points.addAll(strokes.map((s) => DrawingPoint.fromJson(s))); }); }); } void _handlePanStart(DragStartDetails details) { _currentStroke = db.id(); _room?.setPresence({'status': 'drawing'}); final point = DrawingPoint( id: _currentStroke!, x: details.localPosition.dx, y: details.localPosition.dy, isStart: true, ); _addPoint(point); } void _handlePanUpdate(DragUpdateDetails details) { if (_currentStroke == null) return; final point = DrawingPoint( id: _currentStroke!, x: details.localPosition.dx, y: details.localPosition.dy, isStart: false, ); _addPoint(point); // Update cursor position for others _room?.updateCursor( x: details.localPosition.dx, y: details.localPosition.dy, ); } void _handlePanEnd(DragEndDetails details) { _currentStroke = null; _room?.setPresence({'status': 'idle'}); } Future<void> _addPoint(DrawingPoint point) async { final db = InstantProvider.of(context); await db.transact([ ...db.create('points', { 'id': db.id(), 'strokeId': point.id, 'x': point.x, 'y': point.y, 'isStart': point.isStart, 'whiteboardId': 'main', 'createdAt': DateTime.now().millisecondsSinceEpoch, }), ]); } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: const Text('Collaborative Whiteboard'), actions: [ UserAvatars(room: _room!), ], ), body: Stack( children: [ // Drawing canvas GestureDetector( onPanStart: _handlePanStart, onPanUpdate: _handlePanUpdate, onPanEnd: _handlePanEnd, child: CustomPaint( painter: WhiteboardPainter(_points), size: Size.infinite, ), ), // Collaborative cursors CursorOverlay(room: _room!), // Reactions overlay ReactionsOverlay(room: _room!), ], ), floatingActionButton: FloatingActionButton( onPressed: _clearCanvas, child: const Icon(Icons.clear), ), ); } }

Team Chat with Presence

Build a team chat with rich presence information:

class TeamChat extends StatefulWidget { final String teamId; const TeamChat({super.key, required this.teamId}); @override State<TeamChat> createState() => _TeamChatState(); } class _TeamChatState extends State<TeamChat> { final TextEditingController _messageController = TextEditingController(); InstantRoom? _room; StreamSubscription? _chatSubscription; final List<ChatMessage> _messages = []; @override void initState() { super.initState(); _initializeChat(); } void _initializeChat() { final db = InstantProvider.of(context); final currentUser = db.auth.currentUser.value; // Join team room _room = db.presence.joinRoom('team-${widget.teamId}', initialPresence: { 'userName': currentUser?.email ?? 'Anonymous', 'status': 'online', 'avatar': currentUser?.metadata?['avatar'], 'lastSeen': DateTime.now().millisecondsSinceEpoch, }); // Subscribe to chat messages _chatSubscription = _room!.subscribeTopic('messages').listen((data) { final message = ChatMessage.fromJson(data); setState(() { _messages.add(message); _messages.sort((a, b) => a.timestamp.compareTo(b.timestamp)); }); }); // Load existing messages _loadChatHistory(); // Update typing status _messageController.addListener(_handleTyping); } Timer? _typingTimer; void _handleTyping() { _room?.setTyping(true); _typingTimer?.cancel(); _typingTimer = Timer(const Duration(seconds: 2), () { _room?.setTyping(false); }); } Future<void> _loadChatHistory() async { final db = InstantProvider.of(context); final result = await db.queryOnce({ 'messages': { 'where': {'teamId': widget.teamId}, 'orderBy': {'timestamp': 'asc'}, 'limit': 100, } }); final messages = result.data?['messages'] as List? ?? []; setState(() { _messages.clear(); _messages.addAll(messages.map((m) => ChatMessage.fromJson(m))); }); } Future<void> _sendMessage() async { final text = _messageController.text.trim(); if (text.isEmpty) return; final db = InstantProvider.of(context); final currentUser = db.auth.currentUser.value; final message = { 'id': db.id(), 'teamId': widget.teamId, 'userId': currentUser?.id ?? 'anonymous', 'userName': currentUser?.email ?? 'Anonymous', 'text': text, 'timestamp': DateTime.now().millisecondsSinceEpoch, }; // Save to database await db.transact([ ...db.create('messages', message), ]); // Broadcast via presence await _room!.publishTopic('messages', message); _messageController.clear(); _room?.setTyping(false); } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: Text('Team ${widget.teamId}'), actions: [ // Team presence indicators Watch((context) { final presence = _room?.getPresence().value ?? {}; final onlineCount = presence.values .where((p) => p.data['status'] == 'online') .length; return Padding( padding: const EdgeInsets.all(8.0), child: Chip( label: Text('$onlineCount online'), avatar: const Icon(Icons.people, size: 16), ), ); }), ], ), body: Column( children: [ // Messages list Expanded( child: ListView.builder( itemCount: _messages.length, itemBuilder: (context, index) { final message = _messages[index]; return MessageBubble( message: message, isOwn: message.userId == InstantProvider.of(context).auth.currentUser.value?.id, ); }, ), ), // Typing indicators TypingIndicator(room: _room!), // Message input Container( padding: const EdgeInsets.all(8), child: Row( children: [ Expanded( child: TextField( controller: _messageController, decoration: const InputDecoration( hintText: 'Type a message...', border: OutlineInputBorder(), ), onSubmitted: (_) => _sendMessage(), ), ), const SizedBox(width: 8), IconButton( onPressed: _sendMessage, icon: const Icon(Icons.send), ), ], ), ), ], ), ); } }

Collaborative Task Management

Build a shared task board with real-time updates:

class CollaborativeTaskBoard extends StatefulWidget { @override State<CollaborativeTaskBoard> createState() => _CollaborativeTaskBoardState(); } class _CollaborativeTaskBoardState extends State<CollaborativeTaskBoard> { InstantRoom? _room; @override void initState() { super.initState(); _initializeTaskBoard(); } void _initializeTaskBoard() { final db = InstantProvider.of(context); final currentUser = db.auth.currentUser.value; _room = db.presence.joinRoom('task-board', initialPresence: { 'userName': currentUser?.email ?? 'Team Member', 'status': 'viewing', 'currentColumn': null, }); } Future<void> _moveTask(String taskId, String toColumn) async { final db = InstantProvider.of(context); // Optimistic update with presence _room?.setPresence({ 'status': 'moving_task', 'taskId': taskId, 'toColumn': toColumn, }); // Send reaction for visual feedback await _room?.sendReaction('📋', metadata: { 'action': 'task_moved', 'taskId': taskId, 'column': toColumn, }); await db.transact([ db.update(taskId, { 'status': toColumn, 'updatedAt': DateTime.now().millisecondsSinceEpoch, }), ]); _room?.setPresence({'status': 'viewing'}); } @override Widget build(BuildContext context) { final db = InstantProvider.of(context); return Scaffold( appBar: AppBar( title: const Text('Task Board'), actions: [ UserAvatars(room: _room!), IconButton( onPressed: _addNewTask, icon: const Icon(Icons.add), ), ], ), body: Row( children: [ // Task columns Expanded( child: InstantBuilder( query: { 'tasks': { 'where': {'boardId': 'main'}, 'orderBy': {'createdAt': 'desc'}, } }, builder: (context, result) { final tasks = result.data?['tasks'] as List? ?? []; return Row( children: [ TaskColumn( title: 'To Do', status: 'todo', tasks: tasks.where((t) => t['status'] == 'todo').toList(), onTaskMoved: _moveTask, room: _room!, ), TaskColumn( title: 'In Progress', status: 'inprogress', tasks: tasks.where((t) => t['status'] == 'inprogress').toList(), onTaskMoved: _moveTask, room: _room!, ), TaskColumn( title: 'Done', status: 'done', tasks: tasks.where((t) => t['status'] == 'done').toList(), onTaskMoved: _moveTask, room: _room!, ), ], ); }, ), ), ], ), ); } }

Conflict Resolution

InstantDB automatically handles conflicts, but you can implement custom resolution:

class ConflictAwareDocument extends StatefulWidget { @override State<ConflictAwareDocument> createState() => _ConflictAwareDocumentState(); } class _ConflictAwareDocumentState extends State<ConflictAwareDocument> { final TextEditingController _controller = TextEditingController(); String? _localVersion; String? _serverVersion; void _handleConflict(String localContent, String serverContent) { if (localContent == serverContent) return; // Show conflict resolution UI showDialog( context: context, builder: (context) => ConflictResolutionDialog( localVersion: localContent, serverVersion: serverContent, onResolved: _resolveConflict, ), ); } Future<void> _resolveConflict(String resolvedContent) async { final db = InstantProvider.of(context); await db.transact([ db.update('document-id', { 'content': resolvedContent, 'lastModified': DateTime.now().millisecondsSinceEpoch, 'resolvedBy': db.auth.currentUser.value?.id, }), ]); _controller.text = resolvedContent; } @override Widget build(BuildContext context) { return InstantBuilder( query: {'documents': {'where': {'id': 'document-id'}}}, builder: (context, result) { final documents = result.data?['documents'] as List? ?? []; if (documents.isNotEmpty) { final document = documents.first as Map<String, dynamic>; final serverContent = document['content'] as String? ?? ''; // Check for conflicts if (_localVersion != null && _localVersion != serverContent && _controller.text != serverContent) { WidgetsBinding.instance.addPostFrameCallback((_) { _handleConflict(_controller.text, serverContent); }); } } return TextField( controller: _controller, onChanged: (text) => _localVersion = text, ); }, ); } }

Performance Optimization

Optimize collaborative features for large teams:

class OptimizedCollaboration { static const int MAX_CURSORS = 10; static const Duration PRESENCE_THROTTLE = Duration(milliseconds: 100); static const Duration TYPING_DEBOUNCE = Duration(milliseconds: 300); // Throttle cursor updates static Timer? _cursorTimer; static void updateCursor(InstantRoom room, double x, double y) { _cursorTimer?.cancel(); _cursorTimer = Timer(PRESENCE_THROTTLE, () { room.updateCursor(x: x, y: y); }); } // Limit displayed cursors static List<MapEntry<String, dynamic>> getLimitedCursors( Map<String, dynamic> allCursors, ) { final entries = allCursors.entries.toList(); entries.sort((a, b) { final aTime = a.value.data['lastUpdate'] ?? 0; final bTime = b.value.data['lastUpdate'] ?? 0; return bTime.compareTo(aTime); }); return entries.take(MAX_CURSORS).toList(); } // Batch presence updates static void batchPresenceUpdate( InstantRoom room, Map<String, dynamic> updates, ) { Timer(PRESENCE_THROTTLE, () { room.setPresence(updates); }); } }

Best Practices

1. Handle Connection States

Always show connection status in collaborative apps:

Widget buildConnectionAwareUI() { return Column( children: [ ConnectionStatusBanner(), // Your collaborative UI ], ); }

2. Provide Visual Feedback

Show user actions with reactions and animations:

void _showCollaborativeAction(String action, String user) { _room?.sendReaction('✨', metadata: { 'action': action, 'user': user, 'timestamp': DateTime.now().millisecondsSinceEpoch, }); }

3. Handle Graceful Degradation

Ensure your app works offline:

Widget buildOfflineCapableFeature() { return Watch((context) { final isOnline = db.syncEngine?.connectionStatus.value ?? false; return Column( children: [ if (!isOnline) OfflineIndicator(), // Feature works regardless of connection YourFeature(), ], ); }); }

4. Clean Up Resources

Always clean up presence and subscriptions:

@override void dispose() { _room?.setPresence({'status': 'offline'}); _chatSubscription?.cancel(); _typingTimer?.cancel(); super.dispose(); }

Next Steps

Explore more collaborative patterns: