doodle-rtc
v0.1.0
Published
Tiny copy-and-paste signaling for browser-to-browser WebRTC data channels
Maintainers
Readme
doodle-rtc - Microscopic Browser P2P Data Channels
WebRTC has made it possible for web browsers to connect directly to each other for a very long time. Once the connection is established, two browsers can exchange arbitrary data (files, voice, video) over a WebRTC data channel much like two desktop applications talking over a TCP or UDP socket. The hard part is getting the connection established in the first place. A simplified WebRTC handshake looks something like this:
- Alice generates an SDP "offer." This is a fairly large blob of text describing how Bob might be able to reach her, including ICE candidates, encryption parameters and other connection metadata.
- Alice has to put that offer somewhere Bob can retrieve it.
- Bob receives the offer, feeds it into WebRTC and generates an SDP "answer."
- Bob has to get that answer back to Alice.
- Alice and Bob establish a direct WebRTC connection. At this point, the system they used to exchange the offer and answer is no longer involved.
Steps 1, 3 and 5 are complicated internally, but browsers already know how to do them. The awkward part for application developers is steps 2 and 4. There are plenty of libraries that make WebRTC easier to use. Trystero, PeerJS, Peerix and similar projects can hide most of the details around ICE, STUN, TURN, NAT traversal and SDP negotiation. That is useful because raw WebRTC is a complicated API.
Before talking about those libraries, it is worth asking why we care about peer-to-peer applications in the first place. One property I care about is longevity. Properly built P2P apps can live for a really, really long time, even after their original maintainers lose interest. The same properties that make a network resistant to censorship, outages or interference can also make it resistant to neglect. There is no single server bill that has to keep getting paid forever, and this is where I think existing browser P2P has not yet fully matured.
The actual WebRTC data channel is decentralized, but many JavaScript P2P libraries hand-wave away rendezvous and session establishment. They quietly leave the question of how peers find each other to somebody else.
Bootstrapping is not a problem unique to WebRTC. Older P2P systems had to solve it too. Gnutella used mechanisms such as GWebCache and UDP host caches. BitTorrent eventually gained a DHT. These systems treated peer discovery as part of the network rather than something every application developer had to invent independently.
WebRTC P2P libraries tend to take one of two approaches to the rendezvous problem:
- They do nothing. The library gives you an offer and expects your application to deliver it somehow. Some projects provide a public signaling server for demos, but recommend running your own infrastructure for real applications. You must pick between paying a server bill forever, or hoping that the library maintainer keeps paying the bill for you.
- They use some existing centralized service as a signaling channel. This might be a publicly exposed MQTT broker, a WebTorrent tracker or some other general-purpose system that happens to be useful for exchanging WebRTC offers and answers.
My take is that the data-channel part of browser P2P is a solved problem, but rendezvous is not.
There are some solutions out there. Trystero paired with Nostr or IPFS is the cleanest thing I can find in 2026 that meets my definition of genuinely decentralized browser rendezvous. WebTorrent trackers can also be shoe-horned for this purpose, although in my experiments that approach has been extremely fragile. I do think there is still room for exploration here.
doodle-rtc is my exploration of this problem space. It explores the question of what if WebRTC offers and answers were small enough that we did not need a specialized signaling system at all?
Instead of treating signaling as a network service, what if an offer could comfortably fit inside an IRC message, a Bluesky DM, email or URL? Normal WebRTC SDP is far too large and awkward for that to be pleasant. doodle-rtc experiments with encoding only the information required for a small, constrained WebRTC data-channel connection. The resulting offer and answer codes are often around 90 to 200 URL-safe characters. Let's see how far that idea can be pushed.
The library is browser-only, has no runtime dependencies, and supports strings
and ArrayBuffer messages.
Install
npm install doodle-rtcimport { DoodleRTC } from "doodle-rtc";Your build target must provide the browser WebRTC globals, including
RTCPeerConnection and RTCDataChannel.
Connect two browsers
Each side creates its own DoodleRTC. The first browser creates an offer, the
second browser turns that offer into an answer, and the first browser accepts
the answer.
1. Create an offer in browser A
import { DoodleRTC } from "doodle-rtc";
const peer = new DoodleRTC({
onError(error) {
console.error("Peer error:", error);
},
onCode(offerCode, state) {
if (!state.complete) return;
if (state.ipv4Candidates + state.ipv6Candidates === 0) {
console.error("No usable network path was found");
return;
}
showOfferToUser(offerCode);
},
onConnect() {
console.log("Connected to browser B");
},
});
peer.generateOffers();Send the displayed offer code to browser B.
2. Answer in browser B
import { DoodleRTC } from "doodle-rtc";
const peer = new DoodleRTC({
onError: console.error,
onCode(answerCode, state) {
if (state.complete && state.ipv4Candidates + state.ipv6Candidates > 0) {
showAnswerToUser(answerCode);
}
},
onConnect() {
console.log("Connected to browser A");
},
});
peer.generateAnswers(offerCodeFromBrowserA);Send the displayed answer code back to browser A.
3. Accept the answer in browser A
Use the same DoodleRTC that created the offer:
peer.acceptAnswer(answerCodeFromBrowserB);Both peers call their onConnect callbacks after the data channel opens.
Send and receive data
Pass message callbacks when creating the peer so you cannot miss an early message:
const peer = new DoodleRTC({
onMessage(data) {
if (typeof data === "string") {
console.log("text:", data);
} else {
console.log("binary bytes:", data.byteLength);
}
},
onConnect() {
peer.send("hello");
peer.send(new TextEncoder().encode("binary hello").buffer);
},
});Call send() only after onConnect fires. Call close() when the connection
is no longer needed.
peer.close();Signaling-code updates
onCode can run more than once. Browsers discover network candidates
asynchronously, so every call contains the newest usable code and a snapshot of
gathering progress:
const peer = new DoodleRTC({
onCode(code, state) {
renderCode(code);
renderStatus({
finished: state.complete,
ipv4: state.ipv4Candidates,
ipv6: state.ipv6Candidates,
});
},
});For the simplest user experience, wait for state.complete and share the final
code. If your interface shows an earlier code, replace it whenever onCode
runs. Do not combine codes from different updates.
Optional/Advanced Configuration
By default, doodle-rtc uses Google's public STUN server. Production
applications will often supply their own STUN service and WebRTC policy:
const peer = new DoodleRTC({
rtcConfiguration: {
iceServers: [{ urls: "stun:stun.example.com:3478" }],
},
dataChannelLabel: "sync",
dataChannelOptions: {
ordered: false,
maxRetransmits: 0,
},
});dataChannelLabel defaults to "data". dataChannelOptions is used by the
offerer when it creates the channel; the answerer receives that channel through
WebRTC. Out-of-band negotiated channels (negotiated: true) are not supported.
This release encodes direct UDP server-reflexive paths only. TURN relay
candidates, TCP candidates, media tracks, and multiple data channels are not
supported. Adding a TURN server to rtcConfiguration does not add relay
fallback to the signaling code.
API
new DoodleRTC(options?)
Creates one peer for one connection attempt. A peer can be either the offerer or the answerer and cannot be reused for another handshake.
peer.generateOffers()
Starts an offer. Read offer codes with onCode.
peer.generateAnswers(offerCode)
Consumes an offer and starts an answer. Read answer codes with onCode.
peer.acceptAnswer(answerCode)
Completes the offerer's handshake with the answer code. Call it on the same
peer that previously called generateOffers().
peer.send(data)
Sends a string or ArrayBuffer after the channel opens.
peer.close()
Closes the data channel and its WebRTC connection. Calling it more than once is safe.
Event callbacks
const peer = new DoodleRTC({
onCode(code, state) {},
onConnect() {},
onMessage(data) {},
onError(error) {},
});All callbacks are optional constructor properties:
onCode(code, state)— a signaling code or candidate updateonConnect()— the data channel is readyonMessage(data)— a string orArrayBufferarrivedonError(error)— invalid use, handshake failure, or channel error
Operational failures are reported to onError instead of being thrown from the
asynchronous public methods. Without it, the library writes the error to
console.error.
When to use something else
Use a conventional signaling service when peers must find each other automatically, reconnect without user action, negotiate media, or work reliably across restrictive networks. Use a full WebRTC abstraction when you need TURN fallback, trickle ICE messages, multiple channels, streams, or long-lived session management.
Deep Dive
A WebRTC data channel is peer-to-peer after connection, but creating that connection is not automatic. The peers must first exchange:
- ICE credentials, which authenticate connectivity checks;
- a DTLS certificate fingerprint, which binds the encrypted transport to the peer described by signaling; and
- ICE candidates, which describe network endpoints the other peer can try.
Browsers expose this information as SDP. A data-channel-only SDP offer is much
larger than the information that varies from connection to connection.
doodle-rtc extracts that variable data, serializes it into a compact binary
message, and Base64URL-encodes the result. The receiver expands the message into
an SDP description that RTCPeerConnection can consume.
There is no rendezvous server in this flow:
offerer human channel answerer
| |
| createOffer + gather candidates |
|---- compact offer code --------------------------------->|
| |
| setRemoteDescription + createAnswer |
|<------------------- compact answer code -----------------|
| |
| setRemoteDescription |
|<========= ICE + DTLS + SCTP data channel ===============>|The “human channel” can be any transport the application provides. The library does not copy, publish, expire, store, or otherwise manage codes.
Peer state machine
A DoodleRTC starts in new and takes exactly one role:
new -- generateOffers() --> offering -- acceptAnswer(answer) --> connecting --> connected
\
`- generateAnswers(offer) -------------------------------> connecting --> connected
any live state -- close() ------------------------------------> closedCalling generateOffers() or generateAnswers() consumes the new peer.
Calling acceptAnswer() is valid only on the offering peer. Invalid transitions
are delivered through the configured onError callback because the public
handshake methods start asynchronous browser work and return immediately.
The offerer creates the in-band data channel before generating its offer. The
answerer receives that channel from RTCPeerConnection.ondatachannel. When the
channel's open event fires, the library enters connected and calls the
configured onConnect callback.
Offer and answer sequences
Offerer
- Construct an
RTCPeerConnectionfromrtcConfiguration. - Create one data channel using the configured label and options.
- Call
createOffer()andsetLocalDescription(). - Parse the local ICE credentials and SHA-256 DTLS fingerprint.
- Collect supported ICE candidates.
- Emit a new offer code whenever the supported candidate set changes.
- Decode the returned answer and reconstruct its SDP.
- Pass the answer to
setRemoteDescription().
Answerer
- Decode the offer and verify that its role bit says
offer. - Reconstruct the offer SDP and pass it to
setRemoteDescription(). - Call
createAnswer()andsetLocalDescription(). - Parse the answer's ICE credentials and DTLS fingerprint.
- Collect supported ICE candidates and emit updated answer codes.
Once each peer has a local and remote description, the browser performs ICE connectivity checks. A successful pair becomes the transport for DTLS, SCTP, and finally the data channel.
Candidate policy
The compact protocol deliberately accepts only candidates with all of these properties:
- type
srflx(server-reflexive); - transport
udp; - an IPv4 or IPv6 address and a nonzero port.
A STUN server causes the browser to discover a server-reflexive address. For a typical IPv4 NAT, that is the public address and mapped UDP port observed by the STUN server:
192.168.1.20:5000 -- NAT --> 203.0.113.4:51823 -- STUN --> observed endpointIPv4 and IPv6 candidates are deduplicated, sorted by address and port, and capped at three per family. Sorting makes the same candidate set encode the same way regardless of discovery order.
Host, peer-reflexive, relay, and TCP candidates are ignored. In particular, TURN cannot provide a relay fallback with this wire format. This keeps codes small but means a connection can fail behind symmetric NATs, UDP-blocking firewalls, or other restrictive network topologies.
Why codes are emitted repeatedly
ICE gathering starts asynchronously after the local description is installed. The SDP credentials and fingerprint may be ready before any supported candidate arrives. Each accepted candidate changes the complete signaling message, so the library re-encodes and emits it.
CodeState.complete becomes true when the browser signals the end of candidate
gathering. The two counts describe the exact candidate arrays in that emission.
The code is a full snapshot, not a delta: the newest code replaces all previous
codes.
Binary signaling format
All integers use network byte order. Text is UTF-8. The current layout is:
| Offset | Size | Value | | --- | ---: | --- | | 0 | 1 byte | IPv4 count, IPv6 count, answer bit, reserved bits | | 1 | 1 byte | ICE username-length delta and password-length delta | | 2 | variable | ICE username fragment | | variable | variable | ICE password | | variable | 32 bytes | raw SHA-256 DTLS fingerprint | | variable | 6 bytes each | zero to three IPv4 endpoints | | variable | 18 bytes each | zero to three IPv6 endpoints |
Header byte
bit 7 6 5 4 3 2 1 0
+---------+---------+--------+----------+
byte 0 | v4 count| v6 count| answer | reserved |
+---------+---------+--------+----------+Each two-bit count represents zero through three candidates. The answer bit is zero for an offer and one for an answer. Decoders currently reject any nonzero reserved bit, leaving room for an explicitly incompatible future format.
Credential-length byte
ICE requires a username fragment of at least 4 characters and a password of at least 22 characters. The format stores four-bit length deltas:
bit 7 6 5 4 3 2 1 0
+-------------+-------------+
byte 1 | ufrag - 4 | password-22 |
+-------------+-------------+The supported encoded lengths are therefore 4–19 bytes for the username fragment and 22–37 bytes for the password.
Fingerprint
The protocol fixes the DTLS fingerprint algorithm to SHA-256. SDP writes a fingerprint as 32 colon-separated hexadecimal octets; the compact form stores the 32 raw bytes and omits the fixed algorithm name and formatting.
Endpoints
An IPv4 endpoint is four address bytes followed by a two-byte port, for 6 bytes total. An IPv6 endpoint is sixteen address bytes followed by the port, for 18 bytes total. Candidate type, component, and transport are protocol constants and take no space per candidate.
For an 8-byte username fragment, a 24-byte password, one IPv4 candidate, and one IPv6 candidate, the complete binary message is 90 bytes:
headers 2
ICE username fragment 8
ICE password 24
SHA-256 fingerprint 32
IPv4 endpoint 6
IPv6 endpoint 18
---
total 90 bytesBase64URL encodes those 90 bytes as 120 characters without padding. Its
alphabet avoids +, /, and trailing =, so a code can be placed in URLs and
copied through text systems with minimal escaping.
SDP reconstruction
After decoding, the library inserts the variable fields into a fixed, data-channel-only SDP template. It generates:
- the session boilerplate and one bundled application media section;
- ICE username and password attributes;
- UDP server-reflexive candidate lines;
- a SHA-256 DTLS fingerprint and role-appropriate
setupattribute; - SCTP port 5000 and a maximum message size; and
- an end-of-candidates marker.
Offer SDP uses DTLS setup role actpass; answer SDP uses active. Candidate
foundations and priorities are generated deterministically. The reconstructed
description is then supplied to the browser as an RTCSessionDescriptionInit.
This is why the format is smaller than compressed SDP: it does not represent the constant SDP text at all.
Data delivery
The library sets RTCDataChannel.binaryType to arraybuffer. Incoming strings
remain strings; incoming binary messages are exposed as ArrayBuffer. It does
not frame application messages, serialize objects, apply backpressure, retry
application data, or impose an application protocol.
Reliability and ordering come from the offerer's RTCDataChannelInit options.
The browser's defaults provide ordered, reliable delivery. Applications that
send large or sustained streams should observe their own flow-control policy;
DoodleRTC.send() is intentionally a thin interface. The channel uses WebRTC's
in-band negotiation; negotiated: true is rejected because the answerer does
not independently create a channel with an agreed SCTP stream ID.
Security properties
WebRTC encrypts the established data channel with DTLS. The fingerprint in the signaling code lets each browser verify that the DTLS certificate matches the peer described by signaling.
That does not authenticate the signaling channel itself. An attacker who can replace both offer and answer codes may substitute their own peers. Applications that require peer identity or tamper resistance should exchange codes through an authenticated channel or add an authenticated comparison step.
Codes also contain live ICE credentials and reachable endpoints. Treat them as short-lived connection capabilities: avoid logging them unnecessarily, do not reuse them, and close abandoned peers. The library deliberately leaves code expiration policy to the application.
Scope and extension points
The public configuration object can change the STUN server and ordinary
RTCPeerConnection policies without changing the wire format. The data-channel
label and initialization options affect the offerer's in-band channel.
Features such as TURN relays, TCP candidates, media, renegotiation, multiple channels, or automatic reconnection would require broader API and protocol decisions. They are outside the current compact format rather than hidden behind incomplete fallbacks.
