What Happens When You Make the Same Software on GO + Htmx vs JS (Node.js)? Part 2/5


Building a WebRTC Video Calling System: Two Approaches
In this series, we explore implementing the same functionality using two different stacks. Part 2 focuses on our WebRTC video calling implementation across both our Go+HTMX and Node.js applications.
Development Philosophy
To better capture the developer experience with its nuances, we've adopted a micro-task approach, breaking down the implementation into smaller components. This helps both productivity and provides insight into how each stack handles similar challenges differently.
WebRTC Architecture Overview
Our video calling implementation follows the standard WebRTC flow:
Signalling setup: Our server acts as the signaling server for this project
SDP exchange: Exchange of offer and answer messages to negotiate connection parameters
ICE candidate exchange: Establishes P2P communication links with agreed parameters
Media streaming: Browsers share media directly using P2P communication
Call termination: Handled at the server end
In the diagram, red components represent server responsibilities, while green components show front-end actions.
Implementation Strategy
While our project aims to separate the languages as much as possible, WebRTC and media handling must be implemented in the browser. We've chosen a minimal Vanilla JavaScript approach with HTMX for our Go Project and Socket.IO for Node.js project.
JavaScript's Async/Await: The Backbone of WebRTC
Asynchronous JavaScript has been crucial in our implementation. The async/await pattern allows us to handle the complex, event-driven nature of WebRTC connections with cleaner code:
async function fetchUserMedia() {
try {
const stream = await navigator.mediaDevices.getUserMedia({
video: true,
audio: true
});
localVideoEl.srcObject = stream;
localStream = stream;
return stream;
} catch (error) {
console.error('Error accessing media devices:', error);
throw error;
}
}
Benefits of Our WebRTC Setup
This architecture provides several key advantages:
Reduced server complexity: The server only handles signaling, not media
Lower bandwidth costs: After connection establishment, media flows directly between peers
Improved latency: Direct peer-to-peer communication reduces delay
Scalability: The server can handle more concurrent users as it's not processing media
Technology Decisions: Different Approaches to Signaling
Async/Await vs Channels
Feature | async/await | Channels |
Paradigm | Asynchronous programming (typically single-threaded) | Concurrency/Parallelism (usually in multi-threaded systems) |
Concurrency model | Single-threaded event loop with non-blocking I/O | Multi-threaded, typically with independent tasks (e.g., goroutines in Go) |
Synchronization | Handled with Promises (or await blocks) | Handled via channels, ensuring safe communication between concurrent tasks |
Data transfer | Returns values via Promises, can use .then() or await | Data is sent through channels between goroutines |
Primary use case | Asynchronous operations like API calls, timers, etc. | Communication and synchronization between concurrent tasks |
Ease of use | Simpler to use with async code, helps write clean sequential logic | Requires managing multiple threads or goroutines, but provides explicit concurrency primitives |
WebSocket vs Socket.IO
Feature | WebSockets | Socket.IO |
Protocol | WebSocket (ws://, wss://) | Built on top of WebSocket, with fallback protocols |
Browser Support | Good in modern browsers, but can be blocked | Works in modern and older browsers (with fallbacks) |
Automatic Reconnection | No, must be handled manually | Yes, automatic reconnection built-in |
Message Format | Raw text or binary data | JSON, with automatic serialization/deserialization |
Event-based System | No, must implement custom logic | Yes, emits and listens for events |
Rooms & Broadcasting | Must be implemented manually | Built-in support for rooms and broadcasting |
Namespaces | Not available | Yes, provides namespaces for logical separation |
Performance | Generally faster due to minimal overhead | Slightly higher overhead due to added features |
Use Case | Simple real-time communication | Complex real-time applications with built-in features |
Stack-Specific Implementation Choices
Our Go+HTMX project uses WebSockets for signaling, aligning with Go's philosophy of using system utilities directly and working well with Go's channel-based concurrency model.
Conversely, our Node.js project leverages Socket.IO, which fits naturally with Node.js's event-based architecture.
Implementation Details
WebRTC Implementation Pseudocode
1. Initialize Communication Channel
// Go + HTMX approach using WebSockets
const ws = new WebSocket('ws://localhost:8080/rtc');
// Express.js approach using Socket.IO
const socket = io.connect('http://localhost:3000');
socket.emit('auth', { userName, password });
2. DOM References and Global Variables
// DOM elements
const localVideoEl = document.getElementById('localVideo');
const remoteVideoEl = document.getElementById('remoteVideo');
// Global variables
let localStream;
let remoteStream;
let peerConnection;
let didIOffer = false;
const peerConfiguration = {
iceServers: [
{ urls: 'stun:stun.stunprotocol.org' }
]
};
3. Event Handlers for Signaling
// Call button handler
function call() {
fetchUserMedia()
.then(stream => {
createPeerConnection();
return peerConnection.createOffer();
})
.then(offer => {
return peerConnection.setLocalDescription(offer);
})
.then(() => {
// Send offer via WebSocket or Socket.IO
const offerObj = {
sdp: peerConnection.localDescription,
roomId: roomId
};
// Go + HTMX approach
ws.send(JSON.stringify({
type: 'offer',
data: offerObj
}));
// OR Node.js approach
socket.emit('newOffer', offerObj);
didIOffer = true;
});
}
// Handle received offer
function handleOffer(offerObj) {
fetchUserMedia()
.then(stream => {
createPeerConnection();
return peerConnection.setRemoteDescription(offerObj.sdp);
})
.then(() => {
return peerConnection.createAnswer();
})
.then(answer => {
return peerConnection.setLocalDescription(answer);
})
.then(() => {
// Send answer via WebSocket or Socket.IO
const answerObj = {
sdp: peerConnection.localDescription,
roomId: roomId
};
// Go + HTMX approach
ws.send(JSON.stringify({
type: 'answer',
data: answerObj
}));
// OR Node.js approach
socket.emit('newAnswer', answerObj);
});
}
4. ICE Candidate Handling
function createPeerConnection() {
peerConnection = new RTCPeerConnection(peerConfiguration);
// Add local tracks to the connection
localStream.getTracks().forEach(track => {
peerConnection.addTrack(track, localStream);
});
// Handle ICE candidates
peerConnection.onicecandidate = event => {
if (event.candidate) {
// Send ICE candidate via WebSocket or Socket.IO
const candidateObj = {
candidate: event.candidate,
roomId: roomId
};
// Go + HTMX approach
ws.send(JSON.stringify({
type: 'ice-candidate',
data: candidateObj
}));
// OR Node.js approach
socket.emit('newIceCandidate', candidateObj);
}
};
// Handle incoming tracks
peerConnection.ontrack = event => {
remoteVideoEl.srcObject = event.streams[0];
remoteStream = event.streams[0];
};
}
Conclusions
Implementation Differences
The fundamental WebRTC process remains the same across both stacks, but the signaling mechanisms differ:
Go + HTMX: Uses raw WebSockets, requiring more manual handling of message types and events, but with potentially lower overhead and better integration with Go's concurrency model.
Node.js: Leverages Socket.IO's higher-level abstractions for events, rooms, and reconnection, simplifying the signaling process but adding some overhead.
Developer Experience Observations
Go + HTMX: Requires more explicit code for message handling but offers finer control over the communication process.
Node.js: Provides a more streamlined development experience with built-in features but less transparency into the underlying mechanisms.
Next Steps
Our experimentation has revealed a fundamental insight: Go excels at handling high-volume connections efficiently, while JavaScript (Node.js) shines when implementing complex features post-connection. Given these complementary strengths, our next steps will take two parallel paths:
Go + HTMX Project: We'll pivot toward a connection-intensive application that leverages Go's concurrency model and efficient connection handling. Specifically, we're planning:
A real-time monitoring system capable of handling thousands of concurrent connections
Implementation of a high-throughput message broker using WebSockets
Exploration of Go's performance advantages in a high-connection environment with minimal per-connection processing
Node.js Project: We'll focus on feature-rich applications that benefit from JavaScript's flexibility after connections are established:
Advanced room management with dynamic user permissions
Complex UI state synchronization across participants
Rich media sharing and collaborative features
Integration with external APIs for enhanced functionality
Part 3 will document our progress on these diverging implementations, comparing performance metrics, developer experience, and the tradeoffs encountered when playing to each stack's strengths.
Subscribe to my newsletter
Read articles from kafka franz directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
