Choose Theme

Real-Time Collaboration Without the Framework Bloat

--

Real-time collaboration is everywhere now. Google Docs cursors. Figma presence. Notion comments. But the implementation examples you find online usually involve heavyweight frameworks like Y.js, Socket.io clusters, or expensive services like Ably.

What if you just want simple presence indicators and don’t need operational transformation? You can build it with surprisingly little code.

What We’re Building

This site has three real-time features:

  1. Live cursors - See other people’s mouse movements
  2. Live chat - Real-time messaging on any page
  3. Live comments - Inline discussions on blog posts

All of this runs on:

  • Supabase Realtime - WebSocket connection to Postgres
  • Vanilla JavaScript - No React, no heavy libraries
  • Astro Islands - Minimal hydration

Total JavaScript bundle for all three features? ~8KB gzipped.

Let’s break down how it works.

The Foundation: Supabase Realtime

Supabase Realtime turns your Postgres database into a WebSocket server. Any change to a table broadcasts to subscribed clients.

Database Setup

-- Users currently on the page
CREATE TABLE realtime_presence (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
page_url TEXT NOT NULL,
user_id TEXT NOT NULL,
username TEXT NOT NULL,
cursor_x INTEGER,
cursor_y INTEGER,
last_seen TIMESTAMP DEFAULT NOW(),
UNIQUE(page_url, user_id)
);
-- Real-time chat messages
CREATE TABLE realtime_chat (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
page_url TEXT NOT NULL,
user_id TEXT NOT NULL,
username TEXT NOT NULL,
message TEXT NOT NULL,
created_at TIMESTAMP DEFAULT NOW()
);
-- Enable realtime
ALTER PUBLICATION supabase_realtime ADD TABLE realtime_presence;
ALTER PUBLICATION supabase_realtime ADD TABLE realtime_chat;
💡
Tip

Keep realtime tables simple. Don’t put your entire app state here—just the ephemeral stuff that needs to broadcast.

Row Level Security

Open realtime tables to everyone (read-only):

-- Anyone can read presence
CREATE POLICY "Public read presence"
ON realtime_presence FOR SELECT
USING (true);
-- Users can update their own presence
CREATE POLICY "Users update own presence"
ON realtime_presence FOR UPDATE
USING (user_id = current_setting('request.jwt.claims')::json->>'sub');
-- Anyone can read chat
CREATE POLICY "Public read chat"
ON realtime_chat FOR SELECT
USING (true);
-- Anyone can insert chat (with rate limiting elsewhere)
CREATE POLICY "Public insert chat"
ON realtime_chat FOR INSERT
WITH CHECK (true);
⚠️

This allows anyone to read realtime data. That’s fine for public collaboration, but add authentication for private pages.

Feature 1: Live Cursors

The magic of seeing other people’s mice moving around.

Client-Side Setup

import { createClient } from '@supabase/supabase-js';
const supabase = createClient(
import.meta.env.PUBLIC_SUPABASE_URL,
import.meta.env.PUBLIC_SUPABASE_ANON_KEY
);
// Generate anonymous user ID (persists in localStorage)
function getUserId() {
let id = localStorage.getItem('user_id');
if (!id) {
id = crypto.randomUUID();
localStorage.setItem('user_id', id);
}
return id;
}
// Get username (or generate random one)
function getUsername() {
let name = localStorage.getItem('username');
if (!name) {
name = `User${Math.floor(Math.random() * 10000)}`;
localStorage.setItem('username', name);
}
return name;
}

Tracking Cursor Position

const userId = getUserId();
const username = getUsername();
const pageUrl = window.location.pathname;
// Update cursor position (throttled to ~30fps)
let lastUpdate = 0;
document.addEventListener('mousemove', (e) => {
const now = Date.now();
if (now - lastUpdate < 33) return; // ~30fps max
lastUpdate = now;
supabase
.from('realtime_presence')
.upsert({
user_id: userId,
username: username,
page_url: pageUrl,
cursor_x: e.clientX + window.scrollX,
cursor_y: e.clientY + window.scrollY,
last_seen: new Date().toISOString()
}, { onConflict: 'page_url,user_id' });
});

Displaying Other Cursors

const cursors = new Map();
// Subscribe to presence changes
const channel = supabase
.channel(`presence:${pageUrl}`)
.on('postgres_changes',
{
event: '*',
schema: 'public',
table: 'realtime_presence',
filter: `page_url=eq.${pageUrl}`
},
(payload) => {
const data = payload.new;
// Ignore own cursor
if (data.user_id === userId) return;
// Create or update cursor element
let cursor = cursors.get(data.user_id);
if (!cursor) {
cursor = document.createElement('div');
cursor.className = 'remote-cursor';
cursor.innerHTML = `
<svg><!-- cursor SVG --></svg>
<span>${data.username}</span>
`;
document.body.appendChild(cursor);
cursors.set(data.user_id, cursor);
}
// Update position
cursor.style.left = `${data.cursor_x}px`;
cursor.style.top = `${data.cursor_y}px`;
}
)
.subscribe();

Cleanup Stale Cursors

Users closing tabs leave ghosts. Clean them up:

-- PostgreSQL function (run every 10 seconds)
CREATE OR REPLACE FUNCTION cleanup_stale_presence()
RETURNS void AS $$
BEGIN
DELETE FROM realtime_presence
WHERE last_seen < NOW() - INTERVAL '10 seconds';
END;
$$ LANGUAGE plpgsql;
-- Use pg_cron or call from your backend periodically
SELECT cron.schedule('cleanup-presence', '*/10 * * * * *', 'SELECT cleanup_stale_presence()');
ℹ️
Note

Alternative: Track cursors in client-side and remove on heartbeat timeout. Cleaner than database polling.

Feature 2: Live Chat

Simple real-time messaging.

Sending Messages

async function sendMessage(message: string) {
const { error } = await supabase
.from('realtime_chat')
.insert({
page_url: pageUrl,
user_id: userId,
username: username,
message: message
});
if (error) console.error('Failed to send message:', error);
}

Receiving Messages

const channel = supabase
.channel(`chat:${pageUrl}`)
.on('postgres_changes',
{
event: 'INSERT',
schema: 'public',
table: 'realtime_chat',
filter: `page_url=eq.${pageUrl}`
},
(payload) => {
const msg = payload.new;
addMessageToUI(msg.username, msg.message, msg.created_at);
}
)
.subscribe();
function addMessageToUI(username, message, timestamp) {
const chatBox = document.getElementById('chat-messages');
const msgEl = document.createElement('div');
msgEl.innerHTML = `
<div class="message">
<span class="username">${username}</span>
<span class="timestamp">${formatTime(timestamp)}</span>
<p>${escapeHtml(message)}</p>
</div>
`;
chatBox.appendChild(msgEl);
chatBox.scrollTop = chatBox.scrollHeight;
}

Always escape user input! Use a library like DOMPurify or implement HTML escaping.

Feature 3: Comment Threads

More complex—comments linked to specific post sections.

Data Model

CREATE TABLE post_comments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
post_slug TEXT NOT NULL,
section_id TEXT NOT NULL, -- heading ID the comment belongs to
user_id TEXT NOT NULL,
username TEXT NOT NULL,
comment TEXT NOT NULL,
parent_id UUID REFERENCES post_comments(id), -- for threading
created_at TIMESTAMP DEFAULT NOW()
);
ALTER PUBLICATION supabase_realtime ADD TABLE post_comments;

Anchoring Comments to Headings

// Add comment button to each H2/H3 heading
document.querySelectorAll('h2, h3').forEach(heading => {
const id = heading.id;
const btn = document.createElement('button');
btn.className = 'comment-btn';
btn.textContent = '💬';
btn.onclick = () => openCommentBox(id);
heading.appendChild(btn);
});
function openCommentBox(sectionId: string) {
// Show comment input UI
const commentBox = document.createElement('div');
commentBox.innerHTML = `
<textarea placeholder="Add a comment..."></textarea>
<button onclick="postComment('${sectionId}', this.previousElementSibling.value)">
Post
</button>
`;
document.getElementById(sectionId).after(commentBox);
}

Posting Comments

async function postComment(sectionId: string, text: string) {
const { error } = await supabase
.from('post_comments')
.insert({
post_slug: window.location.pathname,
section_id: sectionId,
user_id: userId,
username: username,
comment: text
});
if (error) console.error(error);
}

Subscribing to New Comments

const channel = supabase
.channel(`comments:${postSlug}`)
.on('postgres_changes',
{
event: 'INSERT',
schema: 'public',
table: 'post_comments',
filter: `post_slug=eq.${postSlug}`
},
(payload) => {
const comment = payload.new;
renderComment(comment);
}
)
.subscribe();
function renderComment(comment) {
const section = document.getElementById(comment.section_id);
const commentEl = document.createElement('div');
commentEl.className = 'comment';
commentEl.innerHTML = `
<strong>${comment.username}</strong>
<p>${escapeHtml(comment.comment)}</p>
<small>${formatTime(comment.created_at)}</small>
`;
section.after(commentEl);
}

Performance Considerations

Bundle Size

Our entire realtime system:

  • Supabase JS client: 4.2KB gzipped
  • Custom code: 3.8KB gzipped
  • Total: 8KB

Compare to alternatives:

  • Socket.io client: ~10KB
  • Y.js + bindings: ~30-50KB
  • Ably client: ~50KB

Database Load

Realtime uses Postgres LISTEN/NOTIFY. It’s efficient, but:

  • Throttle updates: Don’t send cursor positions at 144fps
  • Debounce typing: Wait for pause before broadcasting
  • Limit channel subscriptions: Unsubscribe when leaving page

Network Usage

Each cursor update is ~100 bytes. At 30fps with 10 users:

  • 30 updates/sec × 10 users × 100 bytes = 30KB/sec
  • ~1.8MB/min per user

That’s acceptable. More users? Consider:

  • Spatial partitioning (only show nearby cursors)
  • Reduce update frequency
  • Use WebRTC for peer-to-peer (more complex)

Security and Abuse

Rate Limiting

-- Supabase Edge Function for rate limiting
CREATE OR REPLACE FUNCTION check_rate_limit(
user_id TEXT,
action TEXT,
max_per_minute INTEGER
)
RETURNS BOOLEAN AS $$
DECLARE
count INTEGER;
BEGIN
SELECT COUNT(*)
INTO count
FROM rate_limits
WHERE user_id = $1
AND action = $2
AND created_at > NOW() - INTERVAL '1 minute';
RETURN count < max_per_minute;
END;
$$ LANGUAGE plpgsql;

Use this before allowing chat messages or comments.

Moderation

Store all messages for moderation:

-- Add flagged column
ALTER TABLE realtime_chat ADD COLUMN flagged BOOLEAN DEFAULT false;
-- Don't broadcast flagged messages
CREATE POLICY "Hide flagged messages"
ON realtime_chat FOR SELECT
USING (flagged = false);

Spam Prevention

  • Require username: No anonymous posts
  • Profanity filter: Basic client-side filter
  • Report button: Let users flag abuse
  • Admin dashboard: Review flagged content
Important

Real-time features attract trolls. Plan for moderation from day one.

Astro Integration

Bundle this as an Astro Island for minimal JS:

components/RealtimePresence.astro
---
const pageUrl = Astro.url.pathname;
---
<div id="cursors-container"></div>
<div id="chat-widget"></div>
<script>
// All the realtime code from above
import { createClient } from '@supabase/supabase-js';
// ... rest of implementation
</script>
<style>
.remote-cursor {
position: absolute;
pointer-events: none;
transition: all 0.1s ease-out;
z-index: 9999;
}
</style>

Then use it on any page:

---
import RealtimePresence from '../components/RealtimePresence.astro';
---
<Layout>
<h1>My Page</h1>
<p>Content here...</p>
<RealtimePresence client:load />
</Layout>

Alternatives Considered

Why Not Y.js?

Y.js is incredible for collaborative editing (rich text, concurrent edits, conflict resolution). But:

  • Much larger bundle size
  • Complex setup
  • Overkill for simple presence/chat

Use Y.js when you need Google Docs-level collaboration. For cursors and chat, it’s too much.

Why Not Socket.io?

Socket.io is great, but:

  • Requires backend server
  • More infrastructure to manage
  • Doesn’t integrate with database

Supabase Realtime gives you both WebSocket AND database persistence.

Why Not Ably/Pusher?

Paid services are excellent but expensive at scale:

  • Ably: $29/month for 3M messages
  • Pusher: $49/month for 200 connections
  • Supabase: $0 (included in free tier up to 500K realtime messages/month)

Conclusion

You don’t need a heavyweight framework for real-time features. With Supabase Realtime and vanilla JavaScript, you can build:

  • Live cursors
  • Real-time chat
  • Collaborative comments
  • Presence indicators
  • Live notifications

All in under 10KB of JavaScript.

The key insights:

  1. Use the database as message bus - Postgres LISTEN/NOTIFY is underrated
  2. Throttle everything - Don’t broadcast every mousemove
  3. Keep it simple - Vanilla JS is often enough
  4. Clean up stale data - Users close tabs, remove ghosts
  5. Plan for abuse - Rate limiting and moderation from day one

Realtime doesn’t have to be complex. Start simple, scale when needed.

💡
Tip

Check out the Supabase Realtime docs for more advanced patterns like Broadcast, Presence API, and Postgres Changes.

Related