This repository serves as a hands-on guide for WebRTC. It aims to clearly demonstrate how peer-to-peer connections are established and how video streams are exchanged using WebRTC.
Note
You can find a more detailed explanation of the basic concepts of WebRTC and this tutorial’s code in my blog post.
🔗 BenchPress200's Tech Blog
Test Environment
This tutorial code can be tested in a local development environment. To run the source code, you will need to have the following tools installed.
- VS Code
- Chrome
- node (v22.10.0)
- npm (v10.9.0)
- IntelliJ IDEA
- JDK 17
WebRTC Communication Workflow
This tutorial handles not only signaling but all server communications via WebSocket. To establish a peer-to-peer connection, a signaling process is required. The steps involved in the signaling process to connection establishment are as follows.
- Both Peer A and Peer B set up their own media streams(microphone and video) -
Peer A,Peer B - One peer (Peer A) creates an SDP(offer) -
Peer A - Peer A sets the generated SDP(offer) as its local description and sends it to Peer B -
Peer A - Peer B receives the SDP(offer) and sets it as its remote description -
Peer B - Peer B creates an SDP(answer) in response to the received SDP(offer) -
Peer B - Peer B sets its generated SDP(answer) as its local description and sends it to Peer A -
Peer B - Peer A receives the SDP(answer) and sets it as its remote description -
Peer A - Both peers exchange the ICE candidates they collected and add the remote candidates -
Peer A,Peer B- Each peer begins gathering its own ICE candidates after completing SDP creation. A peer can only add the other peer's candidates after setting the remote SDP
- Once ICE candidate exchange is complete and a viable candidate pair is found, the connection is successfully established -
Peer A,Peer B
Key APIs required for the signaling process for video calling
new RTCPeerConnection()- Creates a new WebRTC connection instance for peer-to-peer communication
peerConnection.ontrack = (event) => {}- Triggered when a media track (audio or video) is received from the remote peer
peerConnection.onicecandidate = (event) => {}- Called when a new ICE candidate is discovered
peerConnection.oniceconnectionstatechange = (event) => {}- Monitors changes in the ICE connection state (e.g., connected, disconnected)
navigator.mediaDevices.getUserMedia()- Requests access to the user's camera and microphone
peerConnection.createOffer()- Creates an SDP offer to initiate a WebRTC connection with another peer
peerConnection.createAnswer()- Creates an SDP answer in response to an offer from another peer
peerConnection.setLocalDescription()- Sets the local peer’s SDP (offer or answer) for signaling
peerConnection.setRemoteDescription()- Applies the received SDP from the remote peer
peerConnection.addIceCandidate()- Adds an ICE candidate received from the remote peer to establish connectivity
Custom hooks created to utilize these key APIs in accordance with the tutorial code
A custom WebRTC hook was implemented to establish a peer-to-peer connection through user interaction in the call page component.
It is located at /frontend/src/hooks/useWebRTC.ts
Requirements for Production Deployment
When deploying a WebRTC-based feature to a production environment, there are several critical considerations beyond what is covered in this tutorial:
-
Automated Signaling Flow
In this tutorial, the signaling process is manually triggered by user interactions (e.g., button clicks). However, in a real-world service, signaling should be initiated and completed automatically—such as sending an offer and receiving an answer—without requiring direct user actions. This ensures a seamless connection experience. -
Ping-Pong Mechanism
Since signaling is handled over WebSocket in this tutorial, production environments must account for possible disconnections caused by NATs, firewalls, load balancers, or web servers that enforce idle timeouts. If the WebSocket connection is terminated during signaling, the peer connection cannot be established. To resolve this, a ping-pong mechanism should be implemented to keep the signaling connection alive. -
TURN Server Deployment
Unlike local environments, real-world peer networks are often restricted by NATs or firewalls, which can prevent peers from exchanging usable IP and port information. In such cases, a TURN (Traversal Using Relays around NAT) server acts as a relay to facilitate media transmission. Deploying a TURN server is essential to ensure reliable connectivity across various network conditions.
git clone https://github.com/BenchPress200/webrtc-tutorial
Clone the tutorial repository to your local development environment.
Open webrtc-tutorial/frontend directory in VSCode to run the React app.
npm install
In the VSCode window you just opened, open the terminal and run npm install to install the necessary packages.
npm start
After the installation is complete, run npm start in the terminal to launch the React application.
Note
If you don’t plan to look into the source code or test it directly, and just want to run the backend app without using an IDE like IntelliJ, you can simply open a terminal or PowerShell, navigate to webrtc-tutorial/backend, and run ./gradlew bootRun.
Then, you can skip the backend app setup steps below and start from step 8 🙂
To run the Spring Boot app, open webrtc-tutorial/backend directory as a Gradle project in IntelliJ.
Check the IntelliJ settings to ensure Lombok is working properly.
Run the Spring Boot application.
http://localhost:3000
Open your browser and launch two tabs with http://localhost:3000.

Allow the browser to access media devices (camera and microphone). If access is not granted, errors may occur.

Register as Patrick and SpongeBob, respectively.

Call SpongeBob, who then accepts the call. After that, both users are taken to the call page.
PatrickandSpongeBobeach set up their own video and microphone.SpongeBob, who receives the call, waits forPatrick’soffer.Patrick, who initiates the call, creates and sends the offer first.- When
Patrickcreates the offer, his ICE candidates start to gather. As each candidate is collected, it is immediately sent toSpongeBob.
- When
SpongeBobreceivesPatrick’soffer and sets it as the remote description.- Once the remote offer is set,
SpongeBobis ready to add ICE candidates fromPatrick. He adds any candidates that were buffered during the wait and continues to add new ones as they arrive.
- Once the remote offer is set,
Patrick, after sending the offer, waits forSpongeBob’sanswer.SpongeBobcreates and sends the answer toPatrick.- When
SpongeBobcreates the answer, his ICE candidates start to gather. As each candidate is collected, it is immediately sent toPatrick.
- When
PatrickreceivesSpongeBob’sanswer and sets it as the remote description.- Once the remote offer is set,
Patrickis ready to add ICE candidates fromSpongeBob. He adds any candidates that were buffered during the wait and continues to add new ones as they arrive.
- Once the remote offer is set,
- Once both SDP messages have been exchanged and set, and ICE candidate exchange and connection are complete, the final step is marked as 'Done', and the video call becomes active.
In local test environments, you may observe that the callee (the user who receives the call) reaches an iceConnectionState === 'connected' status before the caller has finished setting the callee’s answer or adding ICE candidates.
This is not a bug, but a valid outcome under WebRTC’s behavior.
WebRTC uses Trickle ICE by default, where ICE candidates are exchanged after the offer/answer is set.
However, if a viable ICE candidate (e.g., a host candidate on the same LAN) is already included in the SDP, a peer connection may reach connected even if the other side has not completed their setup.
This can also occur more often in local networks (with no NAT or STUN/TURN needed), where direct connectivity is easily established.
The callee can technically enter a connected state and begin sending media before the caller has finished applying the answer or ICE candidates.
Once the caller completes setting the callee’s answer and adds the pending candidates, the connection becomes fully established from both sides.
As a result, the call can proceed successfully even if the callee’s connection appears to be completed slightly earlier.
- Patrick (caller) creates an offer and starts gathering candidates.
- SpongeBob (callee) receives the offer, sets it, creates an answer, and sends it back.
- As SpongeBob gathers and sends his ICE candidates, he may already reach connected if a usable candidate pair is found (e.g., host-host).
- Patrick, who hasn't yet set the answer or added candidates, appears “in progress.”
- Once Patrick applies SpongeBob’s answer and adds his candidates, the connection is established from his side as well.
Both users can now proceed to a stable call state with no functional issues.
If you encounter any issues or have questions about the tutorial code, I'd really appreciate it if you could open an issue using the "Issues" tab at the top of the repository page. I'll respond as soon as possible. To create an issue, please follow these steps:
Thank you for your contribution!

















