DEV Community

Aviv Cohen
Aviv Cohen

Posted on

Stop Paying for TURN. Run Your Own in Node.js.

If you've ever built a WebRTC application, you've hit this wall: two users try to connect, and it works perfectly in your office. Then you deploy, and 15% of your users can't connect at all.

The problem is NAT. Most devices sit behind a firewall or router that blocks incoming connections. When two users are both behind NATs, neither can reach the other directly. The WebRTC connection fails silently, and your users stare at a spinner that never goes away.

The solution is a TURN server — a relay that both peers can reach, which forwards traffic between them. It's the fallback that makes WebRTC work for everyone, not just people on favorable networks.

The catch? Running a TURN server has traditionally meant either paying a third-party service ($50–500/month depending on usage) or installing coturn — a C daemon with a 3,000-line configuration file, its own database for credentials, and its own deployment and monitoring story.

What if your TURN server was just another part of your Node.js app?


Why Your WebRTC App Needs TURN

Let's be clear about when TURN is needed and when it isn't.

STUN (Session Traversal Utilities for NAT) handles the easy case. A user behind a NAT sends a request to a STUN server, which tells them their public IP and port. Armed with this information, most peers can connect directly. STUN is lightweight, stateless, and free — Google runs public STUN servers that anyone can use.

TURN (Traversal Using Relays around NAT) handles the hard case. When both peers are behind symmetric NATs — common in corporate networks, university WiFi, and some mobile carriers — direct connection is impossible. No amount of STUN magic will help. The only option is to relay traffic through a server that both sides can reach.

How often does this happen? Studies show that 10–20% of WebRTC connections require TURN relay. On corporate and mobile networks, it can be higher. If you don't have a TURN server, those users simply can't use your app.

This is why every production WebRTC application needs TURN — not as a nice-to-have, but as a reliability requirement.


The Current Options Aren't Great

Paying a service: Twilio, Xirsys, and others offer hosted TURN. They work, but they're expensive at scale. Video traffic through TURN can consume significant bandwidth, and you're paying per gigabyte. For a startup with 1,000 daily users, the bill adds up fast.

Running coturn: The standard open-source option. It's written in C, mature, and feature-complete. But it's also a separate daemon with its own configuration language, its own credential database, its own process management, and its own deployment story. If your stack is Node.js, coturn is a foreign body you have to learn, deploy, and maintain separately.

node-turn: The only Node.js option — but it hasn't been updated in 5 years, supports only basic STUN/TURN, and has no TCP support, no TLS, no hooks, and no production features.


turn-server: STUN/TURN as a Node.js Library

turn-server is a complete STUN/TURN implementation — both server and client — built from scratch in JavaScript with zero dependencies. It's not a wrapper around coturn. It's a full implementation of the protocol stack (RFC 8489, RFC 8656, RFC 6062) that you require() into your app.

npm install turn-server
Enter fullscreen mode Exit fullscreen mode

A Working TURN Server

import { createServer } from 'turn-server';

const server = createServer({
  auth: {
    mechanism: 'long-term',
    realm: 'example.com',
    credentials: { alice: 'password123' }
  },
  relay: { ip: '0.0.0.0', externalIp: '203.0.113.5' }
});

server.listen({ port: 3478 });
Enter fullscreen mode Exit fullscreen mode

That's a production-capable TURN server. Point your WebRTC clients at it:

new RTCPeerConnection({
  iceServers: [{
    urls: 'turn:your-server.com:3478',
    username: 'alice',
    credential: 'password123'
  }]
});
Enter fullscreen mode Exit fullscreen mode

Connections that can go direct will go direct (via STUN). Connections that can't will relay through your server (via TURN). Your users don't know the difference — everything just works.


Production Configuration

The minimal example above works, but a production server needs more control:

import { createServer } from 'turn-server';

const server = createServer({
  auth: {
    mechanism: 'long-term',
    realm: 'example.com',
    secret: 'your-shared-secret'  // Time-limited REST API credentials
  },
  relay: { ip: '0.0.0.0', externalIp: '203.0.113.5' },

  maxConnections: 10000,
  userQuota: 10,           // Max allocations per user
  totalQuota: 5000,        // Max total allocations
  maxDataSize: 65535,      // Max packet size
  idleTimeout: 300000,     // Clean up dead clients after 5 min
});

// Dynamic auth — look up credentials from your database
server.on('authenticate', (username, realm, cb) => {
  db.getHmacKey(username, realm).then(key => cb(key));
});

// Graceful shutdown
process.on('SIGTERM', () => {
  server.drain(30000, () => process.exit(0));
});

// Listen on multiple transports
server.listen([
  { port: 3478 },                                          // UDP + TCP
  { port: 5349, transport: 'tls', cert: CERT, key: KEY },  // TLS
]);
Enter fullscreen mode Exit fullscreen mode

Notice the secret option — this enables the TURN REST API, which generates time-limited credentials. Instead of storing usernames and passwords, your backend generates temporary credentials per session. This is the standard approach for production WebRTC.


14 Hooks: Control Every Decision

This is where turn-server really differentiates itself from coturn. Every decision point in the server is exposed as a hook — accept or reject, with full context:

// Control who can connect
server.on('accept', (info, cb) => {
  cb(isAllowed(info.source.ip));
});

// Control who can allocate relay resources
server.on('beforeAllocate', (info, cb) => {
  if (isPremiumUser(info.username)) {
    info.lifetime = 3600;  // Premium users get longer allocations
  }
  cb(true);
});

// Control what gets relayed
server.on('beforeRelay', (info, cb) => {
  cb(info.size < 65535);  // Drop oversized packets
});

// Track usage
server.on('onRelayed', (info) => {
  metrics.trackBandwidth(info.username, info.size, info.direction);
});
Enter fullscreen mode Exit fullscreen mode

There are 14 hooks in total, covering every phase: connection acceptance, authentication, authorization, quota checks, allocation, permissions, channel binding, TCP connect, and data relay. If no hook listener is attached, the action is auto-approved — so you only hook what you care about.

With coturn, this level of control requires writing C plugins or using external databases with custom schemas. Here, it's JavaScript callbacks.


It's Also a Client

Most TURN libraries are server-only. turn-server includes a complete client implementation — useful for testing, for building server-side WebRTC applications, and for NAT diagnostics.

Get your public IP:

import { getPublicIP } from 'turn-server';

getPublicIP((err, info) => {
  console.log('Public IP:', info.ip);    // "203.0.113.42"
  console.log('Mapped port:', info.port); // 54321
});
Enter fullscreen mode Exit fullscreen mode

Detect your NAT type:

import { detectNAT } from 'turn-server';

detectNAT('stun:stun.example.com:3478', (err, result) => {
  console.log(result.type);  // 'full-cone', 'restricted-cone', 'symmetric-or-port-restricted'
});
Enter fullscreen mode Exit fullscreen mode

Allocate a TURN relay programmatically:

import { connect } from 'turn-server';

connect('turn:turn.example.com:3478', {
  username: 'alice',
  password: 'password123',
}, (err, socket) => {
  socket.allocate({ lifetime: 600 });

  socket.on('allocate:success', (msg) => {
    console.log('Relay address ready');
  });

  socket.on('data', (peer, data) => {
    console.log(`Data from ${peer.ip}:${peer.port}`);
  });
});
Enter fullscreen mode Exit fullscreen mode

The client handles auto-refresh of allocations, permissions, and channels, UDP retransmission with exponential backoff, automatic retry on authentication challenges, and DNS SRV resolution.


Performance: Where It Matters

The most common concern with a JavaScript TURN server is performance. Fair question — TURN is a data relay, and relay performance matters.

Here's the key insight: 99% of TURN traffic uses ChannelData — a minimal 4-byte header format that bypasses the full STUN encoding. It's designed for high-throughput relay with near-zero overhead.

turn-server decodes ChannelData at 9.3 million messages per second on a single core using zero-copy Uint8Array.subarray(). The control plane (allocations, permissions, refreshes) uses full STUN encoding at 36K–218K messages per second — but these operations happen a few times per minute, not per packet.

For context: a 720p video call uses roughly 100–200 packets per second. A single-core JavaScript TURN server can handle thousands of simultaneous video calls before becoming a bottleneck.


Interoperability: Tested Against the Real World

turn-server has been tested in both directions:

Its client → coturn server: Connect to an existing coturn installation, allocate relays, send data. Works.

coturn client → its server: Point coturn's turnutils_uclient at the Node.js server. Works.

Chrome/Firefox → its server: Standard RTCPeerConnection with iceServers configuration. Works.

The wire protocol implementation covers all 62 IANA-registered STUN attributes and passes all 22 RFC 5769 test vectors — the standard conformance test for STUN/TURN implementations.


Embed It, Don't Deploy It

The biggest advantage of a Node.js TURN server isn't performance or features — it's operational simplicity.

Instead of deploying and managing a separate coturn daemon alongside your Node.js app, your TURN server is just another module in your application. Same process, same deployment, same monitoring, same logging, same scaling strategy.

Need to authenticate TURN users against your existing user database? It's a callback, not a database schema migration. Need to track bandwidth per user for billing? It's a hook, not a log parser. Need to dynamically adjust quotas? Change a variable, not a configuration file.

Your infrastructure, your code, your rules.


Getting Started

npm install turn-server
Enter fullscreen mode Exit fullscreen mode

Minimum viable TURN server:

import { createServer } from 'turn-server';

createServer({
  auth: { mechanism: 'long-term', realm: 'example.com', credentials: { test: 'test' } },
  relay: { ip: '0.0.0.0', externalIp: 'YOUR_PUBLIC_IP' }
}).listen({ port: 3478 });
Enter fullscreen mode Exit fullscreen mode

Then in your WebRTC client:

new RTCPeerConnection({
  iceServers: [
    { urls: 'stun:your-server.com:3478' },
    { urls: 'turn:your-server.com:3478', username: 'test', credential: 'test' }
  ]
});
Enter fullscreen mode Exit fullscreen mode

Resources:

Top comments (0)