Hello there! This article is adapted from my talk at MadVue 2026, the amazing Vue conference that took place in Madrid, Spain. Make sure to check out this page for the slides, all the articles and some pics!
This is the third and final article in a three-part series about adding real-time features to your Vue and Nuxt applications. In Part 1 we covered Server-Sent Events, and in Part 2 we covered WebSockets and real-time databases.
We ended Part 2 with a question: WebSockets are great, but you depend on a server. What if you don't want a server in the middle? What if you could connect directly to a peer?
Why Peer-to-Peer?
There are some very valid reasons to go for a peer-to-peer solution. Some of these might apply to you:
- Latency. Imagine you're close to your peer, but the server is far away from both of you. Instead of sending data to the server and waiting for the server to relay it to your peer, you exchange the data directly. That can be much quicker in many scenarios.
- Privacy. You share data directly with your peer, with no third party able to read or leak it... accidentally, of course.
- Cost. With no server in the middle, you have no bandwidth bill. This is great for sharing audio and video, at no extra cost.
WebRTC
We have a technology that ticks all those boxes: WebRTC (Web Real-Time Communication).
With WebRTC you can send data peer to peer, end to end encrypted by default. And you're not limited to text: you can also share audio and video, which makes it the ideal solution for video calls.
Now, if you've ever tried to implement WebRTC, you may have stumbled into a wall of lovely acronyms:
- SDP (Session Description Protocol)
- STUN (Session Traversal Utilities for NAT)
- TURN (Traversal Using Relays around NAT)
- ICE (Interactive Connectivity Establishment)
- And... trickle ICE?
If it feels overwhelming... I don't blame you. To be honest, WebRTC is awesome, but it's kind of tricky.
So I have two things for you in this article:
- A quick explanation of all the scary parts.
- A library that makes working with WebRTC way less complicated.
How Does WebRTC Work?
Let's start with the confusing part, and make it less confusing. By reviewing the flow, we'll understand what these acronyms mean.
Think of it this way. If you want to call me using WebRTC, what do you need? Well, you need to know at least two things:
- The codecs my browser supports, so we can share audio and video.
- And where to find me: a network address.
And vice versa, I need to know the same about you. Peers have to exchange that information in a standard, predefined way that browsers can understand.
SDP
For that, we use an SDP, a Session Description Protocol. It looks something like this:
# Metadata
v=0
o=- 461173140043 2 IN IP4 127.0.0.1
s=-
t=0 0
# Codecs
m=audio 9 UDP/TLS/RTP/SAVPF 111
a=rtpmap:111 opus/48000/2
m=video 9 UDP/TLS/RTP/SAVPF 96
a=rtpmap:96 VP8/90000
# ICE (Interactive Connectivity Establishment) candidates
a=candidate:1 1 udp 2130706431 abc123.local 54321 typ host
The SDP has a few parts:
- The first is just metadata, don't worry too much about that
- Then the codecs: these are the ones my browser supports
- Last but not least, the ICE candidates
ICE
ICE stands for Interactive Connectivity Establishment. These are all the possible network endpoints that can be used to connect to me. They are called candidates because the browsers pick the one that works best for both sides. For example, if two peers are on the same WiFi, they'll happily use their local IPs and skip the public internet entirely.
STUN
Now, the browser can list all your local addresses, but it usually doesn't know your public IP. By default, your local address is even masked behind a fake .local hostname (for privacy, so a random page can't fingerprint you by your network).
So how do you get your public IP? You ask someone outside. That's what a STUN (Session Traversal Utilities for NAT) server is for: you send it a packet, and it pongs back "this is the IP and port I see from the outside, that must be your public IP." That's it. Thanks to the STUN server, we can add a candidate with our public IP to the list of ICE candidates.
# STUN server (stun.l.google.com:19302) gives me my public IP
a=candidate:2 1 udp 1694498815 203.0.113.1 54321 typ srflx raddr 0.0.0.0 rport 0
Trickle ICE
Now, gathering all these candidates can take a few seconds. So you might resort to a technique known as Trickle ICE. You send the SDP first, and then send the candidates over as you discover them, over time, asynchronously.
In your SDP, you announce it like this:
a=ice-options:trickle
And then you send each candidate to the peer as soon as the browser hands it to you.
TURN
Sometimes peers can't reach each other directly because they're behind strict firewalls, symmetric NATs, or corporate networks. In those cases, you can decide to fall back to a TURN server, which actually forwards the data between peers.
It's slower, it costs bandwidth, and it kind of defeats the "no server in the middle" promise. But it's the safety net when a direct connection just isn't possible. It's totally optional, so we can avoid opting in if you don't need it.
The Signaling Server
Alright! How do peers actually share their SDP and ICE candidates? Well, WebRTC doesn't care. The spec leaves that completely up to you. Write it on a napkin, send a carrier pigeon, whatever works, as long as the data gets to the other peer.
The practical answer, of course, is a server.
Nico, you cheater! You told us we didn't need a server!
I hear you! You are right. But this server, which we call the signaling server, is only there so the peers can exchange connection data. Once they do, the server is out of the picture. It can go down, get unplugged, catch on fire. The peers keep talking.
Once the peers are connected, they can start sending each other text, audio, and video directly. No server in the middle!
Now, the Code!
Dealing with the native WebRTC API is, honestly, very tricky. Here's a taste of what setting up a connection looks like:
const SIGNAL_URL = '/api/signal'
const POLL_INTERVAL = 1000
const config = {
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' },
{ urls: 'turn:your.turn.server:3478', username: 'user', credential: 'pass' }
]
}
const peerId = crypto.randomUUID()
const roomId = new URLSearchParams(location.search).get('room') || 'default'
const isCaller = new URLSearchParams(location.search).get('role') === 'caller'
const pc = new RTCPeerConnection(config)
let channel = null
let remoteDescSet = false
const pendingCandidates = []
const seenMessages = new Set()
function wireChannel(dc) {
channel = dc
dc.onopen = () => {
console.log('Data channel open')
dc.send(`hello from ${peerId}`)
}
dc.onclose = () => console.log('Data channel closed')
dc.onerror = (e) => console.error('Data channel error:', e)
dc.onmessage = (event) => console.log('Received:', event.data)
}
if (isCaller) {
wireChannel(pc.createDataChannel('chat'))
} else {
pc.ondatachannel = (event) => wireChannel(event.channel)
}
pc.onicecandidate = (event) => {
if (event.candidate) {
sendSignal({
type: 'candidate',
candidate: event.candidate.toJSON()
})
}
}
// And many, many more lines of code...
In this snippet, we've only scratched the surface of the WebRTC API. To handle errors, edge cases and race conditions, you'd need to write a lot more.
However, my intention is not for you to remember this snippet, because, as promised, there's a much easier way.
PeerJS: WebRTC Without the Boilerplate
I promised you a better way to work with WebRTC, and here it is: PeerJS. It's a free, open-source library that takes all the complex orchestration off your shoulders.
You can install it like this:
npm install peerjs
... and be ready to use it.
Instead of worrying about SDPs, ICE candidates, and all that, you just need to pick an ID. That's the core concept of PeerJS: peers are identified by a unique ID.
To use an ID, you just instantiate a Peer object passing the ID of choice as a parameter:
const peer = new Peer('bob')
Keep in mind that the ID must be unique per server. As server, you can use the free cloud service PeerJS provides. But I strongly recommend self-hosting your own: the server is free, open source and easy to set up.
# Install Node.js (LTS) on Ubuntu
curl -fsSL https://deb.nodesource.com/setup_lts.x | sudo -E bash -
sudo apt-get install -y nodejs
# Install the PeerJS server globally
sudo npm install -g peer
# Run it on port 9000 under the /myapp path
peerjs --port 9000 --path /myapp
Then, on the client, point your Peer instance at it:
const peer = new Peer('bob', {
host: 'example.com',
port: 9000,
path: '/myapp',
secure: true,
})
Here we create our Peer object, passing an ID of our choice, in this case 'bob'. Then we connect to Alice, listen to her messages, and send her one:
import Peer from 'peerjs'
// Select your ID
const peer = new Peer('bob')
peer.on('open', () => {
// Connect to Alice
const conn = peer.connect('alice')
conn.on('open', () => {
// Listen to her messages
conn.on('data', (data) => console.log('Received', data))
// Send her a message
conn.send('Hello!')
})
})
It's super easy: get your ID, connect to your peer, start sending and receiving data. You can have your own peer-to-peer, end-to-end encrypted chat working in almost no time.
Sending Audio and Video
The data channel is cool, but remember, we can also make audio and video calls. With PeerJS, sharing a video stream is just as approachable:
<script setup>
import Peer from 'peerjs'
const localVideo = useTemplateRef('local')
const remoteVideo = useTemplateRef('remote')
onMounted(async () => {
const stream = await navigator.mediaDevices.getUserMedia({
video: true,
audio: true
})
localVideo.value.muted = true
localVideo.value.srcObject = stream
new Peer('bob').on('call', c => {
c.answer(stream)
c.on('stream', s => remoteVideo.value.srcObject = s)
})
})
</script>
<template>
<video ref="local" autoplay playsinline />
<video ref="remote" autoplay playsinline />
</template>
You can share your webcam, your screen, whatever. Compared to the native API, it's a breeze.
Wrapping Up the Series
And that's a wrap! Across these three articles, we went from the bad old days of polling all the way to peer-to-peer video calls:
- Part 1: Polling and Server-Sent Events, for when the server needs to push to the client.
- Part 2: WebSockets and real-time databases, for two-way communication.
- Part 3 (this one): WebRTC and PeerJS, for direct, server-less, end-to-end encrypted connections.
My advice? Pick the simplest tool that solves your problem. Need notifications or a live dashboard? SSE. Need a chat or collaborative drawing? WebSockets. Need direct audio and video, or you really don't want a server in the middle? WebRTC with PeerJS.
Did you enjoy the series? Let me know!
See you next time!



