<?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: Devin</title>
    <description>The latest articles on DEV Community by Devin (@devinios).</description>
    <link>https://dev.to/devinios</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.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4207621%2F9ef45a16-1de6-411a-b71f-fb5d3ed4643f.png</url>
      <title>DEV Community: Devin</title>
      <link>https://dev.to/devinios</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/devinios"/>
    <language>en</language>
    <item>
      <title>18 Games, 617 Assertions: What a One-Person Static Arcade Taught Me About Testing</title>
      <dc:creator>Devin</dc:creator>
      <pubDate>Sun, 08 Nov 2026 20:07:27 +0000</pubDate>
      <link>https://dev.to/devinios/18-games-617-assertions-what-a-one-person-static-arcade-taught-me-about-testing-1n1k</link>
      <guid>https://dev.to/devinios/18-games-617-assertions-what-a-one-person-static-arcade-taught-me-about-testing-1n1k</guid>
      <description>&lt;p&gt;I wanted games that open the moment you click. No install, no sign-in, no loading screen, no cookie banner. Tap a link on a mediocre phone and you are already playing. That sounds like a small constraint. It ended up deciding almost every technical choice in the project.&lt;/p&gt;

&lt;p&gt;The site now has 18 games — board games, puzzles, card games. All free, no login, each one a single file, no external dependencies, no trackers. Plain HTML, CSS and JavaScript. No framework, no build step. The boards and pieces are inline SVG I drew by hand.&lt;/p&gt;

&lt;p&gt;This is not a story about clever architecture. It is a story about how I convinced myself the games actually work, and about two measurement mistakes that nearly made me ship broken pages.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why static, and why hand-drawn
&lt;/h2&gt;

&lt;p&gt;A static site is the only shape that satisfies the constraints. Every game is a file you can open from disk or put on any static host. There is no server that can be down, no database to migrate, nothing to keep warm. On a weak connection the whole game is one HTML document, loaded once.&lt;/p&gt;

&lt;p&gt;No framework because a board game is a state machine. A position, a list of legal moves, and an event handler. Vanilla JavaScript expresses that directly. A framework would add a payload larger than the game itself, plus a build step and a dependency tree that has to stay current. I would rather spend that budget on tests.&lt;/p&gt;

&lt;p&gt;Hand-drawn SVG because it keeps the promise of a single file. Raster images mean extra requests and resolution decisions; an SVG board scales to any screen and stays sharp. Inline SVG also gives one source of truth for a piece: the same shape can be filled, highlighted and animated. And there is no image licensing to audit.&lt;/p&gt;

&lt;p&gt;One rule follows from all of this: no game may depend on another game, a shared runtime, or a CDN. If one page breaks, the other 17 are unaffected, and the broken page is still readable as source.&lt;/p&gt;

&lt;h2&gt;
  
  
  The hard part: testing without looking
&lt;/h2&gt;

&lt;p&gt;Testing a game is not like testing a form. You cannot look at a board and decide whether it is correct. A position can look plausible and be wrong two moves deeper. So most of the suite is built around things you can count.&lt;/p&gt;

&lt;p&gt;The core tool is perft: walk the move generator to a fixed depth and count leaf nodes. The counts are exact. If one edge case in move generation is wrong, the number diverges immediately.&lt;/p&gt;

&lt;p&gt;For Chinese chess (xiangqi), perft at depths 1, 2 and 3 gives 44, 1,920 and 79,666. Those match the standard published values.&lt;/p&gt;

&lt;p&gt;For international chess, perft(1/2/3) is 20, 400 and 8,902. On top of that, 37 targeted tests cover the rules that perft alone does not isolate: 11 for castling, 7 for en passant, 7 for promotion, 3 for checkmate and 5 for stalemate.&lt;/p&gt;

&lt;p&gt;Go scoring is checked against three fixed boards with known results: 45:36, 42:39 and 40.5:40.5. The half-point case is the useful one. It forces the scoring code to keep fractional territory correct — a mistake that is invisible when you eyeball a board.&lt;/p&gt;

&lt;p&gt;The 24 game, where you make 24 from four numbers, has a different failure mode: a deal can be generated correctly and still be unsolvable, and a solver can be too eager and claim a solution that does not exist. So the test deals 1,000 hands in a row and independently verifies every one is solvable, covering 669 distinct combinations. That catches both duplicate deals and a solver that overreports.&lt;/p&gt;

&lt;p&gt;Dou Dizhu is tested by self-play: 200 AI-versus-AI games, 0 illegal plays, landlord win rate 63.0%. The first number is a rules-engine regression test. The win rate is a sanity check that the AI is not reaching its result by breaking the rules. I am not claiming the AI is strong — only that it obeys the rules and wins at a plausible rate.&lt;/p&gt;

&lt;p&gt;Connect Four gets a relative check: the harder AI plays the simple AI 20 times and wins 20. That says the stronger setting is actually stronger. It says nothing about how strong it is against a human.&lt;/p&gt;

&lt;p&gt;All of it runs with one command:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;node games/tests/run-all.js
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;17 test files, 617 assertions. The single command matters more than the count. If running the tests is a chore, they rot. If it is one command that prints pass or fail, I run it after every change.&lt;/p&gt;

&lt;p&gt;There is one more check that unit tests cannot see: a crawl of the whole site. It currently covers 108 pages and 112 internal links, and finds 0 404s. That is where a broken relative path gets caught — not in the game logic, but in the way the pages point at each other.&lt;/p&gt;

&lt;p&gt;A note on what these tests do not prove. Perft proves that move generation matches a known count at a shallow depth. It does not prove the UI renders the move correctly, that the AI is pleasant to play against, or that the game is fun. Those still need a person. The tests exist to keep the parts that a person cannot check by eye from silently drifting.&lt;/p&gt;

&lt;h2&gt;
  
  
  Multiplayer: where the server earns its place
&lt;/h2&gt;

&lt;p&gt;Multiplayer runs on Cloudflare Workers. A Durable Object per room handles signaling; voice is WebRTC mesh, capped at 4 players.&lt;/p&gt;

&lt;p&gt;The Durable Object is there because signaling is stateful. A room has a membership list, join and leave events, and offer/answer routing between peers. In a stateless function you would have to invent a store and handle concurrent updates yourself. A Durable Object is one object per room, and requests to it are serialized. That removes a class of race conditions by construction.&lt;/p&gt;

&lt;p&gt;For three of the board games the server is authoritative: the Durable Object validates moves, so a modified client cannot make an illegal move. In single-player that is a nicety. In multiplayer it is the difference between a game and a free-for-all, because the other players cannot see your screen.&lt;/p&gt;

&lt;p&gt;The mesh is a deliberate ceiling. With 4 players, each peer sends audio to 3 others — 6 connections in total. At 10 players it would be 45, and uplink becomes the bottleneck. So 4 is a hard cap, not an arbitrary number.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two mistakes that lied to me
&lt;/h2&gt;

&lt;p&gt;The first was the 404 fallback. Static hosts often serve the index page for unknown paths, so a typo returns 200 with the home page attached. My first link checker asked, "did this URL return 404?" It reported no problems while links were broken. The fix was to stop trusting the status code and ask whether the page that came back is the page that was requested — compare title and content, not the status.&lt;/p&gt;

&lt;p&gt;The second was "both deployments succeeded". The site ships to Cloudflare Pages and GitHub Pages. A successful deployment does not mean identical bytes: build order, caching and line endings can all differ. The only claim I trust is an md5 comparison of the same path fetched from both hosts. Right now every page is byte-identical. That is a much stronger statement than two green checkmarks, and it is the one I put in the README.&lt;/p&gt;

&lt;p&gt;Both mistakes share a shape: I measured a proxy for correctness instead of correctness itself. A status code is not a page. A green deploy is not the same artifact.&lt;/p&gt;

&lt;h2&gt;
  
  
  Limits I would rather state
&lt;/h2&gt;

&lt;p&gt;The mesh voice only makes sense at 4 players. There are no accounts and no match history. Some of the AIs are heuristics, not search — they play legally and reasonably, and they will not beat a strong human. The site has no analytics, which also means I cannot tell you which game people like most.&lt;/p&gt;

&lt;p&gt;That is the honest shape of a one-person static project: a small surface, a lot of counting, and a few places where the only reliable test is a byte comparison.&lt;/p&gt;

&lt;p&gt;If you want to try it, the games are here: &lt;a href="https://hhfa008.github.io/games/" rel="noopener noreferrer"&gt;https://hhfa008.github.io/games/&lt;/a&gt;&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>webdev</category>
      <category>gamedev</category>
      <category>testing</category>
    </item>
    <item>
      <title>Two consulting slots this week: one hour, one iOS problem</title>
      <dc:creator>Devin</dc:creator>
      <pubDate>Sun, 08 Nov 2026 06:40:40 +0000</pubDate>
      <link>https://dev.to/devinios/two-consulting-slots-this-week-one-hour-one-ios-problem-3o8e</link>
      <guid>https://dev.to/devinios/two-consulting-slots-this-week-one-hour-one-ios-problem-3o8e</guid>
      <description>&lt;p&gt;I'm taking &lt;strong&gt;two consulting slots this week&lt;/strong&gt; — one-hour, focused sessions on a single concrete iOS/macOS problem.&lt;/p&gt;

&lt;p&gt;What I'm good at (13 years native Apple platforms):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A crash you can't reproduce, or a frame-rate drop that only shows up on device&lt;/li&gt;
&lt;li&gt;Memory growth in long sessions&lt;/li&gt;
&lt;li&gt;WebRTC / screen-sharing pipelines: capture, encode, transport, decode&lt;/li&gt;
&lt;li&gt;App Review rejections (2.1, 4.2, 4.3, 5.1) — evidence gathering, not just appeals&lt;/li&gt;
&lt;li&gt;Inherited codebases: where to start, what to leave alone&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Recent work: merged two parallel live-streaming iOS SDKs into one 2M-line codebase (~90% less maintenance, zero high-severity incidents); led client work for a global sports event with 30+ engineers; rebuilt the view layer of a 100M+ DAU app.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How it works&lt;/strong&gt;: $15 for 60 minutes, booked here → &lt;a href="https://devinspire734.gumroad.com/l/60-minute-ios-debugging-session" rel="noopener noreferrer"&gt;https://devinspire734.gumroad.com/l/60-minute-ios-debugging-session&lt;/a&gt;&lt;br&gt;
You send the symptom, the stack, and what you already tried; I reply within 24h with two time slots (GMT+8, EU afternoon / US morning overlap). If I can't reproduce or explain the issue on the call, it's free.&lt;/p&gt;

&lt;p&gt;If you'd rather just read: I also wrote up one of these debugging sessions as a case study — 0% packet loss and still 35 freezes → &lt;a href="https://dev.to/devinios/zero-packet-loss-35-freezes-a-screen-sharing-performance-case-study-3hf8"&gt;https://dev.to/devinios/zero-packet-loss-35-freezes-a-screen-sharing-performance-case-study-3hf8&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Two slots, first come first served.&lt;/p&gt;

</description>
      <category>discuss</category>
      <category>ios</category>
      <category>career</category>
      <category>performance</category>
    </item>
    <item>
      <title>Vì sao chia sẻ màn hình vẫn giật dù mất gói 0%?</title>
      <dc:creator>Devin</dc:creator>
      <pubDate>Sun, 08 Nov 2026 04:42:25 +0000</pubDate>
      <link>https://dev.to/devinios/vi-sao-chia-se-man-hinh-van-giat-du-mat-goi-0-2437</link>
      <guid>https://dev.to/devinios/vi-sao-chia-se-man-hinh-van-giat-du-mat-goi-0-2437</guid>
      <description>&lt;p&gt;Chúng tôi đo một phiên chia sẻ màn hình trên macOS: độ phân giải 3024x1964, H.264 qua VideoToolbox, fps trung vị 29, thời gian encode 12.3 ms, &lt;code&gt;dropped=0&lt;/code&gt; ở phía chia sẻ.&lt;/p&gt;

&lt;p&gt;Mạng thì hoàn hảo: RTT 0–1 ms, mất gói 0%.&lt;/p&gt;

&lt;p&gt;Nhưng ở phía người xem, &lt;code&gt;freezes&lt;/code&gt; vẫn cộng dồn tới 35 lần, có đoạn 2 giây chỉ còn 3 fps.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Kết luận đầu tiên: nút thắt không nằm ở mạng.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Vì sao
&lt;/h2&gt;

&lt;p&gt;Ba thứ thường bị bỏ qua khi đo:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Encode không phải nguyên nhân.&lt;/strong&gt; 12.3 ms/frame trên ngân sách 33 ms ở 30 fps — mới dùng 37%.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Adaptive quality không hề kích hoạt.&lt;/strong&gt; 501/501 mẫu đều &lt;code&gt;limit=none&lt;/code&gt;, tức engine chưa từng giảm chất lượng dù fps rơi xuống 3.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Thứ tự giảm chất lượng bị ngược.&lt;/strong&gt; Với screen sharing, WebRTC xu hướng giữ nguyên độ phân giải và hy sinh frame rate. Kết quả: hình vẫn nét nhưng bị giật.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Nên làm gì
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Đặt &lt;code&gt;degradationPreference&lt;/code&gt; tường minh, đừng để mặc định.&lt;/li&gt;
&lt;li&gt;Tự dựng thang độ phân giải (100% → 66% → 50%) và hạ độ phân giải trước khi fps xuống dưới 15.&lt;/li&gt;
&lt;li&gt;Giới hạn độ phân giải ngay ở capture. Khung 3024x1964 là ~5.9 megapixel; hạ về 1920x1200 giảm 4 lần khối lượng.&lt;/li&gt;
&lt;li&gt;Ghi log riêng &lt;code&gt;dropped&lt;/code&gt;, &lt;code&gt;freezes&lt;/code&gt;, thời gian decode — không có thì chỉ đoán.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Công cụ miễn phí
&lt;/h2&gt;

&lt;p&gt;Mình làm một máy tính ngân sách latency/bandwidth cho chia sẻ màn hình:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://hhfa008.github.io/tools/screen-share-calc/vi/" rel="noopener noreferrer"&gt;https://hhfa008.github.io/tools/screen-share-calc/vi/&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Bộ checklist 18 điểm + script đo đầy đủ (bản trả phí): &lt;a href="https://devinspire734.gumroad.com/l/xejxsp/FIRST50" rel="noopener noreferrer"&gt;https://devinspire734.gumroad.com/l/xejxsp/FIRST50&lt;/a&gt;&lt;/p&gt;

</description>
      <category>vietnamese</category>
      <category>webrtc</category>
      <category>performance</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Kenapa Screen Sharing Tetap Lag Padahal Packet Loss 0%?</title>
      <dc:creator>Devin</dc:creator>
      <pubDate>Sun, 08 Nov 2026 04:42:24 +0000</pubDate>
      <link>https://dev.to/devinios/kenapa-screen-sharing-tetap-lag-padahal-packet-loss-0-3c5f</link>
      <guid>https://dev.to/devinios/kenapa-screen-sharing-tetap-lag-padahal-packet-loss-0-3c5f</guid>
      <description>&lt;p&gt;Kami mengukur satu sesi screen sharing di macOS: resolusi 3024x1964, H.264 lewat VideoToolbox, frame rate median 29 fps, waktu encoding 12.3 ms, dan &lt;code&gt;dropped=0&lt;/code&gt; di sisi pengirim.&lt;/p&gt;

&lt;p&gt;Jaringan? Sempurna. RTT 0–1 ms, packet loss 0%.&lt;/p&gt;

&lt;p&gt;Tapi di sisi penonton, &lt;code&gt;freezes&lt;/code&gt; tetap terakumulasi sampai 35 kali, dengan satu segmen 2 detik yang hanya 3 fps.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Kesimpulan pertama: bottleneck-nya bukan jaringan.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Kenapa bisa begitu
&lt;/h2&gt;

&lt;p&gt;Ada tiga hal yang biasanya luput diukur:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Encoding bukan penyebabnya.&lt;/strong&gt; 12.3 ms per frame dari anggaran 33 ms di 30 fps — masih 37% terpakai.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Adaptive quality tidak pernah aktif.&lt;/strong&gt; Dari 501 sampel, &lt;code&gt;limit=none&lt;/code&gt; di semua sampel. Artinya engine tidak pernah menurunkan kualitas, meski frame rate jatuh ke 3.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Preferensi degradasi salah arah.&lt;/strong&gt; Untuk screen sharing, WebRTC cenderung mempertahankan resolusi dan mengorbankan frame rate. Jadi saat jaringan menurun, yang Anda lihat adalah gambar tetap tajam tapi tersendat.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Yang bisa dilakukan
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Set &lt;code&gt;degradationPreference&lt;/code&gt; secara eksplisit, jangan biarkan default.&lt;/li&gt;
&lt;li&gt;Buat tangga resolusi sendiri (100% → 66% → 50%) dan turunkan resolusi sebelum frame rate jatuh di bawah 15.&lt;/li&gt;
&lt;li&gt;Batasi resolusi di sisi capture, bukan di encoder. Frame 3024x1964 adalah ~5.9 megapiksel; turun ke 1920x1200 memotong kerja 4x.&lt;/li&gt;
&lt;li&gt;Catat &lt;code&gt;dropped&lt;/code&gt;, &lt;code&gt;freezes&lt;/code&gt;, dan waktu decode secara terpisah — tanpa itu Anda hanya bisa menebak.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Alat gratis
&lt;/h2&gt;

&lt;p&gt;Saya membuat kalkulator budget latency &amp;amp; bandwidth untuk screen sharing: masukkan resolusi, fps, codec, dan bandwidth, lalu lihat di tahap mana budget Anda pecah.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://hhfa008.github.io/tools/screen-share-calc/id/" rel="noopener noreferrer"&gt;https://hhfa008.github.io/tools/screen-share-calc/id/&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Kalau butuh checklist lengkap 18 poin plus skrip pengukuran, ada di sini (versi berbayar): &lt;a href="https://devinspire734.gumroad.com/l/xejxsp/FIRST50" rel="noopener noreferrer"&gt;https://devinspire734.gumroad.com/l/xejxsp/FIRST50&lt;/a&gt;&lt;/p&gt;

</description>
      <category>indonesian</category>
      <category>webrtc</category>
      <category>performance</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Estimating screen-sharing bitrate: what bpp actually means and when it lies</title>
      <dc:creator>Devin</dc:creator>
      <pubDate>Sun, 08 Nov 2026 03:01:13 +0000</pubDate>
      <link>https://dev.to/devinios/estimating-screen-sharing-bitrate-what-bpp-actually-means-and-when-it-lies-1l00</link>
      <guid>https://dev.to/devinios/estimating-screen-sharing-bitrate-what-bpp-actually-means-and-when-it-lies-1l00</guid>
      <description>&lt;p&gt;Every screen-sharing feature hits the same meeting: someone asks how much bandwidth a share needs, and someone answers with a formula.&lt;/p&gt;

&lt;p&gt;The formula is usually this:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;bitrate ≈ width × height × fps × bpp&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;It looks authoritative: it has units, and it produces a number with "Mbps" after it. That is exactly why it is dangerous — it fails precisely on the content that matters most.&lt;/p&gt;

&lt;p&gt;This post explains what bpp is, gives the ranges people use (as rules of thumb, not measurements), shows a measured control case where the formula holds, and lists three cases where it lies.&lt;/p&gt;

&lt;h2&gt;
  
  
  What bpp is, and what it is not
&lt;/h2&gt;

&lt;p&gt;bpp — bits per pixel — is not a codec constant; no standard defines it. It is a budgeting convention: take one second of video, count its pixels, and ask how many bits to spend on each.&lt;/p&gt;

&lt;p&gt;For a 1920×1080 stream at 30 fps, one second contains 62,208,000 pixels. At 0.08 bpp, that is about 4.98 Mbps. The arithmetic is trivial; the entire difficulty is choosing bpp.&lt;/p&gt;

&lt;p&gt;The value folds in everything the codec does: motion compensation, quantization, entropy coding, and how much detail the encoder throws away. So bpp is useful as a planning number and useless as a guarantee. It answers "what budget should I reserve", not "what will the encoder produce".&lt;/p&gt;

&lt;h2&gt;
  
  
  Reference ranges (empirical, not measured here)
&lt;/h2&gt;

&lt;p&gt;The numbers below are rules of thumb from common practice. I did not measure them for this post, and you should not quote them as if I did.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Codec / content&lt;/th&gt;
&lt;th&gt;bpp (rule of thumb)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;H.264, screen content&lt;/td&gt;
&lt;td&gt;0.05 – 0.15&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;HEVC, same content and quality target&lt;/td&gt;
&lt;td&gt;roughly 0.6 – 0.7 × the H.264 bitrate&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AV1&lt;/td&gt;
&lt;td&gt;lower again; treat the multiplier as a hypothesis, not a fact&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two warnings: the ranges are wide on purpose, and they describe typical mixed screen content — windows, text, images, some motion. Change the content and the range moves more than the codec does.&lt;/p&gt;

&lt;p&gt;Remember what the formula multiplies. If you feed it a nominal 30 fps but the share emits 12 because the screen is static, you have overestimated by 2.5×. Use frames sent, not frames desired.&lt;/p&gt;

&lt;h2&gt;
  
  
  A control case where the estimate holds
&lt;/h2&gt;

&lt;p&gt;We ran a share at 3024×1964, H.264 hardware encoding, median 29 fps. Per-frame encode time was 12.3 ms, and the encoder dropped 0 frames.&lt;/p&gt;

&lt;p&gt;At 12.3 ms per frame the encoder ceiling is about 81 fps. At 29 fps, encoding occupies roughly 36% of the budget: 12.3 × 29 ≈ 357 ms out of every 1000 ms. The encoder is not saturated, and &lt;code&gt;dropped&lt;/code&gt; stayed at 0.&lt;/p&gt;

&lt;p&gt;That matters because the formula assumes the encoder can deliver the requested frames at the requested size. When it can, the estimate describes a real operating point. At 0.08 bpp this stream implies:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;3024 × 1964 × 29 × 0.08 ≈ 13.8 Mbps&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;The number is a budget, not a measurement. What the test establishes is narrower: nothing on the encode side prevents the stream from spending it, so a bitrate estimate is the right tool.&lt;/p&gt;

&lt;p&gt;Flip the condition and the formula fails. If per-frame encode time were 40 ms, the ceiling would be 25 fps, and asking for 29 fps would drop frames. Actual bits per second would land below the estimate — not because the network refused, but because the encoder never produced those frames.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rule: check encode time × fps before trusting any bitrate estimate. Above roughly 70–80% of the frame budget, stop estimating and start measuring.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Where bpp lies
&lt;/h2&gt;

&lt;p&gt;Three content patterns break the formula in three different directions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Static screens: the estimate is too high.&lt;/strong&gt; Inter-frame prediction makes unchanged regions almost free. A desktop that has not moved for ten seconds can encode to a few kilobits per second, not the 13.8 Mbps the formula predicts. Sizing a link from the estimate reserves capacity nobody uses. Worse, a bpp-driven health check will read a static share as suspiciously cheap.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pure text: the estimate is too high, and quality is the thing to watch.&lt;/strong&gt; Text on a flat background compresses extremely well, especially with screen-content tools, so actual bitrate can sit far below the estimate. The failure mode is not bandwidth but legibility: at a low bitrate the encoder can preserve the rectangles and destroy the strokes. Bits per second looks fine; the reader cannot read the code.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Video playback inside the share: the estimate is too low.&lt;/strong&gt; A video window is high-entropy, fast-changing content with little screen-like redundancy. The encoder must spend real bits; capping it at the formula's budget does not make the content cheaper — it makes it blurrier. Measured bitrate will hit the ceiling and stay there, while quality collapses.&lt;/p&gt;

&lt;p&gt;bpp estimates a typical frame, and screen content is rarely typical. It is a prior, not an answer.&lt;/p&gt;

&lt;h2&gt;
  
  
  The correction loop that actually works
&lt;/h2&gt;

&lt;p&gt;Treat 0.08 bpp as a starting point and let the transport correct it. Measure &lt;code&gt;bytesSent&lt;/code&gt; on the outbound RTP stream, convert to bits per second, and back out the bpp actually used:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;measureBpp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pc&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;height&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;windowMs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;10000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;snap&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;stats&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;pc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getStats&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;stats&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;outbound-rtp&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;kind&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;video&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;bytesSent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;frames&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;framesSent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;ts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;timestamp&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;snap&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;windowMs&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;snap&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;seconds&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ts&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ts&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;bytes&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;seconds&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;height&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;frames&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;frames&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;seconds&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Use frames actually sent, not the configured rate. Read bytes at the transport, not the encoder, so RTP overhead and retransmissions are included. Use a window long enough to cross content states: ten seconds is a default.&lt;/p&gt;

&lt;p&gt;Then close the loop: measure a representative minute, compare to the estimate, and correct the next estimate by the ratio. If measured bpp is 0.04 for your content mix at acceptable quality, plan with 0.04 — do not keep quoting 0.08 because a blog post said so. Including this one.&lt;/p&gt;

&lt;h2&gt;
  
  
  The short version
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;bitrate ≈ width × height × fps × bpp&lt;/code&gt; is a budgeting formula, not a codec guarantee.&lt;/li&gt;
&lt;li&gt;Start at &lt;strong&gt;0.08 bpp&lt;/strong&gt; for H.264 screen content, then measure and correct.&lt;/li&gt;
&lt;li&gt;Empirical ranges: H.264 &lt;strong&gt;0.05–0.15 bpp&lt;/strong&gt;; HEVC roughly &lt;strong&gt;0.6–0.7×&lt;/strong&gt;; AV1 lower. These are rules of thumb, not measurements.&lt;/li&gt;
&lt;li&gt;Before trusting the estimate, check that &lt;strong&gt;encode time × fps&lt;/strong&gt; leaves headroom. In our 3024×1964 / 29 fps / 12.3 ms / 0-dropped run, it did.&lt;/li&gt;
&lt;li&gt;Static screens and text will come in &lt;strong&gt;below&lt;/strong&gt; the estimate; video playback will come in &lt;strong&gt;above&lt;/strong&gt; it.&lt;/li&gt;
&lt;li&gt;Use &lt;strong&gt;frames sent&lt;/strong&gt;, at the &lt;strong&gt;transport layer&lt;/strong&gt;, over a &lt;strong&gt;10-second window&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;The number you should trust is the one you measured on your own content.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;To run the arithmetic and the trade-offs without a spreadsheet, there is a free calculator here: &lt;strong&gt;&lt;a href="https://hhfa008.github.io/tools/screen-share-calc/" rel="noopener noreferrer"&gt;https://hhfa008.github.io/tools/screen-share-calc/&lt;/a&gt;&lt;/strong&gt;. It shows every formula it uses and labels every output as an estimate.&lt;/p&gt;

</description>
      <category>webrtc</category>
      <category>performance</category>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Why your screen sharing drops to 5 fps instead of lowering resolution</title>
      <dc:creator>Devin</dc:creator>
      <pubDate>Sat, 07 Nov 2026 19:53:52 +0000</pubDate>
      <link>https://dev.to/devinios/why-your-screen-sharing-drops-to-5-fps-instead-of-lowering-resolution-4l7f</link>
      <guid>https://dev.to/devinios/why-your-screen-sharing-drops-to-5-fps-instead-of-lowering-resolution-4l7f</guid>
      <description>&lt;p&gt;When a screen share gets choppy, most people assume the network is bad. Sometimes it is. But there is a quieter failure mode where the network is fine, the encoder is fine, and the picture still collapses to single-digit frame rates while resolution never moves. The user ends up with the worst of both worlds: a soft frame that also stutters.&lt;/p&gt;

&lt;p&gt;The reason is a default you probably never set: which dimension the pipeline is allowed to sacrifice under pressure.&lt;/p&gt;

&lt;h2&gt;
  
  
  The default is not "lower resolution"
&lt;/h2&gt;

&lt;p&gt;WebRTC exposes &lt;code&gt;degradationPreference&lt;/code&gt; on a sender, with three values: &lt;code&gt;maintain-resolution&lt;/code&gt;, &lt;code&gt;maintain-framerate&lt;/code&gt;, and &lt;code&gt;balanced&lt;/code&gt;. The names suggest a symmetric trade-off. The behavior is not symmetric in practice.&lt;/p&gt;

&lt;p&gt;For screen content, many stacks lean toward preserving resolution, because text and tables are unreadable if you halve the dimensions. That reasoning is sound for a still image. Video is not a still image. When the encoder is told to keep 3024×1964 and the bitrate budget tightens, it cannot keep quality by magic. It keeps the pixel count, spends fewer bits per frame, and the result is visibly soft. At the same time, the pacing logic has to shed something, and what it sheds is frame rate.&lt;/p&gt;

&lt;p&gt;So "maintain resolution" does not actually protect legibility. It protects a number. What the viewer sees is a soft, low-frame-rate stream that is neither sharp nor fluid.&lt;/p&gt;

&lt;h2&gt;
  
  
  What our measurement showed
&lt;/h2&gt;

&lt;p&gt;We ran an end-to-end capture of a 3024×1964 window, H.264 through the hardware encoder (VideoToolbox), sender and viewer on the same subnet.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Median frame rate: 29 fps. Minimum: 3 fps.&lt;/li&gt;
&lt;li&gt;Single-frame encode time: 12.3 ms.&lt;/li&gt;
&lt;li&gt;Dropped frames reported by the encoder: 0.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;limit&lt;/code&gt; queried from the capture side: &lt;code&gt;none&lt;/code&gt; in all 501 of 501 samples.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Read those together. The encoder is not struggling — 12.3 ms per frame leaves headroom. No frames were dropped at the encoder. The bandwidth estimator never once asked the pipeline to constrain itself. And yet the delivered frame rate bottomed out at 3.&lt;/p&gt;

&lt;p&gt;That is the important part: adaptation never engaged. The 3 fps was not the controller choosing a lower frame rate under a bandwidth limit. It was the absence of any decision. Resolution stayed at full size the entire run because nothing touched it, and frame rate fell for reasons upstream of the decision the controller makes.&lt;/p&gt;

&lt;p&gt;If you only watch average frame rate, this looks like "bad network." It is not. It is a pipeline with no owner for the resolution-versus-frame-rate choice.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the frame rate is the first thing to go
&lt;/h2&gt;

&lt;p&gt;Three mechanisms conspire:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Capture is uneven.&lt;/strong&gt; Screen content alternates between still and scrolling. A capture loop that emits at a fixed rate builds queue depth during scrolls. The frame rate you measure is an average over wildly varying demand.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Encode time is roughly fixed.&lt;/strong&gt; 12.3 ms per frame is a throughput ceiling that does not move when content gets harder; the demand does. Throughput flat, demand spiky: queueing is the only possible result.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The jitter buffer is built for jitter, not for stops.&lt;/strong&gt; If the sender pauses for 200 ms, the buffer underruns and the viewer freezes, while every network counter stays perfect.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;None of these are solved by a preference flag. A preference says what to do if the engine decides to act. It does not make the engine act, and it does not remove capture-side load.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three things you can actually control
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Set &lt;code&gt;degradationPreference&lt;/code&gt; explicitly
&lt;/h3&gt;

&lt;p&gt;Do not inherit it. Decide, per session, what the product needs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sender&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;pc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getSenders&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;track&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;kind&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;video&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;sender&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getParameters&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;degradationPreference&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;maintain-framerate&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;sender&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setParameters&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For screen sharing where motion and responsiveness matter, &lt;code&gt;maintain-framerate&lt;/code&gt; is often the better default — a soft frame that keeps updating is easier to follow than a sharp frame that freezes. For a document being read line by line, the opposite may be true. Either way, the choice is a product decision, not something to leave to the engine.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Maintain your own resolution ladder
&lt;/h3&gt;

&lt;p&gt;Instead of waiting for adaptation, step resolution yourself on a schedule tied to observed conditions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;3024×1964 → 1920×1247 → 1280×831 → 960×623
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Call &lt;code&gt;applyConstraints&lt;/code&gt; with a reduced &lt;code&gt;width&lt;/code&gt;/&lt;code&gt;height&lt;/code&gt;, or use &lt;code&gt;scaleResolutionDownBy&lt;/code&gt; on the sender. The point is that each step is a real, bounded reduction that lowers encode cost and queue depth — not a preference the engine may ignore.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Limit resolution at capture
&lt;/h3&gt;

&lt;p&gt;The cheapest fix is upstream: do not capture at native size and hope the encoder manages it. Cap the capture dimensions to what the viewer can actually resolve. A 3024-pixel-wide frame on a 1440-pixel viewer is mostly wasted bits, and it makes every downstream stage more expensive. The pipeline gets easier at every stage when the source is already the size you intend to send.&lt;/p&gt;

&lt;h2&gt;
  
  
  The decision you are not making
&lt;/h2&gt;

&lt;p&gt;The uncomfortable summary: when the engine keeps resolution and drops frames, it is making a choice you did not make. That choice is defensible for a photograph and wrong for a live screen. A number that preserves legibility at 3 fps is an illusion — a sharp frame no one can read because it never changes.&lt;/p&gt;

&lt;p&gt;Instrument the frame-rate distribution, not the average. Watch whether &lt;code&gt;limit&lt;/code&gt; ever leaves &lt;code&gt;none&lt;/code&gt;. If it never does, adaptation is not saving you, and the resolution ladder is yours to build.&lt;/p&gt;

&lt;p&gt;I also made a free calculator for this: &lt;a href="https://hhfa008.github.io/tools/screen-share-calc/" rel="noopener noreferrer"&gt;Screen Sharing Latency &amp;amp; Bandwidth Calculator&lt;/a&gt; — plug in resolution, frame rate, codec, and available bandwidth to see where the budget breaks.&lt;/p&gt;

</description>
      <category>webrtc</category>
      <category>performance</category>
      <category>ios</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Zero packet loss, 35 freezes: a screen-sharing performance case study</title>
      <dc:creator>Devin</dc:creator>
      <pubDate>Sat, 07 Nov 2026 18:56:24 +0000</pubDate>
      <link>https://dev.to/devinios/zero-packet-loss-35-freezes-a-screen-sharing-performance-case-study-3hf8</link>
      <guid>https://dev.to/devinios/zero-packet-loss-35-freezes-a-screen-sharing-performance-case-study-3hf8</guid>
      <description>&lt;p&gt;Zero packet loss. 0–1 ms round-trip time. A hardware encoder that dropped nothing. And a viewer that froze 35 times, bottoming out at 3 frames per second.&lt;/p&gt;

&lt;p&gt;That was the result of an end-to-end measurement of a macOS screen-sharing stack. The network was not the problem — and also not irrelevant. Understanding how both are true changes what you instrument and what you optimize.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup and the symptom
&lt;/h2&gt;

&lt;p&gt;One Mac sharing a 3024×1964 window, H.264 through the hardware encoder, sender and viewer on the same subnet. Over 501 samples, RTT never left the 0–1 ms band and packet loss stayed at 0%. The encoder reported zero dropped frames.&lt;/p&gt;

&lt;p&gt;The viewer told a different story: 35 cumulative freezes. Frame rate had a median of 29 fps, a minimum of 3, and a maximum of 48 — including a two-second stretch that delivered three frames. Single-frame encode time was 12.3 ms, roughly a third of a core at 29 fps. Sender CPU averaged 27.9% (41.2% peak); viewer CPU averaged 20.4%. Resident memory stayed inside a stable band on both sides, with no monotonic growth. The bandwidth limit queried from the capture side came back &lt;code&gt;none&lt;/code&gt; in all 501 samples.&lt;/p&gt;

&lt;p&gt;So: a perfect channel, an idle-looking endpoint, and a picture that periodically stops.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to measure "stutter"
&lt;/h2&gt;

&lt;p&gt;The word stutter is useless until you define it. I split observation into three planes and required all three to share one timeline.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Process and resources.&lt;/strong&gt; CPU mean and peak, RSS range, and whether memory trends upward. This plane exists to kill the most over-used explanation first: a leak. There was no leak.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Capture and encode.&lt;/strong&gt; Frame rate as a distribution — median, minimum, maximum — not an average. Per-frame encode time, dropped-frame count, capture queue behavior. An average of 29 fps with a minimum of 3 is a localized-stall signature that the mean erases.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Transport.&lt;/strong&gt; RTT, loss, the actual ICE candidate pair type, DTLS-SRTP completion time, and the &lt;code&gt;limit&lt;/code&gt; value asked of the capture side at intervals — "what am I being constrained to right now?"&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Then the definition that matters: a freeze means the viewer's picture did not update for N consecutive frames. It does not mean a packet was lost. Capture stalls, encoder queueing, renderer drops, and jitter-buffer underruns all land in one bucket. That is why the first reaction to a freeze count should be "check the pipeline," not "check the network."&lt;/p&gt;

&lt;h2&gt;
  
  
  Three counterintuitive findings
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. A good network is exactly why adaptation never engages
&lt;/h3&gt;

&lt;p&gt;Five hundred and one samples. Five hundred and one &lt;code&gt;limit=none&lt;/code&gt; answers.&lt;/p&gt;

&lt;p&gt;A bandwidth estimator assumes bandwidth is scarce. At 0–1 ms RTT and 0% loss, it concludes that available capacity is far above the current bitrate and reports no constraint. That is not a bug; it is the design. Adaptation is bandwidth-driven, not quality-driven. Its inputs are channel observations; its outputs can only constrain encoder parameters. When the bottleneck is not in the channel, the controller has no error signal.&lt;/p&gt;

&lt;p&gt;The paradox follows: the better the network and the busier the endpoint — same-subnet casting, LAN collaboration, local demos — the less likely adaptation is to act, because the bottleneck sits in capture and encode queueing, upstream of the channel.&lt;/p&gt;

&lt;p&gt;It also explains "all green in the lab, users still report stutter": a test matrix that covers only weak networks makes adaptation look excellent, and never covers strong network plus endpoint bottleneck — not a rare corner case.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Thirty-five freezes and 0% loss describe the same system
&lt;/h3&gt;

&lt;p&gt;Zero loss proves packets arrived. It does not prove they arrived on time and in a group, and it does not prove the decoder can put them on screen.&lt;/p&gt;

&lt;p&gt;Three stages produce "every packet arrived and the picture still will not move." First, capture does not emit evenly: 3 to 48 fps is a 16× spread, because screen content alternates between still and scrolling. A fixed-rate capture piles up queue depth during scrolls. Second, encode time is fixed while queue length is not. 12.3 ms per frame gives a theoretical ceiling near 81 fps, but it never varies with content while demand spikes when content changes. Demand up, throughput flat: queueing is the only possible outcome. Third, a jitter buffer is built for arrival-time jitter, not for the sender simply stopping. If the sender stalls for 200 ms, the buffer underruns and the player freezes while every network counter stays perfect.&lt;/p&gt;

&lt;p&gt;Those 35 freezes were not 35 network incidents. They were 35 pipeline-rhythm incidents.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. The problem was not the degradation order — it was that nobody made the choice
&lt;/h3&gt;

&lt;p&gt;For screen sharing, the right degradation order is frame rate first, resolution second: text and tables depend on resolution for legibility, not frame rate.&lt;/p&gt;

&lt;p&gt;But nothing degraded in that order. Resolution stayed at 3024×1964 for the entire run, encode time stayed at 12.3 ms, and &lt;code&gt;limit&lt;/code&gt; stayed at &lt;code&gt;none&lt;/code&gt;. Three frames per second was a passive outcome, not an active decision: resolution survived because nothing touched it, not because something protected it.&lt;/p&gt;

&lt;p&gt;The trap is conflating a preference with a constraint. A preference flag says "if you must choose, keep resolution." It neither prevents frame rate from falling to 3 nor reduces capture load. Expecting a preference to act as a guardrail is expecting a recommendation to enforce itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  Eight fixes that ship
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Make capture parameters explicit.&lt;/strong&gt; Cap width and height, set &lt;code&gt;minimumFrameInterval&lt;/code&gt; to 1/15–1/20 s, and keep &lt;code&gt;queueDepth&lt;/code&gt; around 3–5. Native resolution at 3 fps buys readability nothing and costs pipeline pressure.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Skip unchanged frames.&lt;/strong&gt; Combine &lt;code&gt;contentHint&lt;/code&gt; with frame-difference detection and send only 1–5 fps heartbeat frames while the screen is still. This usually moves freeze counts more than encoder tuning, because it removes uneven frame spacing at the source.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Make the frame-rate floor a separate constraint.&lt;/strong&gt; "Maintain resolution" is a preference. Add an independent never-below-10-fps guardrail that lowers capture resolution when triggered instead of letting frames drop.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Evaluate HEVC and screen-content coding.&lt;/strong&gt; Hardware HEVC saves bitrate at equal subjective quality, and screen-content extensions are worth testing. Keep per-frame encode time in monitoring; it is the pipeline's throughput ceiling and does not move with frame rate.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Report receiver-side metrics separately.&lt;/strong&gt; Freeze count alone is not enough: report duration distribution, and distinguish "no new frame arrived" from "a frame arrived but was never rendered." Users call both stutter; they are different chains.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test the TURN path explicitly.&lt;/strong&gt; A 0–1 ms direct-path result does not generalize to a relay. Force the relay candidate and rerun the suite.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Look at audio alongside video.&lt;/strong&gt; If the microphone is open, echo and noise are perceived before video problems. Enable AEC and noise suppression conditionally on the mic, not globally disabled to save CPU.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Shrink the black-screen window at join time.&lt;/strong&gt; Connect and first-frame time leave a gap where the user stares at nothing. Send a low-resolution keyframe to light up the picture, then step quality up. Users tolerate blurry-but-immediate better than sharp-but-late.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Measurement beats optimization
&lt;/h2&gt;

&lt;p&gt;The most valuable part was not the eight fixes, but that three individually normal readings only exposed the problem when read together:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;27.9% CPU says the endpoint is idle.&lt;/li&gt;
&lt;li&gt;0% loss and 0–1 ms RTT say the network is great.&lt;/li&gt;
&lt;li&gt;501 of 501 &lt;code&gt;limit=none&lt;/code&gt; says adaptation is working.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Stack all three and "35 freezes, minimum 3 fps" becomes visible.&lt;/p&gt;

&lt;p&gt;Screen-sharing stutter hides in the frame-rate distribution, not the mean. It hides in frame spacing, not loss. It hides in negative evidence — the constraint that was never set. Get the measurement right first; optimization is one-time, measurement compounds.&lt;/p&gt;

&lt;p&gt;I also built a free calculator for this: &lt;a href="https://hhfa008.github.io/tools/screen-share-calc/" rel="noopener noreferrer"&gt;Screen Sharing Latency &amp;amp; Bandwidth Calculator&lt;/a&gt; — plug in resolution, fps, codec and bandwidth, and it shows where your latency budget breaks.&lt;/p&gt;

&lt;p&gt;If you want the full checklist and scripts, &lt;a href="https://devinspire734.gumroad.com/l/xejxsp" rel="noopener noreferrer"&gt;here's the pack&lt;/a&gt; ($9).&lt;/p&gt;

&lt;p&gt;Disclosure: I sell a paid pack that expands on this case study.&lt;/p&gt;

</description>
      <category>ios</category>
      <category>webrtc</category>
      <category>performance</category>
      <category>macos</category>
    </item>
    <item>
      <title>Items Saturate; Judges Do Not: What a Leaderboard Means When a Model Does the Scoring</title>
      <dc:creator>Devin</dc:creator>
      <pubDate>Tue, 27 Oct 2026 23:36:27 +0000</pubDate>
      <link>https://dev.to/devinios/items-saturate-judges-do-not-what-a-leaderboard-means-when-a-model-does-the-scoring-2m71</link>
      <guid>https://dev.to/devinios/items-saturate-judges-do-not-what-a-leaderboard-means-when-a-model-does-the-scoring-2m71</guid>
      <description>&lt;p&gt;This is part three of a short series on reading AI evaluations. Part one, &lt;a href="https://dev.to/devinios/same-model-same-scaffold-10-runs-agent-benchmark-scores-still-move-by-up-to-6-points-gmb"&gt;Same Model, Same Scaffold, 10 Runs&lt;/a&gt;, measured repeats: one configuration, ten runs, a 2.2–6.0 point window. Part two, &lt;a href="https://dev.to/devinios/62-vs-58-on-the-same-benchmark-how-many-tasks-before-the-gap-means-anything-dkl"&gt;62% vs 58% on the Same Benchmark&lt;/a&gt;, measured task counts. Both assumed a stable instrument. This part asks what happens when the instrument is a model.&lt;/p&gt;

&lt;p&gt;A 0–5 leaderboard puts model A at 4.1 and B at 3.7. Four tenths of a point — enough for a tweet that A leads, enough for B's team to rewrite prompts. Now learn that the minimum detectable difference (MDD) for such leaderboards at native item counts is 0.41 to 1.24 points, and that on the paper's general rubric, 0.4 does not clear it. What do you say then?&lt;/p&gt;

&lt;p&gt;Atul Anand's &lt;em&gt;Ask Which, Not How Good: Sizing Benchmarks Scored by an LLM&lt;/em&gt; (arXiv:2609.27787, v1, preprint, August 17, 2026) decomposes 373,019 judge calls across 4 item sets, a 9-judge panel, and 42 systems. Its conclusion: when a model scores, neither item count nor run count is the variable that matters most.&lt;/p&gt;

&lt;h2&gt;
  
  
  The key is which question, not how good
&lt;/h2&gt;

&lt;p&gt;A pointwise question asks what score an answer deserves; a pairwise question asks which of two answers is better. Most leaderboards use the first because it ranks cleanly, but they are not equally hard: "which is better" is far better conditioned, while "what score" folds the judge's scale habits, severity, and wording into the error.&lt;/p&gt;

&lt;p&gt;Generalizability theory splits score variance into system, item, judge, and interactions such as system × judge, then computes Eρ², the share that is signal: rerun and the ranking holds, from 0 (noise) to 1 (repeatable). It is not accuracy; a judge can be stable and consistently wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the ceiling does not rise with item count
&lt;/h2&gt;

&lt;p&gt;This is the counterintuitive part:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"under a single judge, generalizability asymptotes to σ²s/(σ²s+σ²sj) regardless of item count, because the system-by-judge term carries no n_i. Items saturate; judges do not."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;With one judge, the ceiling is σ²s/(σ²s+σ²sj) however many items you add: system × judge enters the error as σ²sj/nj, with judges in the denominator. Items shrink only the item-sampling term. What remains is "this judge is generous to A and harsh to B," which never cancels: one judge scores every item.&lt;/p&gt;

&lt;p&gt;Two numbers pin it down. On MT-Bench at 0.75, 2 judges × 30 items roughly equals 1 judge × 77 items. At 0.80 nothing suffices: 0.80 sits 0.006 below the single-judge ceiling, and approaching it needs a divergent item count.&lt;/p&gt;

&lt;p&gt;The advice splits in three. More items do not help within a fixed pool. More judges only approach the ceiling. Raising it needs a different judge universe, a different protocol, or item selection by discriminative power — the only item-side lever that raises it, to a 16.6% system variance share against Arena-Hard's 19.2%. Reading "items don't help" as "item design doesn't matter" inverts the point.&lt;/p&gt;

&lt;h2&gt;
  
  
  What 0.986 is, and what 8.6 points are
&lt;/h2&gt;

&lt;p&gt;0.986 is often misread as 98.6% accurate. It is the reliability ceiling one judge reaches under a pairwise protocol that judges each pair in both orders and treats position as a factor. σ²sj falls two orders of magnitude below σ²s and the ceiling rises from 0.600–0.798 to 0.986 (bootstrap [0.934, 1.000], 11 systems); one judge reaches Eρ² = 0.93 at 24 items, so "one judge suffices."&lt;/p&gt;

&lt;p&gt;But 0.986 means "rerun and the ranking holds," not "the judgment is right." On SummEval, reliability rose 0.711 → 0.959 (+0.248) while Kendall τ against a three-expert consensus moved only 0.596 → 0.710 (+0.114). Reliability is necessary for validity, not sufficient.&lt;/p&gt;

&lt;p&gt;Then the 8.6 points, a new problem pairwise buys:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"A system placed in the first slot wins 8.6 percentage points more often than the same system, judged by the same judges on the same items, placed second."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;First slot is presentation order, not first place: same system, same judges, same items, yet going first alone lifts the win rate 8.6 points. The bias varies 7.3 points across systems, reaches 25 at worst, and does not average away. Across 53 published win-rate comparisons the median claimed gain is 7.0 points; position bias is 1.23 times that, so an uncontrolled pairwise evaluation imports a bias larger than the median effect it hunts. In the same data, 70% of win-rate claims fall below pairwise's floor at native item counts.&lt;/p&gt;

&lt;h2&gt;
  
  
  An audit of 628 papers
&lt;/h2&gt;

&lt;p&gt;The audit covers 628 arXiv papers from 2024–2026: five preregistered queries, 320 sampled by fixed seed, 312 coded, 227 reporting judge comparisons, κ = 0.73. Fewer than a quarter say whether they ran more than once (16.7% human, 20.0% automated); 46–67% report uncertainty; and all 17 checkable MT-Bench gains fall below its own floor (0.63 at 30 items, 0.54 at the native 80), which barely drops with more items because items saturate. This does not allege fabrication: the differences sit below the instrument's resolution, and the papers omit what a reader needs to notice.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to do when you run a judge yourself
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Report the judge universe: models, versions, count; otherwise reliability claims float.&lt;/li&gt;
&lt;li&gt;Use the benchmark's native protocol and name it. Under MT-Bench's own template, 1–10 scale, and reference answers, the system variance share rises from 4.8% to 8.4% and the judges needed for 0.8 fall from 18 to 3.&lt;/li&gt;
&lt;li&gt;Spend on judges before items. For 0.8 under a general 0–5 rubric: MT-Bench 25 judges, AlpacaEval 2 seven, the selected set and Arena-Hard two each.&lt;/li&gt;
&lt;li&gt;Report MDD beside delta: the floor is 0.41–1.24 on a 0–5 scale, while 60 recovered pointwise gains have a median of 0.28; below it is not "leading."&lt;/li&gt;
&lt;li&gt;When building a benchmark, select items by discriminative power.&lt;/li&gt;
&lt;li&gt;Disclose repeat runs and response caching. Caching makes repeats fake.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;With pairwise, run both orders and report prompt-level agreement: one judge rerun unchanged at temperature 0 moved the total about 1 point while 21.2% of items flipped win/tie/loss (arXiv:2610.30512).&lt;/p&gt;

&lt;h2&gt;
  
  
  What this paper cannot show
&lt;/h2&gt;

&lt;p&gt;Six things, at least.&lt;/p&gt;

&lt;p&gt;First, it is a single-author preprint v1, not peer reviewed.&lt;/p&gt;

&lt;p&gt;Second, data and code are available from the author, not a public artifact: tables check out, reproduction is not cheap.&lt;/p&gt;

&lt;p&gt;Third, it covers only 4 item sets, one pointwise 0–5 rubric, one scale; the paper concedes the general rubric is the conservative case.&lt;/p&gt;

&lt;p&gt;Fourth, the Arena-Hard cell holds 17 items (others 32–43), making its σ²s estimate least precise; the pairwise arm uses the authors' own items, judge, and fixed baseline, not Arena-Hard's or AlpacaEval 2's pipeline.&lt;/p&gt;

&lt;p&gt;Fifth, the audit samples rather than censuses: 628 framed, 312 coded, 227 reporting comparisons, κ between 0.45 and 1.00. It documents missing disclosure, not incorrect conclusions.&lt;/p&gt;

&lt;p&gt;Sixth, reliability is not validity. A stable judge can stably measure the wrong thing, as SummEval shows.&lt;/p&gt;

&lt;h2&gt;
  
  
  The ruler
&lt;/h2&gt;

&lt;p&gt;Back to 0.4 points: by this ruler it sits below the noise floor of most judge-scored leaderboards, and "who is stronger" is not answerable yet. Keep, not "0.4 points doesn't count," but "Items saturate; judges do not."&lt;/p&gt;

&lt;p&gt;Adding items writes more marks on the ruler; changing judges makes the marks finer; changing the protocol swaps the ruler. Most people only do the first.&lt;/p&gt;

&lt;p&gt;Disclosure: drafted with AI assistance and edited by the author.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>testing</category>
      <category>machinelearning</category>
      <category>statistics</category>
    </item>
    <item>
      <title>62% vs 58% on the Same Benchmark: How Many Tasks Before the Gap Means Anything?</title>
      <dc:creator>Devin</dc:creator>
      <pubDate>Tue, 27 Oct 2026 21:06:29 +0000</pubDate>
      <link>https://dev.to/devinios/62-vs-58-on-the-same-benchmark-how-many-tasks-before-the-gap-means-anything-dkl</link>
      <guid>https://dev.to/devinios/62-vs-58-on-the-same-benchmark-how-many-tasks-before-the-gap-means-anything-dkl</guid>
      <description>&lt;p&gt;Two teams run the same agent benchmark on the same model with the same scaffold. Team A runs 100 tasks and reports 62%. Team B runs 250 and reports 58%. Both publish their task lists and pass rates. Neither is cheating.&lt;/p&gt;

&lt;p&gt;Is the four-point gap real, or did the two teams stop at different points?&lt;/p&gt;

&lt;p&gt;Wei-Jung Huang's paper (arXiv:2607.12338, 14 July 2026, KDD 2026 Agentic AI Evaluation and Trustworthiness workshop) answers this without running any new model. It shuffles the task order of records that three public agent benchmarks already published, then asks whether the decision after the first 15%, 25%, 50%... of tasks matches the decision after all of them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Replay, not rerun
&lt;/h2&gt;

&lt;p&gt;No new data, only a change in how much you see:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;SWE-bench Lite&lt;/strong&gt;: 552 ordered comparisons (276 unordered pairs), median 300 shared tasks&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SWE-bench Verified&lt;/strong&gt;: 156 ordered (78 unordered), median 496&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AppWorld&lt;/strong&gt;: 306 ordered (153 unordered), 585 tasks, 36 packages (18 systems × 2 splits)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;tau-bench&lt;/strong&gt;: 12 ordered (6 unordered), 278 entries per system over airline, retail, and telecom, with cost fields&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each pair gets 500 reveal orders and 200 bootstraps (tau-bench: 2,000 and 500). A real evaluation runs once in one order; replay estimates how often a comparison stalls if you stop early.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three numbers that wear a percent sign
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;0 pp threshold&lt;/strong&gt;: A wins if strictly above B on the completed task set, so one task can flip the whole decision. The strictest setting, no margin.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5 pp budget grid&lt;/strong&gt;: a step size, not an effect threshold — 5% to 95% in 5-point increments. The paper separately tests 5 pp and 10 pp effect thresholds; don't merge them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Unresolved rate&lt;/strong&gt;: comparisons where the policy picks neither side and asks for more tasks or declines to conclude. Three gates hold together: at most 5% conditional decision error among decided comparisons, at most 5% coverage failure, at most 25% unresolved. That last is 25%, not 5%: being right when you decide doesn't help if you rarely decide.&lt;/p&gt;

&lt;h2&gt;
  
  
  Four benchmarks, four answers
&lt;/h2&gt;

&lt;p&gt;At 0 pp on the 5 pp grid:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;AppWorld: 15%&lt;/strong&gt; — about 88 of 585 tasks, ~21% unresolved; still 15% at 5 and 10 pp&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;tau-bench: 25%&lt;/strong&gt; — about 70 of 278 entries; 30% at 5 pp, 35% at 10 pp&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SWE-bench Verified: 90%&lt;/strong&gt; — about 446 of 496, 24.22% unresolved&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SWE-bench Lite: 95% is still not enough.&lt;/strong&gt; At 25%, error and coverage are in range, yet 55.92% of comparisons stay unresolved; at 95% it is 27.25%, above the ceiling.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What differs is the distribution of unresolved comparisons and coverage gaps: Lite decides accurately at 25% but leaves over half undecided; Verified only reaches 24.22% unresolved at 90%.&lt;/p&gt;

&lt;p&gt;AppWorld arrives early because a coverage-aware bootstrap under difficulty grouping covers the required groups at 15%. A forced stratified policy needs 50%; forced uniform random fails coverage 99.96% of the time at 25%. On tau-bench, cheapest tasks first spends 11.51% of cost at 25% with 100% coverage failure.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the answers move
&lt;/h2&gt;

&lt;p&gt;Five levers, none intrinsic to a benchmark: Lite has no sufficient budget at 0 pp but 55% at 5 pp and 35% at 10 pp; one-task slack on a coarse grid cuts its 0 pp budget to 75%, while repository × difficulty leaves more unresolved; low error often means few decisions rather than few mistakes; keeping one direction per unordered pair leaves 275 positives against 1 negative at 0 pp, so the paper targets ordered comparisons; and AppWorld's 15% moves to 10% or 25% as the truncation parameter a goes 0.10, 0.05, 0.02. A proportion is not a count: Lite's 95% of 300 is about 285 tasks, Verified's 90% of 496 about 446.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this means when you read a paper
&lt;/h2&gt;

&lt;p&gt;"We ran 100 tasks and A beat B by 0.5 points" is not a result. The paper's checklist asks five things:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;What is the threshold, in percentage points?&lt;/li&gt;
&lt;li&gt;How were tasks selected — random, stratified, cheapest first?&lt;/li&gt;
&lt;li&gt;What is the coverage rule, and how many tasks per group?&lt;/li&gt;
&lt;li&gt;What is the decision rule — bootstrap tail, paired normal, McNemar?&lt;/li&gt;
&lt;li&gt;Is there a cap on unresolved comparisons, and is the actual rate reported?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Missing any one, "0.5 points higher" is "no conclusion yet": the same records go from no sufficient budget to 55% by changing the threshold.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this paper cannot show
&lt;/h2&gt;

&lt;p&gt;First, replay is not a new experiment: "It does not estimate performance on future tasks, because the replay orders permute a fixed completed record." It tells you how much of this batch was needed, not whether 15% survives a fresh batch.&lt;/p&gt;

&lt;p&gt;Second, every conclusion depends on the threshold, coverage rule, grouping, and policy parameters. Move 0 pp to 5 or 10 pp and Lite goes from nothing to 55% or 35%; one-task slack on a coarse grid brings its 0 pp budget to 75%; AppWorld's 15% drifts from 10% to 25% with the truncation parameter. These are design choices, so a budget must travel with its direction and coverage rule.&lt;/p&gt;

&lt;p&gt;Third, only three benchmark families are covered. OSWorld, WebArena, AgentDojo, AndroidWorld, Terminal-Bench, AgentBench, and MiniWoB++ were not replayed, and tau-bench's grid holds only 12 ordered comparisons (6 pairs). Don't extrapolate "AppWorld is 15%" to other interactive benchmarks.&lt;/p&gt;

&lt;p&gt;Fourth, the public records are filtered leaderboard entries: SWE-bench keeps only near-complete entries covering at least 95% of a split, and AppWorld uses 36 public packages. Voluntary submissions, not random samples, so composition changes move the estimate.&lt;/p&gt;

&lt;p&gt;Fifth, the cost findings hold only on tau-bench. Neither SWE-bench nor AppWorld publishes a cost field, so "cheap tasks first" is checkable on tau-bench alone.&lt;/p&gt;

&lt;p&gt;Sixth, 5% decision error, 5% coverage failure, and 25% unresolved are the paper's shared targets, not an industry standard. Change the targets and the budgets change: a framework for computing a budget, not a universal "15% is enough".&lt;/p&gt;

&lt;h2&gt;
  
  
  One rule you can use now
&lt;/h2&gt;

&lt;p&gt;When you see "we ran X tasks and A beat B by 0.5 points", check three things: is the decision threshold stated, were tasks selected and covered by repository, difficulty, or domain, and is the unresolved share reported. If any is missing, file it as "no conclusion yet". A task proportion is not a decision rule; without one it is just a cost figure.&lt;/p&gt;

&lt;p&gt;More of my writing on AI evaluation: &lt;a href="https://hhfa008.github.io/ai/writing/" rel="noopener noreferrer"&gt;hhfa008.github.io/ai/writing&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This is part two of a short series on reading AI evaluations. Part one: &lt;a href="https://dev.to/devinios/same-model-same-scaffold-10-runs-agent-benchmark-scores-still-move-by-up-to-6-points-gmb"&gt;Same Model, Same Scaffold, 10 Runs&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Disclosure: drafted with AI assistance and edited by the author.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>testing</category>
      <category>machinelearning</category>
      <category>statistics</category>
    </item>
    <item>
      <title>Same Model, Same Scaffold, 10 Runs: Agent Benchmark Scores Still Move by Up to 6 Points</title>
      <dc:creator>Devin</dc:creator>
      <pubDate>Tue, 27 Oct 2026 20:56:08 +0000</pubDate>
      <link>https://dev.to/devinios/same-model-same-scaffold-10-runs-agent-benchmark-scores-still-move-by-up-to-6-points-gmb</link>
      <guid>https://dev.to/devinios/same-model-same-scaffold-10-runs-agent-benchmark-scores-still-move-by-up-to-6-points-gmb</guid>
      <description>&lt;p&gt;Two teams run the same model, scaffold, and temperature on SWE-bench Verified, once each. Team A reports 32.4%; Team B reports 28.8%. Both post their logs. Neither is lying.&lt;/p&gt;

&lt;p&gt;The gap comes from one distribution. Table 1 of &lt;em&gt;On Randomness in Agentic Evals&lt;/em&gt; has this row: DeepSWE-preview on nano-agent, temperature 1.0, ten independent runs, mean 31.4% ± 1.0%, minimum 28.8%, maximum 32.4%. Team A landed at the top of the window; Team B landed at the bottom. They were comparing two samples, not two systems.&lt;/p&gt;

&lt;p&gt;The paper, by Bjarni Haukur Bjarnason, André Silva, and Martin Monperrus, appeared on arXiv as 2602.07150 on February 6, 2026 (v3: March 25) and is a preprint.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the experiment did
&lt;/h2&gt;

&lt;p&gt;Most papers run each configuration once. This one ran three models (Qwen3-32B, DeepSWE-preview, Devstral-2) × two temperatures × two scaffolds (nano-agent, R2E-Gym), ten times each, on 500 SWE-bench Verified tasks: 60,000 trajectories, 25.58B tokens, 1.88M tool calls.&lt;/p&gt;

&lt;p&gt;One definition matters. The headline range of 2.2 to 6.0 percentage points is the spread between the minimum and maximum single-run score within the same configuration. It is not the gap between two models. As the paper puts it: "single-run pass@1 estimates vary by 2.2 to 6.0 percentage points depending on which run is selected." The number measures repeatability, not capability.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three sources of variance
&lt;/h2&gt;

&lt;p&gt;The first is sampling. Above temperature zero, every token is drawn from a distribution. One different token changes the context for the next one, and the run can diverge from there.&lt;/p&gt;

&lt;p&gt;The second is the scaffold. It changes both the level and the spread. DeepSWE-preview scores 31.4% ± 1.0% on nano-agent at temperature 1.0, but 34.4% ± 1.5% on R2E-Gym. Devstral-2 scores 63.5% ± 1.1% on nano-agent at temperature 0.2, but 34.9% ± 1.5% on R2E-Gym. The model did not change; the wrapper did.&lt;/p&gt;

&lt;p&gt;The third is the runtime, and it only becomes visible at temperature zero.&lt;/p&gt;

&lt;h2&gt;
  
  
  Temperature zero is not a determinism switch
&lt;/h2&gt;

&lt;p&gt;Greedy decoding sounds deterministic: always pick the most likely token. The paper shows it is not. Floating-point addition is not associative, and parallel reduction order, hardware-specific kernels, and batching can produce tiny differences in the same logits. Autoregressive decoding amplifies them.&lt;/p&gt;

&lt;p&gt;At temperature 0, the standard deviations shrink but never reach zero. Qwen3-32B on nano-agent is 16.4% ± 1.2%; DeepSWE-preview on nano-agent is 20.4% ± 1.0%; Qwen3-32B on R2E-Gym is 22.3% ± 1.8%. The largest standard deviation at temperature 0 in the table is 1.8 points.&lt;/p&gt;

&lt;p&gt;Lower temperature does not reliably shrink variance either. Qwen3-32B on nano-agent has a standard deviation of 0.7% at temperature 0.6 and 1.2% at temperature 0. Temperature zero delays divergence; it does not remove it.&lt;/p&gt;

&lt;p&gt;So when a paper says temperature=0 makes its result reproducible, ask: over how many runs, with what standard deviation, on which inference engine and hardware?&lt;/p&gt;

&lt;h2&gt;
  
  
  Trajectories split early
&lt;/h2&gt;

&lt;p&gt;For DeepSWE-preview on nano-agent at temperature 1.0, the median position of the first differing token between two runs was token 5, about 0.5% of the trajectory. At temperature 0 it moved to token 56. The paper's conclusion is that the first divergence almost always occurs within the first 1% of a trajectory. The next few hundred steps are the consequence of that one token.&lt;/p&gt;

&lt;p&gt;The paper gives a concrete case: django__django-9296, Qwen3-32B, nano-agent, temperature 0. Two runs matched for 93 tokens. At token 94, one searched for the &lt;code&gt;Paginator&lt;/code&gt; class; the other read the Django source. One patch landed in the wrong place; the other passed.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to read a leaderboard
&lt;/h2&gt;

&lt;p&gt;Before the ranking, ask five questions:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;How many runs? Is this the mean of n independent runs, or the best single run?&lt;/li&gt;
&lt;li&gt;Is there a standard deviation or interval? Without one, the score is a point, not an estimate.&lt;/li&gt;
&lt;li&gt;What temperature? Zero is not deterministic; higher temperatures usually widen the spread.&lt;/li&gt;
&lt;li&gt;What scaffold? The same model can move by tens of points when the harness changes.&lt;/li&gt;
&lt;li&gt;Is this pass@1 or pass@k? DeepSWE-preview on R2E-Gym scores 34.4% pass@1, 52.9% pass@5, and 15.5% pass^5. All three say "pass"; they answer different questions.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The paper supplies a contrast. DeepSWE-preview on nano-agent scores 31.4% ± 1.0% at temperature 1.0 and 20.4% ± 1.0% at temperature 0, an 11.0-point difference the authors treat as real. Devstral-2 on the same scaffold scores 63.5% ± 1.1% at temperature 0.2 and 63.8% ± 1.6% at temperature 0, a 0.3-point difference they treat as noise. The deciding factor is the gap relative to the standard deviation.&lt;/p&gt;

&lt;h2&gt;
  
  
  How many runs you need
&lt;/h2&gt;

&lt;p&gt;The paper uses a power calculation. It assumes single-run scores are approximately normal; Shapiro-Wilk tests passed for 11 of 12 configurations at p ≥ 0.05. Using the observed standard deviations of 0.7% (minimum), 1.5% (median), and 1.8% (maximum):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Detecting a 2-point improvement at p &amp;lt; 0.05 takes 2, 9, or 13 runs.&lt;/li&gt;
&lt;li&gt;Detecting a 1-point improvement takes 8, 36, or 51 runs.&lt;/li&gt;
&lt;li&gt;Detecting a 5-point improvement takes 2 runs at the median variance; 10 points takes 1 run at any of the three.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The paper states: "Detecting a 2% improvement at p &amp;lt; 0.05 with 80% power requires approximately 9 runs per agent under test, while detecting a 1% improvement requires 36 runs." The number depends on the effect you need to detect and the variance of your own setup.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this study cannot show
&lt;/h2&gt;

&lt;p&gt;First, it is a preprint: v1 on February 6, 2026, v3 on March 25, 2026, with no peer review.&lt;/p&gt;

&lt;p&gt;Second, it covers one benchmark, three models, two scaffolds, and two temperatures. SWE-bench Verified has long software-engineering trajectories; the 2.2–6.0-point window may not transfer to short tasks or much longer ones.&lt;/p&gt;

&lt;p&gt;Third, the paper does not link a code or data artifact. Reproducing the distribution means building the scaffolds and running 60,000 trajectories yourself.&lt;/p&gt;

&lt;p&gt;Fourth, there is no calendar window. The paper reports ten runs per configuration but not the dates when the data was collected.&lt;/p&gt;

&lt;p&gt;Fifth, variance at temperature 0 depends on the inference stack. The finding is that temperature 0 does not guarantee determinism, not that every temperature-0 evaluation has a standard deviation of at least 1.5 points.&lt;/p&gt;

&lt;p&gt;Sixth, run-to-run variance and task-sampling variance are separate sources. Too few tasks and too few runs produce different errors, and they should be budgeted separately. This paper addresses only the second.&lt;/p&gt;

&lt;h2&gt;
  
  
  The cost of a single run
&lt;/h2&gt;

&lt;p&gt;An evaluation costs more than the price of running it once. It also costs the price of believing the result. 25.58B tokens is expensive, but it buys a measurement of how unstable a single score is, which is usually cheaper than making a decision on one sample.&lt;/p&gt;

&lt;p&gt;The next time you see "this model improved by 3 points," ask whether that is a difference between two ten-run means, or two single runs that landed at opposite ends of the same window.&lt;/p&gt;




&lt;p&gt;This piece is part of a series on how to read AI evaluations. More writing is indexed at &lt;a href="https://hhfa008.github.io/ai/writing/" rel="noopener noreferrer"&gt;https://hhfa008.github.io/ai/writing/&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Disclosure: drafted with AI assistance and edited by the author.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>testing</category>
      <category>machinelearning</category>
      <category>statistics</category>
    </item>
    <item>
      <title>The Median Paid Newsletter Converts 0.62% of Free Readers. Do the Math Before You Launch</title>
      <dc:creator>Devin</dc:creator>
      <pubDate>Tue, 27 Oct 2026 11:27:29 +0000</pubDate>
      <link>https://dev.to/devinios/the-median-paid-newsletter-converts-062-of-free-readers-do-the-math-before-you-launch-5466</link>
      <guid>https://dev.to/devinios/the-median-paid-newsletter-converts-062-of-free-readers-do-the-math-before-you-launch-5466</guid>
      <description>&lt;p&gt;The most useful number in beehiiv's 2026 paid-newsletter report is not a success story. It is the median.&lt;/p&gt;

&lt;p&gt;The report, published on June 22, 2026 and based on first-party data from thousands of publications on beehiiv, puts the median free-to-paid conversion rate at 0.62%. That works out to roughly six paying subscribers for every 1,000 free readers. The median paid plan is $10 a month or $100 a year, and the typical creator in the dataset launched a paid tier about 45 days after starting the newsletter.&lt;/p&gt;

&lt;p&gt;Those numbers are a baseline, not a verdict. But they change the arithmetic of launching a paid newsletter, and they are worth doing before you write another issue.&lt;/p&gt;

&lt;h2&gt;
  
  
  What 0.62% actually implies
&lt;/h2&gt;

&lt;p&gt;At 0.62%, 100 paying subscribers require about 16,100 free readers. A straightforward rounding gives roughly 16,000. At 1%, the same 100 subscribers need 10,000 free readers. At 5% — a rate that appears in a lot of creator advice — you would need 2,000.&lt;/p&gt;

&lt;p&gt;The gap between those scenarios is not a rounding error. It is the difference between a list you can build in a few months and a list you may spend years building. The report is explicit that 0.62% is a median, not an average, which means the middle publication is doing less well than the headline success stories suggest.&lt;/p&gt;

&lt;p&gt;This is my arithmetic, not the report's: divide 100 by the conversion rate. The report supplies the rate and the pricing; the list-size examples are derived.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pricing is more standardized than the advice
&lt;/h2&gt;

&lt;p&gt;The median paid plan is $10 a month or $100 a year across nearly every industry and list size, according to the report, and it has not moved since 2024. That is a useful anchor. It means a creator does not need to invent a pricing strategy from scratch to be in the middle of the market; $10/$100 is the middle.&lt;/p&gt;

&lt;p&gt;It also means the revenue math is easy to state. A newsletter with 1,000 paying subscribers at $10 a month generates $120,000 a year before platform fees, payment processing, and tax. At a 10% platform cut, $12,000 leaves the business every year. The report uses that example to make a point that is easy to miss: a four-figure subscriber count can still be a small business after the costs that sit between gross revenue and take-home cash.&lt;/p&gt;

&lt;h2&gt;
  
  
  Niche sets the ceiling
&lt;/h2&gt;

&lt;p&gt;The report's sector breakdown is where the median stops being abstract. Sports newsletters have the highest median conversion rate at 1.93%, just over three times the platform average. Finance has a median of 0.78%, but the top 10% reaches 20.00% — a gap the report describes as nearly 26x. The same vertical, the same audience type, very different execution.&lt;/p&gt;

&lt;p&gt;The value per subscriber varies just as much. A 1,000-subscriber investing newsletter can earn more than $2,700 a year, while the median 1,000-subscriber travel newsletter earns about $252. Those figures are not a guarantee; they are a reminder that "newsletter" is not one market.&lt;/p&gt;

&lt;p&gt;The pattern behind the numbers is consistent with what operators already say: subjects tied to a decision, a professional use, or a tax-deductible expense convert and retain better than general-interest topics. A reader who can connect the subscription to a raise, a client, or a cost is easier to sell than a reader who simply finds the topic interesting.&lt;/p&gt;

&lt;h2&gt;
  
  
  Churn is the other half of the equation
&lt;/h2&gt;

&lt;p&gt;The report's monthly churn rates range from 5.06% in Food &amp;amp; Drink to 16.67% in Money. News sits at 5.47%, sports at 7.76%, and AI at 13.33%. Estimated subscriber lifetime runs from about six months in Money to nearly 20 months in Food &amp;amp; Drink. Median lifetime value ranges from $83 in Community to $230 in Investing.&lt;/p&gt;

&lt;p&gt;Those differences compound. A newsletter with 10% monthly churn loses most of a cohort in less than a year; a newsletter with 5% churn keeps it much longer. The report's conclusion is blunt: retention is where the real money is. Conversion gets the subscriber; churn decides whether the subscription becomes a business. One methodological caveat: the report's lifetime figures are steady-state estimates (1 ÷ monthly churn), not cohort survival curves.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the report does not tell you
&lt;/h2&gt;

&lt;p&gt;The data come only from beehiiv publications. They do not necessarily describe Substack, Ghost, Kit, or any other platform. Beehiiv has a commercial interest in showing that paid newsletters can work, and the report also says the company generated $19 million in subscription revenue in 2025, up 138% from 2024, with $35 million projected for 2026.&lt;/p&gt;

&lt;p&gt;The report does not disclose refund rates, sample sizes by industry, or a comparable Substack conversion figure. It reports medians, not averages, so the top decile is hidden inside a single number. Treat 0.62% as a starting assumption for a beehiiv-style publication, not a law of the newsletter market.&lt;/p&gt;

&lt;h2&gt;
  
  
  Four things to do before you launch
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Run the list-size math at three conversion rates. At 0.62%, 1%, and 3%, how many free readers do you need for 100 paying subscribers? If the answer at the median is larger than the audience you can realistically build, change the plan before you change the price.&lt;/li&gt;
&lt;li&gt;Pick a niche tied to a decision or a budget. The sports and finance spreads are not just pricing differences; they reflect whether the reader can justify the cost. "Interesting" is a weak buying reason.&lt;/li&gt;
&lt;li&gt;Launch paid around the six-week mark, not after the archive is perfect. The median creator in the dataset launched a paid tier 45 days after starting. A clear promise and a small archive beat a perfect archive with no offer.&lt;/li&gt;
&lt;li&gt;Model churn, not just MRR. Run your revenue at 5%, 10%, and 16% monthly churn. If the business only works at the low end, you have a retention problem to solve before you have a growth problem.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The median is not a ceiling. The top 10% in finance shows that execution can move conversion by an order of magnitude. But the median is the right place to start, because it is the number most creators will actually experience — and it is much cheaper to plan for it than to discover it after launch.&lt;/p&gt;

</description>
      <category>newsletter</category>
      <category>subscriptions</category>
      <category>monetization</category>
      <category>creators</category>
    </item>
    <item>
      <title>Redis Changed Its License in 2024. The Fork Is Still Shipping.</title>
      <dc:creator>Devin</dc:creator>
      <pubDate>Tue, 27 Oct 2026 09:51:20 +0000</pubDate>
      <link>https://dev.to/devinios/redis-changed-its-license-in-2024-the-fork-is-still-shipping-1mj9</link>
      <guid>https://dev.to/devinios/redis-changed-its-license-in-2024-the-fork-is-still-shipping-1mj9</guid>
      <description>&lt;p&gt;Reconstructed from public reporting; not firsthand, no invented people or dialogue.&lt;/p&gt;

&lt;p&gt;On March 20, 2024, Redis changed its license. Starting with Redis 7.4, new releases would ship under RSALv2 and SSPLv1 instead of BSD-3. The company did not hide the reason. Most of its commercial sales, it wrote, ran through the largest cloud providers, and those providers "commoditize Redis' investments and its open source community."&lt;/p&gt;

&lt;p&gt;Two days later, on March 22, a new repository appeared on GitHub. It was called Valkey. The license was BSD-3. The code was a fork of the last BSD release.&lt;/p&gt;

&lt;p&gt;That is the whole story in three sentences. The interesting part is what happened next, because both sides kept receipts.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fork did not fade
&lt;/h2&gt;

&lt;p&gt;Most forks of a popular project die in a few months. This one did not.&lt;/p&gt;

&lt;p&gt;As of October 27, 2026, the Valkey repository has 27,528 stars and 1,431 forks. Redis has 77,130 stars. The fork is smaller, and it is also still here.&lt;/p&gt;

&lt;p&gt;More useful than stars: release activity. Valkey 9.1.3 shipped on October 26, 2026. In the same 48 hours, the project also published maintenance releases on four older lines: 9.0.7, 8.1.11, 8.0.12, and 7.2.15. Four live branches is not a museum project. It is a project with users who cannot all upgrade at once.&lt;/p&gt;

&lt;p&gt;The 9.x line also carries real engineering claims. The project's site says 9.1 does 2.1 million requests per second on a single server, saves up to 20% memory on small strings, and moves Lua out of the core into an optional module. Treat vendor benchmarks as marketing until you run them yourself. But "Lua is now a module" is the kind of change you only make when you are optimizing a codebase you expect to maintain for years.&lt;/p&gt;

&lt;p&gt;Also worth noting: Valkey is a Linux Foundation project. That matters less for code quality than for governance. A foundation cannot unilaterally relicense the project the way a company can.&lt;/p&gt;

&lt;h2&gt;
  
  
  The reversal, in the company's own words
&lt;/h2&gt;

&lt;p&gt;On May 1, 2025, Redis added AGPLv3 as a licensing option, starting with Redis 8. It kept SSPL. In the announcement, CEO Rowan Trollope wrote the sentence that makes this story worth telling:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"This achieved our goal—AWS and Google now maintain their own fork—but the change hurt our relationship with the Redis community."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Read that twice. The company says the license change worked as intended and still cost it something it cared about. It is rare to get both halves in one sentence from the person who signed the change.&lt;/p&gt;

&lt;p&gt;The same post notes that Salvatore Sanfilippo, the original author, rejoined Redis in November 2024 as a developer evangelist. Redis 8 also folded Redis Stack into the core: JSON, time series, probabilistic types, and the query engine now ship together under AGPL. The company claims more than 30 performance improvements, commands up to 87% faster, and twice the throughput. Those are vendor numbers. The date and the license are facts.&lt;/p&gt;

&lt;h2&gt;
  
  
  What AGPL does and does not fix
&lt;/h2&gt;

&lt;p&gt;Here is the part that gets flattened in arguments. AGPL is OSI-approved. That makes Redis open source again in the formal sense. It does not make Redis BSD again.&lt;/p&gt;

&lt;p&gt;AGPL keeps the copyleft trigger, and extends it to the network. If you modify the software and let other people use it over a network, Section 13 requires you to offer them the corresponding source. For most teams running an unmodified cache, nothing changes. For anyone shipping a modified Redis as part of a hosted product, the compliance work is real, and it did not exist under BSD.&lt;/p&gt;

&lt;p&gt;One detail gets lost in the argument: Redis 8 is dual-licensed. AGPLv3 is an option, not a replacement for SSPL. If your legal team already approved SSPL terms, nothing forces a change. If they rejected SSPL, AGPL is the path back. That distinction matters in procurement, where the real question is not "is it open source" but "which license will our scanner see on the artifact we deploy."&lt;/p&gt;

&lt;p&gt;So the scoreboard is not "open source won." It is narrower. A company traded a permissive license for commercial protection, lost community trust, watched a foundation-backed fork keep shipping, and then added a copyleft license that is open source but stricter than what it removed. Everyone got something. Nobody got 2023 back.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would do this week
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;Audit by version, not by project. Your dependency scanner probably reports "Redis" once. The license depends on which release you actually deploy: 7.2 and earlier are BSD, 7.4 and later are RSALv2/SSPL, and 8.x can be AGPL. Pin the version and record the license next to it.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Ask your managed cache provider two questions: which engine, and which major version. "Redis-compatible" is not an answer. The engine determines your upgrade path if a license or price changes again.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;If you host a modified version as a service, read AGPL Section 13 before the next upgrade, not after. The obligation is to offer source to network users, and "we only run it internally" is a different case from "our customers call it."&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Keep the exit cheap. The core command surface is largely compatible, but modules and managed features are where migrations get expensive. A thin client abstraction and one tested fallback image cost little now and buy you options later.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;None of this requires picking a side. It requires knowing which license is running under your connection string.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part that generalizes
&lt;/h2&gt;

&lt;p&gt;License changes are cheap to announce and expensive to undo. Redis added AGPL thirteen months after leaving BSD, and the fork from March 2024 is still publishing releases every week.&lt;/p&gt;

&lt;p&gt;If you maintain a dependency that a company controls, the lesson is not "avoid company-controlled projects." The lesson is that the license is a versioned artifact, like the code. Treat it that way: record it, diff it, and know what you would do if it changed on a Tuesday.&lt;/p&gt;

&lt;p&gt;The fork is what that plan looks like when you did not have one. It works. It is also more expensive than a one-line entry in your dependency policy.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Sources: Redis announcements of 2024-03-20 and 2025-05-01; valkey.io; GitHub repository and release metadata. Figures are as of 2026-10-27.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>redis</category>
      <category>opensource</category>
      <category>licensing</category>
      <category>devops</category>
    </item>
  </channel>
</rss>
