22 September 2026, 5 min read
How I Built FaceTube: Browser Video Calls, Twice
FaceTube is a browser video chat with no accounts: pick a name, create or join a room, share the ID. I built the call layer two ways, once by hand with WebRTC and a Socket.IO signalling server, once on a video SDK. Here is what each one taught me.
By Tanbir Hossain Ramim. Project page: FaceTube. Source: github.com/TanbirRamim/FaceTube.
FaceTube is a video chat that runs in the browser with no account. You type a name, create or join a room, share the room ID, and you are talking. It is a React app built with Vite, and the repository contains two different implementations of the call itself, which is the most interesting thing about it.
The first is a hand-rolled peer to peer call: WebRTC through simple-peer, with a tiny Socket.IO server doing the signalling. The second is what the routed app actually uses: rooms built on the ZEGOCLOUD UIKit, with a small Express endpoint for tokens. I want to go through both, because building the first one is what made me understand what the second one was doing for me.
The shape of the app
Routing is two pages:
<Routes>
<Route path="/" element={<Home />} />
<Route path="/room/:roomId" element={<Room />} />
</Routes>
The home page collects a name and a room ID, and a toggle decides whether you are creating or joining. Joining is just a navigation:
navigate(`/room/${roomId}?username=${encodeURIComponent(username)}&host=${isCreating}`)
Everything a room needs is in the URL. There is no database and no session, which is exactly what "no account required" should mean. The trade-off is that anyone with the link can open the room, which is fine for quick calls between people who already trust each other.
Version one: WebRTC by hand
WebRTC connects two browsers directly, but they cannot find each other on their own. Before any video flows, each side has to send the other an offer or answer describing its connection. That exchange is called signalling, and WebRTC deliberately leaves it to you.
My signalling server is about thirty lines of Express and Socket.IO. The core is three events:
io.on('connection', (socket) => {
socket.emit('me', socket.id)
socket.on('disconnect', () => {
socket.broadcast.emit('callEnded')
})
socket.on('callUser', ({ userToCall, signalData, from, name }) => {
io.to(userToCall).emit('callUser', { signal: signalData, from, name })
})
socket.on('answerCall', ({ to, signal }) => {
io.to(to).emit('callAccepted', signal)
})
})
The server never touches media. It hands each client its socket ID as a phone number (me), and relays opaque signal blobs from caller to callee and back. Once both sides have the other's description, the video goes browser to browser.
On the client, calling someone means creating a Peer as the initiator and forwarding its signal through the socket:
const callUser = (id) => {
const peer = new Peer({ initiator: true, trickle: false, stream })
peer.on('signal', (data) => {
socket.current.emit('callUser', {
userToCall: id,
signalData: data,
from: me,
name
})
})
peer.on('stream', (currentStream) => {
userVideo.current.srcObject = currentStream
})
socket.current.on('callAccepted', (signal) => {
setCallAccepted(true)
peer.signal(signal)
})
connectionRef.current = peer
}
Answering is the mirror image: a Peer with initiator: false, peer.signal(call.signal) with the caller's offer, and the answer goes back through answerCall.
I set trickle: false on both sides. With trickle ICE, network candidates are sent one by one as they are discovered, which connects faster but means more signalling messages to route. With it off, simple-peer waits and sends one complete description. For a first version with a relay this small, one message each way was much easier to reason about and to debug.
Writing this taught me the pieces that a video SDK usually hides: getting the local stream with getUserMedia({ video: true, audio: true }), muting your own <video> element so you do not hear yourself, attaching the remote stream to a second element, and cleaning up with peer.destroy() when the call ends.
It also showed me the limits quickly. This version is strictly one to one, you call someone by their raw socket ID, and there is no TURN server, so two people behind strict NATs may never connect. The disconnect handler broadcasts callEnded to every connected client, not just the other person in the call. Each of those is fixable, and fixing all of them properly is most of what a video platform is.
Version two: rooms on an SDK
For the version people actually use, I moved the call to the ZEGOCLOUD UIKit. The whole room page is a function that mounts the prebuilt UI into a container:
const zp = ZegoUIKitPrebuilt.create(kitToken)
zp.joinRoom({
container: element,
scenario: {
mode: ZegoUIKitPrebuilt.OneONoneCall,
config: {
role: isHost ? ZegoUIKitPrebuilt.Host : ZegoUIKitPrebuilt.Audience,
},
},
showPreJoinView: true,
showScreenSharingButton: true,
showUserList: true,
showRoomDetailsButton: true,
})
The host flag from the URL becomes a role, and the pre-join view gives people a chance to check their camera before entering. Screen sharing, the user list and in-call messaging come with the kit, and those were exactly the features that would have taken weeks on top of version one.
Because the container is passed as a callback ref (ref={myMeeting}), the SDK mounts as soon as React has the DOM node, without a separate effect.
Keeping the secret on the server
Joining a room needs a signed token. The SDK has a helper that builds one in the browser, which is convenient while prototyping but means the signing secret has to be in the client bundle. So the repository also has a small Express server that generates the token instead:
function generateToken04(appId, serverSecret, userId, roomId, effectiveTimeInSeconds) {
const timestamp = Math.floor(Date.now() / 1000);
const nonce = Math.floor(Math.random() * 2147483647);
const payload = {
app_id: appId,
user_id: userId,
room_id: roomId,
nonce,
ctime: timestamp,
expire: timestamp + effectiveTimeInSeconds
};
const message = JSON.stringify(payload);
const hmac = crypto.createHmac('sha256', serverSecret);
const signature = hmac.update(message).digest('base64');
return `04${Buffer.from(message).toString('base64')}${signature}`;
}
The app ID and secret come from environment variables, the token is bound to one user and one room, and it expires after an hour (3600 seconds). The client posts { roomId, userId } to /api/generate-token and gets back something that is only useful for that room, for that hour.
The room page in the repository still uses the in-browser test helper. Switching it over to this endpoint is the next change, and it is the one I would make before anything else.
What I took from it
Building the call by hand first was the right order. Once I had routed an offer and an answer through my own socket server, the SDK stopped being a black box: I knew which parts were signalling, which were media and which were UI, and what I was paying for with each abstraction.
If I picked FaceTube up again, I would keep the SDK for the product and keep the hand-rolled version as the place to learn, add a TURN server and room-scoped events to it, and move token signing fully to the server.
The code for both versions is on GitHub, and there is a summary on the FaceTube project page.