The Janus WebRTC Server is one of the most pleasant WebRTC media servers to build on, and it’s a bonus that it is open source. It’s small, fast, and modular: a thin C core with plugins for exactly the workloads you need, a clean signaling API, and a videoroom plugin that handles SFU-style conferences efficiently. However, moving beyond one instance and spreading rooms across a pool introduces its own challenges.
Unlike some media servers, Janus has no native multi-replica coordination — there’s no built-in Janus clustering mode. This means it won’t, on its own, decide which instance a new room should live on or let two instances share one.
This isn’t a defect so much as a design boundary: Janus stays lean by leaving cluster-level orchestration to the integrator. The task, then, is to build the thin coordination layer that turns a pool of independent Janus instances into something that behaves like one scalable service.
Such a pattern has a name, the Media Resource Broker, and this post is about building one and scaling the pool behind it.
The one fact that shapes everything: Janus rooms are instance-local
Before any of the design makes sense, you need to understand a single technical fact it all hangs on. A Janus room lives in the memory of exactly one instance. That’s inherent to how an SFU works: the Selective Forwarding Unit receives each publisher’s streams and forwards them to every subscriber, so a room’s media paths all converge on the instance hosting the room. There’s no shared state between instances and no built-in way for a second instance to serve a room created on the first.
The videoroom plugin has a remote_publishers feature that can forward RTP between instances, but that’s a tool for very large single rooms, not a general way to make a room span the pool or survive an instance dying.
It goes one level deeper than “state isn’t shared.” When a browser negotiates its connection, the session description it agrees on embeds that specific instance’s address in its ICE candidates. The media path is pinned to that instance. If it goes away, the room goes with it, and there’s no quietly falling over to a sibling, because the browser is holding an allocation tied to one peer.
Two consequences fall straight out of this, and they define the rest of the post:
- First, something has to decide which instance a room goes on and remember that decision, because once a room is placed it can’t move.
- Second, you can’t treat the instances as interchangeable and cycle them freely the way you would a stateless service; replacing an instance means dropping its rooms, so growth and upgrades have to be deliberate.
The first consequence is the broker. The second is scaling.
What is a Media Resource Broker?
Strip away the implementation and an MRB is a small, authoritative directory. It answers one question, “which instance owns this room?”, and it owns the act of choosing an instance when the answer is “none yet.” Everything else is in service of keeping that directory correct.
Concretely it’s three cooperating pieces:
- There’s a router, a small stateless service exposing an HTTP API that your application server calls.
- There’s a directory store, a key-value store (we use Redis) that persists the room-to-instance mapping so the answer survives a router restart and can be shared across router replicas.
- And there’s the pool of Janus instances themselves, which the router has to be able to address individually, because it needs to create a room on a specific instance and later ask that same instance how it’s doing.

That “address individually” requirement is worth pausing on, because it’s where the platform-agnostic idea meets a concrete platform: The broker needs a stable, per-instance handle for every Janus instance: a name or address that keeps meaning the same instance over time.
On bare VMs that might be a static address list. On Kubernetes, the natural fit is a StatefulSet of Janus pods fronted by a headless Service, which gives each pod a stable ordinal identity (janus-0, janus-1) and a stable per-pod DNS name.
A normal Service would hand out one virtual IP and load-balance across pods at random, which is exactly the wrong behavior when you need to talk to one particular instance.
The headless Service instead returns every pod’s address and gives each its own DNS record. Same idea everywhere; the StatefulSet plus headless Service is just how it’s spelled on Kubernetes.
The room-to-instance directory, and the contract your app server sees
From the application server’s side, the whole broker is three operations. Look up whether a room already has a home. Create one (which pins it to an instance) if it doesn’t. Tear it down when the room is over.
A typical join does a lookup first and only creates on a miss, so the second person into a room lands on the same instance as the first.
Behind that small surface, the directory is one entry per pinned room. The value records which instance owns the room, the instance’s stable address, the Janus session identifier, and timestamps. A minimal shape:
room:abc123 -> {
instance: "janus-1",
addr: "janus-1.janus-pods...",
session: 1879234567,
last_seen: ...
}
Keeping the whole directory under one key prefix makes it easy to back up and easy to namespace if you ever run more than one Janus deployment against the same store.
The last_seen timestamp gets bumped on every lookup, which lets a background sweeper expire rooms nobody has touched in a long time so the directory doesn’t accumulate ghosts.
One thing the broker deliberately does not do is own Janus’s session state. That state lives in the instance’s memory and dies with the instance. When an instance restarts, the next admin call about a room there fails fast, the router clears the stale entry the next time it’s asked, and the app server simply re-pins on its next create.
The honest consequence to design around: every instance restart costs you that instance’s worth of rooms. In other words, this design targets horizontal scaling not high availability. A room still dies with its instance, and the client side has to re-join from scratch — a fresh Janus session and a new media negotiation on a healthy instance. The careful scaling discipline later in this post exists precisely to make those restarts rare and controlled.
Load balancing rooms across Janus instances, and keeping the directory honest
When the router does have to place a new room, it needs a current picture of the pool. It maintains an in-memory cache of which instances are healthy and how loaded each one is, refreshed on a short interval.
Each discovery cycle resolves the pool (on Kubernetes, by looking up the headless Service to get the live pod addresses), asks each instance over the admin API how many active sessions it has, skips any instance marked as draining, and updates the cache. A short per-instance timeout keeps one hung instance from stalling the whole sweep.
# discovery cycle — runs every interval (e.g. 10s)
for each instance in pool (live pod IPs behind the headless Service):
skip if instance is draining
cache[instance] = active session count # admin API call, short per-instance timeout
The same loop in JavaScript:
// runs every discovery interval (e.g. 10s)
async function refreshCache() {
// live pod IPs behind the headless Service
for (const instance of await resolvePool()) {
// skip anything being drained
if (instance.draining) continue;
// admin API, short per-instance timeout
cache[instance] = await countActiveSessions(instance);
}
}
and in Python:
# runs every discovery interval (e.g. 10s)
def refresh_cache():
# live pod IPs behind the headless Service
for instance in resolve_pool():
# skip anything being drained
if instance.draining:
continue
# admin API, short per-instance timeout
cache[instance] = count_active_sessions(instance)
Placing a room is then just picking the minimum-load entry, with the instance’s ordinal as a tiebreak so a cold pool fills in a predictable order. Least-loaded is the sensible default. Round-robin is a valid alternative you can enable when you want a distribution that’s predictable rather than load-following, which is useful during isolation testing.
Two correctness details separate a directory you can trust from one that slowly lies to you.
- Startup race: the router waits for one full discovery cycle before it reports itself ready, which closes the window where a freshly started router with an empty cache accepts a create and fails because it doesn’t yet know about any instances.
- Concurrency. This is the subtle one. If you run two router replicas for availability (or scale horizontally), both can field a create for the same room at the same instant. They race to claim the directory key, and only one wins. The loser has, by then, already created a session on its chosen instance, and that session is now an orphan nobody will route signaling to.
Left alone, each contended create would leak a session, those leaks would skew the load counts, and selection would slowly drift toward the losing replica’s instances. So the loser detects that it lost the race and destroys its orphan before returning the winner’s answer. It’s a small piece of cleanup, and it’s the thing that keeps load-based selection from quietly going wrong under concurrent writes.
// create-and-pin: session is created first, then the atomic claim
// putRoomNX: return result === 'OK';
// 'OK' = won; null = key already existed
const won = await putRoomNX(deps, room_id, pin);
if (!won) {
// loser: tear down the orphan session
await destroyVideoroom(deps, pod, room_id);
// return the winner's pin instead
return await getRoom(deps, room_id);
}
Troubleshooting: the Janus admin API is disabled by default
Here’s the one that sends people debugging the wrong thing for hours, because the symptom points everywhere except the cause.
The broker creates rooms and polls load through Janus’s admin HTTP interface. In the stock HTTP transport configuration, that interface is disabled by default: admin_http is set to false, so it stays off until you explicitly turn it on. Set your own admin password while you do, because left unset, Janus falls back to a well-known default.
Left at the default, the whole control path silently fails. The router can’t create rooms, load polling reads nothing, and any drain logic can’t see active room counts. None of those symptoms name the cause, so you end up suspecting the store, the network policy, or the router itself when the real fix is a single boolean. That gap between symptom and cause is what costs everyone the afternoon, so it’s the first thing to check when the broker returns errors on every create.
Turning the interface on raises a smaller question: the admin password shouldn’t sit in plain text in a config file, and Janus’s config format can’t expand environment variables on its own. So the password gets injected before Janus starts.
The clean shape is a one-time render step that runs ahead of Janus: it reads the password from a secret, produces a copy of the config with the secret filled in, and points Janus at that copy.
It needs no special privileges, the filled-in config never persists the secret to durable storage, and the main process stays free of any templating logic. How you run that step depends on how you run Janus, but a dedicated one-time render keeps the blast radius small.
# janus.transport.http.jcfg — the Janus HTTP transport
general: {
http = true
port = 8088
# The trap: admin_http ships as false, so the admin API is off and
# the router gets 503s on every create until you flip it on.
# upstream default: false
admin_http = true
admin_port = 7088
# unset -> Janus uses a well-known default
admin_secret = "<set your own>"
}
Scaling Janus horizontally: grow freely, shrink carefully
With the broker in place, horizontal scaling splits into two halves that behave very differently, and the asymmetry is the whole story:
Scaling out is easy and safe. Add an instance, and the next discovery cycle picks it up; because the router only pins new rooms and prefers the least-loaded instance, fresh capacity starts absorbing new rooms immediately without touching anything already running. Nothing in flight is disturbed.
Scaling in, and upgrading, is where instance-local rooms force your hand. Removing an instance means ending its rooms, because they can’t migrate. The discipline that follows is platform-agnostic: never yank an instance out from under live rooms.
Instead, mark instances to be removed as draining so the broker stops placing new rooms on it, waits for its existing rooms to end (or a deadline to pass), and only then removes it. Drain before you remove, every time.
On Kubernetes this principle shows up in a specific and initially surprising place: the update strategy. A rolling update would walk the pool replacing pods one by one, and every replacement would drop that pod’s rooms.
So a Janus StatefulSet uses the OnDelete strategy instead. In the Kubernetes documentation, OnDelete means the controller won’t update pods on its own; when you change the pod template the spec updates, but nothing restarts. The new image sits in the desired state while the running pods keep their old one, until you delete each pod deliberately.
The consequence surprises anyone used to rolling updates, because you bump the image, the upgrade reports success, and nothing happens. That’s correct. Pods don’t roll until you’ve decided to drain them, one at a time, on your schedule, and GitOps tooling that auto-syncs has to learn that this pending state is deliberate rather than drift.
Rather than drain by hand, a controlled job can be used to automate the loop: mark a pod draining on the router, wait for its active rooms to fall to zero or a timeout to expire, then delete it and wait for the replacement to come up healthy before moving on.
The router-mark is the important half, because it stops the bleeding so the pod empties naturally instead of being yanked with rooms still live.

There’s also the uncontrolled path to plan for. The cluster can take a pod down for reasons that never run your job: a node retirement, an eviction, an out-of-memory kill, an autoscaler reclaiming capacity.
For those, each pod carries its own pre-stop hook that performs the same mark-and-wait during its shutdown grace period, falling back to its local session count if the router is unreachable at that moment.
Either way the pod tries to leave gracefully before the system kills it. Treat it as a safety net rather than the primary mechanism, and don’t let it tempt you out of running the real drain.
Autoscaling Janus with KEDA, and the guardrails on scale-in
You can let the pool size itself. The session count the broker already collects is the natural scaling signal, and on Kubernetes a KEDA ScaledObject can read that metric and scale the pool against a per-instance session target you pick from your own load testing.
The asymmetry from the last section carries straight over: scale-out is safe, but scale-in can drop calls, since a live room can’t move.
So guard scale-in on both ends. Have the autoscaler wait out a window of sustained low load before it reclaims an instance, and give that instance a drain grace period (call it X) long enough for its pre-stop hook to empty it gracefully.
The caveat to keep in mind: an autoscaler-driven scale-in fires the per-instance pre-stop hook, not the orchestrated pod-by-pod drain, so an instance that hasn’t emptied within X still drops its live rooms. Set X to your true maximum call length, and verify the drain against a non-empty pool before you let an autoscaler make that call for you.
A note on packaging
How you package all this is your call, and the trade-off is worth knowing. Bundling the instances, the router, and the store into a single release is convenient: one install brings everything up with the cross-references already aligned, and for demos and dev clusters that’s the right choice.
The friction shows up once the two halves want different release cadences, say a router fix on a fast cycle while Janus images are bumped rarely. In a single bundled release every router-only change re-templates the instance workload and can show up to GitOps tooling as drift even when nothing about the instances needs to change.
If that noise bothers you, split the router and the instances into separate releases that share a small piece of common configuration. There’s no single right answer; architect it the way your rollout cadence wants.
Where this leaves you
A Media Resource Broker is a modest amount of code doing one disciplined thing: owning the room-to-instance directory and the choice of instance, so a pool of independent Janus instances behaves like one elastic service.
Rooms are pinned where they’re born, so the broker remembers placements, grows the pool freely, and shrinks it only after draining, and the one piece of Janus configuration that quietly gates the whole thing is the admin interface, off by default, so turn it on first.
That’s the coordination layer. I’ll be posting a companion piece that picks up where this ends and makes the pool reachable, running this multi-instance Janus setup privately on Kubernetes and serving its media through STUNner without giving any instance a public address.
Running Janus in production, or planning to scale past a single instance? The WebRTC.ventures team designs, builds, and operates media infrastructure like this for clients every day. Contact us to talk through your architecture.
Further Reading:
- Reduce WebRTC Infrastructure Costs with a Hybrid P2P Architecture
- How to Deploy STUNner as a WebRTC STUN/TURN Server on Kubernetes
- Watch WebRTC Live #99: Running WebRTC Media Servers in Kubernetes
- Open Source WebRTC Media Servers: Choosing the Right One for Your Use Case
- Scalable WebRTC VoIP Infrastructure Architecture: Essential DevOps Practices
