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

kafka franzkafka franz
6 min read

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

WebRTC Communication Flow

Our video calling implementation follows the standard WebRTC flow:

  1. Signalling setup: Our server acts as the signaling server for this project

  2. SDP exchange: Exchange of offer and answer messages to negotiate connection parameters

  3. ICE candidate exchange: Establishes P2P communication links with agreed parameters

  4. Media streaming: Browsers share media directly using P2P communication

  5. 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:

  1. Reduced server complexity: The server only handles signaling, not media

  2. Lower bandwidth costs: After connection establishment, media flows directly between peers

  3. Improved latency: Direct peer-to-peer communication reduces delay

  4. Scalability: The server can handle more concurrent users as it's not processing media

Technology Decisions: Different Approaches to Signaling

Async/Await vs Channels

Featureasync/awaitChannels
ParadigmAsynchronous programming (typically single-threaded)Concurrency/Parallelism (usually in multi-threaded systems)
Concurrency modelSingle-threaded event loop with non-blocking I/OMulti-threaded, typically with independent tasks (e.g., goroutines in Go)
SynchronizationHandled with Promises (or await blocks)Handled via channels, ensuring safe communication between concurrent tasks
Data transferReturns values via Promises, can use .then() or awaitData is sent through channels between goroutines
Primary use caseAsynchronous operations like API calls, timers, etc.Communication and synchronization between concurrent tasks
Ease of useSimpler to use with async code, helps write clean sequential logicRequires managing multiple threads or goroutines, but provides explicit concurrency primitives

WebSocket vs Socket.IO

FeatureWebSocketsSocket.IO
ProtocolWebSocket (ws://, wss://)Built on top of WebSocket, with fallback protocols
Browser SupportGood in modern browsers, but can be blockedWorks in modern and older browsers (with fallbacks)
Automatic ReconnectionNo, must be handled manuallyYes, automatic reconnection built-in
Message FormatRaw text or binary dataJSON, with automatic serialization/deserialization
Event-based SystemNo, must implement custom logicYes, emits and listens for events
Rooms & BroadcastingMust be implemented manuallyBuilt-in support for rooms and broadcasting
NamespacesNot availableYes, provides namespaces for logical separation
PerformanceGenerally faster due to minimal overheadSlightly higher overhead due to added features
Use CaseSimple real-time communicationComplex 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:

  1. 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.

  2. 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:

  1. 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

  2. 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.

0
Subscribe to my newsletter

Read articles from kafka franz directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

kafka franz
kafka franz