Your resource for web content, online publishing
and the distribution of digital products.
«  
  »
S M T W T F S
 
 
1
 
2
 
3
 
4
 
5
 
6
 
7
 
8
 
9
 
10
 
11
 
12
 
13
 
14
 
15
 
16
 
17
 
18
 
19
 
20
 
21
 
22
 
23
 
24
 
25
 
26
 
27
 
28
 
29
 
30
 
31
 
 
 

The HackerNoon Newsletter: Junior Cybersecurity Roles Are Vanishing—Blame Agentic AI (7/7/2025)

DATE POSTED:July 7, 2025

Traditional social platforms control your data, decide what you see, and can censor or make money from your content without asking. By switching to a browser-only P2P model, each user's browser acts as both client and server, allowing them to own their data and decide who to connect with. This removes central control points, giving users complete control over their posts, timelines, and privacy.

Difference between a centralized micro-blog (Twitter) and a browser-only P2P mesh

On Twitter(X), every tweet goes through and is stored on Twitter’s servers. You depend on their uptime, their rules, and their business model. In a browser-only P2P mesh, users find each other directly (with minimal signaling), share updates over WebRTC, and use a CRDT-based store to stay in sync. There’s no single authority, no server farm, and no central point of failure.

Prerequisites

Before starting with the code, make sure you have the latest version of Node.js (version 20 LTS or higher) installed, and choose a package manager like npm or pnpm. You also need a modern browser that supports WebRTC and ES modules. I recommend using the latest version of a Chromium-based browser (Chrome, Edge, or Brave) or Firefox. These browsers provide the necessary APIs for peer-to-peer connections, IndexedDB storage, and ES module imports without additional bundling.

On the conceptual side, you should be familiar with the basics of WebRTC handshakes. Understanding ICE candidates, STUN/TURN servers, and the SDP offer/answer exchange will be important when we set up peer communication. It will also help to know about CRDTs (Conflict-free Replicated Data Types) and how they manage updates in distributed systems. If you've used libraries like Yjs or Automerge, you'll recognize similar concepts in our timeline store: every peer eventually agrees on the same order of posts, even if they go offline or lose network connections.

If you are new to programming, I will guide you through the process. I will break down each code snippet in this guide so you can understand what we are doing.

Bootstrap the Project Folder

To begin, we'll set up a new Vite project that's ready for React and TypeScript. Vite is great because it provides almost instant hot-module reloads and supports ES modules right away, which is perfect for our browser-only P2P app.

First, run this in your terminal:

\

npx create-vite p2p-twitter --template react-ts

Here’s what happens behind the scenes:

  • npx create-vite starts Vite’s project setup without needing a global install.
  • p2p-twitter is used as both the folder name and the project's npm package name.
  • --template react-ts tells Vite to set up a React project with TypeScript, including tsconfig.json, React build settings, and type-safe JSX.

Once that command finishes, change into your new directory:

\

cd p2p-twitter

Inside, you’ll see the default Vite structure: a src folder with main.tsx and App.tsx, a public folder for static assets, and basic configuration files (package.json, tsconfig.json, vite.config.ts). Now, you can start the development server to make sure everything is working:

\

npm install # or `pnpm install` if you prefer npm run dev

Visit the URL shown in your terminal (usually http://localhost:5173) to see the Vite welcome screen. With the basic setup running smoothly, you're ready to add P2P signaling, WebRTC channels, and the other features of our serverless Twitter clone.

Add a Minimal Signalling Stub

Our peer-to-peer mesh needs a simple “lobby” to handle the initial handshake—exchanging session descriptions and ICE candidates—before browsers can communicate directly. Instead of using tiny-ws, we’ll build a minimal stub with the ws library. Once peers have each other’s connection info, all further data flows peer-to-peer over WebRTC.

1. Install the signalling library npm install ws

\

Tip: To use ESM import syntax in Node, make sure your package.json includes

\

"type": "module"

\ or rename your stub file to server.mjs.

\

2. Create the signalling server

Create a file called server.js (or server.mjs):

\

import { WebSocketServer } from 'ws'; const PORT = 3000; const wss = new WebSocketServer({ port: PORT }); console.log(`⮞ WebSocket signalling server running on ws://localhost:${PORT}`); wss.on('connection', (ws) => { console.log('⮞ New peer connected'); ws.on('message', (data) => { // Broadcast to all *other* clients for (const client of wss.clients) { if (client !== ws && client.readyState === WebSocketServer.OPEN) { client.send(data); } } console.log('⮞ Broadcasted message to peers:', data.toString()); }); ws.on('close', () => console.log('⮞ Peer disconnected')); });

This stub will:

  • Listen on port 3000 for incoming WebSocket connections.
  • When one client sends a message (an SDP offer/answer or ICE candidate), forward it to every other connected client.
  • Log connections, broadcasts, and disconnections to the console.
3. Add a convenience script

In your package.json, under "scripts", add:

{ "scripts": { "dev:signal": "node server.js", // …your existing scripts } } 4. Run your app + signalling stub
  1. Start the Vite React app (usually on port 5173):
npm run dev

\

  1. In a second terminal, start the signalling server:
npm run dev:signal

\

  1. Open two browser windows pointing at your React app. In each console you’ll see logs like:
⮞ New peer connected ⮞ Broadcasted message to peers: {"type":"offer","sdp":"…"}

\ Once both offer and answer messages appear, your peers have exchanged ICE and SDP, and can establish a direct WebRTC connection.

With just this tiny stub, you’ve replaced tiny-ws without adding any heavyweight dependencies or extra servers—just the essential broadcast logic to bootstrap your P2P Twitter clone.

Establish Browser-to-Browser WebRTC Channels

Browsers can't connect directly until they share enough information to find each other. That's where ICE (Interactive Connectivity Establishment) helps, it collects possible connection points (like your local IPs, your public IP through a STUN server, and any TURN relay if direct paths don't work). Once you have ICE candidates and a pair of SDP (Session Description Protocol) blobs, an offer from one peer and an answer from the other; a RTCPeerConnection connects everything.

In your React app, create a module (for example webrtc.ts) that exports a function to set up a peer connection:

\

// webrtc.ts export async function createPeerConnection( sendSignal: (msg: any) => void, onData: (data: any) => void ) { const config = { iceServers: [ { urls: 'stun:stun.l.google.com:19302' } ] }; const pc = new RTCPeerConnection(config); const channel = pc.createDataChannel('chat', { negotiated: true, id: 0, maxPacketLifeTime: 3000 }); channel.binaryType = 'arraybuffer'; channel.onmessage = ({ data }) => onData(data); pc.onicecandidate = ({ candidate }) => { if (candidate) sendSignal({ type: 'ice', candidate }); }; pc.ondatachannel = ({ channel: remote }) => { remote.binaryType = 'arraybuffer'; remote.onmessage = ({ data }) => onData(data); }; // Begin handshake const offer = await pc.createOffer(); await pc.setLocalDescription(offer); sendSignal({ type: 'offer', sdp: pc.localDescription }); return async function handleSignal(message: any) { if (message.type === 'offer') { await pc.setRemoteDescription(new RTCSessionDescription(message.sdp)); const answer = await pc.createAnswer(); await pc.setLocalDescription(answer); sendSignal({ type: 'answer', sdp: pc.localDescription }); } else if (message.type === 'answer') { await pc.setRemoteDescription(new RTCSessionDescription(message.sdp)); } else if (message.type === 'ice') { await pc.addIceCandidate(new RTCIceCandidate(message.candidate)); } }; }

Here’s what’s happening:

  1. The RTCPeerConnection is configured with a public STUN server so each browser can discover its public-facing address.
  2. We immediately open a data channel named “chat” with negotiated parameters (no out-of-band negotiation round) and allow up to 3 seconds of packet retransmission (maxPacketLifeTime). Setting binaryType = 'arraybuffer' ensures we can handle both text and binary blobs later.
  3. As ICE candidates are gathered, onicecandidate fires; we serialize each candidate through our signalling stub.
  4. If the remote peer creates a channel first, ondatachannel catches it so both sides can send and receive.
  5. We kick off negotiation by creating an SDP offer, sending it, then waiting in handleSignal to react to offer, answer, or ICE messages.

To test, connect this to your app’s console. In one tab, run:

\

window.signalHandler = await createPeerConnection(msg => ws.send(JSON.stringify(msg)), data => console.log('received', data));

…in another tab, do the same, but forward incoming WebSocket messages into window.signalHandler(JSON.parse(evt.data)). Once both peers have exchanged the offer, answer, and all ICE candidates, type into one console:

\

const buf = new TextEncoder().encode('ping'); channel.send(buf);

The other tab's console should log received Uint8Array([...]), confirming a direct browser-to-browser channel. From this point, every message, including our future CRDT updates, travels peer-to-peer without ever going through a backend server again.

Wire Up a CRDT Timeline Store

To keep every peer's timeline in sync, even when someone goes offline or multiple people post at once, we'll use Yjs, a reliable CRDT library, along with its y-webrtc adapter. Yjs ensures all updates merge without conflicts, while y-webrtc uses the same peer connection channels we've already opened.

First, install both packages in your project root:

\

npm install yjs y-webrtc

Here, yjs provides the core CRDT types and algorithms; y-webrtc plugs directly into WebRTC data channels so changes propagate instantly to every connected peer.

Next, create a new file (src/crdt.ts) to initialize and export your shared timeline:

\

// src/crdt.ts import * as Y from 'yjs' import { WebrtcProvider } from 'y-webrtc' // A Yjs document represents the shared CRDT state const doc = new Y.Doc() // Name “p2p-twitter” ensures all peers join the same room const provider = new WebrtcProvider('p2p-twitter', doc, { // optional: pass our own RTCPeerConnection instances if you’d like }) // Use a Y.Array to hold an ordered list of posts const posts = doc.getArray<{ id: string; text: string; ts: number }>('posts') // Whenever `posts` changes, fire a callback so the UI can re-render posts.observe(() => { // you’ll wire this to your React state later renderTimeline(posts.toArray()) }) export function addPost(text: string) { const entry = { id: crypto.randomUUID(), text, ts: Date.now() } // CRDT push: this update goes to every peer posts.push([entry]) } export function getPosts() { return posts.toArray() }

Here’s what happens above:

  • A Y.Doc holds all your CRDT types in one in-memory document.
  • WebrtcProvider joins the “p2p-twitter” room over WebRTC, relaying Yjs updates on the same peer channels you set up earlier.
  • doc.getArray('posts') creates (or returns) a shared array keyed by the string “posts.” Each element is an object containing a UUID, the tweet text, and a timestamp.
  • Calling addPost(...) pushes a new entry into the array locally and broadcasts the change to every connected peer.
  • Subscribing via posts.observe(...) lets you react to any remote or local update—perfect for calling your React state setter to refresh the UI.

To verify, run your Vite dev server (npm run dev) and open the app in two separate browser windows (or devices). In one window’s console, type:

\

import { addPost } from './src/crdt.js' addPost('Hello from Tab A!')

Almost instantly, in the other window, you should see your renderTimeline callback activate with the new post. This single line of code shows complete P2P, CRDT-backed replication with no server needed. From here, you can connect addPost to a form submit handler and call getPosts() to initialize your React state, providing every user with the same chronological feed of tweets.

Build the Tweet-Like UI

With our CRDT store set up, let's create a user-friendly interface for writing and viewing posts. We'll use Tailwind CSS to style quickly without making custom CSS.

First, install Tailwind and create its config files:

\

npx tailwindcss init -p

This creates tailwind.config.js and a PostCSS setup. In your tailwind.config.js, ensure the content array covers all your React files:

\

/** @type {import('tailwindcss').Config} */ export default { content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'], theme: { extend: {} }, plugins: [] }

Next, open src/index.css and replace its contents with Tailwind’s base imports:

\

@tailwind base; @tailwind components; @tailwind utilities;

Now every class like p-4, bg-gray-100, or rounded-xl is available.

Inside src/App.tsx, import your CRDT helpers and Tailwind styles:

\

import React, { useEffect, useState } from 'react' import './index.css' import { addPost, getPosts } from './crdt' export default function App() { const [timeline, setTimeline] = useState(getPosts()) const [draft, setDraft] = useState('') // Re-render on CRDT updates useEffect(() => { const handleUpdate = () => setTimeline(getPosts()) // assume posts.observe calls handleUpdate under the hood return () => {/* unsubscribe if you wire it up */} }, []) function submitPost(e: React.FormEvent) { e.preventDefault() if (!draft.trim()) return addPost(draft.trim()) setDraft('') } return (