Code Sharing and Real-Time Collaboration
How to implement code sharing via URLs and real-time collaborative editing in a browser-based code playground, covering state serialization, operational transformation, and presence indicators.
A code playground becomes exponentially more useful when code can be shared with a link and edited collaboratively in real time. Sharing transforms a personal scratchpad into a communication tool: a developer can demonstrate a bug, a teacher can distribute an exercise, or a team can brainstorm a solution together. Real-time collaboration adds another dimension, allowing multiple cursors to edit the same document simultaneously. This article covers how Kodepad implements both features, from the compact URL encoding scheme for sharing to the conflict resolution strategy for collaborative editing.
Shareable URLs: Encoding State Compactly
The simplest approach to code sharing is storing the code on a server and generating a unique URL. But this introduces server costs, storage management, and latency. For small to medium code snippets, encoding the state directly in the URL is more elegant and requires no backend infrastructure.
Kodepad encodes the editor state, including HTML, CSS, and TypeScript content, into a compressed URL hash:
// src/sharing/url-encoder.ts
import { compressToEncodedURIComponent, decompressFromEncodedURIComponent } from "lz-string";
interface ShareableState {
html: string;
css: string;
typescript: string;
settings?: {
target?: string;
strict?: boolean;
};
}
function encodeToUrl(state: ShareableState): string {
const payload = JSON.stringify(state);
const compressed = compressToEncodedURIComponent(payload);
return `${window.location.origin}/#code/${compressed}`;
}
function decodeFromUrl(url: string): ShareableState | null {
const hashPrefix = "#code/";
const hash = new URL(url).hash;
if (!hash.startsWith(hashPrefix)) return null;
const compressed = hash.slice(hashPrefix.length);
try {
const payload = decompressFromEncodedURIComponent(compressed);
if (!payload) return null;
return JSON.parse(payload) as ShareableState;
} catch {
return null;
}
}
// Usage
function shareCurrentState(state: ShareableState): void {
const url = encodeToUrl(state);
window.history.replaceState(null, "", url);
navigator.clipboard.writeText(url).then(() => {
showToast("Link copied to clipboard");
});
}LZ-String provides surprisingly good compression for code, typically achieving 60-70% size reduction. A 2KB TypeScript snippet compresses to roughly 600-800 characters in the URL, well within the safe limits for URLs in modern browsers and sharing platforms. For larger code, Kodepad falls back to server-side storage with a short ID.
Server-Side Storage for Larger Projects
When compressed code exceeds URL length limits, Kodepad stores the state on the server and generates a short ID:
// src/sharing/server-storage.ts
interface StoredPad {
id: string;
html: string;
css: string;
typescript: string;
settings: Record<string, unknown>;
createdAt: string;
expiresAt: string;
}
class PadStorage {
private apiBase: string;
constructor(apiBase: string) {
this.apiBase = apiBase;
}
async save(state: ShareableState): Promise<string> {
const response = await fetch(`${this.apiBase}/pads`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
html: state.html,
css: state.css,
typescript: state.typescript,
settings: state.settings ?? {},
}),
});
if (!response.ok) {
throw new Error(`Failed to save pad: ${response.status}`);
}
const { id } = await response.json();
return `${window.location.origin}/p/${id}`;
}
async load(id: string): Promise<ShareableState | null> {
const response = await fetch(`${this.apiBase}/pads/${id}`);
if (response.status === 404) return null;
if (!response.ok) throw new Error(`Failed to load pad: ${response.status}`);
const pad: StoredPad = await response.json();
return {
html: pad.html,
css: pad.css,
typescript: pad.typescript,
settings: pad.settings,
};
}
}
// Decision logic: URL encoding vs server storage
const URL_SIZE_THRESHOLD = 4000; // characters
async function share(
state: ShareableState,
storage: PadStorage
): Promise<string> {
const urlEncoded = encodeToUrl(state);
if (urlEncoded.length <= URL_SIZE_THRESHOLD) {
return urlEncoded;
}
return storage.save(state);
}Server-stored pads have an expiration date. Anonymous pads expire after 30 days. Authenticated users get permanent storage. This keeps storage costs manageable while providing a reliable sharing experience.
Real-Time Collaboration with CRDTs
Real-time collaborative editing is one of the hardest problems in distributed systems. Two users editing the same document simultaneously can produce conflicting changes that must be resolved deterministically. Kodepad uses Conflict-free Replicated Data Types (CRDTs) via the Yjs library, which provides eventual consistency without a central authority:
// src/collaboration/collab-session.ts
import * as Y from "yjs";
import { WebsocketProvider } from "y-websocket";
import { MonacoBinding } from "y-monaco";
interface CollabSession {
doc: Y.Doc;
provider: WebsocketProvider;
bindings: MonacoBinding[];
destroy: () => void;
}
function startCollabSession(
roomId: string,
editors: Map<string, monaco.editor.IStandaloneCodeEditor>,
wsUrl: string
): CollabSession {
const doc = new Y.Doc();
// Connect to the collaboration server
const provider = new WebsocketProvider(wsUrl, roomId, doc, {
connect: true,
maxBackoffTime: 10000,
});
// Set local user awareness (cursor color, name)
provider.awareness.setLocalStateField("user", {
name: generateAnonymousName(),
color: generateUserColor(),
});
// Bind each editor tab to a shared text type
const bindings: MonacoBinding[] = [];
for (const [filename, editor] of editors) {
const ytext = doc.getText(filename);
const model = editor.getModel();
if (model) {
const binding = new MonacoBinding(
ytext,
model,
new Set([editor]),
provider.awareness
);
bindings.push(binding);
}
}
return {
doc,
provider,
bindings,
destroy: () => {
bindings.forEach((b) => b.destroy());
provider.destroy();
doc.destroy();
},
};
}Yjs represents text as a sequence of items, each with a unique ID derived from the originating client and a logical clock. When two users insert text at the same position, the CRDT's ordering rules deterministically decide which insertion comes first. This happens without coordination, meaning the system works even when the network is temporarily unavailable. Changes are buffered locally and synchronized when the connection is restored.
Presence and Cursor Indicators
Collaborative editing without presence indicators is disorienting. Users need to see where others are working. Kodepad renders remote cursors and selections as colored decorations in the editor:
// src/collaboration/presence-renderer.ts
interface RemoteUser {
clientId: number;
name: string;
color: string;
cursor: { line: number; column: number } | null;
selection: { start: Position; end: Position } | null;
}
class PresenceRenderer {
private editor: monaco.editor.IStandaloneCodeEditor;
private decorations: Map<number, string[]> = new Map();
constructor(editor: monaco.editor.IStandaloneCodeEditor) {
this.editor = editor;
}
updatePresence(users: RemoteUser[]): void {
for (const user of users) {
const newDecorations: monaco.editor.IModelDeltaDecoration[] = [];
if (user.cursor) {
// Cursor line decoration
newDecorations.push({
range: new monaco.Range(
user.cursor.line,
user.cursor.column,
user.cursor.line,
user.cursor.column + 1
),
options: {
className: `remote-cursor-${user.clientId}`,
beforeContentClassName: `remote-cursor-widget`,
hoverMessage: { value: user.name },
},
});
// Name label above the cursor
newDecorations.push({
range: new monaco.Range(
user.cursor.line,
user.cursor.column,
user.cursor.line,
user.cursor.column
),
options: {
before: {
content: user.name,
inlineClassName: `remote-cursor-label`,
inlineClassNameAffectsLetterSpacing: true,
},
},
});
}
if (user.selection) {
newDecorations.push({
range: new monaco.Range(
user.selection.start.line,
user.selection.start.column,
user.selection.end.line,
user.selection.end.column
),
options: {
className: `remote-selection`,
stickiness:
monaco.editor.TrackedRangeStickiness
.NeverGrowsWhenTypingAtEdges,
},
});
}
// Apply decorations, replacing any previous ones for this user
const oldDecorations = this.decorations.get(user.clientId) ?? [];
const applied = this.editor.deltaDecorations(
oldDecorations,
newDecorations
);
this.decorations.set(user.clientId, applied);
// Inject dynamic CSS for user-specific colors
this.injectUserStyles(user);
}
}
private injectUserStyles(user: RemoteUser): void {
const styleId = `remote-cursor-style-${user.clientId}`;
let style = document.getElementById(styleId) as HTMLStyleElement | null;
if (!style) {
style = document.createElement("style");
style.id = styleId;
document.head.appendChild(style);
}
style.textContent = `
.remote-cursor-${user.clientId} {
border-left: 2px solid ${user.color};
}
.remote-selection {
background-color: ${user.color}33;
}
.remote-cursor-label {
background-color: ${user.color};
color: white;
font-size: 11px;
padding: 1px 4px;
border-radius: 2px;
position: relative;
top: -1.2em;
}
`;
}
removeUser(clientId: number): void {
const decorations = this.decorations.get(clientId) ?? [];
this.editor.deltaDecorations(decorations, []);
this.decorations.delete(clientId);
const style = document.getElementById(`remote-cursor-style-${clientId}`);
style?.remove();
}
}Connection Resilience
Network connections drop. The collaboration system must handle disconnections gracefully, buffering local changes and replaying them when the connection is restored:
class ResilientConnection {
private provider: WebsocketProvider;
private statusListeners: Set<(status: ConnectionStatus) => void> = new Set();
constructor(provider: WebsocketProvider) {
this.provider = provider;
provider.on("status", (event: { status: string }) => {
const status = this.mapStatus(event.status);
this.statusListeners.forEach((fn) => fn(status));
});
provider.on("sync", (synced: boolean) => {
if (synced) {
this.statusListeners.forEach((fn) => fn("synced"));
}
});
}
private mapStatus(
wsStatus: string
): ConnectionStatus {
switch (wsStatus) {
case "connected": return "connected";
case "connecting": return "reconnecting";
case "disconnected": return "disconnected";
default: return "disconnected";
}
}
onStatusChange(listener: (status: ConnectionStatus) => void): () => void {
this.statusListeners.add(listener);
return () => this.statusListeners.delete(listener);
}
}
type ConnectionStatus = "connected" | "reconnecting" | "disconnected" | "synced";Yjs handles the hard part of offline buffering and reconciliation. Our responsibility is communicating the connection state to the user so they know whether their changes are being shared. A subtle status indicator in the toolbar shows green for connected, yellow for reconnecting, and red for disconnected.
Conclusion
Code sharing and real-time collaboration transform a code playground from a personal tool into a social platform for developers. URL-encoded sharing provides zero-latency, zero-cost distribution for small snippets. Server-side storage extends this to larger projects. CRDT-based collaborative editing enables multiple users to work on the same code simultaneously without conflicts.
The technical foundations, LZ-String compression, Yjs CRDTs, WebSocket providers, and Monaco bindings, are mature and well-documented. The engineering challenge lies in the integration: making sharing feel instant, making collaboration feel natural, and handling the inevitable network disruptions gracefully. Kodepad's approach prioritizes simplicity at each layer, using URL encoding before server storage, CRDTs before operational transformation, and clear status indicators to keep users informed. These choices result in a collaboration experience that feels lightweight and reliable.
Related Articles
Rapid Prototyping: From Idea to Demo in Minutes
How browser-based code playgrounds enable rapid prototyping workflows that compress the journey from concept to working demo, and why this capability is a competitive advantage for engineering teams.
Code Playgrounds in Developer Education
How code playgrounds are transforming developer education by providing immediate feedback, reducing setup barriers, and enabling interactive learning experiences that scale.
Developer Tools That Boost Productivity
An exploration of how browser-based developer tools like code playgrounds reduce friction in the development workflow, accelerating everything from prototyping to debugging to knowledge sharing.