<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Anton Bukarev</title>
    <description>The latest articles on DEV Community by Anton Bukarev (@newlc).</description>
    <link>https://dev.to/newlc</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3835227%2F42a8b0e8-9c7b-4fcf-bd52-f463ce116c1d.png</url>
      <title>DEV Community: Anton Bukarev</title>
      <link>https://dev.to/newlc</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/newlc"/>
    <language>en</language>
    <item>
      <title>I Let AI Loose on My 300K-Email Inbox. Here's What Happened.</title>
      <dc:creator>Anton Bukarev</dc:creator>
      <pubDate>Sat, 25 Apr 2026 19:51:45 +0000</pubDate>
      <link>https://dev.to/newlc/i-let-ai-loose-on-my-300k-email-inbox-heres-what-happened-4jmj</link>
      <guid>https://dev.to/newlc/i-let-ai-loose-on-my-300k-email-inbox-heres-what-happened-4jmj</guid>
      <description>&lt;p&gt;You have too many emails. Everyone does. Somewhere between the GitHub notifications, the newsletters you swore you'd read, and the receipts from 2019, your inbox turned into a graveyard of good intentions.&lt;/p&gt;

&lt;p&gt;What if you could tell an AI "sort this mess out" and it actually could?&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem
&lt;/h2&gt;

&lt;p&gt;You open your inbox. 10,000+ unread. You think "I'll organize this weekend." That weekend never comes. You search for something important and your mail client spins for 30 seconds. You give up.&lt;/p&gt;

&lt;p&gt;AI assistants are great at understanding text - summarizing, categorizing, drafting replies. But they can't touch your email. They don't have access. The tools that try to bridge that gap choke on large mailboxes, hit the mail server on every single query, and some want your password in a plaintext config file.&lt;/p&gt;

&lt;p&gt;I had 300,000 emails. None of the existing tools could handle that.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I built
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/newlc/IMAP-mcp" rel="noopener noreferrer"&gt;IMAP-MCP&lt;/a&gt; connects AI assistants like Claude to your email via standard IMAP. It downloads emails into a local cache, and then everything runs instantly. No server roundtrips. No timeouts.&lt;/p&gt;

&lt;p&gt;You talk to your AI assistant in plain English. It does the rest.&lt;/p&gt;

&lt;h2&gt;
  
  
  "Sort my 300K emails"
&lt;/h2&gt;

&lt;p&gt;This started the whole project.&lt;/p&gt;

&lt;p&gt;Years of accumulated email. Newsletters I never unsubscribed from. GitHub notifications from repos I'd forgotten. Shopping receipts, conference invitations, LinkedIn spam, monitoring alerts - all in one massive inbox.&lt;/p&gt;

&lt;p&gt;I connected Claude and said: &lt;em&gt;"Analyze the senders from the last 6 months. What categories do you see?"&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Minutes later, a breakdown: 40% monitoring alerts, 15% GitHub notifications, 10% shopping, 8% LinkedIn. Only about 3% were messages from actual humans.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;"Propose a folder structure and start sorting."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Claude created 12 folders - Dev, Shopping, Newsletters, LinkedIn, Finance, Travel - and started moving emails in batches. It matched patterns like "everything from &lt;code&gt;@github.com&lt;/code&gt; goes to Dev" and processed hundreds at a time. Over a few sessions, it sorted about 200,000 emails.&lt;/p&gt;

&lt;p&gt;Something I'd been postponing for years took one afternoon.&lt;/p&gt;

&lt;h2&gt;
  
  
  "Find that email I lost"
&lt;/h2&gt;

&lt;p&gt;A few months ago I got an invitation from IEEE to join a reviewer panel. When I needed to respond, I couldn't find it. Inbox? Spam? Some subfolder?&lt;/p&gt;

&lt;p&gt;&lt;em&gt;"Find all emails from IEEE about a reviewer panel invitation."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Found in 2 seconds. It had landed in a subfolder I didn't remember creating. Claude pulled up the full thread, showed me follow-up messages I'd missed, and I had everything I needed.&lt;/p&gt;

&lt;p&gt;No digging through folders. No trying six different search terms.&lt;/p&gt;

&lt;h2&gt;
  
  
  "Who haven't I replied to?"
&lt;/h2&gt;

&lt;p&gt;This became a Monday routine.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;"Show me emails from the last 2 weeks that I haven't replied to."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Claude cross-references sent and received mail, finds unanswered threads, and gives a prioritized list. Last week: 12 emails waiting for a response, each with a one-line summary.&lt;/p&gt;

&lt;p&gt;It catches things I'd otherwise forget. The colleague who asked for feedback. The conference organizer waiting on a confirmation. The client question buried under notifications.&lt;/p&gt;

&lt;h2&gt;
  
  
  "Draft a reply for me"
&lt;/h2&gt;

&lt;p&gt;For emails that need a response, Claude reads the full thread and writes a draft.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;"Read the thread with Sarah about the Q3 budget review and draft a reply confirming the meeting time she proposed."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;It picked up that Sarah suggested Thursday at 2pm, drafted a confirmation, and saved it in my Drafts folder. I opened it, tweaked one sentence, hit send.&lt;/p&gt;

&lt;p&gt;Important: it never sends anything automatically. It creates drafts. You review, you send.&lt;/p&gt;

&lt;h2&gt;
  
  
  "Clean up newsletters and notifications"
&lt;/h2&gt;

&lt;p&gt;Ongoing maintenance is where this gets addictive.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;"Archive all newsletters older than 3 months."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;"Move all GitHub notifications to the Dev folder."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;"Show me everything from no-reply addresses older than a year."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;There's a dry-run mode - preview exactly what would be moved before committing. I ran it once, it showed 3,000 matching emails. I reviewed the list, confirmed, and they were processed in minutes.&lt;/p&gt;

&lt;p&gt;Thousands of emails sorted while I drank my coffee.&lt;/p&gt;

&lt;h2&gt;
  
  
  "Analyze my communication patterns"
&lt;/h2&gt;

&lt;p&gt;This one turned out more useful than I expected.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;"Who emails me the most?"&lt;/em&gt; - top senders ranked by volume.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;"What percentage of my inbox is automated versus human?"&lt;/em&gt; - 97% automated. 3% real people writing real messages.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;"Which senders do I never open?"&lt;/em&gt; - a hit list for unsubscribing.&lt;/p&gt;

&lt;p&gt;Once you see patterns in your email, you make better decisions about what to filter and where your attention actually goes.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it works
&lt;/h2&gt;

&lt;p&gt;No deep technical dive - just what you'd want to know before trying it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Works with any email provider.&lt;/strong&gt; Gmail, Outlook, Yahoo, Exchange, self-hosted. If it speaks IMAP, it works.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Local cache means instant queries.&lt;/strong&gt; Emails download once and stay cached. Searching 100,000 emails takes seconds.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your password stays in your OS keychain.&lt;/strong&gt; macOS Keychain, Windows Credential Locker, or Linux secret service. Not in a config file.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cache encryption is optional.&lt;/strong&gt; Personal email with sensitive content? Encrypt it. High-volume shared mailbox? Skip encryption, save memory.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Works with Claude Code, Claude Desktop, VS Code, Cursor, and Windsurf.&lt;/strong&gt; Anything that speaks MCP.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Get started
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pipx &lt;span class="nb"&gt;install &lt;/span&gt;git+https://github.com/newlc/IMAP-mcp.git
imap-mcp &lt;span class="nt"&gt;--set-password&lt;/span&gt; &lt;span class="nt"&gt;--config&lt;/span&gt; config.json
claude mcp add imap-mcp &lt;span class="nt"&gt;--&lt;/span&gt; imap-mcp &lt;span class="nt"&gt;--config&lt;/span&gt; config.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then tell Claude:&lt;/p&gt;

&lt;p&gt;&lt;em&gt;"Connect to my email and load the last 200 emails. Who's been emailing me the most?"&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;That's it.&lt;/p&gt;




&lt;p&gt;Source code: &lt;strong&gt;&lt;a href="https://github.com/newlc/IMAP-mcp" rel="noopener noreferrer"&gt;github.com/newlc/IMAP-mcp&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>productivity</category>
      <category>ai</category>
      <category>nocode</category>
    </item>
    <item>
      <title>I Built a 700-Person Video Call Without Zoom. Here's Every Mistake I Made</title>
      <dc:creator>Anton Bukarev</dc:creator>
      <pubDate>Fri, 20 Mar 2026 18:48:10 +0000</pubDate>
      <link>https://dev.to/newlc/i-built-a-700-person-video-call-without-zoom-heres-every-mistake-i-made-5dam</link>
      <guid>https://dev.to/newlc/i-built-a-700-person-video-call-without-zoom-heres-every-mistake-i-made-5dam</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;TL;DR: I ditched Zoom for a self-hosted solution using Janus WebRTC Gateway. It works, but I hit three painful production issues: a signaling storm that crashed the client, silent session timeouts that confused users, and a ulimit that killed me at participant #200. This post covers the full architecture, the API flow that actually matters, and a pre-flight checklist before you try this at scale.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;My company needed to host a 700-person all-hands call (on-premises, no data leaving the corporate network, recordings on our own disks). The usual answer ("just use Zoom") wasn't on the table. Compliance requirements were strict, and this landed on me.&lt;/p&gt;

&lt;p&gt;So I had to own the stack.&lt;/p&gt;

&lt;p&gt;Under the hood: &lt;strong&gt;Janus WebRTC Gateway&lt;/strong&gt;, an open-source C-based media server from Meetecho, GPLv3, on GitHub since 2014. What I write below applies to any WebRTC infrastructure; Janus is just what I ran.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why a Media Server at All?
&lt;/h2&gt;

&lt;p&gt;In a naive P2P mesh, each participant sends a video stream to every other participant. Connections grow as N×(N−1)/2. At 700 people, no client can hold 699 simultaneous outgoing streams at 1.5 Mbps. Your laptop would overheat before the first frame arrives. You need a central server.&lt;/p&gt;

&lt;p&gt;Janus works as an &lt;strong&gt;SFU (Selective Forwarding Unit)&lt;/strong&gt;: each participant sends one stream to the server, and the server forwards it to subscribers. No re-encoding, no decryption of payloads. Just routing encrypted SRTP packets. For audio, it ships an &lt;strong&gt;MCU (AudioBridge plugin)&lt;/strong&gt; that mixes all Opus streams into one, so each client receives a single audio track regardless of room size.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Plugin Architecture
&lt;/h2&gt;

&lt;p&gt;Janus's design principle: the core handles WebRTC connections (ICE, DTLS, SRTP) and JSON message transport (REST, WebSocket, RabbitMQ, MQTT). All application logic lives in plugins. You don't get a monolithic server. You get the plugins you actually need:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;VideoRoom&lt;/strong&gt;: SFU room with publish/subscribe model&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AudioBridge&lt;/strong&gt;: MCU audio mixing&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Streaming&lt;/strong&gt;: for broadcasting pre-recorded or live streams&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Admin API&lt;/strong&gt;: diagnostics, pcap dumps, live draining&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  The API: Three Steps to Media
&lt;/h2&gt;

&lt;p&gt;Interaction with Janus is a strict state machine. Skip a step, get an unhelpful error.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 1: Create a session&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;POST&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;/janus&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"janus"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"create"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"transaction"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"tx-001"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;Response:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"janus"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"success"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"data"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;3718046820721403&lt;/span&gt;&lt;span class="p"&gt;}}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Step 2: Attach to a plugin&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;POST&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;/janus/&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="err"&gt;session_id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"janus"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"attach"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"plugin"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"janus.plugin.videoroom"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"transaction"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"tx-002"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Step 3: Send plugin messages.&lt;/strong&gt; Here's creating a VideoRoom with sane defaults for large calls:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"janus"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"message"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"transaction"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"tx-010"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"body"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"request"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"create"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"room"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1234&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"publishers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"bitrate"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1500000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"notify_joining"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"record"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"lock_record"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;notify_joining: false&lt;/code&gt; line? I'll come back to it. It's the one I ignored and paid for.&lt;/p&gt;

&lt;p&gt;Once a participant publishes their stream with an SDP offer, Janus returns an SDP answer and ICE candidates flow via trickle requests. After ICE and DTLS complete, Janus starts emitting state events you must handle:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;webrtcup&lt;/code&gt;: media channel established. If this never arrives, look at NAT/firewall.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;media&lt;/code&gt;: Janus started (or stopped) receiving media. &lt;code&gt;receiving: false&lt;/code&gt; means the publisher dropped.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;slowlink&lt;/code&gt;: packet loss detected via RTCP NACK. The &lt;code&gt;nacks&lt;/code&gt; field tells you how bad. Act before the user complains.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;hangup&lt;/code&gt;: connection torn down. &lt;code&gt;reason&lt;/code&gt; explains why (ICE failed, DTLS alert, close).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I built a real-time dashboard from these events. At 700 people, flying blind is not an option.&lt;/p&gt;




&lt;h2&gt;
  
  
  Mistake #1: The Signaling Storm
&lt;/h2&gt;

&lt;p&gt;I turned on &lt;code&gt;notify_joining: true&lt;/code&gt; so the UI could show a participant list: every join and leave event, not just active publishers.&lt;/p&gt;

&lt;p&gt;The docs literally warn: &lt;em&gt;"in large rooms this can be overly verbose and chatty."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;I ignored it. At 700 participants, the signaling channel became a flood of join/leave events that overwhelmed the client-side event handler. The UI froze. Users thought the call was broken. I got paged.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; Keep &lt;code&gt;notify_joining: false&lt;/code&gt;. If you need a participant list, fetch it on demand from your app server. Don't fan-out every presence event through Janus.&lt;/p&gt;

&lt;p&gt;Two other VideoRoom parameters that matter at scale:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;threads&lt;/code&gt;: number of forwarding threads for publisher-to-subscriber fan-out. Increase this when the plugin starts falling behind under heavy load.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;bitrate&lt;/code&gt;: enforced via RTCP REMB. Set it at room level, override individually for screen-share presenters who need more bandwidth.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Mistake #2: Sessions Silently Dying
&lt;/h2&gt;

&lt;p&gt;The most common complaint I heard from users: &lt;em&gt;"the call just dropped."&lt;/em&gt; In 80% of cases, the session had expired because I broke the keepalive cycle somewhere in the reconnect logic.&lt;/p&gt;

&lt;p&gt;Janus will silently kill a connection if it stops receiving activity signals. The mechanism depends on transport.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;REST:&lt;/strong&gt; You must maintain a continuous long-poll loop:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;GET /janus/{session_id}?maxev=5
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If no events arrive within 30 seconds, Janus returns &lt;code&gt;{"janus": "keepalive"}&lt;/code&gt; and you must immediately open the next long-poll. Breaking that cycle for longer than &lt;code&gt;session_timeout&lt;/code&gt; destroys the session.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;WebSocket:&lt;/strong&gt; No long-poll needed, but the client must periodically send:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"janus"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"keepalive"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"session_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;3718046820721403&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"transaction"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"tx-ka-001"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Mobile tabs going into the background, frozen JavaScript timers due to power-saving. I hit both of these on the first load test. They're boring bugs that cost you real user trust.&lt;/p&gt;

&lt;p&gt;There's a &lt;code&gt;reclaim_session_timeout&lt;/code&gt; parameter that gives a short window to reclaim an expired session, but don't rely on it as a primary mechanism. Implement proper keepalive with reconnect logic from day one.&lt;/p&gt;




&lt;h2&gt;
  
  
  Authentication: Don't Ship the Dev Default
&lt;/h2&gt;

&lt;p&gt;By default, Janus API is open to everyone. That's an intentional choice to simplify development. Deploying it that way to production is the equivalent of SSH with no password.&lt;/p&gt;

&lt;p&gt;Three real options:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A. Stored tokens:&lt;/strong&gt; every request carries a &lt;code&gt;token&lt;/code&gt; field. Tokens are created/deleted via Admin API and can be scoped to specific plugins. Works well, but requires synchronizing token state across all Janus instances if you run multiple.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;B. HMAC-signed tokens:&lt;/strong&gt; your app server signs short-lived tokens with a shared HMAC secret; Janus validates the signature without external storage. Stateless and clean, but only available for VideoRoom, and tokens can't be revoked before TTL expires. Keep TTL in minutes, not hours.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;C. Janus hidden behind your app server:&lt;/strong&gt; external clients never see Janus at all. Only your backend talks to Janus using a static &lt;code&gt;api_secret&lt;/code&gt;. The Janus docs explicitly describe this pattern as &lt;em&gt;"useful when wrapping the Janus API."&lt;/em&gt; This is what I went with. For a corporate environment with strict security requirements, it's the right answer.&lt;/p&gt;




&lt;h2&gt;
  
  
  Scaling: Sharding and Cascading
&lt;/h2&gt;

&lt;p&gt;Janus is stateful at the room level. Participants, subscriptions, and transport state all live inside a specific instance. Horizontal scaling means room sharding: "room X lives on instance Y." Your signaling server maintains a registry and routes join requests accordingly.&lt;/p&gt;

&lt;p&gt;For storage, I used a PostgreSQL table; Redis or etcd work just as well. The important part is consistency at room creation time and handling the case where an instance crashes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cascading for rooms that outgrow one instance:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;When a room exceeds single-instance capacity (by traffic or geography), VideoRoom supports cascading via remote publishers:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Publisher connects to Janus A&lt;/li&gt;
&lt;li&gt;Your signaling server calls &lt;code&gt;add_remote_publisher&lt;/code&gt; to project them into a room on Janus B&lt;/li&gt;
&lt;li&gt;Subscribers on Janus B see the remote publisher exactly like a local one&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The instances exchange RTP packets directly. Unlike the older &lt;code&gt;rtp_forward&lt;/code&gt; mechanism, remote publishers integrate transparently with VideoRoom: subscriptions, presence events, and SDP negotiation all work through the standard API.&lt;/p&gt;

&lt;p&gt;One thing that surprised me coming from a Kubernetes mindset: &lt;strong&gt;this is not auto-clustering&lt;/strong&gt;. Janus gives you the primitives: &lt;code&gt;add_remote_publisher&lt;/code&gt;, &lt;code&gt;publish_remotely&lt;/code&gt;, &lt;code&gt;update_remote_publisher&lt;/code&gt;, &lt;code&gt;remove_remote_publisher&lt;/code&gt;. Deciding when to cascade, which instances to use, and monitoring load is entirely your signaling server's responsibility. There's no control plane magic.&lt;/p&gt;




&lt;h2&gt;
  
  
  Recording: MJR, janus-pp-rec, ffmpeg
&lt;/h2&gt;

&lt;p&gt;Janus records in MJR format (Meetecho Janus Recording): a structured dump of raw RTP packets with metadata headers (codec, SSRC). The key property: &lt;strong&gt;recording adds no CPU overhead&lt;/strong&gt; beyond normal forwarding. No decoding, no re-encoding.&lt;/p&gt;

&lt;p&gt;I enabled it at room creation with &lt;code&gt;record: true&lt;/code&gt; and &lt;code&gt;lock_record: true&lt;/code&gt; (prevents toggling without room secret, which my compliance team cared about). You can also toggle it for the whole room on demand:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"request"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"enable_recording"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"room"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1234&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"secret"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"room-secret-XYZ"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"record"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;MJR files aren't directly playable. Run them through &lt;code&gt;janus-pp-rec&lt;/code&gt; to get standard containers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Audio: MJR to Opus&lt;/span&gt;
janus-pp-rec /recordings/room1234-user42-audio.mjr /output/user42-audio.opus

&lt;span class="c"&gt;# Video: MJR to WebM&lt;/span&gt;
janus-pp-rec /recordings/room1234-user42-video.mjr /output/user42-video.webm
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;From there, ffmpeg handles multi-track mixing, sync, and final encoding. &lt;strong&gt;Do not run post-processing on the same server handling live calls.&lt;/strong&gt; I learned this the hard way; janus-pp-rec and ffmpeg will spike CPU and I/O at exactly the wrong moment.&lt;/p&gt;

&lt;p&gt;From &lt;code&gt;.opus&lt;/code&gt; output you can pipe into any ASR engine (Whisper, Vosk, etc.) and get a timestamped transcript. Useful if your compliance team wants searchable call records linked to business objects.&lt;/p&gt;




&lt;h2&gt;
  
  
  Mistake #3: The ulimit That Killed Me at Participant 200
&lt;/h2&gt;

&lt;p&gt;Each WebRTC session in Janus consumes several file descriptors: RTP/RTCP sockets, DTLS state. At 700 participants with audio and video, you're in the tens of thousands.&lt;/p&gt;

&lt;p&gt;Default &lt;code&gt;ulimit -n&lt;/code&gt; on most Linux distros: &lt;strong&gt;1024&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;I found this on my first load test at 300 emulated participants. Janus started rejecting new connections silently. It took me an embarrassing amount of time to diagnose because nothing in the Janus logs pointed obviously at the OS limit.&lt;/p&gt;

&lt;p&gt;Set &lt;code&gt;ulimit -n 65536&lt;/code&gt; (or higher) and update &lt;code&gt;/etc/security/limits.conf&lt;/code&gt; so it persists across reboots.&lt;/p&gt;




&lt;h2&gt;
  
  
  Pre-Flight Checklist for 700 People
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Network:&lt;/strong&gt; UDP range for RTP (typically 10000-60000) must be reachable between clients and media server. Janus itself should not be externally reachable. Put a reverse proxy (nginx, HAProxy) in front, TLS-terminate on 443. Admin API: never exposed to the internet.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;TURN:&lt;/strong&gt; Verify ICE/STUN/TURN reachability from every network segment your users will connect from. Set per-username allocation limits and bandwidth caps on your TURN server.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keepalive:&lt;/strong&gt; Implement a robust loop with reconnect handling. Test with mobile clients, background tabs, and power-saving modes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Room params:&lt;/strong&gt; &lt;code&gt;notify_joining: false&lt;/code&gt;, &lt;code&gt;publishers&lt;/code&gt; set to your actual speaker count, &lt;code&gt;bitrate&lt;/code&gt; via REMB. Increase &lt;code&gt;threads&lt;/code&gt; in VideoRoom config for heavy fan-out.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Recording I/O:&lt;/strong&gt; 10 publishers recording audio + video = 20 simultaneous write streams. NVMe handles it; spinning disk may not. Run post-processing on separate machines.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;File descriptors:&lt;/strong&gt; &lt;code&gt;ulimit -n 65536&lt;/code&gt; before anything else.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Load test:&lt;/strong&gt; Not optional. Your first test will find something you missed. GStreamer with WebRTC support can simulate hundreds of fake clients.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Admin API for production incidents&lt;/strong&gt; (keep it behind VPN or allow-list):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;handle_info&lt;/code&gt;: snapshot of WebRTC/ICE/DTLS/RTCP stats for a specific handle. Use it when one participant has frozen video or no audio.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;start_pcap&lt;/code&gt; / &lt;code&gt;stop_pcap&lt;/code&gt;: targeted RTP dump for a single handle, opens in Wireshark. Invaluable for debugging individual participants without capturing everyone's traffic.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;accept_new_sessions: false&lt;/code&gt;: puts an instance in draining mode. Existing sessions finish, new joins go elsewhere. Use this before upgrades.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Janus Documentation:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://janus.conf.meetecho.com/docs/" rel="noopener noreferrer"&gt;General Docs&lt;/a&gt;: architecture overview, installation, configuration&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://janus.conf.meetecho.com/docs/rest.html" rel="noopener noreferrer"&gt;REST API&lt;/a&gt;: sessions, attach, message, trickle, keepalive&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://janus.conf.meetecho.com/docs/videoroom.html" rel="noopener noreferrer"&gt;VideoRoom Plugin&lt;/a&gt;: SFU rooms, publish/subscribe, remote publishers, cascading&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://janus.conf.meetecho.com/docs/audiobridge.html" rel="noopener noreferrer"&gt;AudioBridge Plugin&lt;/a&gt;: MCU audio mixing&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://janus.conf.meetecho.com/docs/admin.html" rel="noopener noreferrer"&gt;Admin/Monitor API&lt;/a&gt;: handle_info, pcap dumps, draining, Event Handlers&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://janus.conf.meetecho.com/docs/auth.html" rel="noopener noreferrer"&gt;Authentication&lt;/a&gt;: stored tokens, HMAC-signed tokens, api_secret&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://janus.conf.meetecho.com/docs/recordings.html" rel="noopener noreferrer"&gt;Recording (MJR)&lt;/a&gt;: MJR format, janus-pp-rec, mjr2pcap&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/meetecho/janus-gateway" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;: source code, issues, demos&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;What's your current self-hosted video stack? Drop a comment. Curious what others are running.&lt;/p&gt;

</description>
      <category>webrtc</category>
      <category>tutorial</category>
      <category>selfhosted</category>
      <category>opensource</category>
    </item>
  </channel>
</rss>
