A production-ready Golang WebSocket signaling server for voice calling with Redis-backed state management and WebRTC support.
- Single WebSocket per user - Enforced via Redis with atomic SET NX
- Redis as single source of truth - No in-memory session state
- JWT authentication - Validated during WebSocket upgrade
- User profile info - Name, username, and profile image propagated in call events
- Cross-instance messaging - Redis pub/sub for horizontal scaling
- Race-condition safe - Lua scripts for atomic state transitions
- Graceful shutdown - Clean connection handling on SIGINT/SIGTERM
- Full WebRTC signaling - SDP offer/answer and ICE candidate exchange
- Working example client - React + TypeScript web client included
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Client A │ │ Client B │ │ Client C │
└──────┬──────┘ └──────┬──────┘ └──────┬──────┘
│ │ │
│ WebSocket │ WebSocket │ WebSocket
│ │ │
▼ ▼ ▼
┌──────────────────────────────────────────────────────┐
│ Server Instance 1 │
│ ┌─────────┐ ┌────────────┐ ┌──────────────────┐ │
│ │ Hub │──│CallManager │──│ SessionManager │ │
│ └────┬────┘ └────────────┘ └────────┬─────────┘ │
│ │ │ │
│ └────────────┬───────────────────┘ │
│ │ │
└────────────────────┼─────────────────────────────────┘
│
▼ Redis
┌──────────────────────────────────────────────────────┐
│ ┌─────────────────┐ ┌──────────────────────────┐ │
│ │ Sessions/Keys │ │ Pub/Sub Channels │ │
│ │ ws:user:* │ │ ws:signal:* │ │
│ │ call:session:* │ │ │ │
│ │ call:user:* │ │ │ │
│ └─────────────────┘ └──────────────────────────┘ │
└──────────────────────────────────────────────────────┘
goring/
├── cmd/server/ # Application entry point
├── internal/
│ ├── auth/ # JWT token validation
│ ├── protocol/ # WebSocket message definitions
│ ├── redis/ # Redis client and session management
│ ├── signaling/ # Call logic and pub/sub
│ └── ws/ # WebSocket hub and client handlers
├── examples/
│ └── web-client/ # React + TypeScript example client
│ ├── src/
│ │ ├── lib/SignalingClient.ts # WebRTC/WebSocket client
│ │ ├── components/CallUI.tsx # Call interface component
│ │ └── App.tsx # Main application
│ └── package.json
├── docker-compose.yml # Docker orchestration
├── Dockerfile # Multi-stage build
└── .env.example # Configuration template
The fastest way to see Goring in action is to use the included React example client:
# Start server and Redis with Docker
docker compose up -d
# In a separate terminal, run the example web client
cd examples/web-client
npm install
npm run devOpen the URL shown (typically http://localhost:5173) in two different browser tabs. You can now make test calls between the tabs using the demo JWT tokens provided in the UI.
# Start server with Redis
docker compose up -d
# Test horizontal scaling (two server instances)
docker compose --profile scaling up -d
# View logs
docker compose logs -f server
# Stop
docker compose downThe server will be available at ws://localhost:8080/ws?token=<JWT>
Prerequisites: Go 1.21+, Redis 6+
# Set environment variables
export JWT_SECRET="your-secret-key"
export REDIS_ADDR="localhost:6379"
export PORT="8080"
# Run the server
go run ./cmd/serverFor development and testing, you can generate test JWT tokens:
// The auth package includes a test token generator
// In production, use your auth service to generate tokens
// Example JWT payload structure:
{
"hash": "user-123", // User ID (required)
"name": "Alice", // Display name (optional)
"username": "alice", // Username (optional)
"image_profile": "https://...", // Profile image URL (optional)
"iat": 1768720767,
"exp": 1768732667 // Token expiration
}Connect via WebSocket using wscat or any WebSocket client:
wscat -c "ws://localhost:8080/ws?token=<JWT>"| Variable | Default | Description |
|---|---|---|
PORT |
8080 |
HTTP server port |
JWT_SECRET |
dev-secret-change-in-production |
Secret key for JWT validation |
REDIS_ADDR |
localhost:6379 |
Redis server address |
REDIS_PASSWORD |
(empty) | Redis password |
SERVER_ID |
(hostname) | Unique server instance identifier |
# Copy the example file
cp .env.example .env
# Edit with your values
nano .envThe .env file is gitignored. Docker Compose automatically loads it.
All messages follow this envelope format:
{
"type": "event.name",
"payload": {}
}Caller Server Callee
│ │ │
│──call.start────────────▶│ │
│ {callee_id: "userB"} │ │
│ │──call.ring─────────────▶│
│◀───call.ringing─────────│ {session_id, caller_id,│
│ {session_id, callee_id}│ caller_info} │
│ │ │
│ │◀────────call.accept─────│
│ │ {session_id} │
│◀───call.accepted────────│ │
│ {session_id, │ │
│ callee_info} │ │
│ │ │
│ ─────────── WebRTC Signaling Phase ─────────── │
│ │ │
│──webrtc.offer──────────▶│──webrtc.offer─────────▶│
│ {session_id, sdp} │ {session_id, sdp} │
│ │ │
│◀─────────webrtc.answer──│◀─────────webrtc.answer─│
│ {session_id, sdp} │ {session_id, sdp} │
│ │ │
│──webrtc.ice────────────▶│──webrtc.ice───────────▶│
│◀─────────────webrtc.ice─│◀────────────webrtc.ice─│
│ (ICE candidates exchanged in both directions) │
│ │ │
│ ═══════════ P2P Media Connection ═══════════ │
│ │ │
│──call.end──────────────▶│ │
│ {session_id} │ │
│ │──call.ended────────────▶│
│ │ {session_id, reason} │
call.start - Initiate a call
{
"type": "call.start",
"payload": {
"callee_id": "user-456"
}
}call.accept - Accept incoming call
{
"type": "call.accept",
"payload": {
"session_id": "550e8400-e29b-41d4-a716-446655440000"
}
}call.reject - Reject incoming call
{
"type": "call.reject",
"payload": {
"session_id": "550e8400-e29b-41d4-a716-446655440000"
}
}call.end - End active call
{
"type": "call.end",
"payload": {
"session_id": "550e8400-e29b-41d4-a716-446655440000"
}
}These messages are forwarded to the peer. Only valid after call is accepted.
webrtc.offer - Send SDP offer (caller → callee)
{
"type": "webrtc.offer",
"payload": {
"session_id": "550e8400-e29b-41d4-a716-446655440000",
"sdp": "v=0\r\no=- 123456 2 IN IP4 127.0.0.1\r\n..."
}
}webrtc.answer - Send SDP answer (callee → caller)
{
"type": "webrtc.answer",
"payload": {
"session_id": "550e8400-e29b-41d4-a716-446655440000",
"sdp": "v=0\r\no=- 789012 2 IN IP4 127.0.0.1\r\n..."
}
}webrtc.ice - Send ICE candidate (both directions)
{
"type": "webrtc.ice",
"payload": {
"session_id": "550e8400-e29b-41d4-a716-446655440000",
"candidate": "candidate:1 1 UDP 2122252543 192.168.1.1 12345 typ host"
}
}call.ringing - Call is ringing (sent to caller)
{
"type": "call.ringing",
"payload": {
"session_id": "550e8400-e29b-41d4-a716-446655440000",
"callee_id": "user-456"
}
}call.ring - Incoming call notification (sent to callee)
{
"type": "call.ring",
"payload": {
"session_id": "550e8400-e29b-41d4-a716-446655440000",
"caller_id": "user-123",
"caller_info": {
"user_id": "user-123",
"name": "Alice",
"username": "alice",
"image_profile": "https://example.com/alice.jpg"
}
}
}call.accepted - Call was accepted
{
"type": "call.accepted",
"payload": {
"session_id": "550e8400-e29b-41d4-a716-446655440000",
"callee_info": {
"user_id": "user-456",
"name": "Bob",
"username": "bob",
"image_profile": "https://example.com/bob.jpg"
}
}
}call.rejected - Call was rejected
{
"type": "call.rejected",
"payload": {
"session_id": "550e8400-e29b-41d4-a716-446655440000",
"callee_info": {
"user_id": "user-456",
"name": "Bob",
"username": "bob",
"image_profile": "https://example.com/bob.jpg"
}
}
}call.ended - Call has ended
{
"type": "call.ended",
"payload": {
"session_id": "550e8400-e29b-41d4-a716-446655440000",
"reason": "ended_by_user",
"peer_info": {
"user_id": "user-456",
"name": "Bob",
"username": "bob",
"image_profile": "https://example.com/bob.jpg"
}
}
}error - Error occurred
{
"type": "error",
"payload": {
"code": "user_busy",
"message": "User is busy"
}
}| Code | Description |
|---|---|
invalid_message |
Malformed message or unknown type |
unauthorized |
Not authorized for this operation |
user_busy |
Target user is already in a call |
user_offline |
Target user is not connected |
session_not_found |
Call session does not exist |
invalid_state |
Invalid state transition (e.g., double accept) |
internal_error |
Server-side error |
| Key Pattern | Type | TTL | Purpose |
|---|---|---|---|
ws:user:{user_id} |
STRING | 30s | User's WebSocket server ID |
call:session:{session_id} |
HASH | 120s | Call session state |
call:user:{user_id} |
STRING | 120s | User's active call session |
caller_id - ID of the user who initiated the call
callee_id - ID of the user being called
state - Current state: ringing | accepted | rejected | ended
created_at - Unix timestamp of session creation
The server supports horizontal scaling via Redis pub/sub:
- Each instance subscribes to channels for its connected users
- When sending a message to a user on another instance, publish to their channel
- The instance with the connection receives and forwards the message
┌──────────────┐ ┌──────────────┐
│ Instance 1 │ │ Instance 2 │
│ (User A) │ │ (User B) │
└──────┬───────┘ └──────┬───────┘
│ │
│ Redis Pub/Sub │
│ ws:signal:userB │
└─────────────────────────┘
curl http://localhost:8080/healthReturns 200 OK if the server and Redis are healthy.
The project includes a fully functional React + TypeScript web client that demonstrates complete WebRTC integration.
- Complete call lifecycle (initiate, ring, accept/reject, end)
- Real-time WebRTC audio calling
- User profile display (name, username, avatar) for callers and peers
- Call duration timer
- Connection status indicators
- Error handling with user-friendly messages
- Microphone permission handling
# Start the server
docker compose up -d
# Install and run the example client
cd examples/web-client
npm install
npm run devOpen two browser tabs with the provided URL to test calls between different users.
The example client consists of:
-
SignalingClient.ts - Core WebSocket and WebRTC integration
- Manages WebSocket connection to signaling server
- Handles WebRTC peer connection setup
- State machine for call lifecycle
- SDP offer/answer negotiation
- ICE candidate exchange
UserInfointerface for profile data in events
-
CallUI.tsx - React UI component
- Dynamic UI based on call state
- Incoming call notifications with caller profile
- Active call display with peer avatar and name
- Active call timer
- Call controls
-
App.tsx - Main application
- Connection management
- JWT token input
- Server URL configuration
# Build the Go server
go build -o server ./cmd/server
# Run with verbose logging
./server
# Run tests
go test ./...
# Build example client for production
cd examples/web-client
npm run buildThe server expects JWT tokens with a hash claim as the user ID. Profile fields (name, username, image_profile) are extracted and included in call events:
{
"hash": "6217138344386500",
"name": "User Name",
"username": "username",
"image_profile": "https://example.com/avatar.jpg",
"user_type": "16587740479514765",
"is_admin": "0",
"iat": 1768720767,
"exp": 1768732667
}Note: A complete, working example client is available in examples/web-client. The code snippets below show the core integration pattern.
class SignalingClient {
private ws: WebSocket;
private sessionId: string | null = null;
private pc: RTCPeerConnection | null = null;
constructor(private serverUrl: string, private token: string) {}
connect() {
this.ws = new WebSocket(`${this.serverUrl}/ws?token=${this.token}`);
this.ws.onmessage = (event) => this.handleMessage(JSON.parse(event.data));
}
private handleMessage(msg: { type: string; payload: any }) {
switch (msg.type) {
case 'call.ringing':
// Call is ringing - save session_id to allow cancelling
this.sessionId = msg.payload.session_id;
this.onCallRinging?.(msg.payload.session_id, msg.payload.callee_id);
break;
case 'call.ring':
// Incoming call - show UI to accept/reject
// caller_info contains: user_id, name, username, image_profile
this.sessionId = msg.payload.session_id;
this.onIncomingCall?.(msg.payload.caller_id, msg.payload.session_id, msg.payload.caller_info);
break;
case 'call.accepted':
// Call accepted - start WebRTC as caller
// callee_info contains: user_id, name, username, image_profile
this.sessionId = msg.payload.session_id;
this.onCallAccepted?.(msg.payload.callee_info);
this.startWebRTC(true);
break;
case 'webrtc.offer':
// Received offer - create answer
this.handleOffer(msg.payload.sdp);
break;
case 'webrtc.answer':
// Received answer - set remote description
this.pc?.setRemoteDescription({ type: 'answer', sdp: msg.payload.sdp });
break;
case 'webrtc.ice':
// Received ICE candidate
if (msg.payload.candidate) {
this.pc?.addIceCandidate({ candidate: msg.payload.candidate });
}
break;
case 'call.ended':
// peer_info contains info about the user who ended the call
this.cleanup();
this.onCallEnded?.(msg.payload.reason, msg.payload.peer_info);
break;
case 'error':
this.onError?.(msg.payload.code, msg.payload.message);
break;
}
}
// Initiate a call
startCall(calleeId: string) {
this.send('call.start', { callee_id: calleeId });
}
// Accept incoming call
acceptCall(sessionId: string) {
this.sessionId = sessionId;
this.send('call.accept', { session_id: sessionId });
this.startWebRTC(false); // Start as callee
}
// Reject incoming call
rejectCall(sessionId: string) {
this.send('call.reject', { session_id: sessionId });
}
// End active call
endCall() {
if (this.sessionId) {
this.send('call.end', { session_id: this.sessionId });
this.cleanup();
}
}
private async startWebRTC(isCaller: boolean) {
this.pc = new RTCPeerConnection({
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]
});
// Handle ICE candidates
this.pc.onicecandidate = (event) => {
if (event.candidate) {
this.send('webrtc.ice', {
session_id: this.sessionId,
candidate: event.candidate.candidate
});
}
};
// Handle incoming tracks
this.pc.ontrack = (event) => {
this.onRemoteStream?.(event.streams[0]);
};
// Add local audio track
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
stream.getTracks().forEach(track => this.pc!.addTrack(track, stream));
if (isCaller) {
// Create and send offer
const offer = await this.pc.createOffer();
await this.pc.setLocalDescription(offer);
this.send('webrtc.offer', {
session_id: this.sessionId,
sdp: offer.sdp
});
}
}
private async handleOffer(sdp: string) {
if (!this.pc) return;
await this.pc.setRemoteDescription({ type: 'offer', sdp });
const answer = await this.pc.createAnswer();
await this.pc.setLocalDescription(answer);
this.send('webrtc.answer', {
session_id: this.sessionId,
sdp: answer.sdp
});
}
private send(type: string, payload: any) {
this.ws.send(JSON.stringify({ type, payload }));
}
private cleanup() {
this.pc?.close();
this.pc = null;
this.sessionId = null;
}
// Event callbacks - user info includes: user_id, name, username, image_profile
onCallRinging?: (sessionId: string, calleeId: string) => void;
onIncomingCall?: (callerId: string, sessionId: string, callerInfo?: UserInfo) => void;
onCallAccepted?: (calleeInfo?: UserInfo) => void;
onCallEnded?: (reason: string, peerInfo?: UserInfo) => void;
onRemoteStream?: (stream: MediaStream) => void;
onError?: (code: string, message: string) => void;
}
interface UserInfo {
user_id: string;
name?: string;
username?: string;
image_profile?: string;
}
// Usage
const client = new SignalingClient('ws://localhost:8080', 'your-jwt-token');
client.onCallRinging = (sessionId, calleeId) => {
// Call is now ringing - can use sessionId to cancel with endCall()
console.log(`Calling ${calleeId}, session: ${sessionId}`);
};
client.onIncomingCall = (callerId, sessionId, callerInfo) => {
// Display caller's name and profile image if available
const callerName = callerInfo?.name || callerInfo?.username || callerId;
if (confirm(`Incoming call from ${callerName}`)) {
client.acceptCall(sessionId);
} else {
client.rejectCall(sessionId);
}
};
client.onCallAccepted = (calleeInfo) => {
// Display callee's profile info when call is accepted
console.log(`Call accepted by ${calleeInfo?.name || calleeInfo?.username}`);
};
client.onCallEnded = (reason, peerInfo) => {
console.log(`Call ended: ${reason} by ${peerInfo?.name || 'unknown'}`);
};
client.onRemoteStream = (stream) => {
document.querySelector('audio')!.srcObject = stream;
};
client.connect();import 'dart:convert';
import 'package:web_socket_channel/web_socket_channel.dart';
import 'package:flutter_webrtc/flutter_webrtc.dart';
class SignalingClient {
late WebSocketChannel _channel;
RTCPeerConnection? _pc;
String? _sessionId;
final String serverUrl;
final String token;
Function(String sessionId, String calleeId)? onCallRinging;
Function(String callerId, String sessionId)? onIncomingCall;
Function(MediaStream stream)? onRemoteStream;
Function(String reason)? onCallEnded;
SignalingClient({required this.serverUrl, required this.token});
void connect() {
_channel = WebSocketChannel.connect(Uri.parse('$serverUrl/ws?token=$token'));
_channel.stream.listen(_handleMessage);
}
void _handleMessage(dynamic data) {
final msg = jsonDecode(data);
switch (msg['type']) {
case 'call.ringing':
_sessionId = msg['payload']['session_id'];
onCallRinging?.call(_sessionId!, msg['payload']['callee_id']);
break;
case 'call.ring':
_sessionId = msg['payload']['session_id'];
onIncomingCall?.call(msg['payload']['caller_id'], _sessionId!);
break;
case 'call.accepted':
_sessionId = msg['payload']['session_id'];
_startWebRTC(isCaller: true);
break;
case 'webrtc.offer':
_handleOffer(msg['payload']['sdp']);
break;
case 'webrtc.answer':
_pc?.setRemoteDescription(RTCSessionDescription(msg['payload']['sdp'], 'answer'));
break;
case 'webrtc.ice':
if (msg['payload']['candidate'] != null) {
_pc?.addCandidate(RTCIceCandidate(msg['payload']['candidate'], '', 0));
}
break;
case 'call.ended':
_cleanup();
onCallEnded?.call(msg['payload']['reason']);
break;
}
}
void startCall(String calleeId) {
_send('call.start', {'callee_id': calleeId});
}
void acceptCall(String sessionId) {
_sessionId = sessionId;
_send('call.accept', {'session_id': sessionId});
_startWebRTC(isCaller: false);
}
void rejectCall(String sessionId) {
_send('call.reject', {'session_id': sessionId});
}
void endCall() {
if (_sessionId != null) {
_send('call.end', {'session_id': _sessionId});
_cleanup();
}
}
Future<void> _startWebRTC({required bool isCaller}) async {
_pc = await createPeerConnection({
'iceServers': [{'urls': 'stun:stun.l.google.com:19302'}]
});
_pc!.onIceCandidate = (candidate) {
_send('webrtc.ice', {
'session_id': _sessionId,
'candidate': candidate.candidate
});
};
_pc!.onTrack = (event) {
if (event.streams.isNotEmpty) {
onRemoteStream?.call(event.streams[0]);
}
};
final stream = await navigator.mediaDevices.getUserMedia({'audio': true});
stream.getTracks().forEach((track) => _pc!.addTrack(track, stream));
if (isCaller) {
final offer = await _pc!.createOffer();
await _pc!.setLocalDescription(offer);
_send('webrtc.offer', {'session_id': _sessionId, 'sdp': offer.sdp});
}
}
Future<void> _handleOffer(String sdp) async {
await _pc?.setRemoteDescription(RTCSessionDescription(sdp, 'offer'));
final answer = await _pc?.createAnswer();
await _pc?.setLocalDescription(answer!);
_send('webrtc.answer', {'session_id': _sessionId, 'sdp': answer!.sdp});
}
void _send(String type, Map<String, dynamic> payload) {
_channel.sink.add(jsonEncode({'type': type, 'payload': payload}));
}
void _cleanup() {
_pc?.close();
_pc = null;
_sessionId = null;
}
}-
cmd/server/main.go - Application entry point
- Initializes Redis, Hub, CallManager, and PubSub
- Sets up HTTP server with
/wsand/healthendpoints - Handles graceful shutdown
-
internal/auth/jwt.go - JWT authentication
- Validates tokens using HMAC-SHA256
- Extracts user ID from
hashclaim - Extracts user profile info (
name,username,image_profile) from claims - Test token generation for development (with
GenerateTokenWithInfo)
-
internal/protocol/messages.go - Protocol definitions
- All WebSocket message types and structures
- Call states and error codes
UserInfostruct for profile data in call events
-
internal/redis/ - Redis integration
- client.go - Redis client wrapper
- session.go - Session and state management with atomic operations
-
internal/signaling/ - Call orchestration
- call.go - Call lifecycle management (start, accept, reject, end)
- pubsub.go - Cross-instance messaging for horizontal scaling
-
internal/ws/ - WebSocket handling
- handler.go - Hub for connection management and message routing
- client.go - Individual WebSocket connection handling
- Stateless servers - All state stored in Redis for horizontal scalability
- Atomic operations - Lua scripts prevent race conditions
- Pub/Sub architecture - Cross-instance messaging via Redis channels
- Connection deduplication - Redis SET NX ensures single connection per user
- Automatic cleanup - TTLs and disconnect handlers prevent orphaned sessions
- Heartbeat mechanism - Periodic Redis TTL refresh keeps sessions alive
- Go 1.21+ with standard library HTTP server
- gorilla/websocket for WebSocket connections
- redis/go-redis/v9 for state management
- golang-jwt/jwt/v5 for authentication
- rs/zerolog for structured logging
- React 18 with TypeScript 5
- Vite 5 for development and building
- Native WebRTC API for peer-to-peer media
- Native WebSocket API for signaling
- Redis 7 for state and pub/sub
- Docker and Docker Compose for deployment
- Always use secure JWT secrets in production
- Consider rate limiting on WebSocket connections
- Use WSS (WebSocket Secure) in production
- Implement user authentication in your auth service
- Validate and sanitize all client inputs
- The server is stateless and can scale horizontally
- Use a load balancer (e.g., nginx, HAProxy) in front of multiple instances
- Redis can be clustered for high availability
- Monitor Redis memory usage and set appropriate TTLs
- Health check endpoint at
/healthfor load balancer checks - Structured JSON logs for aggregation (ELK, Datadog, etc.)
- Monitor WebSocket connection count
- Track call success/failure rates
- Monitor Redis latency and memory
MIT