Building Live Collaborative Features with Appwrite Real-Time Database Subscriptions
Learn how to replace polling with Appwrite's WebSocket-based subscriptions to get instant updates in a collaborative todo app, plus the trade-offs you need to watch.
28 May 2026, 15:59 UTC

Why polling feels slow for collaborative apps
When multiple users edit the same data, waiting for a periodic fetch means stale views and unnecessary network traffic. Appwrite offers a built-in real-time layer that pushes database changes over a WebSocket as soon as they happen, letting you update the UI instantly.
Setting up a subscribable collection
Before you can subscribe, the collection must exist and have read permissions for the API key you'll use.
- Open the Appwrite console, create a project (or use an existing one).
- Enable the
Databaseservice. - Add a collection called
todoswith attributes: title(string, required)completed(boolean, default false)- In the
Settings → Permissionstab for the collection, grantreadto the role that matches your API key (e.g.,*for public testing or a specific team). - Optionally add a
writerule so only authenticated users can create or modify items.
With these rules in place, any client holding a valid API key can open a subscription stream.
Subscribing with the JavaScript SDK
The Appwrite SDK exposes a subscribe method on a collection. You pass a query string (or empty for all documents) and a callback that receives events of type INSERT, UPDATE, or DELETE.
import { Client, Databases } from 'appwrite';
// Initialize the client – replace with your endpoint and project ID
const client = new Client()
.setEndpoint('https://cloud.appwrite.io/v1')
.setProject('YOUR_PROJECT_ID')
.setKey('YOUR_API_KEY'); // use a key with appropriate permissions
const databases = new Databases(client);
const collectionId = 'YOUR_TODOS_COLLECTION_ID';
// Subscribe to all changes in the collection
const subscription = databases.subscribe(
collectionId,
'', // empty query → all documents
(response) => {
const { event, payload } = response;
switch (event) {
case 'databases.collections.documents.create':
console.log('Todo added:', payload);
// e.g., add a new row to your UI list
break;
case 'databases.collections.documents.update':
console.log('Todo updated:', payload);
// e.g., replace the existing row with payload data
break;
case 'databases.collections.documents.delete':
console.log('Todo removed:', payload.$id);
// e.g., remove the row with this ID
break;
default:
// ignore other events
}
}
);
// To stop listening later (e.g., on page unload):
// subscription.unsubscribe();
The callback receives a response object where event identifies the mutation type and payload holds the document data (including $id and $createdAt). You can update your UI directly inside each case.
Worked example: a minimal collaborative todo list
Below is a self-contained snippet you can paste into a browser console (assuming you've loaded the Appwrite SDK via a <script> tag or bundler). It renders a simple list, lets you add items, and shows updates from other clients in real time.
// HTML placeholder (add to your page)
// <ul id="todo-list"></ul>
// <input id="new-todo" placeholder="What needs to be done?">
// <button id="add-btn">Add</button>
const listEl = document.getElementById('todo-list');
const inputEl = document.getElementById('new-todo');
const addBtn = document.getElementById('add-btn');
function renderTodo(doc) {
const li = document.createElement('li');
li.dataset.id = doc.$id;
li.textContent = doc.title;
if (doc.completed) li.style.textDecoration = 'line-through';
return li;
}
function renderList(docs) {
listEl.innerHTML = '';
docs.forEach(doc => listEl.appendChild(renderTodo(doc)));
}
// Fetch initial state (optional but helps avoid flash of empty list)
databases.listDocuments('YOUR_TODOS_COLLECTION_ID')
.then(res => renderList(res.documents))
.catch(console.error);
// Subscribe to live changes
const sub = databases.subscribe(
'YOUR_TODOS_COLLECTION_ID',
'',
(resp) => {
const { event, payload } = resp;
if (event.startsWith('databases.collections.documents.create')) {
listEl.appendChild(renderTodo(payload));
} else if (event.startsWith('databases.collections.documents.update')) {
const el = listEl.querySelector(`[data-id="${payload.$id}"]`);
if (el) {
el.textContent = payload.title;
if (payload.completed) el.style.textDecoration = 'line-through';
else el.style.textDecoration = 'none';
}
} else if (event.startsWith('databases.collections.documents.delete')) {
const el = listEl.querySelector(`[data-id="${payload.$id}"]`);
if (el) el.remove();
}
}
);
// Add a new todo
addBtn.onclick = async () => {
const title = inputEl.value.trim();
if (!title) return;
try {
await databases.createDocument(
'YOUR_TODOS_COLLECTION_ID',
'unique()', // let Appwrite generate an ID
{ title, completed: false }
);
inputEl.value = '';
} catch (e) {
console.error('Create failed', e);
}
};
// Optional: mark as complete on click
listEl.onclick = async (e) => {
if (e.target.tagName !== 'LI') return;
const id = e.target.dataset.id;
try {
await databases.updateDocument(
'YOUR_TODOS_COLLECTION_ID',
id,
{ completed: !e.target.style.textDecoration.includes('line-through') }
);
} catch (e) {
console.error('Update failed', e);
}
};
// Cleanup on page unload
window.addEventListener('beforeunload', () => sub.unsubscribe());
When you open this page in two browser tabs and type a new todo in one, the other tab instantly shows the new item—no refresh needed.
Trade-offs and practical limits
- Connection overhead: Each active subscription opens a WebSocket. If many users keep the page open, the Appwrite instance must sustain that many concurrent connections. On the free tier, monitor the
WebSocket Connectionsmetric in the console; consider consolidating subscriptions (e.g., one per page) or using server-to-server events for backend-only workflows. - Eventual consistency: The real-time layer strives for ordered delivery, but under heavy load a client might briefly see a stale state before the next update arrives. Design UI to be tolerant (e.g., show optimistic updates and reconcile on the next event).
- Security surface: Because any client with a valid API key can subscribe, enforce strict collection-level read rules. Avoid exposing wildcard keys to untrusted environments; instead, generate scoped keys per user or session with the
Accountservice. - Filtering: You can pass a query string to limit the stream (e.g.,
status=["active"]). This reduces payload size but still consumes a WebSocket connection per subscription.
Actionable checklist
- Verify your collection's read/write rules in the Appwrite console.
- Open the browser's Network tab, filter by WS, and confirm a single WebSocket connection appears when you load the page.
- Perform a create, update, and delete via the SDK or console and watch the events appear in your callback.
- Check the
WebSocket Connectionsgauge underMetrics → Usageto ensure you stay within your plan's limits. - If you expect >100 concurrent users, consider aggregating changes on a backend service and pushing a summarized feed to clients, or enable Appwrite's
Functionsto debounce updates.
By following these steps you get a low-latency, real-time collaborative experience without building your own WebSocket infrastructure—just keep an eye on connection counts and rule tightness to keep the system scalable and secure.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.