<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Becky Builds]]></title><description><![CDATA[Becky Builds]]></description><link>https://beckybuilds.hashnode.dev</link><image><url>https://cdn.hashnode.com/uploads/logos/69c4dd0910e664c5da65b3c8/7f5a40b1-6eef-4dc6-9023-badee63ad581.jpg</url><title>Becky Builds</title><link>https://beckybuilds.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Wed, 23 Sep 2026 12:37:56 GMT</lastBuildDate><atom:link href="https://beckybuilds.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Designing a Generation Pipeline — A 4-Axis Content Model, Queue-Based Scheduling, 3-Layer Attribution]]></title><description><![CDATA[An engineering log of building a Threads marketing agent, split into two parts.

Part 1: The data-acquisition layer — a crawler designed around an anti-bot threat model (TLS fingerprint · CDP detectio]]></description><link>https://beckybuilds.hashnode.dev/designing-a-generation-pipeline-a-4-axis-content-model-queue-based-scheduling-3-layer-attribution</link><guid isPermaLink="true">https://beckybuilds.hashnode.dev/designing-a-generation-pipeline-a-4-axis-content-model-queue-based-scheduling-3-layer-attribution</guid><category><![CDATA[Threads]]></category><category><![CDATA[systemdesign]]></category><category><![CDATA[FastAPI]]></category><category><![CDATA[SQLite]]></category><category><![CDATA[datamodeling]]></category><category><![CDATA[KPI ]]></category><category><![CDATA[automation]]></category><dc:creator><![CDATA[Becky]]></dc:creator><pubDate>Mon, 29 Jun 2026 06:28:42 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/69c4dd0910e664c5da65b3c8/d8756403-c25b-4fc7-a8f3-c54c0b176889.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<blockquote>
<p>An engineering log of building a Threads marketing agent, split into two parts.</p>
<ul>
<li><p><a href="https://beckybuilds.hashnode.dev/designing-a-crawler-that-beats-three-tiers-of-anti-bot-from-tls-fingerprints-to-cdp-detection"><strong>Part 1:</strong> The data-acquisition layer — a crawler designed around an anti-bot threat model (TLS fingerprint · CDP detection)</a></p>
</li>
<li><p><strong>Part 2 (this post):</strong> The generation pipeline — iterative content-model design, scheduled publishing, performance attribution</p>
</li>
</ul>
</blockquote>
<hr />
<h2>TL;DR</h2>
<p>Part 1 secured posts from arbitrary sites into a single normalized schema (<code>{title, body, comments[], images[]}</code>). Part 2 covers three design decisions that wire it into an <strong>operable publish-and-measure pipeline.</strong></p>
<ol>
<li><p><strong>Content model</strong> — evolved iteratively from a single <code>content_mode</code> (different criteria conflated on one axis) into <strong>four orthogonal axes (hook × persona × format × topic)</strong>. The "4 axes" weren't designed up front; they're the result of resolving coupling / separation-of-concerns problems one at a time.</p>
</li>
<li><p><strong>Scheduled publishing</strong> — <strong>rejected SaaS (Buffer etc.) on a build-vs-buy basis</strong> and implemented it with a SQLite queue + a 1-minute cron worker. Idempotency via atomic claim.</p>
</li>
<li><p><strong>Attribution</strong> — measured the clicks and conversions Threads doesn't give you, in <strong>three layers (engagement → click → conversion)</strong>. The hard part wasn't visualization but enforcing double-counting prevention, empty states, and the bot filter at the data-contract level.</p>
</li>
</ol>
<blockquote>
<p>Scope note: this is currently a <strong>measurement-infrastructure-first phase.</strong> It's a record of system design and problem-solving; business outcomes (ER/CVR/ROAS) require accumulated samples and are deliberately <em>not</em> claimed here (see §4).</p>
</blockquote>
<hr />
<h2>1. Content model — iterative evolution toward orthogonal axes</h2>
<p>The current generation skill defines a post as the product of four axes.</p>
<pre><code class="language-plaintext">post = hook_engine × persona × format × (topic_tag*)   + voice (community vernacular, enforced)
</code></pre>
<ul>
<li><p><strong>hook_engine</strong> — the hook angle (why you stop): hot_take, money_truth, regret_story, versus, rank_bait …</p>
</li>
<li><p><strong>persona</strong> — speaker/target: first_car, young_budget / purist, tuner, data_geek …</p>
</li>
<li><p><strong>format</strong> — body skeleton (the "vessel"): list_drip, confession, fill_in_blank, one_line_thread …</p>
</li>
<li><p><strong>topic_tag</strong> — topic domain: EV, depreciation, options, tuning …</p>
</li>
</ul>
<p>This structure wasn't designed in one shot. It went through <strong>five turning points</strong>, each a classic software-design problem (separation of concerns, orthogonality, scope, a missing abstraction).</p>
<h3>Turning point 0 — the root of a wrong abstraction: identity</h3>
<p>The initial identity was a "latest-trend car curator." No amount of tone rules stopped the output from regressing to a "tidy news report." The cause wasn't tone — it was the higher abstraction, identity.</p>
<blockquote>
<p>A curator organizes information; an operator designs reactions. Platform algorithms and communities respond not to <em>organized information</em> but to <em>emotion and engagement</em>. If the identity is "curator," output structurally converges to "read-and-done." Tone is a dependent variable of identity — without changing identity, no tone rule has force.</p>
</blockquote>
<p>The structural flaws of "recency" dependence were big too. Material exhausts fast, differentiation drops to zero, and you end up chained to press releases (de facto PR work). The actual high performers were evergreen hooks (first-car regret, sucker options, "for that money…").</p>
<p>So I redefined the identity as a <strong>"community operator who designs reactions."</strong> Crucially, this didn't discard information — it kept information as the trust base layer. Too much info is boring; too much provocation is backlash. The target is the middle.</p>
<p>In parallel I enforced <strong>separation of concerns between SOUL (identity / invariant constraints) and SKILL (execution procedure).</strong> At the time, image policy, output format, comment composition, and body length were duplicated across both docs, causing real conflicts (comment composition: fixed vs dynamic; length: 6–9 lines vs 5–7 sentences). The rule is simple: numbers/format/procedure live in SKILL as the single source; SOUL holds only "the why and the ceilings." Single source of truth.</p>
<h3>Turning point 1 — anti-pattern diagnosis: four criteria conflated on one axis</h3>
<p>The initial four <code>content_mode</code>s mixed different classification criteria on a single axis.</p>
<table>
<thead>
<tr>
<th>mode</th>
<th>actual classification axis</th>
</tr>
</thead>
<tbody><tr>
<td><code>debate_thread</code></td>
<td>intent (provoke debate)</td>
</tr>
<tr>
<td><code>buyer_dilemma</code></td>
<td>topic (purchase)</td>
</tr>
<tr>
<td><code>rank_bait</code></td>
<td>format (listing)</td>
</tr>
<tr>
<td><code>trend_brief</code></td>
<td>recency (latest)</td>
</tr>
</tbody></table>
<p>The result was classification collision: one post qualified for multiple modes at once (especially debate vs buyer). Worse, the "speaker (who's talking)" axis was missing entirely, so I couldn't regenerate the same car with just a different speaker. A textbook anti-pattern of cramming multiple concerns into one enum.</p>
<h3>Turning point 2 — orthogonal decomposition: enum → product structure</h3>
<p>I decomposed the conflated axis into <strong>three orthogonal axes (hook_engine × persona × topic_tag).</strong> Because the axes are independent, combinatorial variety is secured as a product. <code>debate_thread</code> was strengthened into <code>hot_take</code>; <code>money_truth</code> / <code>regret_story</code> / <code>versus</code> were added; persona was promoted to a first-class input (6 buyer types + 6 enthusiast types); and comments shifted from a fixed 3-persona set to dynamic mapping, where a speaker opposed to the body's axis pushes back.</p>
<blockquote>
<p><strong>Hidden coupling — schema migration.</strong> The publish-ledger recording script's <code>validate()</code> allowed only the old 4 modes, so generating with a new engine killed the record with <code>SystemExit</code>. Generation worked, but the input to the learning loop (anti-repeat / performance weighting) was severed. I fully replaced the vocabulary with v2 and normalized the past ledger via an idempotent migration to restore data consistency. A textbook case of a model change entailing a data-layer migration.</p>
</blockquote>
<h3>Turning point 3 — scope decision: introducing then retracting auto-discovery (Auto-Pick)</h3>
<p>On top of the 3 axes I added Auto-Pick ("simple input → bot auto-selects the axes"). It worked, but I rejected it in operation.</p>
<blockquote>
<p>Auto-discovering the <em>material</em> too increases topic dispersion. A marketing channel must select vetted posts, and that judgment is human intuition &gt; bot heuristics. So I fixed the scope to <strong>"a human picks the source URL"</strong> (same as Part 1's Principle 2) and removed auto-discovery. The bot is responsible only for transformation and variety — a decision binding the scope of automation to the scale of operation.</p>
</blockquote>
<h3>Turning point 4 — discovering the missing abstraction: format (the body vessel)</h3>
<p>Even with 3 axes, the body kept regressing to article-speak. Digging into why: hook_engine decides only "why you click (the first line / hook)", and there was no axis to set the body skeleton. So hook_engine took on that responsibility too and fell into information-listing. The fix was a <strong>fourth axis,</strong> <code>format</code><strong>, to enforce the body skeleton.</strong></p>
<table>
<thead>
<tr>
<th>format</th>
<th>skeleton</th>
<th>number cap</th>
</tr>
</thead>
<tbody><tr>
<td><code>list_drip</code></td>
<td>TOP/ranking list, one-line quip per item</td>
<td>minimal</td>
</tr>
<tr>
<td><code>owner_stereotype</code></td>
<td>"○○ owner type" relatable quips</td>
<td>0</td>
</tr>
<tr>
<td><code>one_line_thread</code></td>
<td>single group-chat bait, clipped sentences</td>
<td>0–1</td>
</tr>
<tr>
<td><code>fill_in_blank</code></td>
<td>"you buy ___, you ___" vote-baiting</td>
<td>0</td>
</tr>
<tr>
<td><code>confession</code></td>
<td>first-person regret/brag story</td>
<td>0–1</td>
</tr>
<tr>
<td><code>data_brief</code></td>
<td>info-summary form — <strong>only on explicit request</strong></td>
<td>high</td>
</tr>
</tbody></table>
<p>Key: exclude the info-summary form (<code>data_brief</code>) from the default and allow it only on explicit request. The default is one of 5 community vessels. The same material becomes a new post by switching the vessel (sales figures → a <code>list_drip</code>, not a table).</p>
<h3>voice hard constraints (reject on violation)</h3>
<p>Even with the right axes, article-speak sentences mean failure, so the body voice has enforced rules.</p>
<ol>
<li><p><strong>Number cap</strong> — outside <code>data_brief</code>, ≤2 key numbers per body (rate-of-change / multi-model number dumps auto-rejected)</p>
</li>
<li><p><strong>First sentence is situation/emotion/provocation</strong> — never open with data</p>
</li>
<li><p><strong>No links in the body</strong> — sources go in comment footnotes only (reason in §2)</p>
</li>
<li><p><strong>No source-community exposure</strong> — banned phrasing like "I saw on Bobaedream…"; rewrite in first person</p>
</li>
</ol>
<p>Part 1's <strong>comment N&lt;3 DROP gate</strong> operates here too.</p>
<blockquote>
<p><strong>Design retrospective:</strong> the 4 axes aren't a top-down design but an emergent structure from sequentially resolving <em>separation of concerns (TP 1–2) → scope (3) → a missing abstraction (4)</em>. Each turn began with the observation "without this, it keeps falling into the same failure mode." A content model is a refactoring target, just like code.</p>
</blockquote>
<hr />
<h2>2. Scheduled publishing — build vs buy, and an idempotent worker</h2>
<blockquote>
<p><strong>Decision:</strong> rejected publishing SaaS (Buffer etc.). With a Discord bot, VPS, and SQLite already in place and simple requirements (slot scheduling / auto-publish / Discord integration), a self-built <strong>SQLite queue + 1-minute cron worker</strong> won on both coupling and cost.</p>
</blockquote>
<h3>Architecture</h3>
<pre><code class="language-plaintext">Discord: a human drops a source URL
   → generation skill: body + 3 comments + images
   → clarify buttons: [pick topic] [time: 14:23 / 19:47 / 🚀now]
   → threads_enqueue.py: INSERT into publish_queue (status=pending, scheduled_for)
        ┄┄┄ (async boundary) ┄┄┄
   → cron(1m) threads_publish_worker.py:
        atomically claim rows where scheduled_for ≤ now → 'publishing'
        → Threads Graph API publish (carousel ≤10)
        → update published (thread_url, published_at) + Discord notification
</code></pre>
<p>Design points:</p>
<ul>
<li><p><strong>Idempotency</strong> — the worker first locks a due row to <code>publishing</code> (atomic UPDATE) before processing. No double-publish even on 1-minute re-entry.</p>
</li>
<li><p><strong>Slot-recommendation UX</strong> — so a human doesn't type a time, offer two slots within KST 10:00–24:00 + a "now" three-way choice.</p>
</li>
<li><p><strong>Rate limit</strong> — Threads API allows 250/account/24h. The queue drain rate is capped to that.</p>
</li>
</ul>
<blockquote>
<p><strong>The real reason for the no-link-in-body rule (coupled with §1 voice #3):</strong> the performance-collection cron matches metrics to a post by comparing the <em>published body</em> and the <em>DB-stored body</em> character-by-character. A link in the body breaks that match. So the body is pure text and inbound links go entirely into comments (footnotes). It looks like a constraint, but it's intentional coupling for §3 attribution accuracy.</p>
</blockquote>
<hr />
<h2>3. Attribution dashboard — the data contract <em>is</em> the design</h2>
<blockquote>
<p><strong>The hard part wasn't visualization; it was enforcing measurement integrity at the data-contract level.</strong></p>
</blockquote>
<h3>3.1 Why measure it ourselves — the 3-layer measurement boundary</h3>
<p>Threads insights provide only impressions, engagement, and followers. A marketing channel's core question ("did it produce clicks → did they convert?") can only be answered off-platform.</p>
<pre><code class="language-plaintext">Layer 3 conversion ← quote / topup        [only via PG / quote events]
Layer 2 click      ← go-redirect self-redirect  [not provided by Threads]
Layer 1 engagement ← hourly 6 metrics (views/likes/replies/reposts/quotes/shares)  [Threads API]
</code></pre>
<p>The boundary is clear. Threads is primarily responsible only up to "impression → profile." After that, <strong>"bio click → landing → conversion" is tracked solely via our own</strong> <code>cid</code> <strong>(click id).</strong> The <code>go.차이사.com/{slug}</code> redirector records the click and assigns a <code>cid</code>, and that value propagates idempotently through 302 → landing cookie → conversion event (<code>/conv</code>).</p>
<h3>3.2 Data-first design — four expert lenses + grounding in real measurements</h3>
<p>The design was driven not by intuition but by four parallel perspectives (admin UX / ads-conversion / PM-IA / platform ops) → reconciliation. And every decision was grounded in a real snapshot.</p>
<blockquote>
<p>Snapshot: 44 posts published (1 account), 13 valid clicks, <strong>126 bot clicks (≈90%)</strong>, 0 conversions. → Implication: the data is nearly empty. The MVP's first value isn't analytics but making visible "is the pipe alive + can I trust the bot-90% filter + the bio link isn't applied yet." Learning-style analytics (hook×format heatmaps) are deferred until samples accumulate.</p>
</blockquote>
<p>The four key decisions that came out of this grounding:</p>
<p><strong>(1) Empty state as a first-class state.</strong> With 0 conversions, the screen must not break or mislead. Every widget mandates empty-state copy + a sample-size <code>n</code> badge (n&lt;5 yellow, n=0 gray), with the bot ratio always shown. Four empty states are explicitly classified (collecting / under-sampled / empty bucket / not-connected).</p>
<p><strong>(2) Diagnostic separation of "0 conversions ≠ a bug."</strong> I live-verified the whole path — <code>/conv</code> ingestion, token, 302 cid propagation, landing cookie — to confirm the pipe is healthy. Zero POSTs simply means there are no real payments/quotes yet, so the conversion view starts in an "awaiting collection" empty state. The design goal: keep the operator from mistaking an empty screen for an outage.</p>
<p><strong>(3) Blocking double-counting at the schema.</strong> Summing partner top-ups (<code>topup</code>, prepayment) and customer quotes (<code>quote</code>, realized revenue) inflates revenue. So I deliberately removed a "total revenue" column from the data contract. Realized revenue=<code>SUM(quote.value)</code> and top-up inflow=<code>SUM(topup.value)</code> are exposed only as two physically separated rows, with the sum slot left empty in the layout to block addition at the source. ROAS numerator = quote only.</p>
<p><strong>(4) The bot filter as the default denominator.</strong> At ≈90% bots, click/conversion metrics default to <code>is_bot=0</code> (+ ip_hash×slug 24h dedup). An unattributed-rate badge sits next to CVR so denominator trustworthiness is always visible.</p>
<h3>3.3 Single data contract (VIEW)</h3>
<p>Direct access to source tables is forbidden; everything is a VIEW + compute-at-query to force a single interface. The data is small, so materialization (aggregate tables) is deferred (switch on reaching a threshold). The two-DB join (<code>clicks.db ⨝ editorial.db</code>) uses <code>ATTACH</code>.</p>
<pre><code class="language-plaintext">v_funnel_daily (account × audience × day):
  clicks(is_bot=0) | quote_cnt, quote_value | topup_cnt, topup_value | follower_ctr
  ※ a 'total_revenue' column is intentionally absent → blocks summation at the source
age-normalization: ROW_NUMBER() OVER(PARTITION BY short_id ORDER BY ABS(age_hours-24))
                   — no raw cumulative sort (avoids unfair comparison across post ages)
</code></pre>
<h3>3.4 Stack right-sizing</h3>
<table>
<thead>
<tr>
<th>Item</th>
<th>Choice</th>
<th>Rationale</th>
</tr>
</thead>
<tbody><tr>
<td>Placement</td>
<td>add an <code>/admin/*</code> router to go-redirect (no separate container)</td>
<td>read-only, no data conflict. separation benefit &lt; complexity</td>
</tr>
<tr>
<td>Render</td>
<td>FastAPI + Jinja2 SSR</td>
<td>few users · 6 views. an SPA is over-engineering</td>
</tr>
<tr>
<td>Charts</td>
<td>Chart.js (CDN)</td>
<td>zero build; covers bar/line/scatter/heatmap</td>
</tr>
<tr>
<td>Aggregation</td>
<td>SQLite VIEW + compute-at-query</td>
<td>small scale; materialization deferred</td>
</tr>
<tr>
<td>Auth</td>
<td>traefik basicAuth</td>
<td>few operators; zero app-side auth code</td>
</tr>
<tr>
<td>DB</td>
<td><code>mode=ro</code> + <code>query_only=ON</code>, ATTACH</td>
<td>WAL concurrent reads → no conflict with the collector cron's writes</td>
</tr>
</tbody></table>
<blockquote>
<p><strong>Two load-bearing gotchas:</strong> ① bind-mounting editorial.db as <code>:ro</code> makes WAL reads throw <code>disk I/O error</code> → fix with an rw mount + read-only at the app connection only. ② go-redirect's <code>@app.get("/{slug}")</code> catch-all swallows all paths → <code>/admin/*</code> must be declared above it.</p>
</blockquote>
<p><strong>MVP value proposition:</strong> the overview's three widgets — <em>funnel waterfall (views→clicks→conversions) + bot ratio + freshness</em> — convey daily "pipe alive, 90% bots, apply the bio link." Not flashiness; the single signal to watch every day, first.</p>
<hr />
<h2>4. Operational status — scope and limits (in good faith)</h2>
<p>This is a record of a well-designed system, not a system with proven marketing outcomes. It's right to state the two separately.</p>
<ul>
<li><p><strong>Current phase = measurement-infrastructure-first.</strong> It's a cold start: 44 posts, &lt;100 followers, 0 conversions. Quantitative business outcomes (ER lift, CVR, ROAS) require accumulated samples and real conversions, which is exactly why §3 deliberately leaves learning analytics and the conversion view "awaiting collection." Not claiming outcomes is the honest stance at this point.</p>
</li>
<li><p><strong>There is, however, qualitative evidence of direction.</strong> Content-model changes were driven by operational feedback, not intuition: "bot comments are too tidy / unnatural" → mimic real comments + N&lt;3 DROP; "AI-generated images cause backlash" → real photos only. That is, I iterated toward reducing the bot signature, and recorded the basis for those judgments (off-topic / length-variance of real comments) as data.</p>
</li>
<li><p><strong>What's proven vs not:</strong> Proven — pipeline liveness (generate → queue → publish → 3-layer collection end-to-end), measurement integrity (double-counting / bot filter enforced at the schema), inbound real clicks starting after the bio link. Unproven — whether the content actually drives higher engagement/conversion (causation). That's the next phase's task.</p>
</li>
</ul>
<h2>5. Retrospective</h2>
<ul>
<li><p><strong>A content model is a refactoring target.</strong> enum conflation (TP1) → orthogonal decomposition (2) → filling a missing abstraction (4) is isomorphic to code refactoring. Tracing "why the same failure mode every time" reveals the missing axis.</p>
</li>
<li><p><strong>A model change entails data migration.</strong> The ledger validation logic was a hidden coupling point of the learning loop (TP2). When changing schema/vocabulary, design past-data normalization alongside it.</p>
</li>
<li><p><strong>Judge build-vs-buy by coupling.</strong> The decisive reason for queue+cron over publishing SaaS was coupling, not cost (reuse existing Discord/DB/cron, simple via an idempotent worker).</p>
</li>
<li><p><strong>Measurement integrity lives in the schema, not the UI.</strong> Double-counting is blocked by "remove the total-revenue column," bot contamination by "default denominator is_bot=0" — i.e. enforcing it in the data contract prevents screen-level mistakes at the source.</p>
</li>
<li><p><strong>Next:</strong> close the self-improvement loop with hook×format learning heatmaps once samples suffice; refine attribution quality (cid capture rate / unattributed rate) once real conversions appear.</p>
</li>
</ul>
<p>The one line that runs through both parts: <strong>in bot content, "the smell of automatic generation" is a trust cost.</strong> Part 1 was the anti-bot threat model; Part 2 was content authenticity and measurement integrity. In the end both are the same question — in "a human picks vetted material, the bot transforms it, the system automates publishing and measurement," <strong>where do you draw the boundary?</strong></p>
<blockquote>
<p><a href="https://beckybuilds.hashnode.dev/designing-a-crawler-that-beats-three-tiers-of-anti-bot-from-tls-fingerprints-to-cdp-detection">👈 <strong>Part 1:</strong> "Designing a Crawler That Beats Three Tiers of Anti-Bot — From TLS Fingerprints to CDP Detection"</a></p>
</blockquote>
<hr />
<p><em>A build log of a Threads marketing agent (Hermes) in actual operation. Feedback welcome.</em></p>
]]></content:encoded></item><item><title><![CDATA[Designing a Crawler That Beats Three Tiers of Anti-Bot — From TLS Fingerprints to CDP Detection]]></title><description><![CDATA[An engineering log of building a Threads marketing agent, split into two parts.

Part 1 (this post): The data-acquisition layer — a crawler designed around an anti-bot threat model

Part 2: The genera]]></description><link>https://beckybuilds.hashnode.dev/designing-a-crawler-that-beats-three-tiers-of-anti-bot-from-tls-fingerprints-to-cdp-detection</link><guid isPermaLink="true">https://beckybuilds.hashnode.dev/designing-a-crawler-that-beats-three-tiers-of-anti-bot-from-tls-fingerprints-to-cdp-detection</guid><category><![CDATA[webscraping ]]></category><category><![CDATA[crawling]]></category><category><![CDATA[cloudflare]]></category><category><![CDATA[TLS]]></category><category><![CDATA[systemdesign]]></category><category><![CDATA[anti bot]]></category><category><![CDATA[Threads]]></category><dc:creator><![CDATA[Becky]]></dc:creator><pubDate>Mon, 29 Jun 2026 06:25:52 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/69c4dd0910e664c5da65b3c8/79d4e77e-613a-4966-b4a6-da76e176d339.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<blockquote>
<p>An engineering log of building a Threads marketing agent, split into two parts.</p>
<ul>
<li><p><strong>Part 1 (this post):</strong> The data-acquisition layer — a crawler designed around an anti-bot threat model</p>
</li>
<li><p><a href="https://beckybuilds.hashnode.dev/designing-a-generation-pipeline-a-4-axis-content-model-queue-based-scheduling-3-layer-attribution"><strong>Part 2:</strong> The generation pipeline — iterative content-model design, scheduled publishing, and performance attribution</a></p>
</li>
</ul>
</blockquote>
<hr />
<h2>TL;DR</h2>
<p>In an agent that turns Korean car-community posts into Threads content, <strong>the real bottleneck wasn't LLM generation — it was data acquisition (crawling).</strong> Each target site enforces a different level of anti-bot protection, so no single collection strategy covers them all.</p>
<p>The key insight was to <strong>tier the collection strategy by <em>which signal the anti-bot inspects</em>.</strong></p>
<table>
<thead>
<tr>
<th>Tier</th>
<th>Threat signal</th>
<th>Representative site</th>
<th>Where it blocks</th>
<th>Solution adopted</th>
<th>Cost</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Tier 1</strong></td>
<td>IP reputation only</td>
<td>Clien, DCInside, Bobaedream</td>
<td>— (effectively none)</td>
<td>direct <code>urllib</code> request</td>
<td>lowest</td>
</tr>
<tr>
<td><strong>Tier 2</strong></td>
<td>IP + <strong>TLS fingerprint (JA3)</strong></td>
<td>FMKorea</td>
<td>curl/requests blocked (430)</td>
<td>residential IP + real headless Chrome; VPS delegates via Tailscale</td>
<td>medium</td>
</tr>
<tr>
<td><strong>Tier 3</strong></td>
<td>+ <strong>CDP automation detection</strong></td>
<td>Upwork</td>
<td>every automation browser blocked</td>
<td>userscript inside a human session (Tampermonkey)</td>
<td>high (gives up full automation)</td>
</tr>
</tbody></table>
<p>Regardless of how data is fetched, the output is unified into a single normalized schema, and the caller is shielded from per-site complexity. Below, each tier is laid out as <strong>hypothesis → experiment → conclusion.</strong></p>
<hr />
<h2>1. Problem definition — the bottleneck is acquisition, not generation</h2>
<p>The popular image of "AI marketing automation" puts the weight on generation (prompt → text). In practice, generation converges fast. What eats your time is securing the input: <em>what should we write about?</em> The raw material lives on third-party communities, and those sites block bots.</p>
<p>Two design principles, both learned in operation, governed this whole layer.</p>
<p><strong>Principle 1 — an extraction API ≠ a structure crawler.</strong> Early on I used a body-extraction API (e.g. Tavily). For one Bobaedream post: raw HTML 95KB → cleaned output 5KB. Clean, but the tags, post date, and comments (the AJAX payload) were all gone. Our pipeline treats comments as a first-class input — they're central to generation quality — so an extraction API was structurally unfit.</p>
<p>The first fork in tooling, then, is "do I need only body text, or also structure / dynamic content?" The "tidiness" of a cleaning API <em>is</em> information loss.</p>
<p><strong>Principle 2 — bind the scope of automation to the scale of operation.</strong> The first design auto-discovered material ("hot tags + last N days + multi-candidate queue"). But scraping unvetted posts by volume only increases topic dispersion. Selecting posts whose engagement is already proven is something human judgment does better than a bot's heuristic scoring. So I fixed the premise to <strong>"a human picks the source URL,"</strong> and let the bot focus on extracting structure losslessly from that one URL.</p>
<p>That made the collection engine's single responsibility crisp: <strong>"one human-chosen URL → losslessly normalize {title, body, comments, images}."</strong> It sounds trivial, but per-site HTML structure and anti-bot all differ, so a single implementation is impossible.</p>
<hr />
<h2>2. Interface design — encapsulating per-site complexity</h2>
<p>Even when the strategy diverges per site, the caller must not know about that branching. A single helper (<code>fetch_source.py</code>) takes only a URL, dispatches internally, and returns one schema.</p>
<pre><code class="language-bash">python3 bin/fetch_source.py "&lt;url&gt;"   # site-agnostic → normalized JSON
</code></pre>
<pre><code class="language-plaintext">detect_site(url)   # domain → {bobaedream, clien, dcinside, fmkorea}
derive_board(url)  # derive board code / post id from the URL (never hard-code)
→ single schema: {ok, site, title, body, images[], comments[{nick,text}], recommend_count, comment_count}
</code></pre>
<blockquote>
<p><strong>Design decision — board identifiers are always derived from the URL.</strong> DCInside gallery ids, Clien board names, and Bobaedream code/No are all encoded in the URL. Baking them in as constants means a code change for every new board. Regex derivation supports any board on the same site with zero changes — in effect, the open–closed principle applied to the data layer.</p>
</blockquote>
<p>Every per-site hell that follows is locked behind this abstraction. The caller's contract is simply "URL in, normalized JSON out."</p>
<hr />
<h2>3. Tier 1 — direct <code>urllib</code> requests (Clien, DCInside, Bobaedream)</h2>
<p>What these three share: body and comments are server-rendered HTML, and the anti-bot is only at the IP-reputation level. So a direct <code>urllib</code> request (with a CookieJar) suffices. The only variables are the comment-collection path and the selectors.</p>
<table>
<thead>
<tr>
<th>Site</th>
<th>Body</th>
<th>Comment strategy</th>
<th>Note</th>
</tr>
</thead>
<tbody><tr>
<td>Clien</td>
<td><code>post_article</code></td>
<td><strong>inline</strong> (<code>data-role="autolink"</code>)</td>
<td>done in a single GET</td>
</tr>
<tr>
<td>DCInside</td>
<td><code>write_div</code></td>
<td><strong>AJAX + CSRF-style token</strong></td>
<td>extract <code>e_s_n_o</code> from the view → POST to <code>/board/comment/</code> with cookie+Referer → JSON</td>
</tr>
<tr>
<td>Bobaedream</td>
<td>comment marker → <code>article-body</code> priority</td>
<td><strong>inline, then fallback</strong></td>
<td>if &lt;3, separate call to <code>comment_list.php</code></td>
</tr>
</tbody></table>
<p>DCInside is the trickiest. The <code>e_s_n_o</code> token is a one-time value refreshed per view page, so it can't be cached — it forces a two-step dance: "extract token → POST for comments with that token." Bobaedream is scraped via the mobile view with a mobile UA, but "best" posts need special handling: re-parse the real code/No from the inline markup to build the comment endpoint.</p>
<p><strong>Tier 1 conclusion:</strong> bodies are all server-rendered (no JS execution needed) and there's effectively no anti-bot. The only variable is the comment path. But the fourth site breaks that premise.</p>
<hr />
<h2>4. Tier 2 — FMKorea: IP and TLS fingerprint, a double gate ⭐</h2>
<p>FMKorea's body is server-rendered too. The variable is the anti-bot. I narrowed the blocking surface with hypothesis-driven experiments.</p>
<h3>4.1 Experiment log</h3>
<pre><code class="language-plaintext">hypothesis / attempt                          result
──────────────────────────────────────────────────────
H1: datacenter IP is the problem
  VPS + curl                              →  HTTP 430 (immediate)
H2: residential IP fixes it
  residential IP + curl, cold first call  →  200 (passes!)
  residential IP + curl, subsequent calls →  430 "FMKorea security system"
H3: header spoofing gets around it
  residential IP + curl + full browser headers + h2 → 430 (no change)
H4: a real browser passes
  residential IP + real headless Chrome   →  200, 112KB, full body + comments
</code></pre>
<p>H2's cold-pass was a trap. It tempted a hasty generalization — "residential IP means curl can automate this" — but on replay that was a lucky one-off and it converged to 430. The blocking surface was two layers: ① IP reputation (datacenter), ② TLS fingerprint (curl). The decisive clue was H3: perfect header spoofing still failed, meaning the anti-bot inspects the TLS layer beneath, not the L7 headers.</p>
<h3>4.2 Why header spoofing is futile — the TLS fingerprint (JA3/JA4)</h3>
<p>An <code>https</code> connection sends a <strong>ClientHello</strong> during the <strong>TLS handshake</strong>, before any real traffic. That ClientHello carries the cipher suites, extensions, elliptic curves, and signature algorithms the client supports — and their order. The key fact: this list and ordering are unique per implementation. curl, Chrome, and Safari each differ. Hashing it gives the <strong>JA3/JA4 fingerprint.</strong></p>
<blockquote>
<p>The User-Agent is a self-reported application-layer value, freely forgeable. The TLS fingerprint, however, comes from the TLS-stack implementation itself. So even if you spoof the header to Safari, the handshake fingerprint is still curl. The anti-bot flags this mismatch ("UA says Safari but JA3 says curl") as a lying bot and returns 430. Add a behavioral signal (high frequency from one IP) and you get the immediate block right after the cold pass.</p>
</blockquote>
<p>In short, L7 spoofing can't fake an L6 fingerprint. Passing requires satisfying <em>both</em> "residential IP (reputation)" and "real browser TLS stack (fingerprint)" at once.</p>
<h3>4.3 Solution architecture — delegate to a residential node via Tailscale</h3>
<pre><code class="language-plaintext">[VPS / hermes-agent]  (datacenter IP)
   ├─ Bobaedream/Clien/DCInside → direct request (Tier 1)
   └─ fmkorea → delegated call (Tailscale tailnet)
                  │
                  ▼
        [Mac mini fetch service]  (residential IP + real Chrome)
                  │  chrome --headless=new --dump-dom &lt;url&gt;
                  ▼
              fmkorea.com  → passes IP + TLS fingerprint → 200
</code></pre>
<p>An always-on Mac mini runs real Chrome headless to clear both gates at once; the VPS delegates to this node <strong>only for FMKorea</strong> over Tailscale and just receives the result JSON.</p>
<blockquote>
<p><strong>Rejected alternatives:</strong> ① Routing all VPS traffic through a Tailscale exit node → single point of failure, complex policy routing, latency on all traffic. Selective delegation for FMKorea only is far lower-coupling. ② <code>curl-impersonate</code> (a build that mimics Chrome's JA3) → works, but with a real Chrome available the operational simplicity wins. ③ An extraction API (Tavily) → its servers do the fetching, so IP evasion is moot (you only get a shell).</p>
</blockquote>
<h3>4.4 Trade-off — Chrome CLI vs Playwright</h3>
<p>First, a common myth: <strong>Playwright is not what beats the anti-bot.</strong> What passes the TLS fingerprint is "real Chrome," not the driver. Playwright is just a question of <em>how</em> you drive that Chrome.</p>
<table>
<thead>
<tr>
<th></th>
<th>Adopted: Chrome CLI</th>
<th>Playwright</th>
</tr>
</thead>
<tbody><tr>
<td>Surface</td>
<td><code>chrome --headless=new --dump-dom URL</code></td>
<td>DevTools-Protocol driver library</td>
</tr>
<tr>
<td>Capability</td>
<td>single dump of final DOM</td>
<td>click / scroll / wait / network intercept</td>
</tr>
<tr>
<td>Dependency</td>
<td>none (just Chrome)</td>
<td>runtime + bundled browser</td>
</tr>
<tr>
<td>Detection surface</td>
<td>real Chrome as-is</td>
<td>bundled Chromium risks <code>navigator.webdriver</code> etc.</td>
</tr>
</tbody></table>
<p>FMKorea posts are server-rendered with title/body/comments all in the initial DOM, so no interaction is needed. A single <code>--dump-dom</code> returns 112KB including comments. So I chose the direct CLI: zero dependencies, simple code, minimal detection surface. If dynamic loading (load-more / infinite scroll / login) ever appears, escalating to Playwright then is the rational move. YAGNI.</p>
<blockquote>
<p><strong>Implementation detail (non-blocking termination):</strong> a fresh-profile Chrome doesn't exit immediately after <code>--dump-dom</code> (background tasks), causing a ~45s timeout per request. The fix: stream stdout and kill the process the moment <code>&lt;/html&gt;</code> appears, clean the profile lock per call, and serialize with a global lock. Response dropped to 3–5s.</p>
</blockquote>
<p><strong>Tier 2's premise:</strong> "we can launch a real browser ourselves." Tier 3 breaks that premise.</p>
<hr />
<h2>5. Tier 3 — Upwork: the automation browser itself is the detection target ⭐</h2>
<p>A separate task required collecting Upwork job data (rate/proposal distribution per keyword = a bidding price dataset). Every Tier-2 weapon was neutralized.</p>
<blockquote>
<p>ℹ️ Upwork collection is a <strong>separate task, unrelated</strong> to this marketing agent's material pipeline. But it presented a <strong>fundamentally different class of anti-bot (detecting automation itself)</strong> and therefore demanded a <strong>completely different crawling paradigm (userscripts)</strong> — so I record it here as the final tier of the threat model.</p>
</blockquote>
<h3>5.1 Failure matrix</h3>
<pre><code class="language-plaintext">attempt                                               result
──────────────────────────────────────────────────────────
curl / requests                                    →  403
public RSS                                         →  410 Gone (deprecated)
Playwright (bundled Chromium)                       →  Cloudflare infinite challenge
  + stealth plugin                                 →  infinite challenge
  + system Chrome (channel:chrome), headed         →  infinite challenge
  + real login profile + cf_clearance cookie       →  infinite challenge
</code></pre>
<p>The last line is the crux. Even with real Chrome + headed + a valid login session + a passing cookie (cf_clearance) all present, Cloudflare kept re-challenging. Failure <em>despite</em> satisfying every condition that beat Tier 2 → a signal that the blocking criterion isn't "identity" but something else.</p>
<h3>5.2 Probable cause — Cloudflare's automation (agency) detection</h3>
<p>The probable cause is <strong>runtime detection of automation-control signals</strong>, chiefly <strong>CDP (Chrome DevTools Protocol)</strong> exposure. CDP is the protocol Playwright/Puppeteer/Selenium use to drive a browser externally. In other words, an open CDP session is itself the signal that "this browser is being controlled by code."</p>
<p>Enterprise Cloudflare evaluates TLS, behavior, and automation markers together. Yet H4 (real Chrome + headed + login + cf_clearance) satisfied every identity/session signal and still got blocked. That narrows the remaining variable to one: the <strong>agency signal — "is this being controlled by automation?"</strong></p>
<blockquote>
<p>⚠️ <strong>Limits of proof (in good faith):</strong> I did not run the experiment that <em>isolates</em> CDP as the sole cause (e.g. a CDP-free build as control). Cloudflare uses multiple signals, so other automation markers like <code>navigator.webdriver</code> may also have contributed. What I can assert is only this: even satisfying every identity/session signal, an automation-controlled session does not pass.</p>
</blockquote>
<p>The depth of the threat signal is one level different — that's the core.</p>
<ul>
<li><p><strong>TLS fingerprint (Tier 2):</strong> "who (which implementation) connected" — <em>identity</em></p>
</li>
<li><p><strong>Automation detection (Tier 3):</strong> "is this session controlled by code" — <em>agency</em></p>
</li>
</ul>
<p>cf_clearance, real Chrome, and login all satisfy the <em>identity</em> signal, but the <em>agency</em> signal can't be turned off as long as you use an automation tool. → <strong>Practical conclusion: you cannot reliably collect Upwork with Playwright/Selenium-class tools.</strong> It's not about evasion difficulty; the fact of automation <em>is</em> the detection surface.</p>
<h3>5.3 Paradigm shift — a userscript inside a human session</h3>
<p>If what's detected is "a CDP-controlled browser," then run the code inside a browser with no CDP — a session the user opened themselves. That's a <strong>Tampermonkey userscript</strong>: ordinary JS running in the page context, with no CDP connection and no <code>navigator.webdriver</code>. From Cloudflare's view there's no automation marker to detect.</p>
<pre><code class="language-javascript">// ==UserScript==
// @name         Upwork Job Harvester → CSV
// @match        https://www.upwork.com/nx/search/jobs/*
// @grant        GM_getValue
// @grant        GM_setValue
// ==/UserScript==
const TARGET_PER_KEYWORD = 200;  // auto-stop once the per-keyword target is hit
const MIN_DELAY = 4500, MAX_DELAY = 8000;  // randomized delay between page turns — avoid behavioral signals
</code></pre>
<p>Implementation points:</p>
<ul>
<li><p><strong>State persistence</strong> — accumulate data in <code>GM_setValue</code> so nothing is lost across page transitions (SPA routing). Auto-paging + dedup by job id (<code>~0...</code>).</p>
</li>
<li><p><strong>Behavioral-signal management</strong> — even a real browser gets flagged on high frequency, so 4.5–8s randomized delays.</p>
</li>
<li><p><strong>Tiered data sources</strong> — progressively enrich fields from list cards / the search page's <code>window.__NUXT__</code> / the detail page's <code>__NUXT_DATA__</code> (Nuxt3 flat array).</p>
</li>
</ul>
<h3>5.4 Decision framework — threat signal → strategy</h3>
<p>Generalizing all three tiers into one decision tree:</p>
<table>
<thead>
<tr>
<th>Top signal the anti-bot inspects</th>
<th>Where it blocks</th>
<th>Strategy</th>
<th>Automation loss</th>
</tr>
</thead>
<tbody><tr>
<td>IP reputation</td>
<td>datacenter IP</td>
<td>route via residential node</td>
<td>none</td>
</tr>
<tr>
<td>+ TLS fingerprint (identity)</td>
<td>curl/requests</td>
<td><strong>we drive a real browser</strong></td>
<td>none</td>
</tr>
<tr>
<td>+ CDP (agency)</td>
<td>every automation browser</td>
<td><strong>inject code into a human session (userscript)</strong></td>
<td>gives up full automation</td>
</tr>
</tbody></table>
<blockquote>
<p><strong>The core judgment:</strong> if the anti-bot inspects only <em>identity</em>, we drive a browser to get through. If it inspects <em>agency</em> (whether it's automated), then the very fact that we drive the browser is grounds for blocking, so the code must be carried inside a human session. The latter trades away full automation to eliminate detection at the root. Designing well <em>is</em> recognizing and choosing that trade-off explicitly.</p>
</blockquote>
<hr />
<h2>6. Data-trust gate — collected ≠ usable</h2>
<p>A successful fetch isn't usable yet. A quality gate sits before generation.</p>
<p><strong>Comments N&lt;3 → DROP (no synthetic fallback).</strong> This is the most important rule. A bot's formulaic "empathy / rebuttal / verification" comment set is too tidy, and that tidiness itself becomes a bot signature. Real community comments are different: an off-topic top reply ("…bought 10 Samsung shares yesterday"), high length variance ("Fighting!!!"), chit-chat chains.</p>
<p>So I use the actually-fetched comments as the source and mimic their tone / length / off-topic distribution. Verbatim copying is banned (author protection). If fewer than 3 sources exist, the post is dropped rather than padded with synthetic fillers.</p>
<blockquote>
<p>A synthetic comment's naturalness comes not from "being well-written" but from "resembling the real thing." The closer you get to an ideal debate structure, the more it reeks of a bot — the goal is to mimic the flaws, not the polish.</p>
</blockquote>
<p>Also: a hard-reject filter (death/serious injury, legal disputes, politics, hate), and images are original real photos only (no AI generation — the "🎨 AI image" label is itself a trust cost for a bot).</p>
<hr />
<h2>7. Retrospective</h2>
<ul>
<li><p><strong>Threat-model-first design paid off.</strong> Classifying by "what the anti-bot inspects" rather than "how do I scrape" means a new site drops cleanly into a tier via the decision tree.</p>
</li>
<li><p><strong>The value of hypothesis-driven debugging.</strong> I once drew a wrong conclusion (curl can automate this), fooled by H2's cold pass. A replay experiment isolated the real cause — the TLS fingerprint. The discipline of not trusting a one-off success is the key.</p>
</li>
<li><p><strong>The abstraction boundary.</strong> Locking per-site branching and anti-bot evasion behind <code>fetch_source</code> lets the generation pipeline (Part 2) consume one schema, blind to the data's origin.</p>
</li>
<li><p><strong>Known limits (honestly):</strong> the Mac mini node is a single point of failure and can't fetch while asleep; Chrome runs serially (3–5s/request), a bottleneck for bulk collection → multi-node / browser-pool is the next task. <code>--dump-dom</code> captures only the initial DOM, so exhaustive comment-pagination would require escalating to Playwright.</p>
</li>
</ul>
<p><strong>State reached:</strong> four sites (Bobaedream, Clien, DCInside, FMKorea) collected through a single interface; FMKorea cut from ~45s to 3–5s via the delegate node; Upwork given a collection path through userscripts in an environment where automation is structurally impossible. Every source unifies into one normalized schema.</p>
<p>Part 2 covers turning this normalized material into community-style content without a bot signature (the iterative generation-skill design), plus scheduled publishing and performance attribution.</p>
<hr />
<p><em>A build log of a Threads marketing agent (Hermes) in actual operation. Feedback welcome.</em></p>
]]></content:encoded></item><item><title><![CDATA[Scaling QPS 10x in Production: 3 Strategies That Actually Move the Needle]]></title><description><![CDATA["Make it faster" sounds like a vague mandate. It isn't. Performance work turns out to be one of the most learnable parts of backend engineering, because it almost always comes down to the same three m]]></description><link>https://beckybuilds.hashnode.dev/scaling-qps-10x-in-production-3-strategies-that-actually-move-the-needle</link><guid isPermaLink="true">https://beckybuilds.hashnode.dev/scaling-qps-10x-in-production-3-strategies-that-actually-move-the-needle</guid><category><![CDATA[System Design]]></category><category><![CDATA[caching]]></category><category><![CDATA[performance]]></category><category><![CDATA[optimization]]></category><dc:creator><![CDATA[Becky]]></dc:creator><pubDate>Fri, 29 May 2026 15:03:38 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/69c4dd0910e664c5da65b3c8/fc672eb0-c4c5-4423-afe8-5adff53b454d.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<blockquote>
<p>"Make it faster" sounds like a vague mandate. It isn't. Performance work turns out to be one of the most <em>learnable</em> parts of backend engineering, because it almost always comes down to the same three moves. These are the three I shipped in production — and they took our QPS up more than 10x.</p>
</blockquote>
<p>Let me walk through them the way I actually think about them now: <strong>why the bottleneck exists → how to fix it → how much it actually moved the needle.</strong></p>
<h2>First, what <em>is</em> QPS — and why does it hit a wall?</h2>
<p>QPS (Queries Per Second) is just <strong>how many requests you handle per second</strong>. But you can't will it higher. It's bound by a formula:</p>
<pre><code class="language-plaintext">QPS ≈ number of concurrent workers / time to process one request
</code></pre>
<p>The <strong>checkout counter analogy</strong> makes this click. Think of a worker (a thread or a connection) as a <em>cashier</em>, and request-processing-time as <em>how long it takes to ring up one customer</em>.</p>
<ul>
<li><p>1 cashier, 0.5s per customer → 2 customers/sec = <strong>2 QPS</strong></p>
</li>
<li><p>Speed them up to 0.1s per customer → 1 cashier now does <strong>10 QPS</strong> (you shrank the denominator)</p>
</li>
<li><p>Add 2 more cashiers → at 0.1s each, <strong>30 QPS</strong> (you grew the numerator)</p>
</li>
</ul>
<pre><code class="language-plaintext">QPS = cashiers / time-per-customer   →   3 / 0.1s = 30 QPS
</code></pre>
<p>The trouble starts when customers pour in faster than that. Hit a 30-QPS shop with <strong>40 customers per second</strong>, and the line grows by 10 every second → wait times explode → customers walk out (timeouts) → the shop seizes up (an outage). Servers behave exactly the same way.</p>
<p><strong>So there are only two ways to raise QPS:</strong></p>
<ol>
<li><p><strong>Shrink the denominator</strong> (time per request) → caching, DB optimization</p>
</li>
<li><p><strong>Pull work <em>out</em> of the denominator</strong> — anything that doesn't have to happen inside the request → async / batching</p>
</li>
</ol>
<p>The three strategies below attack exactly these two levers. And in practice, whenever you start tuning, you reach for them in this order — it's the order of best bang-for-buck: <strong>Caching → DB optimization → Async/Batching.</strong></p>
<hr />
<h2>1. Caching — "Never compute the same thing twice"</h2>
<h3>The idea</h3>
<p>Caching is fundamentally about <strong>storing the result of an expensive operation in a cheap place and reusing it for a little while.</strong> The two words that matter are <em>expensive</em> and <em>a little while</em>.</p>
<p>How expensive? The response-time gap is why caching is the #1 bang-for-buck optimization:</p>
<table>
<thead>
<tr>
<th>Data source</th>
<th>Rough latency</th>
<th>vs. DB</th>
</tr>
</thead>
<tbody><tr>
<td>Application memory (in-process)</td>
<td>~0.001ms</td>
<td>~50,000x faster</td>
</tr>
<tr>
<td>Redis (same network)</td>
<td>~0.5–1ms</td>
<td>~50–100x faster</td>
</tr>
<tr>
<td>DB, simple query (indexed)</td>
<td>~10ms</td>
<td>baseline</td>
</tr>
<tr>
<td>DB, complex query (JOIN/aggregation)</td>
<td>~50–200ms</td>
<td>—</td>
</tr>
</tbody></table>
<p>So if a result took 50ms to fetch from the DB once, caching it in Redis means you can return it in ~1ms from then on. <strong>The per-request time (the denominator) drops by 50x — and QPS jumps right along with it.</strong></p>
<h3>Multi-layer caching</h3>
<p>In production you rarely use just one cache. You <strong>stack layers</strong>, and the closer to the user you can stop the request, the better. In the order a request travels through them:</p>
<pre><code class="language-plaintext">User request
   │
   ▼
① CDN cache (CloudFront/CloudFlare)   ← if it hits here, it never reaches your server (best)
   │ miss
   ▼
② App memory cache (in-process LRU)   ← 0.001ms, but lives per-server
   │ miss
   ▼
③ Redis (distributed cache)           ← 1ms, shared across all servers
   │ miss
   ▼
④ Database (the source of truth)      ← 50ms; reaching here is a "cache miss"
</code></pre>
<ul>
<li><p><strong>① CDN</strong> — static assets (images, JS, CSS) or public API responses that don't change. It ends at an edge near the user, so your servers don't even see the traffic.</p>
</li>
<li><p><strong>② App memory</strong> — fastest, but independent per server: 10 servers means 10 copies and painful invalidation. Best for <em>tiny, rarely-changing</em> data (exchange-rate tables, shared codes).</p>
</li>
<li><p><strong>③ Redis</strong> — shared by all servers, so invalidation is one shot. The workhorse of production caching.</p>
</li>
</ul>
<blockquote>
<p>Heads-up on naming: some texts call these <strong>L1 (app memory) / L2 (Redis) / L3 (DB or CDN)</strong>. There, the numbers go by <em>closeness to the data/CPU</em> (L1 is closest to the app), which is the <strong>opposite direction</strong> from the request-flow numbering above (①CDN first). It's easy to mix up, so I numbered purely by request order here.</p>
</blockquote>
<h3>The genuinely hard part: <em>what</em> to cache and for <em>how long</em></h3>
<p>Writing cache code is easy. The hard part is the <strong>TTL and invalidation strategy.</strong> There's a reason for the old joke that the two hardest problems in computer science are cache invalidation and naming things.</p>
<p>Strategy depends on the nature of the data:</p>
<table>
<thead>
<tr>
<th>Data</th>
<th>Change frequency</th>
<th>Strategy</th>
<th>Why</th>
</tr>
</thead>
<tbody><tr>
<td>Forum post list</td>
<td>Occasionally (new post)</td>
<td>TTL 5 min</td>
<td>A 5-minute-stale list is fine</td>
</tr>
<tr>
<td>Product detail</td>
<td>Occasionally</td>
<td><strong>Invalidate on write</strong></td>
<td>Price changes must show immediately</td>
</tr>
<tr>
<td>My profile</td>
<td>Only I edit it</td>
<td>TTL 30s or invalidate on write</td>
<td>Not seeing my own edit feels like a bug</td>
</tr>
<tr>
<td>Live inventory</td>
<td>Very frequently</td>
<td>Don't cache / 1s TTL</td>
<td>Wrong value → oversell incident</td>
</tr>
</tbody></table>
<p><strong>Invalidate-on-write pattern:</strong></p>
<pre><code class="language-ts">// Read: cache first, fall back to DB, then populate the cache (cache-aside)
async function getProduct(id: string) {
  const cached = await redis.get(`product:${id}`);
  if (cached) return JSON.parse(cached);          // cache hit → 1ms

  const product = await db.product.findUnique({ where: { id } }); // miss → 50ms
  await redis.set(`product:${id}`, JSON.stringify(product), "EX", 300); // 5-min TTL
  return product;
}

// Write: update the DB, then DELETE the cache (next read repopulates it)
async function updateProduct(id: string, data) {
  const product = await db.product.update({ where: { id }, data });
  await redis.del(`product:${id}`);   // ← invalidation. This one line is the point
  return product;
}
</code></pre>
<blockquote>
<p>⚠️ Common mistake: after a write, <strong>deleting</strong> the cache (<code>del</code>) is usually safer than <strong>updating</strong> it (<code>set</code>). If two writes update concurrently, a stale value can win the race; deleting means "fetch fresh from the DB next time," which always reflects the latest state.</p>
</blockquote>
<h3>💡 What I shipped — the "view-count batching" on 3w CMS</h3>
<p>A post's view count is the textbook <strong>"read-heavy, accuracy-doesn't-really-matter"</strong> kind of data. But the original design fired a DB <code>UPDATE</code> on <em>every single view</em>.</p>
<p><strong>Before — 1 view = 1 write query:</strong></p>
<pre><code class="language-sql">-- runs every time someone opens a post
UPDATE posts SET view_count = view_count + 1 WHERE id = ?;
</code></pre>
<p>The problem: a popular post getting 500 readers/sec means <strong>500 write queries per second</strong> hammering the <em>same row</em>. Concurrent UPDATEs on one row create <strong>row-lock contention</strong>, so bumping a view counter ends up slowing the entire DB. Writes can't be cached and can't be offloaded to replicas — it's the most painful kind of load.</p>
<p><strong>After — Redis counter + a 5-minute batch flush:</strong></p>
<pre><code class="language-ts">// On view: increment a Redis counter, not the DB (0.5ms, no lock)
await redis.incr(`view:post:${postId}`);

// A job every 5 minutes: collect and apply to the DB in one shot
async function flushViewCounts() {
  const keys = await redis.keys("view:post:*");
  for (const key of keys) {
    const postId = key.split(":")[2];
    const count = await redis.getdel(key);  // read value and delete atomically
    await db.post.update({
      where: { id: postId },
      data: { viewCount: { increment: Number(count) } },
    });
  }
}
</code></pre>
<p><strong>Result:</strong> 1,500 views over 5 minutes hit the DB as a <strong>single UPDATE</strong>. Write load dropped by <strong>1,500x.</strong> The count can lag by up to 5 minutes, but for view counts that delay is completely harmless — the trade-off is a perfect fit.</p>
<blockquote>
<p>This is caching (temporary storage in Redis) fused with batching (collect, then flush once) — which leads straight into strategy #3.</p>
</blockquote>
<hr />
<h2>2. Database optimization — where 80% of bottlenecks live</h2>
<p>Application code usually runs in microseconds, but the DB operates in milliseconds to hundreds of milliseconds — so <strong>most backend bottlenecks happen at the DB.</strong> That's why it's the next thing you touch after caching. Three things come as a set.</p>
<blockquote>
<p><strong>The big premise: the fastest query is the one you never ran.</strong> The #1 priority in read optimization isn't writing a clever query — it's <strong>reducing how often you reach the DB at all.</strong> So in practice, before hitting the DB directly, you filter reads in this order:</p>
<ol>
<li><p><strong>Can the cache answer it?</strong> — the multi-layer cache from #1 stops a large share of reads before the DB. Hot data barely reaches it.</p>
</li>
<li><p><strong>Can several queries collapse into one?</strong> — fold N+1 into an IN-query / JOIN / DataLoader (see batching in #3). 11 round-trips → 2.</p>
</li>
<li><p><strong>Are you fetching only what you need?</strong> — specific columns instead of <code>SELECT *</code>, paginate to cap rows, drop needless JOINs.</p>
</li>
</ol>
<p>So the index/pool/replica below are there to serve <strong>the queries that still have to reach the DB after all that filtering</strong> — quickly and reliably. "Run fewer queries" always comes before "run queries faster."</p>
</blockquote>
<h3>2-1. Index optimization — "the index at the back of a book"</h3>
<p>Without an index, the DB has to <strong>read the table from start to finish (a Full Table Scan)</strong> to find the rows you want. A million rows means scanning all million. An index is like the index pages at the back of a book — a pre-sorted structure (a B-Tree) that says "this value is on page N."</p>
<blockquote>
<p><strong>A table and an index are not the same thing.</strong> The table is the <em>original data</em> (the book's body); the index is an <em>auxiliary lookup</em> to find it fast (the back-of-book index pages).</p>
<ul>
<li><p>An index isn't new data — it's a <strong>derived copy</strong> of select columns, sorted. Drop it and no data is lost; only search gets slow again → so you add/drop them freely, even in production.</p>
</li>
<li><p>When you insert new data, the DB <strong>automatically updates the table <em>and</em> its indexes together.</strong> ↔ This is exactly why "too many indexes slow down writes."</p>
</li>
<li><p>One table can have <strong>several</strong> indexes for different search patterns, and the DB picks the most useful one per query. (This is why the <em>same SQL</em> can be fast or slow depending only on which indexes exist.)</p>
</li>
</ul>
</blockquote>
<p><strong>Concrete example — fetching one user's orders from a 100k-row table:</strong></p>
<pre><code class="language-sql">SELECT * FROM orders WHERE user_id = 'u_123' ORDER BY created_at DESC LIMIT 20;
</code></pre>
<ul>
<li><p><strong>No index</strong>: read all 100k → filter by user_id → sort. ~<strong>120ms</strong>.</p>
</li>
<li><p><strong>Index on</strong> <code>user_id</code> <strong>only</strong>: finds that user's orders (say 500) fast, but still sorts in memory. ~<strong>15ms</strong>.</p>
</li>
<li><p><strong>Composite index</strong> <code>(user_id, created_at DESC)</code>: find <em>and</em> sort finish inside the index at once. ~<strong>0.5ms</strong>. 👈</p>
</li>
</ul>
<p><strong>Column order in a composite index matters.</strong> <code>(user_id, created_at)</code> is ideal for "narrow by user_id, then sort by created_at," but it can't serve a query that searches by <code>created_at</code> alone. The rule of thumb: <strong>equality (</strong><code>=</code><strong>) columns first, range/sort columns last.</strong></p>
<blockquote>
<p>⚠️ Trade-off: indexes aren't free. Every INSERT/UPDATE has to update the indexes too, so <strong>writes get slower and you use more storage.</strong> Slapping an index on every column "just in case" backfires. Look at your actual query patterns and add only what you need.</p>
</blockquote>
<h3>2-2. Connection pooling — "don't open a new connection every time"</h3>
<p>Opening a new DB connection costs tens of milliseconds (a <strong>TCP handshake + authentication</strong>). Doing that per request is absurd when the query itself is 1ms — you'd burn 30ms on the connection alone.</p>
<p>A <strong>connection pool</strong> pre-creates N connections and lends them out and takes them back for reuse.</p>
<pre><code class="language-plaintext">[req A] ─┐                  ┌─ [conn 1] ─┐
[req B] ─┤  Pool (reuse)    ├─ [conn 2] ─┤── DB
[req C] ─┘                  └─ [conn 3] ─┘
         (wait queue)
</code></pre>
<p><strong>Sizing the pool — too small and too big both hurt:</strong></p>
<ul>
<li><p><strong>Too small</strong>: requests queue up waiting to borrow a connection → latency.</p>
</li>
<li><p><strong>Too big</strong>: the DB sees a flood of concurrent connections → its memory/CPU blows up, since each connection costs the DB memory.</p>
</li>
<li><p><strong>Rule of thumb</strong>: start around <code>CPU cores × 2–4</code> and tune with load tests.</p>
</li>
</ul>
<p>As you add app servers, each holds its own pool, so total DB connections multiply — which is why you often put a pooler like <strong>PgBouncer</strong> in front as an extra layer.</p>
<h3>2-3. Read replicas (read/write splitting)</h3>
<p>Most services have a <strong>read:write ratio of 9:1 or higher</strong> (write one post, hundreds read it). So spreading reads across several replicas takes a huge load off the single master.</p>
<pre><code class="language-plaintext">                ┌─ writes (INSERT/UPDATE/DELETE) ─→ [Master DB]
[application] ──┤                                       │ replication
                └─ reads (SELECT) ─→ [Replica 1] [Replica 2] [Replica 3]
</code></pre>
<p><strong>Effect:</strong> with reads split across 3 replicas, the master focuses on writes, and read capacity effectively triples.</p>
<blockquote>
<p>⚠️ <strong>Replication lag</strong>: data written to the master takes anywhere from a few to a few hundred milliseconds to copy to the replicas. So a <strong>read-after-write</strong> case (e.g., post a comment, then immediately view the comment list) will read from a replica and show "wait, where's my comment?" Those reads must be <strong>forced to the master.</strong></p>
</blockquote>
<pre><code class="language-ts">// Normal reads go to a replica
const posts = await readClient.post.findMany(...);

// Reads that must reflect a just-made write go to the master
await writeClient.comment.create({ data });
const comments = await writeClient.comment.findMany(...); // read from master
</code></pre>
<h3>How the three connect</h3>
<p>These aren't separate — they're one bundle: <strong>indexes make a single query fast, the pool removes connection waste, and replicas spread out the reads.</strong> Cut a query from 100ms to 1ms with an index, and the same pool can now serve 100x more requests — so the three effects <em>multiply</em>.</p>
<hr />
<h2>3. Async + batching — "pull everything non-essential out of the request"</h2>
<p>If #1 and #2 were about making each task faster, #3 is the mental flip: <strong>take the task out of the user's critical path entirely.</strong></p>
<h3>3-1. Async processing via a message queue</h3>
<p>Think about signup. From the user's perspective, "signup complete" means <strong>the user exists in the DB</strong> — that's it. But if you send the welcome email (an external SMTP call, 500ms) <em>synchronously</em> inside that request, the user just sits there waiting for an email to send.</p>
<p><strong>Before — synchronous (signup API takes 510ms):</strong></p>
<pre><code class="language-ts">async function signup(data) {
  const user = await db.user.create({ data });   //  10ms
  await sendWelcomeEmail(user.email);            // 500ms ← the user waits on this
  return user;                                    // 510ms total
}
</code></pre>
<p><strong>After — enqueue and respond immediately (signup API takes 12ms):</strong></p>
<pre><code class="language-ts">async function signup(data) {
  const user = await db.user.create({ data });   // 10ms
  await queue.enqueue("send-welcome-email", {     //  2ms (just enqueue)
    email: user.email,
  });
  return user;                                    // 12ms total ← user response
}
// A separate worker consumes the queue and actually sends the email (in the background)
</code></pre>
<p><strong>Result:</strong> 510ms → 12ms. <strong>Per-request time dropped ~40x,</strong> and QPS rises with it. The email arrives a second later and nobody notices.</p>
<p><strong>Good candidates to push onto a queue:</strong></p>
<ul>
<li><p>Email/SMS, push notifications</p>
</li>
<li><p>Image resizing / thumbnail generation</p>
</li>
<li><p>Stats / analytics aggregation</p>
</li>
<li><p>Third-party API calls (slow, failure-prone)</p>
</li>
</ul>
<p>Tools: Kafka, RabbitMQ, Redis Streams, AWS SQS, etc. As a bonus you get <strong>retries and failure isolation</strong> for free — if the email server is briefly down, jobs pile up in the queue and send once it recovers, while signup itself never blocks.</p>
<h3>3-2. Batching — "collect many, do them at once"</h3>
<p>Every network/DB round-trip has a fixed cost. Sending 100 items one-by-one (100 trips) is far more expensive than bundling them into a single trip.</p>
<p><strong>Classic example — sending analytics events:</strong></p>
<pre><code class="language-ts">// Before: send on every event → 1000 round-trips
track(event) { await http.post("/analytics", event); }

// After: buffer, then send once per 100 events or every 1s → 10 round-trips
const buffer = [];
track(event) {
  buffer.push(event);
  if (buffer.length &gt;= 100) flush();
}
function flush() {
  http.post("/analytics/bulk", buffer.splice(0));  // everything collected, in one shot
}
setInterval(flush, 1000); // flush every 1s even if 100 isn't reached
</code></pre>
<p><strong>Fixing N+1 queries is batching too (the DataLoader pattern):</strong></p>
<pre><code class="language-ts">// Before (N+1): 10 posts → 10 separate author lookups
for (const post of posts) {
  post.author = await db.user.findUnique({ where: { id: post.authorId } });
}
// 1 (posts) + 10 (authors) = 11 queries

// After (batched): collect author ids into one IN-query
const authorIds = posts.map(p =&gt; p.authorId);
const authors = await db.user.findMany({ where: { id: { in: authorIds } } });
// 1 + 1 = 2 queries  (DataLoader does this collecting automatically)
</code></pre>
<h3>💡 What I shipped — the "50ms-window notification aggregation" on 3w CMS</h3>
<p>In our notification system, a single user sometimes got a burst of events in a tiny window (e.g., 10 messages in a group chat within one second). Firing a push per event meant:</p>
<ul>
<li><p>The user's phone buzzed <strong>10 times</strong> → terrible UX</p>
</li>
<li><p><strong>10 outbound calls</strong> to the push provider (FCM/APNs) → load + cost</p>
</li>
</ul>
<p><strong>Fix — bundle within a 50ms window:</strong> when a notification arrives, don't fire immediately. Wait 50ms; if more notifications for the same user arrive, merge them and send <strong>one</strong> "10 new messages" push.</p>
<pre><code class="language-ts">// Open a 50ms window per user and collect notifications that arrive within it
function onNotification(userId, payload) {
  if (!windows.has(userId)) {
    windows.set(userId, { items: [] });
    setTimeout(() =&gt; flushWindow(userId), 50);  // send once, 50ms later
  }
  windows.get(userId).items.push(payload);
}

function flushWindow(userId) {
  const { items } = windows.get(userId);
  windows.delete(userId);
  // one item → send as-is; many → merge into "N new notifications" and send once
  sendPush(userId, summarize(items));
}
</code></pre>
<p><strong>Result:</strong> during bursts, push calls dropped to <strong>a fraction</strong> of before, and users got one clean summary instead of a notification bomb. Performance and UX, fixed in one move. 50ms is imperceptible to a human, so the trade-off was practically free.</p>
<h3>3-3. Debouncing / throttling — batching on the client side</h3>
<p>Rapidly repeating requests, like search autocomplete, should be <strong>bundled or thinned out.</strong></p>
<ul>
<li><p><strong>Debounce</strong>: "fire one request only after 300ms of no further input." Type <code>S</code>, <code>Se</code>, <code>Seoul</code> within half a second → not 3 requests, just <strong>1</strong> for <code>Seoul</code>.</p>
</li>
<li><p><strong>Throttle</strong>: "at most once per second, no matter how often it fires." Thins out things like scroll events that fire hundreds of times a second.</p>
</li>
</ul>
<p>Blocking on the client is the cheapest optimization of all, because <strong>the request never reaches the server.</strong></p>
<hr />
<h2>Wrapping up — why this order, and why the effects multiply</h2>
<p>There's plenty beyond these three — horizontal scaling (load balancer + multiple servers), HTTP/2, gzip/brotli compression, separating static assets — but <strong>applying just these three properly usually lifts QPS by 10x or more.</strong> So when you start tuning, you almost always go in this order (best bang-for-buck first):</p>
<table>
<thead>
<tr>
<th>Order</th>
<th>Strategy</th>
<th>What it attacks</th>
<th>One-liner</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
<td><strong>Caching</strong></td>
<td>Per-request time (denominator)</td>
<td>Store expensive results in a cheap place, reuse</td>
</tr>
<tr>
<td>2</td>
<td><strong>DB optimization</strong></td>
<td>Per-request time (denominator)</td>
<td>Index the query, pool the connections, replicate the reads</td>
</tr>
<tr>
<td>3</td>
<td><strong>Async / batching</strong></td>
<td>The <em>amount</em> of work</td>
<td>Pull non-essential work out of the response path</td>
</tr>
</tbody></table>
<p>The key is that <strong>the three multiply.</strong> Caching keeps 80% of reads from ever reaching the DB (5x), indexes speed up the remaining 20% (10x), and heavy work moves to a queue — the gains don't add, they compound, and total QPS jumps by tens of times.</p>
<p>And the thread running through all three: <strong>consciously choosing your trade-offs.</strong> View counts can lag 5 minutes, notifications can bundle for 50ms, emails can arrive a second late. <strong>Knowing what's allowed to be slightly wrong or slightly late</strong> — that's the real skill in performance engineering.</p>
<hr />
<p><em>Thanks for reading! If you've shipped a performance fix that fit one of these buckets — or broke one of these rules and paid for it — I'd love to hear about it in the comments.</em></p>
]]></content:encoded></item><item><title><![CDATA[There Was a Deadlock Hiding in AI-Generated Code — How We Replaced `unreadCount` with `lastReadAt`]]></title><description><![CDATA[42.8% HTTP error rate, 9 minutes into a load test. The culprit was a single updateMany line carelessly written during AI-assisted coding.


1. Discovery — "Why are we doing 9.5 TPS when the target is ]]></description><link>https://beckybuilds.hashnode.dev/there-was-a-deadlock-hiding-in-ai-generated-code-how-we-replaced-unreadcount-with-lastreadat</link><guid isPermaLink="true">https://beckybuilds.hashnode.dev/there-was-a-deadlock-hiding-in-ai-generated-code-how-we-replaced-unreadcount-with-lastreadat</guid><category><![CDATA[PostgreSQL]]></category><category><![CDATA[prisma]]></category><category><![CDATA[Databases]]></category><category><![CDATA[concurrency]]></category><category><![CDATA[Node.js]]></category><category><![CDATA[debugging]]></category><dc:creator><![CDATA[Becky]]></dc:creator><pubDate>Mon, 11 May 2026 12:08:11 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/69c4dd0910e664c5da65b3c8/2bb89e63-3025-4c80-b096-f668d1d674c1.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<blockquote>
<p>42.8% HTTP error rate, 9 minutes into a load test. The culprit was a single <code>updateMany</code> line carelessly written during AI-assisted coding.</p>
</blockquote>
<hr />
<h2>1. Discovery — "Why are we doing 9.5 TPS when the target is 75?"</h2>
<p>We ran k6 against a load-test environment, targeting 75 TPS of chat traffic. The numbers came back ugly.</p>
<table>
<thead>
<tr>
<th>Metric</th>
<th>Value</th>
</tr>
</thead>
<tbody><tr>
<td>Target TPS</td>
<td>75 req/s</td>
</tr>
<tr>
<td>Achieved TPS</td>
<td><strong>9.5 req/s</strong> (12% of target)</td>
</tr>
<tr>
<td><strong>HTTP error rate</strong></td>
<td><strong>42.8%</strong></td>
</tr>
<tr>
<td>HTTP 5xx</td>
<td>4.07 req/s</td>
</tr>
<tr>
<td>Postgres active connections</td>
<td>11 / 27 max — plenty of headroom</td>
</tr>
<tr>
<td>Centrifugo publish failures</td>
<td>0</td>
</tr>
</tbody></table>
<p>The first suspect was always going to be connection pool exhaustion. But Postgres was only using 11 of 27 connections, and Centrifugo publish was 100% successful. Every signal pointed at <strong>the app server's database queries themselves</strong>.</p>
<h2>2. The Real Culprit — <code>40P01 deadlock detected</code></h2>
<p><code>docker logs witim-web</code> showed all the 5xx lines in identical shape:</p>
<pre><code class="language-plaintext">prisma:error
Error occurred during query execution:
ConnectorError(... QueryError(PostgresError {
  code: "40P01", message: "deadlock detected", severity: "ERROR",
  detail: Some("Process 111892 waits for ShareLock on transaction 158028;
                blocked by process 111881.
                Process 111881 waits for ShareLock on transaction 158027;
                blocked by process 111892.")
}))
</code></pre>
<p>Not <code>P2024</code> (pool timeout). Not <code>ETIMEDOUT</code>. <strong>Pure PostgreSQL row-lock deadlock.</strong> Sixteen concurrent transactions were locking the same set of rows in different orders and crashing into each other.</p>
<h2>3. Code Audit — Wait, We Do 4–5 Writes Per Message?</h2>
<p>The offending handler, <code>POST /api/channels/[id]/messages</code>, was doing <strong>four to five serial writes with no transaction wrapper</strong>:</p>
<table>
<thead>
<tr>
<th>#</th>
<th>Operation</th>
<th>Lock impact</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
<td><code>message.create</code> (Message + Mention + Attachment nested)</td>
<td>Channel/User row KEY SHARE</td>
</tr>
<tr>
<td>2</td>
<td><code>message.update</code> (parent.replyCount++) [reply only]</td>
<td>parent Message row exclusive</td>
</tr>
<tr>
<td>3</td>
<td><code>channel.update</code> (updatedAt)</td>
<td>Channel row exclusive</td>
</tr>
<tr>
<td>4</td>
<td><code>channelMember.updateMany</code> <strong>(others' unreadCount++)</strong></td>
<td><strong>multiple ChannelMember rows exclusive</strong> ← deadlock vector</td>
</tr>
<tr>
<td>5</td>
<td><code>channelMember.updateMany</code> (sender unreadCount=0)</td>
<td>sender ChannelMember row exclusive</td>
</tr>
</tbody></table>
<p>#4 is the killer. When users A and B send messages to the same channel concurrently:</p>
<pre><code class="language-plaintext">Tx-A: UPDATE ChannelMember WHERE channelId=X AND userId != A
        → locks rows B, C, D, ... one at a time, in scan order
Tx-B: UPDATE ChannelMember WHERE channelId=X AND userId != B
        → locks rows A, C, D, ... one at a time, in scan order
</code></pre>
<p><code>updateMany</code> is one SQL statement, but with <strong>no</strong> <code>ORDER BY</code>, Postgres picks lock order based on the plan. Cache state, parallel worker distribution, and table statistics all influence it — meaning <strong>the order can change between two concurrent executions of the same SQL</strong>.</p>
<h3>Direct Evidence From the PG Log</h3>
<p>Every deadlock had a different <code>CONTEXT</code> line:</p>
<table>
<thead>
<tr>
<th>Time</th>
<th>CONTEXT</th>
</tr>
</thead>
<tbody><tr>
<td>05:28:46</td>
<td><code>while updating tuple (44,45)</code></td>
</tr>
<tr>
<td>05:28:54</td>
<td><code>while updating tuple (105,19)</code></td>
</tr>
<tr>
<td>05:28:55</td>
<td><code>while updating tuple (89,28)</code></td>
</tr>
<tr>
<td>05:29:09</td>
<td><code>while locking tuple (214,56)</code></td>
</tr>
<tr>
<td>05:29:10</td>
<td><code>while locking tuple (1364,5)</code></td>
</tr>
</tbody></table>
<p><code>(page, slot)</code> is different every time → <strong>lock order is non-deterministic</strong>. Hypothesis confirmed.</p>
<h2>4. Honest Confession — This Was an Embarrassing AI-Coding Mistake</h2>
<p>Let me come clean. This <code>updateMany</code> was <strong>generated during AI-assisted coding</strong>.</p>
<pre><code class="language-typescript">// Intent: "When a message is sent, bump unreadCount by 1 for every other member"
await prisma.channelMember.updateMany({
  where: { channelId, userId: { not: senderId } },
  data: { unreadCount: { increment: 1 } },
});
</code></pre>
<p>Syntactically perfect. Works flawlessly in a single-channel, single-message, single-user test. PR review passed.</p>
<p>But it silently skipped <strong>two questions that should have been asked</strong>:</p>
<ol>
<li><p><em>"Should we really write to N rows on every single message?"</em> — A channel with 200 members means 200 row writes per message. 100 users sending = 20,000 writes/sec. At that volume, RDBMS row writes per message are <strong>structurally inefficient</strong>.</p>
</li>
<li><p><em>"What happens when two people post to the same channel at the same time?"</em> — That's the core usage pattern of any messaging app. The AI didn't think about it. And neither did I when I merged the PR.</p>
</li>
</ol>
<p>This is the AI-coding trap. Code that <strong>looks like it works</strong> lets you skip thinking about concurrency scenarios. Without the load test, this would have detonated in production the same way.</p>
<h2>5. Prescriptions on the Table — How Far Should We Go?</h2>
<p>The issue listed three tiers of fixes:</p>
<p><strong>P0 (immediate):</strong></p>
<ul>
<li><p>Deadlock retry interceptor for <code>40P01</code></p>
</li>
<li><p>Rewrite <code>updateMany</code> as raw SQL with <code>ORDER BY id FOR UPDATE</code> → deterministic lock order</p>
</li>
</ul>
<p><strong>P1 (short term):</strong></p>
<ul>
<li>Move <code>unreadCount</code> to Redis HINCRBY, periodically flushed to DB</li>
</ul>
<p><strong>P2 (medium term):</strong></p>
<ul>
<li>Partition Message INSERTs by <code>channelId</code> hash</li>
</ul>
<p>P0 alone makes the deadlock go away. But <strong>1 message = N row writes</strong> stays. At 100K CCU, this will explode again — just in a different form.</p>
<p>P1 (Redis) is faster but introduces Redis ↔ Postgres synchronization complexity. unreadCount temporarily diverges from Postgres, and a failed Redis flush can permanently skew the count.</p>
<p>So we chose <strong>a different direction</strong>.</p>
<h2>6. What We Picked — The <code>lastReadAt</code> Column</h2>
<p>Core idea:</p>
<blockquote>
<p><strong>"Don't write a counter on every message. Compute the count when you read."</strong></p>
</blockquote>
<p>Drop the <code>ChannelMember.unreadCount</code> column. Replace it with <strong>a single</strong> <code>ChannelMember.lastReadAt</code> <strong>timestamp</strong>.</p>
<h3>Before — write-heavy</h3>
<pre><code class="language-plaintext">[Send 1 message]
  ├─ INSERT Message                              (write 1)
  ├─ UPDATE ChannelMember unreadCount++ for N    (write N) ← deadlock vector
  └─ UPDATE ChannelMember unreadCount=0 (sender) (write 1)

→ 1 message = (N+2) writes
</code></pre>
<h3>After — read-on-demand</h3>
<pre><code class="language-plaintext">[Send 1 message]
  └─ INSERT Message                              (write 1)
                                                          ← done.

[Fetch channel list]
  └─ SELECT count(*) FROM Message
       WHERE channelId = $1 AND createdAt &gt; lastReadAt   (read, hits index)

[Enter channel / mark as read]
  └─ UPDATE ChannelMember SET lastReadAt = NOW()
       WHERE userId = $1 AND channelId = $2              (write 1, own row only)
</code></pre>
<h3>Why This Kills the Deadlock</h3>
<p><code>updateMany WHERE userId != sender</code> <strong>disappears</strong>. Sending a message no longer touches other users' rows. The lock-contention vector is removed from the code entirely.</p>
<p><code>lastReadAt</code> updates only touch <strong>your own row</strong>. User A only ever updates A's <code>ChannelMember</code> row, user B only B's. <strong>There is no scenario where two transactions compete for the same row</strong> — it's eliminated by construction, not by retry logic.</p>
<h3>Why This Isn't a Performance Regression</h3>
<p>At first glance, "running <code>count(*)</code> every time" sounds slower. It isn't, because:</p>
<ol>
<li><p><strong>Reads are cache-friendly.</strong> With a composite index on <code>(channelId, createdAt)</code>, the count becomes an index range scan.</p>
</li>
<li><p><strong>Read frequency &lt;&lt; write frequency.</strong> Users see the channel list when they open the app or enter a channel. Messages are sent dozens to hundreds of times per second. <strong>Moving N writes → 1 read is an enormous win on its own.</strong></p>
</li>
<li><p><strong>Reads can be served from a read replica.</strong> Write locks cannot.</p>
</li>
</ol>
<h3>Sequence Diagram (Conceptual)</h3>
<pre><code class="language-plaintext">[Before — count at write time]
User A ─── POST /messages ───▶ Web
                                 ├─ INSERT Message
                                 ├─ UPDATE ChannelMember(N rows) ← 💥 deadlock
                                 └─ 200 OK

[After — count at read time]
User A ─── POST /messages ───▶ Web
                                 ├─ INSERT Message
                                 └─ 200 OK                       ← done

User B ─── GET /channels  ───▶ Web
                                 ├─ SELECT channels ...
                                 ├─ SELECT count(*) FROM Message
                                 │    WHERE createdAt &gt; lastReadAt
                                 └─ [{ id, name, unread: 3 }, ...]

User B ─── POST /channels/X/read ▶ Web
                                 └─ UPDATE ChannelMember
                                      SET lastReadAt = NOW()
                                      WHERE userId = B           ← own row only
</code></pre>
<h2>7. Migration Strategy — Don't Flip Everything At Once</h2>
<p>Phased rollout for backward compatibility:</p>
<ol>
<li><p><strong>Phase 1</strong>: Add the <code>lastReadAt</code> column. <strong>Keep the existing</strong> <code>unreadCount</code> <strong>write path untouched.</strong></p>
</li>
<li><p><strong>Phase 2</strong>: Switch <strong>only the read API</strong> to the new approach (<code>count(*) WHERE createdAt &gt; lastReadAt</code>).</p>
</li>
<li><p><strong>Phase 3</strong>: Re-run the load test. Confirm 75 TPS passes cleanly.</p>
</li>
<li><p><strong>Phase 4</strong>: Delete the <code>unreadCount</code> write code and drop the column.</p>
</li>
</ol>
<p>By the end of Phase 2, <strong>the deadlock vector is already gone</strong>. Why? Once the read path is based on <code>lastReadAt</code>, a wrong <code>unreadCount</code> value is no longer visible to users. After that, we can clean up the write path on our own schedule, with no production pressure.</p>
<h2>8. Lessons — AI Coding and Concurrency</h2>
<p>This incident wasn't a one-line bug. It was <strong>a thinking-pattern trap</strong>.</p>
<ul>
<li><p>The AI-generated <code>updateMany</code> is a correct answer to <em>"How do I express this in Prisma syntax?"</em></p>
</li>
<li><p>It is not an answer to <em>"In a messaging app, how does this SQL acquire locks when two users post to the same channel simultaneously?"</em> <strong>Because nobody asked that question.</strong></p>
</li>
<li><p>The faster AI-generated code lands, the more <strong>human review of concurrency, isolation, and scalability</strong> matters. That's the actual value of a PR review.</p>
</li>
</ul>
<p>One more lesson. <strong>Load testing belongs before production, not after.</strong> Without an isolated load-test environment, this would have detonated under 100K CCU traffic. We'd have been chasing a 5xx storm caused by deadlock cycles in production. The environment itself prevented one incident.</p>
]]></content:encoded></item><item><title><![CDATA[Building a Reusable Load-Test Environment for a Messenger Service — Make-based IaC, Observability, and Auth Seeding]]></title><description><![CDATA[I wanted to know exactly how many TPS our messenger's send API could survive — and where it would actually break. So I mirrored production into a 7-node load-test environment, and instead of treating ]]></description><link>https://beckybuilds.hashnode.dev/building-a-reusable-load-test-environment-for-a-messenger-service-make-based-iac-observability-and-auth-seeding</link><guid isPermaLink="true">https://beckybuilds.hashnode.dev/building-a-reusable-load-test-environment-for-a-messenger-service-make-based-iac-observability-and-auth-seeding</guid><category><![CDATA[Load Testing]]></category><category><![CDATA[k6]]></category><category><![CDATA[observability]]></category><category><![CDATA[Grafana]]></category><category><![CDATA[#prometheus]]></category><category><![CDATA[PostgreSQL]]></category><category><![CDATA[Terraform]]></category><category><![CDATA[websockets]]></category><category><![CDATA[Devops]]></category><dc:creator><![CDATA[Becky]]></dc:creator><pubDate>Mon, 11 May 2026 11:42:53 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/69c4dd0910e664c5da65b3c8/df88d491-7351-4782-9fcf-d832981a7df9.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<blockquote>
<p>I wanted to know exactly how many TPS our messenger's send API could survive — and <em>where</em> it would actually break. So I mirrored production into a 7-node load-test environment, and instead of treating it as a one-shot setup, I designed it to be <strong>reproducible on demand</strong>.</p>
</blockquote>
<p>This post is a record of that process — what we wanted to measure, what had to be prepared first, and how the environment itself was wrapped so anyone can re-run the whole thing.</p>
<hr />
<h2>Why "100 MPS" isn't an answer</h2>
<p>People love to say "our service handles 100 MPS." Try answering any of the following with just that number:</p>
<ul>
<li><p>At what <strong>WebSocket concurrency</strong> was the 100 MPS measured?</p>
</li>
<li><p>What <strong>message type</strong>? (Plain text? Mentions? Attachments?)</p>
</li>
<li><p>Into what kind of <strong>channels</strong>? (5-member? 50-member? Thousands?)</p>
</li>
<li><p>For <strong>how long</strong> did the load hold?</p>
</li>
<li><p>What did <strong>CPU, DB connections, Redis, Centrifugo</strong> look like during the run?</p>
</li>
<li><p>What were the <strong>4xx / 5xx rates</strong> and the <strong>REST→WS p95/p99 delivery latency</strong>?</p>
</li>
</ul>
<p>A single number that strips all of those variables is meaningless. So the goal of this project was not "produce a bigger number" — it was <strong>"build a measurement environment that can answer all of the above."</strong></p>
<p>There was one more design rule.</p>
<blockquote>
<p><strong>Don't load a single channel with thousands of members. Distribute the load across hundreds of small channels (5-/50-member mix).</strong></p>
</blockquote>
<p>Our message API enforces a <code>msg:{userId}:{channelId}</code> rate limit (10 requests / 10 seconds, i.e. ~1 TPS per pair). <strong>Pile load onto a single channel and you hit 429 right around 100 TPS, well before any real backend pressure shows up.</strong> To exercise 1k TPS under production-like conditions you need at least 1,000 distinct sender/channel pairs, ideally 2,000+. The dataset itself has to be designed around the production traffic shape.</p>
<hr />
<h2>Topology — a faithful copy of production</h2>
<p>If the test topology differs from production, the numbers will too. So we provisioned 7 Vultr VMs on the same plans as production, including the separate ALB layer.</p>
<pre><code class="language-text">k6 / WS Client
    │
    │ HTTP /api/*  +  WS /connection/*
    ▼
HAProxy ALB
    │ :80   public
    │ :8000 internal Centrifugo API
    │
    ├── /api/*, /api/centrifugo/* ──► Next.js App
    │                                      │
    │                                      │ App → HAProxy:8000 publish
    │                                      ▼
    └── /connection/* ───────────────► Centrifugo C1 / C2 (round-robin)
                                              │
                                              ▼
                                       Redis (broker / cache / rate limit)

Next.js App writes → PostgreSQL
</code></pre>
<table>
<thead>
<tr>
<th>Host</th>
<th>Role</th>
<th>Plan</th>
</tr>
</thead>
<tbody><tr>
<td><code>HAProxy</code></td>
<td>HAProxy ALB</td>
<td>2C / 8GB</td>
</tr>
<tr>
<td><code>app</code></td>
<td>Next.js web</td>
<td>2C / 8GB</td>
</tr>
<tr>
<td><code>centrifugo-1</code>, <code>centrifugo-2</code></td>
<td>Centrifugo v5 (RR)</td>
<td>2C / 8GB ×2</td>
</tr>
<tr>
<td><code>postgres</code></td>
<td>PostgreSQL + production dev dump</td>
<td>2C / 8GB</td>
</tr>
<tr>
<td><code>redis</code></td>
<td>broker / cache / rate limit</td>
<td>2C / 8GB</td>
</tr>
<tr>
<td><code>client</code></td>
<td>k6 sender + Go WS receiver</td>
<td>4C / 8GB</td>
</tr>
</tbody></table>
<hr />
<h2>Reusable infrastructure — Makefile + Terraform as a single entry point</h2>
<p>The biggest trap with a load-test environment is "set it up once, never touch it again." When a 1k TPS smoke breaks the DB and you come back a week later to run a knee sweep, if anyone has to remember the PG password or where cloud-init last stalled, the whole afternoon is gone.</p>
<p>So everything is wrapped into <strong>one Makefile-driven flow</strong>. Terraform manages all 7 hosts in a single state, and Make sequences <code>up → wait → cutover → smoke → down</code> on top of it.</p>
<pre><code class="language-make"># standard flow
make up         # provision 7 VMs + cloud-init + auto-refresh HOSTS.env
make wait       # wait for cloud-init on all 7
make secrets-check   # verify compose/.env (HMAC, API key) consistency
make cutover    # deploy HAProxy → App → Centrifugo
make smoke      # /healthz, /api/health, publish, RR distribution checks
make down       # destroy all 7
</code></pre>
<p>Three details matter.</p>
<p><strong>(1) Terraform output →</strong> <code>scripts/HOSTS.env</code> <strong>auto-refresh.</strong> IPs are never hardcoded — not in the README, not in scripts. After <code>make up</code>, env vars like <code>HAPROXY_IP</code>, <code>APP_IP</code>, <code>CENTRIFUGO_1_IP</code>, … are populated automatically, and every downstream script starts with <code>source scripts/HOSTS.env</code>. <strong>IPs can change without a single line of code being touched.</strong></p>
<p><strong>(2) The</strong> <code>secrets-check</code> <strong>gate.</strong> If App and Centrifugo end up with <strong>different HMAC secrets</strong>, JWT verification silently fails everywhere. Hunting that down manually eats an hour. So before <code>cutover</code>, we automatically verify <code>CENTRIFUGO_HMAC_SECRET_KEY</code> matches between <code>compose/.env.app</code> and <code>compose/.env.centrifugo</code>.</p>
<pre><code class="language-make">secrets-check:
	@A=$$(grep '^CENTRIFUGO_HMAC_SECRET_KEY=' compose/.env.app);
	 B=$$(grep '^CENTRIFUGO_HMAC_SECRET_KEY=' compose/.env.centrifugo);
	 [ "$$A" = "$$B" ] || { echo "ERR: app/centrifugo HMAC mismatch"; exit 1; }
</code></pre>
<p><strong>(3) PG data preservation (</strong><code>dump-pg</code> <strong>/</strong> <code>restore-pg</code><strong>).</strong> Vultr VMs are billed even in the stopped state, so the natural cycle is "tear down after a run, spin up next time." But that cycle is dangerous if it loses the PG seed in between.</p>
<pre><code class="language-bash">make dump-pg           # dump current PG VM → data/postgres-dumps/
make list-dumps        # list saved dumps
make restore-pg        # auto-restore the latest dump to a fresh PG VM
make restore-pg DUMP=data/postgres-dumps/workb-XXXX.dump   # pin a specific dump
</code></pre>
<p>After restore, the script automatically prints <code>ANALYZE</code> results, table counts, DB size, and row counts for top tables — so nobody has to manually verify "did the restore actually work?"</p>
<p>Once wrapped this tightly, <strong>a new teammate or a future sprint can rebuild the environment with five commands</strong>. That's what "reusable" actually means here.</p>
<hr />
<h2>Observability — tuning without measuring is just guessing</h2>
<p>Observability has to be alive <strong>before</strong> load goes in. Discovering breakage by running blind first is too late. So the monitoring stack was the first thing built, not the last.</p>
<table>
<thead>
<tr>
<th>Layer</th>
<th>Tool</th>
<th>Where</th>
</tr>
</thead>
<tbody><tr>
<td>Metric store</td>
<td>Prometheus</td>
<td>local Mac (<code>localhost:9090</code>)</td>
</tr>
<tr>
<td>Dashboard</td>
<td>Grafana (provisioning)</td>
<td>local Mac (<code>localhost:3000</code>)</td>
</tr>
<tr>
<td>System metrics</td>
<td><code>node_exporter</code></td>
<td>all 7 VMs (<code>:9100</code>)</td>
</tr>
<tr>
<td>DB metrics</td>
<td><code>postgres_exporter</code></td>
<td>Postgres VM (<code>:9187</code>)</td>
</tr>
<tr>
<td>Cache metrics</td>
<td><code>redis_exporter</code></td>
<td>Redis VM (<code>:9121</code>)</td>
</tr>
<tr>
<td>Infra metrics</td>
<td>HAProxy / Centrifugo <code>/metrics</code></td>
<td>each VM</td>
</tr>
<tr>
<td>Load metrics</td>
<td>k6 → Prometheus remote-write</td>
<td>client VM → Mac (SSH reverse tunnel)</td>
</tr>
</tbody></table>
<p>Setup is three commands:</p>
<pre><code class="language-bash">docker compose -f monitoring/docker-compose.monitor.yml up -d
./scripts/setup-monitoring-exporters.sh all
./scripts/verify-prometheus-targets.sh
</code></pre>
<p><code>setup-monitoring-exporters.sh</code> is worth pointing out: <strong>it never puts PG/Redis passwords into the repo.</strong> The script SSHes into the remote VMs and reads <code>/etc/postgres/postgres.env</code> and <code>/etc/redis/redis.env</code> to build exporter-only env files there. There's no structural way for the secret to land in a commit.</p>
<p>The dashboard is split the way an operator actually thinks during a run — <strong>three layers</strong>:</p>
<pre><code class="language-plaintext">(A) k6 / Sender         →  how much I tried to send
(B) Delivery / Receiver →  how much actually arrived
(C) Server / Resource   →  did the infra survive while that happened
</code></pre>
<p>Key panels per layer:</p>
<ul>
<li><p><strong>(A) Sender</strong> — target TPS, achieved TPS, successful MPS, HTTP p50/p95/p99, 429 / 5xx split, k6 dropped iterations (how often constant-arrival-rate failed to keep up).</p>
</li>
<li><p><strong>(B) Delivery</strong> — REST-to-WS p50/p95/p99, receiver msgs/sec, active clients/subscriptions, expected traces (registered / delivered / pending), and anomalies (duplicate / parse failure / negative latency).</p>
</li>
<li><p><strong>(C) Server</strong> — node CPU/memory/network, Redis ops/memory/latency, Postgres connections/locks/<strong>deadlocks</strong>, Centrifugo per-node client/channel distribution, HAProxy 5xx by backend.</p>
</li>
</ul>
<p>The value of this split is <strong>how fast you can isolate the cause</strong>. When 5xx spikes, if (B) latency is fine but (C) shows Postgres connections climbing — it's the DB pool. No guessing.</p>
<hr />
<h2>Auth / authorization seeding — the hardest part</h2>
<p>This was the conceptually trickiest piece of the whole setup. It deserves its own section.</p>
<h3>Why we bypass the login flow</h3>
<p>The thing under test is <code>POST /api/channels/{channelId}/messages</code> — the <strong>write path</strong>. We're measuring the cost of: auth middleware → DB write → fanout → Centrifugo publish → WS delivery at the moment a message is sent.</p>
<p>If you include the full login flow (OAuth callback, session issuance, token exchange), the cost of the auth service blends with the message write path and <strong>you can't isolate where the knee actually is</strong>. So we preserve production's auth model exactly, but <strong>pre-seed everything that happens before the message is sent</strong>.</p>
<h3>Two different auth models, kept distinct</h3>
<p>The product has two client classes; the load test mirrors that.</p>
<table>
<thead>
<tr>
<th>Client</th>
<th>Token</th>
<th>Verifier</th>
<th>Storage lookup</th>
</tr>
</thead>
<tbody><tr>
<td>k6 (REST sender)</td>
<td>Desktop Bearer <strong>opaque token</strong></td>
<td>Next.js auth middleware</td>
<td><strong>Redis</strong> (token→user) + <strong>Postgres</strong> (channel membership)</td>
</tr>
<tr>
<td>Go receiver (WS)</td>
<td>Centrifugo <strong>JWT (HS256, self-contained)</strong></td>
<td>Centrifugo HMAC check</td>
<td><strong>none</strong></td>
</tr>
</tbody></table>
<p>The cost structures are completely different:</p>
<ul>
<li><p><strong>REST auth</strong>: Redis GET + Postgres SELECT <em>per message</em>. Cost multiplies by message count.</p>
</li>
<li><p><strong>WS auth</strong>: one HMAC check at connect time. Zero cost afterwards.</p>
</li>
</ul>
<p>That's why <strong>we never merge the two tokens</strong>. The production cost shape has to survive into the test.</p>
<h3>"Seeding" means serializing the auth state</h3>
<p>The word "serialize" is the right one. We're freezing into files the auth state that's normally scattered across memory, DB, and Redis. The state to freeze:</p>
<ul>
<li><p>Which users exist (<code>User.id</code>, email, name)</p>
</li>
<li><p>Which workspace each user belongs to</p>
</li>
<li><p>Which channels each user is a member of</p>
</li>
<li><p>What access token to attach to each REST request</p>
</li>
<li><p>Which Centrifugo channels each receiver may subscribe to</p>
</li>
</ul>
<p>All of this is serialized into nine <code>data/generated/*.jsonl</code> files. <strong>These files are the SSOT of the test.</strong> Postgres, Redis, and k6 must all be looking at the same snapshot at the same time — drift between them produces an immediate flood of 401/403.</p>
<h3>The generation pipeline</h3>
<pre><code class="language-text">data/generated/users.jsonl  ← SSOT
        │
        ├──► seed-export-postgres.js
        │     → Postgres: upsert Workspace / User / WorkspaceMember /
        │                 Channel / ChannelMember
        │
        ├──► build-desktop-bearer-tokens.js
        │     → tokens.jsonl              (k6 attaches in Authorization)
        │     → redis-token-seed.jsonl    (input for Redis SETEX)
        │     → seed-redis-tokens.sh → Redis: desktop:token:access:*
        │
        └──► build-centrifugo-channel-allowlist.js
              → centrifugo-channel-allowlist.jsonl
              → mint-centrifugo-tokens.js  (HS256 sign with CENTRIFUGO_HMAC_SECRET_KEY)
              → centrifugo-jwts.jsonl     (Go receiver uses on WS connect)
</code></pre>
<p>Splitting the nine files into <strong>two groups by volatility</strong> turned out to be the operationally important part.</p>
<p><strong>A. Auth-independent — reusable as long as the Postgres seed survives (6 files)</strong> <code>users.jsonl</code>, <code>channels.jsonl</code>, <code>channel-members.jsonl</code>, <code>sender-channel-pairs.jsonl</code>, <code>mention-targets.jsonl</code>, <code>centrifugo-channel-allowlist.jsonl</code></p>
<p><strong>B. Expiring / volatile — re-mint or re-seed every cycle (3 files)</strong> <code>tokens.jsonl</code> (1-year TTL, invalidated on Redis FLUSH), <code>centrifugo-jwts.jsonl</code> (1-year TTL, invalidated when HMAC secret rotates), <code>redis-token-seed.jsonl</code> (must be re-SET if Redis goes empty).</p>
<p>This grouping removes the recurring "do I have to regenerate everything?" question. <strong>As long as PG/Redis are alive and the tokens aren't expired, all nine files drop straight into the next 1k smoke or DB knee sweep.</strong></p>
<h3>Auth flows, end-to-end</h3>
<p><strong>REST sender:</strong></p>
<pre><code class="language-text">k6 → Authorization: Bearer &lt;opaque token&gt;
   → Next.js auth middleware: Redis GET desktop:token:access:&lt;token&gt;
   → req.user injected
   → Postgres channel membership check
   → POST /api/channels/{id}/messages
</code></pre>
<p><strong>WS receiver:</strong></p>
<pre><code class="language-text">Go receiver → WS connect with token=&lt;JWT&gt;
           → Centrifugo: HS256 signature + exp check (no Redis/PG lookup)
           → subscribe chat:*
</code></pre>
<p>Pre-seeded auth is standard practice for message-path load tests. The goal isn't to benchmark the auth product itself — it's to find the knee in the <strong>message write path and fanout path</strong>.</p>
<hr />
<h2>Sender / receiver split measurement architecture</h2>
<p>This is the core design choice of the load model.</p>
<pre><code class="language-text">[ client VM ]
   k6 (REST sender)  ──HTTP──►  HAProxy ──► App ──► (publish) ──► Centrifugo
                                                                      │
                                                                      ▼ (fanout)
   Go receiver (WS)  ◄──────────────────────────  Centrifugo ◄────────┘
</code></pre>
<p><strong>Why send and receive don't live in the same process:</strong></p>
<ul>
<li><p>k6 is optimized as an HTTP load generator. Bolting a WS subscriber onto it warps the generator-side load curve.</p>
</li>
<li><p>The Go receiver holds 1,000+ concurrent WS connections and has to perform traceId matching + latency measurement per message. A goroutine/channel model fits naturally.</p>
</li>
<li><p><strong>Each side doing only its own job</strong> is what makes "which side is the bottleneck?" answerable.</p>
</li>
</ul>
<h3>Trace measurement — true REST → WS E2E latency</h3>
<p>When k6 sends a message, it embeds <code>metadata.clientTrace.traceId</code> and <code>sentAt</code> in the payload. When the Go receiver gets a publication from Centrifugo, it matches by traceId and records <code>now - sentAt</code>.</p>
<pre><code class="language-text">sender POST timestamp ─────────────►  receiver WS receive timestamp
                  ▲                          ▲
            sentAt recorded            latency = now - sentAt
</code></pre>
<p>That value is the real <strong>REST-to-WS delivery latency p50/p95/p99</strong> — not HTTP response time, but "how long from send until the message appears on someone else's screen."</p>
<p>Turn on <code>EXPECTED_TRACE_MODE=direct</code> and every successful send also registers its traceId at the receiver's <code>/expected</code> endpoint. The receiver now knows <strong>exactly which traceIds were supposed to arrive</strong>, which gives:</p>
<ul>
<li><p><code>registered</code> — traceIds registered</p>
</li>
<li><p><code>delivered</code> — traceIds actually received</p>
</li>
<li><p><code>pending</code> — registered but never delivered (loss candidates)</p>
</li>
<li><p><code>duplicate</code> — same traceId received multiple times</p>
</li>
<li><p><code>negativeLatency</code> — clock skew suspects</p>
</li>
</ul>
<p>At end of run, <code>pending</code> should converge to 0. Whatever's left is <strong>lost messages</strong>.</p>
<h3>k6 scenario shape</h3>
<pre><code class="language-javascript">export const options = {
  scenarios: {
    chat_smoke: {
      executor: 'constant-arrival-rate',  // arrival-rate based, not VU based
      rate: targetTps,
      timeUnit: '1s',
      duration,
      preAllocatedVUs,
      maxVUs,
    },
  },
  thresholds: {
    http_req_failed:       'rate&lt;0.01',
    http_req_duration:     'p(95)&lt;1000',
    dropped_iterations:    'count&lt;1',
    message_send_429_total:'count&lt;1',
    message_send_5xx_total:'count&lt;1',
  },
};
</code></pre>
<p><code>constant-arrival-rate</code> is the key. With VU-based scenarios, slower responses just lower the arrival rate, and you can't tell "did we fail to <em>generate</em> load, or did the server <em>refuse</em> it?" Arrival-rate-based means when the server can't keep up, <code>dropped_iterations</code> rises and the truth is visible.</p>
<p>The DB knee sweep takes this further: it auto-sweeps <code>50, 75, 100, 125, 150, 200, 250, 300, 400, 500, 650, 800, 1000</code> TPS with a 1-minute ramp + 5-minute plateau per stage. One run and the knee point is right there on the chart.</p>
<hr />
<h2>Execution flow — 12 fixed steps</h2>
<p>For an environment to be genuinely reusable, the number of things humans have to remember has to be small. So a single load-test run is fixed into <strong>12 ordered steps</strong>:</p>
<pre><code class="language-plaintext">0.  Set working directory + source HOSTS.env
1.  Save existing PG dump          (make dump-pg)
2.  Provision 7-host infra          (make up → make ssh-test)
3.  Restore DB                      (make restore-pg)
4.  Deploy apps                     (make secrets-check → make cutover → make smoke)
5.  Bring up monitoring             (docker compose up + install exporters)
6.  Seed/export test data           (data:seed-postgres, data:validate)
7.  Refresh JWTs + Redis seed       (tokens:desktop-bearer, tokens:centrifugo)
8.  Upload artifacts to client VM   (upload-loadtest-data.sh, upload-k6-scripts.sh)
9.  Reset App connection pool       (docker restart witim-web)
10. k6 REST smoke probe             (chat-smoke.js, 30s)
11. Open the 3-terminal session     (receiver / Prom reverse tunnel / k6 sender)
12. Monitor abort criteria
</code></pre>
<p>Step 11 — the three-terminal pattern — is what gets used in every run:</p>
<ul>
<li><p><strong>Terminal 1</strong>: Go receiver on the client VM — WS connections + trace matching + metric exporter.</p>
</li>
<li><p><strong>Terminal 2</strong>: SSH reverse tunnel so k6 on the client VM can reach the <strong>local Mac's</strong> Prometheus remote-write endpoint.</p>
</li>
<li><p><strong>Terminal 3</strong>: k6 sender on the client VM, streaming metrics out via Prometheus remote-write.</p>
</li>
</ul>
<pre><code class="language-bash"># Terminal 2: SSH reverse tunnel (k6 → Mac's Prometheus)
ssh -i "$SSH_KEY" -N -R localhost:9090:localhost:9090 "$SSH_USER@$CLIENT_IP"
</code></pre>
<p>The reverse tunnel is what makes k6 metrics show up in <strong>real time</strong> on the local Grafana dashboard. You watch the load break, in motion, and stop it the moment it does.</p>
<h3>Abort criteria</h3>
<p>If any of these become true, abort before overload and recovery curves bleed into each other:</p>
<pre><code class="language-plaintext">message_send_5xx_total &gt; 0
dropped_iterations &gt; 0 (reproducibly)
HTTP p95 ≥ 1s
REST-to-WS p99 rising into seconds
Postgres connection exhaustion in logs
Postgres deadlocks repeating
client CPU pinned at 80–90% AND achieved TPS falling behind target
</code></pre>
<p>Why write these down ahead of time? Simple: <strong>if "should we keep going?" gets answered on gut feel mid-run, the resulting report loses credibility.</strong></p>
<hr />
<h2>Reading a real run on Grafana — the 75→100 TPS knee</h2>
<p>About 7–8 minutes into <code>chat-knee-sweep.js</code>, just as the sweep stage stepped from <strong>75 TPS to 100 TPS</strong>, we caught this dashboard snapshot. What makes the moment interesting is that <strong>infra resources are barely touched, yet HTTP error rate spikes to 42.8%</strong> — a clear signal that the knee isn't a resource ceiling, it's something inside the app's processing path.</p>
<p>The dashboard is read in the three-layer order set up earlier: <code>(A) k6 / Sender → (B) Delivery / Receiver → (C) Server / Resource</code>.</p>
<p><em>(Continued —</em> "I'll dig into the app server logs in the next post and unpack the cause of the 42.8% HTTP error rate*.)*</p>
<img src="https://cdn.hashnode.com/uploads/covers/69c4dd0910e664c5da65b3c8/469c2171-2a30-4c0a-8dd0-373aa02d4bec.png" alt="" style="display:block;margin:0 auto" />

<img src="https://cdn.hashnode.com/uploads/covers/69c4dd0910e664c5da65b3c8/35960873-fdfd-46bc-b73c-31bce2c7f2e3.png" alt="" style="display:block;margin:0 auto" />

<img src="https://cdn.hashnode.com/uploads/covers/69c4dd0910e664c5da65b3c8/c83745e8-8bc4-4fc5-b94e-00045ed3078e.png" alt="" style="display:block;margin:0 auto" />

<img src="https://cdn.hashnode.com/uploads/covers/69c4dd0910e664c5da65b3c8/08dddcc4-0e94-4e9c-9a7c-13c7f4710088.png" alt="" style="display:block;margin:0 auto" />

<hr />
<p><em>Thanks for reading. If you're running a similar measurement effort, the two patterns most worth taking from this post are probably</em> <em><strong>"wrap IaC + deploy + smoke + seeding + teardown into one Make-driven flow"</strong></em> <em>and</em> <em><strong>"serialize the auth state into files and treat them as the SSOT."</strong></em> <em>Those are the two things that decide whether the environment is reusable or just a one-shot.</em></p>
]]></content:encoded></item><item><title><![CDATA[Why You Should Never Use KeyDB as a Pub/Sub Broker for Centrifugo]]></title><description><![CDATA[Introduction
I was building a real-time chat system designed to handle 100K concurrent WebSocket connections at 25K+ messages per second. The architecture was a symmetric dual-stack — two identical se]]></description><link>https://beckybuilds.hashnode.dev/why-you-should-never-use-keydb-as-a-pub-sub-broker-for-centrifugo</link><guid isPermaLink="true">https://beckybuilds.hashnode.dev/why-you-should-never-use-keydb-as-a-pub-sub-broker-for-centrifugo</guid><category><![CDATA[Redis]]></category><category><![CDATA[websockets]]></category><category><![CDATA[Docker]]></category><category><![CDATA[Devops]]></category><category><![CDATA[System Design]]></category><dc:creator><![CDATA[Becky]]></dc:creator><pubDate>Sun, 29 Mar 2026 08:12:38 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/69c4dd0910e664c5da65b3c8/d5e3b733-7e11-4d61-a025-f14b90eba78b.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h2>Introduction</h2>
<p>I was building a real-time chat system designed to handle <strong>100K concurrent WebSocket connections at 25K+ messages per second.</strong> The architecture was a symmetric dual-stack — two identical servers (16 vCPU / 58 GB each) running 5 Centrifugo nodes apiece, with Cloudflare DNS splitting traffic 50/50.</p>
<p>The critical challenge was <strong>cross-server message synchronization.</strong> When a user connected to Server A sends a message, it must reach a user on Server B. Centrifugo delegates this to Redis Pub/Sub — whichever Redis-compatible store sits behind it becomes the backbone of the entire messaging pipeline.</p>
<p>I chose KeyDB. It was a multithreaded Redis fork with <strong>active-replica mode</strong>, allowing bidirectional writes. Server A and B could both write, and changes would sync automatically. It looked perfect for a symmetric architecture.</p>
<pre><code class="language-plaintext">Server A: KeyDB Primary         (read/write)
              ↕  (bidirectional replication)
Server B: KeyDB Active-Replica  (read/write)
</code></pre>
<p>Early load tests hit 23.6K TPS. Everything seemed fine.</p>
<p>Then I started reading GitHub Issues.</p>
<hr />
<h2>1. The Problem — 5 Years of the Same Bug</h2>
<p>I began auditing KeyDB's issue tracker before production deployment. What I expected to find were old, resolved bugs. What I actually found was a pattern that spanned half a decade.</p>
<h3>A Timeline of Hangs and Deadlocks</h3>
<table>
<thead>
<tr>
<th>Date</th>
<th>Issue</th>
<th>Symptom</th>
<th>Status</th>
</tr>
</thead>
<tbody><tr>
<td>2019.11</td>
<td>#103</td>
<td>deadlock &amp; hang</td>
<td>Closed (version upgrade)</td>
</tr>
<tr>
<td>2023.03</td>
<td>#619</td>
<td>active-replica hang</td>
<td><strong>Open</strong></td>
</tr>
<tr>
<td>2024.03</td>
<td>#794</td>
<td>full server hang</td>
<td><strong>Open</strong></td>
</tr>
<tr>
<td>2024.06</td>
<td>#845</td>
<td>Pub/Sub freeze</td>
<td><strong>Open</strong></td>
</tr>
<tr>
<td>2024.10</td>
<td>#878</td>
<td>KEYS command hang</td>
<td><strong>Open</strong></td>
</tr>
<tr>
<td>2024.11</td>
<td>#883</td>
<td>replication deadlock</td>
<td><strong>Open</strong></td>
</tr>
</tbody></table>
<p>From 2019 to 2024, <strong>the same category of hang/deadlock kept recurring.</strong> And the latest version, v6.3.4, had not fixed any of them.</p>
<h3>Structural Flaw: Multithreaded Deadlock</h3>
<p>Issue #883 was the most alarming. It wasn't a simple bug — it was a <strong>structural flaw in KeyDB's multithreaded architecture itself.</strong></p>
<pre><code class="language-plaintext">bgsaveCommand attempts to acquire global WRITE lock
  ↓
AsyncWorkerQueue thread(1): waiting for global READ lock, holding m_mutex
  ↓
AsyncWorkerQueue thread(2): holding READ lock, waiting for m_mutex
  ↓
→ 3-way deadlock
  → CPU usage drops to 0%
  → All client connections unresponsive
  → Does not respond to SIGTERM (only SIGKILL works)
</code></pre>
<p>The entire server goes silent. No automatic recovery is possible. In Issue #845, users reported that even <code>sudo reboot</code> would hang — a <strong>hardware-level hard reboot was required.</strong></p>
<h3>A Project Effectively Abandoned</h3>
<p>The last release, v6.3.4, was in October 2023. I was evaluating this in February 2026 — <strong>over two years with no updates.</strong> 233 open issues had accumulated on GitHub. Maintainer responses had virtually ceased since 2024.</p>
<p>I recalled what antirez, the creator of Redis, once said: "The risk of bugs in threaded code is very high, and Redis's non-threaded architecture is a design for stability."</p>
<hr />
<h2>2. Why This Is Catastrophic for Centrifugo</h2>
<p>Centrifugo uses Redis Pub/Sub as the <strong>core path for inter-node message delivery.</strong> This isn't a cache layer. It's not a nice-to-have. If Pub/Sub freezes, every single Centrifugo node loses its ability to relay messages.</p>
<pre><code class="language-plaintext">When KeyDB Pub/Sub freezes:
  ├─ Inter-node message delivery: completely halted
  ├─ All 10 Centrifugo instances: unable to deliver messages
  ├─ 100K connected users: total loss of real-time functionality
  └─ KeyDB ignores SIGTERM → no automatic recovery
      → An operator must manually hard-reboot the server
</code></pre>
<p>The Centrifugo documentation explicitly states: "KeyDB compatibility in future releases is not guaranteed." Every company running Centrifugo in production that I could find — VK, Badoo, ManyChat, Grafana — was using Redis or Redis + Sentinel. <strong>Not a single production deployment used KeyDB as a Pub/Sub broker.</strong></p>
<hr />
<h2>3. Evaluating Alternatives — DragonflyDB vs. Valkey</h2>
<p>KeyDB had to go. The question was what to replace it with.</p>
<h3>DragonflyDB — Better Multithreading, But…</h3>
<p>DragonflyDB is designed from the ground up as a multithreaded, shared-nothing architecture. Per-shard independent threads with minimal locking. The global lock contention that causes KeyDB's deadlocks simply cannot occur structurally. When deadlock issues are reported, a full-time team patches them within days to weeks.</p>
<p>But Centrifugo doesn't officially test against DragonflyDB. And there was a more fundamental question to ask.</p>
<h3>Does Pub/Sub Even Need Multithreading?</h3>
<p>For multithreading to provide any benefit, there's a prerequisite: <strong>a single core must be hitting 100%.</strong> The entire point of multithreading is distributing work across cores because one core can't keep up. So the real question is — when used as a Pub/Sub broker for Centrifugo, would Valkey's single-threaded design ever saturate one core?</p>
<p>The Centrifugo official benchmark shows that at <strong>1 million WebSocket connections delivering 500K messages per second</strong>, each Redis instance used roughly 5% CPU. The developer himself stated: "I handled 500K connections with 10 Centrifugo nodes + 1 Redis instance, and Redis used only 60% of a single core."</p>
<p>Our environment is 100K connections. Proportionally, that's about 12–15% of a single core. On a 16 vCPU server, that's <strong>under 1% of total capacity.</strong> Pub/Sub simply relays messages — it's one of the lightest workloads Redis handles.</p>
<pre><code class="language-plaintext">CPU load by Redis workload type:

GET/SET cache (hundreds of K ops/sec)   ████████████████  ← heavy
LPUSH/RPOP queue (bulk processing)      ██████████████    ← heavy
Sorted Set operations (ranking/analytics) ████████████    ← heavy
PUBLISH/SUBSCRIBE (message relay)       ███               ← light
</code></pre>
<p>But here's the critical insight. Let's say, for the sake of argument, that traffic spikes and Valkey's single core reaches 60%. Should the response be "switch to a multithreaded database"?</p>
<p>No. <strong>Just add another Valkey instance.</strong></p>
<p>Centrifugo natively accepts Redis addresses as an array. When configured this way, it applies consistent hashing on channel names and automatically distributes traffic. This isn't Redis Cluster — the Valkey instances don't even know about each other. Centrifugo handles the routing: "this channel goes to A, that channel goes to B."</p>
<pre><code class="language-json">// Before: 1 Valkey instance — single core at 60%
{ "address": "redis://valkey-a:6379" }

// After: 2 Valkey instances — each core at 30%
{ "address": [
    "redis://valkey-a:6379",
    "redis://valkey-b:6379"
  ]
}
</code></pre>
<p>One line added to the config, and the load is halved. Three instances, one-third each. <strong>Linear horizontal scaling.</strong></p>
<p>Here's what this means:</p>
<pre><code class="language-plaintext">When is multithreading needed?
  = When a single core reaches 100%

What actually happens with a Pub/Sub broker:
  Valkey's single core reaches ~60%
  → Add one more Valkey instance, add one address to Centrifugo config
  → Each instance now handles ~30%
  → A single core never reaches 100%
  → The situation that requires multithreading simply never arrives
</code></pre>
<p><strong>Instead of breaking the single-core ceiling with multithreading, you deploy multiple single-core instances.</strong> This is safer, more predictable, and the industry standard. And since Centrifugo supports this natively — there's no reason to use multithreaded Redis variants like KeyDB or DragonflyDB as a Pub/Sub broker.</p>
<h3>The Verdict: Valkey</h3>
<table>
<thead>
<tr>
<th>Factor</th>
<th>KeyDB</th>
<th>DragonflyDB</th>
<th>Valkey</th>
</tr>
</thead>
<tbody><tr>
<td>Deadlock risk (Pub/Sub)</td>
<td>High</td>
<td>Low</td>
<td><strong>Impossible</strong></td>
</tr>
<tr>
<td>Project health</td>
<td>Abandoned</td>
<td>Active</td>
<td>Active</td>
</tr>
<tr>
<td>Centrifugo official support</td>
<td>Not supported</td>
<td>Not tested</td>
<td><strong>Officially supported</strong></td>
</tr>
<tr>
<td>License</td>
<td>BSD</td>
<td>BSL</td>
<td><strong>BSD</strong></td>
</tr>
</tbody></table>
<p>Valkey is a fork of Redis 7.2.4, led by the Linux Foundation with backing from AWS, Google, and Oracle. From Centrifugo's perspective, Valkey and Redis are indistinguishable — identical protocol, commands, Pub/Sub, and Sentinel support.</p>
<p>Being single-threaded means <strong>the entire bug category of "multithreaded deadlock" does not exist.</strong> If the top priority is "absolutely no freezing," Valkey + Sentinel was the most conservative and safest choice.</p>
<hr />
<h2>4. Building It — Valkey Primary + Replica + Sentinel (3-Node Quorum)</h2>
<h3>Architecture Change</h3>
<p>I dropped KeyDB's active-replica (bidirectional writes) and switched to Valkey Primary → Replica unidirectional replication with Sentinel automatic failover.</p>
<pre><code class="language-plaintext">Before (KeyDB):
  Server A: KeyDB Primary    ↔ (bidirectional)  Server B: KeyDB Active-Replica
  → Deadlock risk, no automatic recovery

After (Valkey + Sentinel):
  Server A: Valkey Primary   → (unidirectional)  Server B: Valkey Replica
  Sentinel ×3 (Server A, Server B, Runner) — quorum=2

  → Deadlock impossible, automatic failover in 5–15 seconds
</code></pre>
<p>Three Sentinels were placed on Server A, Server B, and a $6/month Runner server. With quorum=2, any single server failure still leaves two Sentinels to form a majority and trigger failover.</p>
<table>
<thead>
<tr>
<th>Failure Scenario</th>
<th>Sentinel Status</th>
<th>Result</th>
</tr>
</thead>
<tbody><tr>
<td>Server A (Primary + S1) down</td>
<td>S2 + S3 = 2/3</td>
<td>Failover executed</td>
</tr>
<tr>
<td>Server B down</td>
<td>S1 + S3 = 2/3</td>
<td>Primary maintained</td>
</tr>
<tr>
<td>Runner (S3) down</td>
<td>S1 + S2 = 2/3</td>
<td>Normal operation</td>
</tr>
</tbody></table>
<h3>The Hard Part: Docker Networking vs. Sentinel</h3>
<p>The most painful part of the migration wasn't Valkey itself — it was <strong>Docker networking.</strong></p>
<p>Sentinel's job is to tell every node "here's where the current master is." But when Sentinel runs inside a Docker bridge network, it sees the master's address as <code>172.18.0.2</code> — a Docker-internal IP. When it passes this address to the Sentinel on Server B, that address either points to a completely different container or doesn't exist at all. Connection fails.</p>
<pre><code class="language-plaintext">Sentinel inside Server-a's Docker bridge:
  "master is at 172.18.0.2!"
           ↓
Server-b's Docker bridge:
  "172.18.0.2? That doesn't exist here... → connection failed"
</code></pre>
<p>Think of it as giving someone a building's internal extension number and expecting it to work from a different building. Internal extensions only work within their own building.</p>
<p><strong>The fix was running Sentinel with</strong> <code>network_mode: host</code><strong>.</strong> When Sentinel sits directly on the host network instead of inside the Docker bridge, it sees the master at its VPC IP (<code>10.10.0.3</code>). VPC IPs are routable from any server in the network.</p>
<pre><code class="language-plaintext">Sentinel (host network):
  "master is at 10.10.0.3:6379!"
           ↓
Server-b:
  "10.10.0.3? That's a VPC address — reachable! → connection success"
</code></pre>
<p>But there was another problem. Centrifugo containers still lived inside the Docker bridge, while Sentinel was now outside it. To reach from inside to outside, I needed the <code>extra_hosts</code> directive:</p>
<pre><code class="language-yaml">centrifugo-1:
  extra_hosts:
    - "sentinel:host-gateway"  # Route through bridge gateway to reach host's Sentinel
</code></pre>
<p><code>host-gateway</code> is a special Docker keyword that resolves to the bridge network's gateway IP — essentially a lobby phone that connects the inside of the building to the outside. <strong>Finding this single line took longer than any other part of the migration.</strong></p>
<h3>Minimal Configuration Changes</h3>
<p>No application code or Centrifugo configuration structure needed to change. One environment variable swap:</p>
<pre><code class="language-bash"># Before
CENTRIFUGO_REDIS_ADDRESS=keydb:6379

# After
CENTRIFUGO_REDIS_ADDRESS=redis+sentinel://sentinel:26379,&lt;server-b&gt;:26379,&lt;runner&gt;:26379?master=mymaster
</code></pre>
<p>The address format changed to <code>redis+sentinel://</code>, and Centrifugo automatically queries Sentinel for the current Primary and connects. On the infrastructure side, I deleted three KeyDB-specific options (<code>server-threads</code>, <code>server-thread-affinity</code>, <code>active-replica</code>), replaced the KeyDB conf with a Valkey conf, and that was it.</p>
<hr />
<h2>5. Validation — Load Test Results</h2>
<p>After migration, I ran the same stress test: 100K concurrent connections, 4-step progressive load.</p>
<h3>TPS and Latency</h3>
<table>
<thead>
<tr>
<th>Step</th>
<th>Combined TPS</th>
<th>P50</th>
<th>P95</th>
<th>P99</th>
<th>Overall Grade</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
<td>15,305</td>
<td>1.7ms</td>
<td>34.8ms</td>
<td>692.7ms</td>
<td><strong>Good</strong></td>
</tr>
<tr>
<td>2</td>
<td>18,591</td>
<td>2.0ms</td>
<td>485.0ms</td>
<td>971.0ms</td>
<td><strong>Fair</strong></td>
</tr>
<tr>
<td>3</td>
<td>23,504</td>
<td>3.6ms</td>
<td>270.5ms</td>
<td>905.4ms</td>
<td><strong>Fair</strong></td>
</tr>
<tr>
<td>4</td>
<td>31,171</td>
<td>875.0ms</td>
<td>2,492ms</td>
<td>4,963ms</td>
<td><strong>Fail</strong></td>
</tr>
</tbody></table>
<p>The error-free sustainable ceiling was <strong>~23.5K TPS.</strong> Through Step 3, the error rate remained at 0.0001–0.0002% — effectively zero.</p>
<h3>Valkey and Sentinel Resource Usage</h3>
<p>This is the answer to "isn't single-threaded a bottleneck?"</p>
<table>
<thead>
<tr>
<th>Container</th>
<th>Server-A (Primary)</th>
<th>Server-B (Replica)</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Valkey</strong></td>
<td>88–98% (single core)</td>
<td>31–35%</td>
</tr>
<tr>
<td><strong>Sentinel</strong></td>
<td>0.4–0.5%</td>
<td>0.5–1.0%</td>
</tr>
<tr>
<td><strong>HAProxy</strong></td>
<td>114–202%</td>
<td>127–216%</td>
</tr>
</tbody></table>
<p>Valkey Primary reached 88–98% on its single core, but that's only ~6% of the 16 vCPU server's total capacity. The actual bottleneck was <strong>HAProxy at 216%.</strong> Sentinel overhead was negligible — 0.4–1.0% CPU and roughly 3.4 MB RAM.</p>
<p>Compared to the KeyDB era, removing bidirectional replication overhead noticeably reduced Replica-side CPU. And most importantly — we gained the <strong>structural guarantee that deadlocks cannot occur.</strong></p>
<h3>Failover Test</h3>
<p>I forcefully killed the Primary and measured recovery time.</p>
<table>
<thead>
<tr>
<th>Time</th>
<th>Event</th>
</tr>
</thead>
<tbody><tr>
<td>T+0s</td>
<td>Primary process terminated</td>
</tr>
<tr>
<td>T+5s</td>
<td>3 Sentinels: SDOWN → vote → ODOWN (majority reached)</td>
</tr>
<tr>
<td>T+6s</td>
<td>Sentinel leader elected</td>
</tr>
<tr>
<td>T+7s</td>
<td>Replica promoted to new Primary (<code>REPLICAOF NO ONE</code>)</td>
</tr>
<tr>
<td>T+8s</td>
<td>Centrifugo auto-reconnects → service restored</td>
</tr>
</tbody></table>
<p><strong>Total downtime: approximately 5–15 seconds.</strong> Under KeyDB, there was no automatic failover — an operator had to intervene manually. In the worst case, a hardware-level hard reboot was required. Now, a $6/month server provides fully automatic recovery.</p>
<hr />
<h2>Lessons Learned</h2>
<p>The takeaways from this migration are clear.</p>
<p><strong>Match the tool to the workload.</strong> Caches and queues are throughput-bound — multithreading has real benefits there. But a Pub/Sub broker is different. When a broker freezes, every connected client simultaneously loses real-time functionality. Retries are meaningless — the messages are already lost. For workloads where <strong>stability &gt; performance</strong>, a multithreaded engine with deadlock risk is a liability, not an asset.</p>
<p><strong>Single-threaded is not a weakness — it's a design decision.</strong> Redis and Valkey are single-threaded not because they gave up on performance, but because they <strong>eliminated the entire bug category of multithreaded deadlocks.</strong> When you need more throughput, add instances. It's safer, more predictable, and the industry standard.</p>
<p><strong>GitHub Issues don't lie.</strong> I should have checked the Issues tab before choosing the stack. A project where the same bug recurs for five years, the last release was two years ago, and maintainers have stopped responding — putting that on a production critical path is a ticking time bomb.</p>
]]></content:encoded></item><item><title><![CDATA[Building a Crash-Resilient, End-to-End Meeting Recording Pipeline in the Browser]]></title><description><![CDATA[The Problem
Our platform, workb, had a straightforward meeting recording feature: hit record in the browser, stop when you're done, upload the file, click a button to transcribe. Simple — and deeply f]]></description><link>https://beckybuilds.hashnode.dev/building-a-crash-resilient-end-to-end-meeting-recording-pipeline-in-the-browser</link><guid isPermaLink="true">https://beckybuilds.hashnode.dev/building-a-crash-resilient-end-to-end-meeting-recording-pipeline-in-the-browser</guid><category><![CDATA[Web Audio API]]></category><category><![CDATA[cloudflare]]></category><category><![CDATA[Speech Recognition]]></category><category><![CDATA[AI]]></category><category><![CDATA[presignedurl]]></category><category><![CDATA[bullmq]]></category><dc:creator><![CDATA[Becky]]></dc:creator><pubDate>Thu, 26 Mar 2026 09:32:14 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/69c4dd0910e664c5da65b3c8/b9794e56-f294-4378-a01d-8ea05ea42097.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h2>The Problem</h2>
<p>Our platform, <strong>workb</strong>, had a straightforward meeting recording feature: hit record in the browser, stop when you're done, upload the file, click a button to transcribe. Simple — and deeply flawed.</p>
<p>Three pain points kept surfacing:</p>
<p><strong>Browser memory blowup.</strong> The <code>MediaRecorder</code> API accumulates audio chunks as <code>Blob</code>/<code>ArrayBuffer</code> in memory. For a one-hour meeting recorded in WebM Opus, that's roughly 60–100 MB of raw chunks. When recording stops, <code>new Blob(chunks)</code> merges everything — and for a brief moment, both the original chunks <em>and</em> the merged blob coexist in memory, spiking to 120–200 MB. Add <code>FormData</code> conversion for upload and you're looking at 200–300 MB in a single tab's JS heap. Chrome enforces a per-tab memory ceiling of roughly 1–4 GB, and with 10+ tabs open, the GC starts thrashing, the UI janks, and the tab can outright crash. Worst part: a crash meant <strong>total data loss</strong> — nothing had been sent to the server yet.</p>
<p><strong>Poor Korean STT quality.</strong> We were using OpenAI Whisper, which delivered around 85% accuracy on Korean speech — no speaker diarization, and a hard 25 MB file-size cap that made long meetings impossible to process.</p>
<p><strong>Manual workflow friction.</strong> Users had to manually click "Transcribe" after recording. Forget to click? No transcript. Results were checked via polling every 5 seconds — 300+ redundant API calls over a typical 10–25 minute transcription window.</p>
<hr />
<h2>The New Architecture: A 6-Stage Automated Pipeline</h2>
<p>We redesigned the entire flow into six stages that require zero user intervention after pressing "Stop":</p>
<ol>
<li><p><strong>Recording Capture</strong> — 5-minute savepoints with automatic cloud backup</p>
</li>
<li><p><strong>Cloud Upload</strong> — Direct browser-to-R2 upload via presigned URLs</p>
</li>
<li><p><strong>Auto-Trigger</strong> — Server-side transcription kickoff on finalize</p>
</li>
<li><p><strong>Speech Recognition</strong> — VITO AI (sommers model) with speaker diarization</p>
</li>
<li><p><strong>AI Summary</strong> — Claude API generates structured meeting minutes</p>
</li>
<li><p><strong>Real-Time Delivery</strong> — Centrifugo WebSocket pushes status updates live</p>
</li>
</ol>
<p>Let's walk through each stage.</p>
<hr />
<h2>Stage 1: 5-Minute Savepoints — Taming Browser Memory</h2>
<h3>The Core Idea</h3>
<p>Instead of buffering an entire recording in memory, we flush audio to the cloud every 5 minutes. A 5-minute WebM Opus segment is only ~5–10 MB, so the browser never holds more than that at any point. One hour of recording? Still 10 MB in memory.</p>
<h3>How It Works</h3>
<ol>
<li><p>Recording starts → the client sends an init request to the server and receives a recording ID + a presigned upload URL for the first part.</p>
</li>
<li><p>Every 5 minutes → accumulated audio chunks are bundled into a single blob and uploaded directly to Cloudflare R2. On success, the client notifies the server ("Part N saved"), receives the next presigned URL, and clears the chunks from memory.</p>
</li>
<li><p>Recording stops → the last partial segment is uploaded, and a <code>finalize</code> request tells the server to merge all parts.</p>
</li>
</ol>
<h3>Crash Recovery — Dual Mechanism</h3>
<p>The biggest win is resilience. If the browser crashes mid-recording, every savepoint up to that moment is already safe in R2. Maximum data loss: 5 minutes.</p>
<p>We implemented two complementary recovery mechanisms:</p>
<p><strong>Server-side timeout.</strong> Each savepoint schedules a "process automatically in 10 minutes" delayed job via BullMQ. When the next savepoint arrives, the previous timer is cancelled. If no savepoint shows up for 10 minutes, the server assumes a crash and proceeds to merge and transcribe whatever parts it has.</p>
<p><strong>Client-side recovery UI.</strong> If the user reconnects, the app detects the interrupted session and displays: <em>"Your previous recording was interrupted. Recover the saved 20 minutes?"</em></p>
<p>Why both? A single timeout creates a tradeoff between "safe wait time" and "fast recovery." A short timeout risks false positives from temporary network hiccups; a long one delays recovery for users who reconnect quickly. The dual approach lets us keep the timeout conservatively long while offering instant manual recovery.</p>
<h3>The WebM Header Problem</h3>
<p>An interesting gotcha: when we collected <code>MediaRecorder</code> chunks every 5 minutes and created a new <code>Blob</code>, the second segment onward was missing the WebM EBML header. <code>ffmpeg</code> refused to parse them.</p>
<p>The root cause is that <code>MediaRecorder</code> emits the container header only at the start of recording. Our solution: <strong>stop and restart</strong> the <code>MediaRecorder</code> on the same audio stream at each savepoint boundary. Each segment becomes an independent, fully valid WebM file. The resulting audio gap is 5–10 ms — completely imperceptible in a meeting context. This is a well-known pattern used by libraries like RecordRTC.</p>
<hr />
<h2>Stage 2: Direct-to-R2 Upload via Presigned URLs</h2>
<p>In the old system, audio files passed through our application server on the way to storage. The new system eliminates the server from the data path entirely.</p>
<p><strong>Presigned URLs</strong> are time-limited, pre-authenticated URLs that allow the browser to <code>PUT</code> directly to Cloudflare R2. The server only issues the URL (a lightweight operation involving signing, not data transfer). The actual file bytes never touch the server.</p>
<p>The result: zero server bandwidth consumption for uploads, regardless of how many users are recording simultaneously. At 100K users, the server's only job during recording is issuing URLs — a negligible cost.</p>
<p>Downloads work the same way: the server performs an auth check, logs the access, and redirects to a presigned download URL. R2 serves the file directly. Server memory usage for file I/O: zero.</p>
<p>We also discovered during code review that the R2 integration layer (provider class, presigned URL generation, storage key management) already existed in our codebase from a previous feature. We only needed to build the savepoint-specific API routes.</p>
<hr />
<h2>Stage 3: Automatic Transcription Trigger</h2>
<p>Previously, users had to remember to click "Transcribe." If they forgot, no transcription happened — ever.</p>
<p>Now, when the server completes part merging via <code>ffmpeg</code> (the <code>finalize</code> step), it automatically enqueues a transcription job in BullMQ. No user action required. The old "Transcribe" button remains only as a manual retry option for failure cases.</p>
<hr />
<h2>Stage 4: STT Engine — From OpenAI Whisper to VITO</h2>
<h3>Evaluating 6 STT Engines</h3>
<p>We benchmarked three commercial APIs (VITO, ElevenLabs, Google Chirp 3) and three open-source options (Whisper, Qwen3-ASR, Voxtral) as of March 2026.</p>
<p><strong>Korean accuracy (Character Error Rate):</strong></p>
<table>
<thead>
<tr>
<th>Engine</th>
<th>CER</th>
</tr>
</thead>
<tbody><tr>
<td>VITO sommers</td>
<td>&lt; 8%</td>
</tr>
<tr>
<td>ElevenLabs Scribe v2</td>
<td>8.5%</td>
</tr>
<tr>
<td>Whisper large-v3</td>
<td>10.8–11.4%</td>
</tr>
<tr>
<td>Google Chirp 3</td>
<td>11.3%</td>
</tr>
</tbody></table>
<p><strong>Speaker diarization:</strong> VITO (2–5 speakers, auto-detect), ElevenLabs (up to 32 speakers), and Google all have built-in support. Whisper requires external tooling like WhisperX.</p>
<p><strong>VITO's differentiating features:</strong></p>
<ul>
<li><p><strong>ITN (Inverse Text Normalization):</strong> Converts spoken numbers to written form — "삼천원" → "3,000원."</p>
</li>
<li><p><strong>Disfluency filtering:</strong> Automatically removes filler words like "음," "어."</p>
</li>
<li><p><strong>Keyword boosting:</strong> Register up to 500 domain-specific terms to improve recognition accuracy.</p>
</li>
</ul>
<p>None of the other engines include these features natively.</p>
<p><strong>File size limits:</strong> Whisper's 25 MB cap was a hard blocker for long meetings. VITO Batch STT has no limit.</p>
<h3>Cost Analysis by Usage Tier</h3>
<p>Cost dynamics shift significantly across usage volumes (at $1 = ₩1,430):</p>
<table>
<thead>
<tr>
<th>Monthly Hours</th>
<th>Cheapest Option</th>
<th>Notes</th>
</tr>
</thead>
<tbody><tr>
<td>2.5–10h</td>
<td>VITO / ElevenLabs (free tier)</td>
<td>VITO offers 10h free on signup</td>
</tr>
<tr>
<td>~20h</td>
<td>VITO (~₩10K)</td>
<td>2.7–3.1× cheaper than Google/ElevenLabs</td>
</tr>
<tr>
<td>~50h</td>
<td>ElevenLabs Creator ($22)</td>
<td>Begins to undercut VITO at this tier</td>
</tr>
<tr>
<td>~100h</td>
<td>VITO (~₩90K)</td>
<td>1.5–1.6× cheaper than alternatives</td>
</tr>
<tr>
<td>200–500h</td>
<td>ElevenLabs subscription plans</td>
<td>1.3–2.1× cheaper than VITO</td>
</tr>
<tr>
<td>500h+</td>
<td>Google Dynamic Batch</td>
<td>Lowest per-hour rate at scale</td>
</tr>
</tbody></table>
<h3>Why We Chose VITO</h3>
<p>For workb, Korean meeting transcript quality is the core product value. Our decision came down to three factors:</p>
<ol>
<li><p><strong>Best Korean accuracy</strong> at CER &lt; 8%. In meeting transcription, lower accuracy doesn't just mean more errors — it means higher post-processing cost and user frustration.</p>
</li>
<li><p><strong>All-in-one package.</strong> Speaker diarization + ITN + disfluency filtering are built-in. ElevenLabs has lower unit costs in some tiers but requires custom implementations for ITN and disfluency filtering. Google had reported stability issues with diarization.</p>
</li>
<li><p><strong>Natural integration with our BullMQ architecture.</strong> We can pass the <code>CalendarEvent</code> attendee count as <code>spk_count</code> to improve diarization accuracy automatically.</p>
</li>
</ol>
<p>We chose <strong>Batch STT over Streaming STT</strong> because batch mode processes the full audio file, producing significantly better speaker diarization. Streaming mode must make real-time speaker assignments without full context, leading to label swaps and split-speaker errors — unacceptable for multi-participant meeting minutes.</p>
<h3>Streaming File Transfer: R2 → VITO</h3>
<p>When feeding audio from R2 to VITO's API, we use <strong>stream piping</strong> instead of buffering the file into server memory:</p>
<table>
<thead>
<tr>
<th>Concurrent Users</th>
<th>Buffer Approach</th>
<th>Streaming Approach</th>
</tr>
</thead>
<tbody><tr>
<td>10 (60 MB each)</td>
<td>600 MB</td>
<td>~KB</td>
</tr>
<tr>
<td>50</td>
<td>3 GB</td>
<td>~KB</td>
</tr>
<tr>
<td>100</td>
<td>6 GB → OOM risk</td>
<td>~KB</td>
</tr>
</tbody></table>
<p>The implementation uses <code>https.request()</code> + <code>form.pipe()</code> to stream directly from R2's response to VITO's upload endpoint. We couldn't use Node.js's native <code>fetch</code> for this because it doesn't support <code>Readable</code> stream bodies — a notable limitation that required dropping down to the <code>https</code> module.</p>
<p>This design lets us set BullMQ concurrency aggressively, independent of available server RAM.</p>
<h3>Async Polling Without Blocking Workers</h3>
<p>VITO's Batch API is asynchronous: you submit audio, receive a job ID, and must poll for completion. VITO doesn't support webhooks, so polling is the only option.</p>
<p>The naive approach — <code>while (!done) { await sleep(30s); check(); }</code> — locks a BullMQ worker slot for the entire 10–25 minute transcription duration. That's a severe bottleneck.</p>
<p>Our solution: <strong>BullMQ delayed jobs as a state machine.</strong></p>
<ol>
<li><p><code>submit-transcription</code> job → sends file to VITO → returns immediately (slot freed).</p>
</li>
<li><p>Schedules a <code>check-transcription</code> job with a 30-second delay.</p>
</li>
<li><p><code>check-transcription</code> runs → queries VITO once → if not done, schedules another check → returns immediately.</p>
</li>
<li><p>When complete → parses segments → enqueues AI summary job.</p>
</li>
</ol>
<p>Each step occupies a worker for only a few seconds. 100 concurrent transcriptions? No problem — workers are never blocked.</p>
<h3>Normalized Segment Storage</h3>
<p>VITO's results are stored in two forms:</p>
<ul>
<li><p><code>segments</code> <strong>column (JSON):</strong> Structured data with speaker ID, start/end timestamps in milliseconds, and text. Used for speaker filtering in the UI, timestamp-based playback, and AI summary input.</p>
</li>
<li><p><code>transcript</code> <strong>column (plain text):</strong> Human-readable format like <code>[Speaker 1] 00:00:05 - Today's sprint review...</code>. Used for full-text search, previews, and Claude API input.</p>
</li>
</ul>
<p>We normalize VITO's field names into our own schema so that swapping the STT engine in the future won't break the downstream data contract.</p>
<hr />
<h2>Stage 5: AI Summary with Claude</h2>
<h3>The Summary Pipeline</h3>
<p>Once VITO produces the transcript, Claude (claude-sonnet-4-20250514) generates structured meeting minutes. We feed it the <code>transcript</code> column (plain text) rather than the <code>segments</code> JSON — the summary only needs speaker labels and utterances, not millisecond-precision timestamps. Plain text also yields a more concise prompt and lower token usage.</p>
<p>Claude returns a JSON object containing: meeting title, duration, attendees, overall summary (3–10 sentences), per-agenda discussion points with decisions, action items (with owners and deadlines), and key insights.</p>
<h3>Adaptive Tier System</h3>
<p>Early on, we noticed that a fixed-length prompt produced oddly uniform summaries: a 10-minute standup and a 2-hour strategy session both got the same depth of coverage. Agenda discussion points were being truncated for longer meetings.</p>
<p>We introduced a <strong>three-tier system based on transcript length:</strong></p>
<table>
<thead>
<tr>
<th>Transcript Length</th>
<th>Summary</th>
<th>Agenda Items</th>
</tr>
</thead>
<tbody><tr>
<td>&lt; 2,000 chars</td>
<td>2–3 sentences</td>
<td>1–2 items</td>
</tr>
<tr>
<td>2,000–8,000 chars</td>
<td>4–6 sentences with context</td>
<td>3–5 items</td>
</tr>
<tr>
<td>&gt; 8,000 chars</td>
<td>6–10 sentences with background, arguments, conclusions</td>
<td>5–10 items</td>
</tr>
</tbody></table>
<hr />
<h2>Stage 6: Real-Time Status via Centrifugo WebSocket</h2>
<h3>The Polling Problem</h3>
<p>The old system used React Query to poll the server every 5 seconds: <em>"Is it done yet?"</em> Over a 10–25 minute transcription, that's ~300 wasted API calls.</p>
<h3>Hybrid Approach: Push + Safety-Net Polling</h3>
<p>The primary update channel is now <strong>Centrifugo WebSocket</strong>. Each pipeline stage (merging → transcribing → summarizing → complete) pushes a status update to the user in real time.</p>
<p>We still maintain a <strong>safety-net poll</strong> — but at 30-second intervals instead of 5. If the WebSocket connection drops, the client falls back to aggressive 5–15 second polling based on the current pipeline state.</p>
<p>Result: API calls dropped from ~300 to ~50 per transcription cycle.</p>
<h3>Notification Routing</h3>
<ul>
<li><p><strong>Users viewing the recording detail page</strong> receive real-time UI updates via Centrifugo channel subscription.</p>
</li>
<li><p><strong>The person who initiated the recording</strong> receives a personal notification (toast + notification bell) regardless of where they are in the app — even in the desktop messenger client. Notifications are persisted in the database.</p>
</li>
<li><p><strong>Other meeting attendees</strong> are not notified in real time. They can access the completed transcript and summary through the meeting viewer.</p>
</li>
</ul>
<hr />
<h2>The Common Design Philosophy</h2>
<p>Looking across the three most impactful architectural decisions — R2 streaming pipe, 5-minute savepoints with presigned URLs, and BullMQ delayed-job polling — a shared principle emerges:</p>
<p><strong>As concurrency scales, resource consumption stays nearly constant.</strong></p>
<table>
<thead>
<tr>
<th>Pattern</th>
<th>Traditional Approach</th>
<th>Our Approach</th>
</tr>
</thead>
<tbody><tr>
<td>R2 → VITO file transfer</td>
<td>Memory grows linearly with concurrent users</td>
<td>Constant ~KB via stream piping</td>
</tr>
<tr>
<td>Browser recording + upload</td>
<td>Memory grows with recording duration; server bandwidth grows with users</td>
<td>Fixed ~10 MB in browser; zero server bandwidth</td>
</tr>
<tr>
<td>STT completion polling</td>
<td>Worker slots locked for minutes per job</td>
<td>Workers occupied for seconds; slots freed immediately</td>
</tr>
</tbody></table>
<p>This isn't premature optimization — it's the difference between a system that falls over at 50 concurrent users and one that handles 100+ without architectural changes.</p>
<hr />
<h2>Tech Stack</h2>
<table>
<thead>
<tr>
<th>Layer</th>
<th>Technology</th>
</tr>
</thead>
<tbody><tr>
<td>Frontend</td>
<td>MediaRecorder API, React Query</td>
</tr>
<tr>
<td>Storage</td>
<td>Cloudflare R2, Presigned URLs</td>
</tr>
<tr>
<td>Job Queue</td>
<td>BullMQ, Redis</td>
</tr>
<tr>
<td>Audio Processing</td>
<td>ffmpeg</td>
</tr>
<tr>
<td>Speech-to-Text</td>
<td>VITO sommers (Batch STT)</td>
</tr>
<tr>
<td>AI Summary</td>
<td>Claude Sonnet</td>
</tr>
<tr>
<td>Real-Time</td>
<td>Centrifugo WebSocket</td>
</tr>
<tr>
<td>Container</td>
<td>Separate web + worker deployments</td>
</tr>
</tbody></table>
<hr />
<h2>Results</h2>
<p>All six pipeline stages were completed and deployed as of March 25, 2026.</p>
<p><strong>Memory stability:</strong> Browser memory stays under ~10 MB regardless of recording duration. Tab crashes now lose at most 5 minutes of audio.</p>
<p><strong>STT quality:</strong> Korean recognition accuracy improved from ~85% to over 92%. Speaker diarization enables "who said what" — a feature that was previously impossible. No file-size limitations.</p>
<p><strong>User experience:</strong> Transcription and summarization run automatically after recording stops. Real-time status updates replaced 300 polling calls with ~50. Zero manual steps required.</p>
<p><strong>Server efficiency:</strong> File data never passes through the application server. Streaming transfer keeps server memory at ~KB even with 100 concurrent transcriptions. The architecture is designed to scale to 100K users.</p>
<hr />
<p><em>This post documents the recording system overhaul for the workb platform. All benchmarks, cost figures, and architecture decisions reflect the state of things as of March 2026.</em></p>
]]></content:encoded></item></channel></rss>