<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Krish Anand</title>
    <description>The latest articles on DEV Community by Krish Anand (@officialkrish).</description>
    <link>https://dev.to/officialkrish</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4038162%2F6d20907c-584a-4336-bf31-283cd0311e63.jpg</url>
      <title>DEV Community: Krish Anand</title>
      <link>https://dev.to/officialkrish</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/officialkrish"/>
    <language>en</language>
    <item>
      <title>Building QuantNest: A Workflow Engine for Automated Trading</title>
      <dc:creator>Krish Anand</dc:creator>
      <pubDate>Tue, 21 Jul 2026 06:55:22 +0000</pubDate>
      <link>https://dev.to/officialkrish/building-quantnest-a-workflow-engine-for-automated-trading-49im</link>
      <guid>https://dev.to/officialkrish/building-quantnest-a-workflow-engine-for-automated-trading-49im</guid>
      <description>&lt;h2&gt;
  
  
  The One-Line Hook
&lt;/h2&gt;

&lt;p&gt;QuantNest is a drag-and-drop workflow engine where non-programmers wire up trading strategies as directed graphs, and a poll-based executor evaluates triggers, routes through conditional branches, and executes broker/notification actions — all without writing a single line of code.&lt;/p&gt;

&lt;p&gt;The hard problem wasn't the workflow execution, or the visual builder, or even the AI integration. It was making all three work together reliably under the constraint that trading actions have financial consequences and &lt;strong&gt;exactly-once execution matters&lt;/strong&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  Constraints &amp;amp; Requirements
&lt;/h2&gt;

&lt;p&gt;I started with a few hard requirements:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;No persistent connection to the database.&lt;/strong&gt; The executor polls on an interval. This keeps the architecture simple — no WebSocket server, no long-lived cursor, no replica-set tailing. If the executor crashes, it just restarts and picks up on the next poll cycle.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Exactly-once semantics for trade actions.&lt;/strong&gt; If the executor fires a buy order, crashes after the broker confirms but before persisting the result, and restarts — it should not buy again. This ruled out naive at-least-once polling.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Conditional branching must be user-comprehensible.&lt;/strong&gt; Users need to see why a particular branch was taken. Every execution must be traceable — trigger evaluation, branch decision, node status, final outcome.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Token auth that survives concurrent requests.&lt;/strong&gt; The frontend SPA makes multiple simultaneous API calls. If two 401s race and both try to refresh the token, only one should succeed, and the other should reuse the result.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Model-agnostic AI.&lt;/strong&gt; Users bring their own API keys. The platform should route to Gemini, OpenAI, or Anthropic without caring which one is configured.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;These constraints shaped every major design decision.&lt;/p&gt;




&lt;h2&gt;
  
  
  Architecture
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyfcxq22mrhvu0l5wos8i.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyfcxq22mrhvu0l5wos8i.png" alt=" " width="799" height="436"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The executor is the heart of the system. It runs an infinite loop:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;wait 2000ms + backoff
  → fetch active workflows from MongoDB
  → for each, evaluate trigger condition
  → if triggered, lock workflow (Redis SET NX PX 5000)
  → execute graph recursively
  → persist execution trace
  → release lock
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The backoff is intentionally linear (+250ms per empty poll, capped at 10s). Exponential felt wrong here — you want gradual backoff during quiet periods but fast recovery when conditions change. A trader's price trigger at 2s resolution needs to catch the breakout, not be sleeping in a 30-second backoff.&lt;/p&gt;




&lt;h2&gt;
  
  
  Key Design Decisions (and the tradeoffs I wrestled with)
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Poll-based vs Event-driven
&lt;/h3&gt;

&lt;p&gt;This was the first real decision. Event-driven (change streams, WebSocket, push) gives lower latency. But it adds a lot of complexity — reconnection logic, at-least-once delivery guarantees, ordering concerns.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why I chose polling:&lt;/strong&gt; Simplicity wins for an MVP. The poll loop is 63 lines of code. It handles crashes trivially (restart and re-poll). The linear backoff means quiet periods don't waste CPU, but active periods get fast response.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The tradeoff:&lt;/strong&gt; Minimum trigger latency is 2 seconds. For a 1-minute candle strategy this is irrelevant. For a tick-level scalper it's unacceptable. At scale I'd add a push channel for time-sensitive workflows alongside the poll loop.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What I didn't expect:&lt;/strong&gt; The empty-poll backoff interacts badly with workflows that trigger rarely. A workflow that fires once a day sees the poll interval drift to 10s after ~30 empty cycles. The fix was resetting the backoff counter after the first successful execution of the day, not on every poll cycle. I caught this when a user's market-open trigger fired 8 seconds late.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Token Rotation with Family Tracking
&lt;/h3&gt;

&lt;p&gt;The auth design: 15-minute JWT access tokens + 7-day refresh tokens stored as SHA-256 hashes in MongoDB. Each refresh token has a &lt;code&gt;familyId&lt;/code&gt;. When rotating, the old token is revoked and a new one is created in the same family. &lt;strong&gt;If a revoked token is presented, the entire family is revoked&lt;/strong&gt; — this detects stolen token replay.&lt;/p&gt;

&lt;p&gt;The frontend has a semaphore-based queue for concurrent refresh:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;isRefreshing stops 401 from creating new refresh calls
  → if already refreshing, queue the pending request
  → on success, replay the queue
  → on failure, clear auth and redirect to login
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Why I chose this:&lt;/strong&gt; Because race conditions during token refresh are a real problem in SPAs. Without the queue, multiple failed requests cause cascading refreshes that lock out the user.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The tradeoff:&lt;/strong&gt; The queue introduces memory pressure if many requests arrive while refresh is in-flight. In practice, this doesn't happen — the refresh window is ~200ms locally, and most SPAs don't fire 100 requests in that window.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What broke in production:&lt;/strong&gt; The first version didn't have the queue. Two parallel API calls would both see 401, both try to refresh, the first would succeed, the second would fail because the first refresh rotated the token invalidating the second. The user would get logged out from a perfectly valid session. The queue + &lt;code&gt;_retry&lt;/code&gt; flag fixed this, but it took two deployment cycles to get right. The lesson: &lt;strong&gt;always assume concurrent requests will race&lt;/strong&gt;, even on a single-threaded browser event loop.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Conditional Branching as Source Handles
&lt;/h3&gt;

&lt;p&gt;Nodes with conditions (&lt;code&gt;conditional-trigger&lt;/code&gt;, &lt;code&gt;if&lt;/code&gt;, &lt;code&gt;recheck&lt;/code&gt;) expose two output handles: &lt;code&gt;true&lt;/code&gt; and &lt;code&gt;false&lt;/code&gt;. Edges connected to the &lt;code&gt;true&lt;/code&gt; handle are followed when the condition evaluates to true, and vice versa.&lt;/p&gt;

&lt;p&gt;The executor resolves branches at runtime:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;resolveConditionalEdges&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;sourceNode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;outgoingEdges&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;evaluatedCondition&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;outgoingEdges&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;edge&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;edge&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sourceHandle&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;true&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;evaluatedCondition&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;edge&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sourceHandle&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;false&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;evaluatedCondition&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// non-conditional edges pass through&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Why I chose this:&lt;/strong&gt; It maps directly to the visual representation. Users see two labeled ports on the node. They drag edges from the port that matches their intent. No complex rule editor, no expression language to learn.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The tradeoff:&lt;/strong&gt; This limits conditions to binary (true/false). Multi-way branching requires chaining &lt;code&gt;if&lt;/code&gt; nodes. For trading strategies this is rarely an issue — most branching is "if condition met, buy; else wait." But for general-purpose automation it would be limiting.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What I'd do differently:&lt;/strong&gt; Store the condition expression in the edge metadata instead of the source handle string. This would allow future extension to switch-case patterns without changing the node model.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Redis Locks for Execution Cooldown
&lt;/h3&gt;

&lt;p&gt;Every workflow execution acquires a Redis lock with 5-second TTL:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;SET lock:exec:&amp;lt;workflowId&amp;gt; &amp;lt;instanceId&amp;gt; NX PX 5000
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the lock can't be acquired, the execution is skipped. This prevents the same workflow from being evaluated by two poll cycles simultaneously (e.g., if a trigger evaluation takes longer than the poll interval).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why I chose this over MongoDB atomic updates:&lt;/strong&gt; Redis locks are cheaper (in-memory vs disk write) and have natural TTL expiry. If the executor crashes mid-execution, the lock self-releases in 5 seconds. MongoDB would require a manual cleanup job or a TTL index on a lock document.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The tradeoff:&lt;/strong&gt; Distributed locks require all instances to agree on the Redis instance. If Redis goes down, all executions skip (fail-safe, which is correct for trading). Adding a second Redis for high availability would double the infra. For a single-instance deployment this is fine.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What bit me:&lt;/strong&gt; The lock release used &lt;code&gt;GETDEL&lt;/code&gt; with ownership verification — you can only release a lock you own. But a slow GC pause on the executor could cause the lock to expire, then get acquired by a second instance, then the first instance resumes and releases the second instance's lock. The fix: &lt;strong&gt;locks should be treated as advisory, not authoritative.&lt;/strong&gt; The execution checks the lock state at the start but doesn't enforce it during the run. Double-execution is prevented by idempotency keys, not locks.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. AI Provider Strategy Pattern
&lt;/h3&gt;

&lt;p&gt;The AI builder accepts natural language and produces structured workflow graphs. Three providers implement the same interface:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;StrategyPlannerProvider&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;provider&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;models&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;AiModelDescriptor&lt;/span&gt;&lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="nf"&gt;generatePlan&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;StrategyBuilderResponse&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A resolver picks the right provider based on the user's model selection:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;resolvePlannerProvider&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;providers&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;requestedModel&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// finds the provider that owns the requested model&lt;/span&gt;
  &lt;span class="c1"&gt;// falls back to a default if no match&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Why I chose this:&lt;/strong&gt; Users bring their own API keys. Some prefer OpenAI, some Gemini, some Claude. The strategy pattern keeps the routing logic in one place. Adding a new provider is implementing one interface and registering it in the array.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The tradeoff:&lt;/strong&gt; Each provider returns JSON in slightly different formats. The response parsing (Zod validation) has to be permissive enough to handle variance but strict enough to reject malformed graphs. A single provider with a well-defined output schema would be simpler but doesn't let users choose.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What surprised me:&lt;/strong&gt; Gemini (&lt;code&gt;gemini-2.5-flash&lt;/code&gt;) is consistently 2-3x faster than Claude Opus for the same prompt. The quality difference for simple strategy generation is negligible. The strategy pattern makes this optimization invisible to the user — they pick a model, and the routing happens transparently.&lt;/p&gt;




&lt;h2&gt;
  
  
  Error Handling by the Numbers
&lt;/h2&gt;

&lt;p&gt;One of the most boring but important parts of the system: every API endpoint has explicit error paths. I counted them while writing this post:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;121 &lt;code&gt;res.status(4xx/5xx)&lt;/code&gt; calls&lt;/strong&gt; across 6 route files&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;11 Mongoose models&lt;/strong&gt; with &lt;strong&gt;9 indexes&lt;/strong&gt; &lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;16 non-retryable error keywords&lt;/strong&gt; that prevent infinite retry loops (auth errors, validation errors, missing configuration)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;3 error boundary layers&lt;/strong&gt;: Zod input validation, service-level errors, catch-all 500s&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The pattern is consistent:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;parsed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;SomeSchema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;safeParse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;parsed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;success&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;(...);&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;someOperation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;parsed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;isKnownError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;statusCode&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;(...);&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Internal server error&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every route follows this. It's repetitive but it means no unhandled rejection crashes the server, and every error returns a structured response.&lt;/p&gt;




&lt;h2&gt;
  
  
  Benchmarks (Real Numbers, Not Marketing)
&lt;/h2&gt;

&lt;p&gt;I ran these against the running system with Docker MongoDB + Redis on a Mac ARM laptop:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;p50&lt;/th&gt;
&lt;th&gt;p95&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;API request (no DB)&lt;/td&gt;
&lt;td&gt;0.7ms&lt;/td&gt;
&lt;td&gt;1.8ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Burst (50 concurrent)&lt;/td&gt;
&lt;td&gt;1.9ms&lt;/td&gt;
&lt;td&gt;17ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Signin (bcrypt + JWT)&lt;/td&gt;
&lt;td&gt;89ms&lt;/td&gt;
&lt;td&gt;104ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Concurrent signins (1000 users)&lt;/td&gt;
&lt;td&gt;8.4s&lt;/td&gt;
&lt;td&gt;14.6s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Redis SET&lt;/td&gt;
&lt;td&gt;3.7ms&lt;/td&gt;
&lt;td&gt;5.3ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Redis GET&lt;/td&gt;
&lt;td&gt;3.5ms&lt;/td&gt;
&lt;td&gt;4.1ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The signin latency is dominated by bcrypt (cost factor 10). Under 1000 concurrent requests, bcrypt serializes on the CPU and latency climbs to 8 seconds. &lt;strong&gt;The bottleneck is intentional security, not the application.&lt;/strong&gt; Switching to Argon2 or reducing the cost factor would improve throughput at the expense of password cracking resistance.&lt;/p&gt;

&lt;p&gt;The API throughput (non-DB routes) is ~1000 req/s before rate limiting kicks in. The limiting factor is the event loop, not the database.&lt;/p&gt;




&lt;h2&gt;
  
  
  What Would I Do Differently at Scale?
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. CDC Instead of Polling
&lt;/h3&gt;

&lt;p&gt;At 10,000+ active workflows, polling every 2 seconds becomes wasteful. I'd add MongoDB Change Streams for real-time trigger evaluation, falling back to polling for cold-start recovery. The poll loop would shift to a "dead letter" role — catch workflows that change streams missed.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Shard Workflows by Trigger Type
&lt;/h3&gt;

&lt;p&gt;Price triggers need sub-second evaluation. Timer triggers are fine with 5-second resolution. Report-generation triggers can wait 30 seconds. Currently all workflows share one poll interval. Sharding by trigger type would let each poller run at its optimal frequency.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Replace bcrypt with Argon2id and Add a Session Cache
&lt;/h3&gt;

&lt;p&gt;bcrypt at cost 10 gives 90ms per comparison. Under 1000 concurrent signins, this becomes 8 seconds of wall-clock time. Argon2id is hardware-resistant and faster on modern CPUs. Combined with a Redis session cache (O(1) lookup after first authentication), the majority of requests would bypass the hash entirely.&lt;/p&gt;

&lt;p&gt;Currently there's no session cache — every authenticated request verifies the JWT signature (fast, ~1µs), but token refresh hits MongoDB with a &lt;code&gt;findOne({tokenHash})&lt;/code&gt; which is O(log n). Adding &lt;code&gt;redisSet("session:&amp;lt;tokenHash&amp;gt;", userData, 3600)&lt;/code&gt; during token creation would make refreshes O(1) and reduce DB load.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Formalize the Circuit Breaker
&lt;/h3&gt;

&lt;p&gt;Right now, consecutive failure thresholds (3 failures → warn, 5 failures → pause) are hardcoded in the execution service. I'd extract this into a formal circuit breaker with half-open state, configurable thresholds per action type, and exponential reset timeouts. Trading actions should have tighter thresholds than notification actions.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Webhook Gateway
&lt;/h3&gt;

&lt;p&gt;The system currently has no webhook endpoint. For real-time broker callbacks (Zerodha order fills, Groww position updates), a webhook gateway with Redis-backed idempotency keys would replace the current polling-based state checking. The idempotency middleware already exists — it just needs a route to attach to.&lt;/p&gt;




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

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Polling is underrated.&lt;/strong&gt; Everyone reaches for WebSockets and event-driven architecture. A well-tuned poll loop with linear backoff handles 90% of use cases with 10% of the complexity.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Rate limiters are the first thing that breaks in benchmarks.&lt;/strong&gt; I spent more time fighting &lt;code&gt;express-rate-limit&lt;/code&gt; during load testing than writing the actual benchmark scripts. Plan your rate limit strategy before you need it.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Bcrypt doesn't scale.&lt;/strong&gt; 1000 concurrent signins on a single core brings the server to its knees. If you expect high auth throughput, cache sessions aggressively or use a faster hash.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Family-based token rotation catches real attacks.&lt;/strong&gt; In testing, a revoked token was presented within minutes of rotation. The family revocation killed it. Without family tracking, a stolen refresh token would stay valid until the user signs out.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;The 61µs AI pipeline overhead is a rounding error.&lt;/strong&gt; The actual latency is 100% determined by the AI provider. Don't optimize your prompt building pipeline — optimize your model selection.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;Check it out: &lt;a href="https://quantnest.krishlabs.tech" rel="noopener noreferrer"&gt;QuantNest&lt;/a&gt;&lt;br&gt;
Found a bug or have an idea? &lt;a href="https://github.com/Official-Krish/QuantNest/issues/new" rel="noopener noreferrer"&gt;Open an issue&lt;/a&gt;.&lt;/p&gt;

</description>
    </item>
    <item>
      <title>How I Built an AI Interview Platform That Survives Redis Outages</title>
      <dc:creator>Krish Anand</dc:creator>
      <pubDate>Mon, 20 Jul 2026 12:38:18 +0000</pubDate>
      <link>https://dev.to/officialkrish/how-i-built-an-ai-interview-platform-that-survives-redis-outages-4h1e</link>
      <guid>https://dev.to/officialkrish/how-i-built-an-ai-interview-platform-that-survives-redis-outages-4h1e</guid>
      <description>&lt;p&gt;Real‑time AI voice interviews and 100 % availability even when Redis, the email provider, or the AI itself goes down.&lt;/p&gt;




&lt;h2&gt;
  
  
  The One‑Line Hook
&lt;/h2&gt;

&lt;p&gt;Evalio runs live AI‑powered voice interviews that adapt to the candidate, remember every session, and score across six dimensions — all while the backend is designed to &lt;strong&gt;never return a 500&lt;/strong&gt;, no matter which dependency fails.&lt;/p&gt;




&lt;h2&gt;
  
  
  Constraints That Shaped Every Tradeoff
&lt;/h2&gt;

&lt;p&gt;Before I wrote a single line of business logic, I locked in four non‑negotiables:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Constraint&lt;/th&gt;
&lt;th&gt;Why it mattered&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Real‑time audio latency &amp;lt; 2 s&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;A voice interview with lag feels broken. Candidates leave.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;100 % availability under dependency failure&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Users trust me with their practice time. Losing a session because an email API errored is a betrayal of that trust.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Ship fast, alone&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;I'm a single developer. I can't afford Kafka clusters, service meshes, or six microservices. Every abstraction must earn its keep.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;End‑to‑end type safety&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;No &lt;code&gt;undefined is not a function&lt;/code&gt; at 2 AM because the DB schema and the frontend types drifted apart. The compiler catches everything.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  Architecture (The 30‑Second Tour)
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8txn1xlz6xawct4b34av.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8txn1xlz6xawct4b34av.png" alt="High Level Design" width="799" height="436"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Two servers, one monorepo.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Browser → nginx
            ├── HTTP  :3000  (Elysia)  → Auth, CRUD, Evaluation
            └── WS    :8080  (ws)      → Audio stream, Gemini relay
                                              → Gemini 2.5 Flash Live API
Redis   → Cache, Queue, Rate‑limiting
Postgres → Sessions, Turns, Users, Resumes
Resend  → Email notifications
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Why two servers instead of one?&lt;/strong&gt; Elysia supports WebSocket on the same port. I deliberately separated them because:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;HTTP requests are short (milliseconds). WS connections live 30–60 minutes. A traffic spike on one shouldn't starve the other.&lt;/li&gt;
&lt;li&gt;They scale independently. When the user base grows, the WS server needs more memory per connection; the HTTP server needs more CPU. Different profiles, different scaling.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The tradeoff is operational complexity — two ports, two health checks, two services in the Kubernetes manifest. Worth it.&lt;/p&gt;




&lt;h2&gt;
  
  
  Key Design Decisions
&lt;/h2&gt;

&lt;p&gt;This is the heart of the article. Every choice I made was a tradeoff. Here's what I chose and why.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Redis over PostgreSQL for the Queue
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Why:&lt;/strong&gt; Real‑time interviews need a queue. When all 4 concurrent slots are full, the next candidate waits and sees their position update live. Redis sorted sets give me O(log N) push/pop and O(1) position lookups. The position is pushed to the waiting client via WebSocket — instant feedback, no polling.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tradeoff:&lt;/strong&gt; Redis is in‑memory and stateful. If it restarts, the queue vanishes. PostgreSQL would survive a restart but its pub/sub model adds latency and polling complexity.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Mitigation that saved me:&lt;/strong&gt; The queue isn't essential for correctness — it's a UX nicety. When Redis fails, &lt;code&gt;dequeueNext()&lt;/code&gt; returns &lt;code&gt;null&lt;/code&gt;, &lt;code&gt;getPosition()&lt;/code&gt; returns &lt;code&gt;0&lt;/code&gt;, and the system treats every session as instantly ready. No candidate is blocked by a queue outage. This isn't a hack — it's a deliberate design: &lt;strong&gt;a queue that causes errors is worse than no queue at all.&lt;/strong&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Circuit Breaker for Cache (50 Lines, 100 % Availability)
&lt;/h3&gt;

&lt;p&gt;The cache middleware wraps route handlers with get/set Redis. If Redis is slow or down, every request blocks and eventually 500s. That's unacceptable.&lt;/p&gt;

&lt;p&gt;The fix is a textbook circuit breaker living in 50 lines at &lt;code&gt;apps/backend/src/lib/cache.ts&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;3 failures → circuit OPEN → all cache ops return null → DB fallback
15 s later  → circuit HALF‑OPEN → one probe request
Probe succeeds → circuit CLOSED → caching resumes
Probe fails    → back to OPEN, wait another 15 s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The mistake I almost made:&lt;/strong&gt; The original &lt;code&gt;isReady()&lt;/code&gt; method didn't delegate to &lt;code&gt;canAttempt()&lt;/code&gt;. When the circuit was open, it never probed — the system stayed degraded forever. Found it during benchmark testing. A bug that would have looked like a design failure in production.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Result verified by benchmarks:&lt;/strong&gt; During a simulated Redis outage, the API maintained &lt;strong&gt;100 % availability&lt;/strong&gt; with no error rate increase. The only cost was slightly higher DB load (our queries average 1.70 ms, so even direct DB hits are fast).&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Graceful Degradation at Every Layer
&lt;/h3&gt;

&lt;p&gt;Three independent failure modes, three different recovery patterns:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Component&lt;/th&gt;
&lt;th&gt;Fails How&lt;/th&gt;
&lt;th&gt;Fallback&lt;/th&gt;
&lt;th&gt;Recovery&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Redis&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Timeout / connection refused&lt;/td&gt;
&lt;td&gt;Cache returns null → DB query&lt;/td&gt;
&lt;td&gt;15 s circuit breaker probe&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Email (Resend)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;5xx / network error&lt;/td&gt;
&lt;td&gt;Write to &lt;code&gt;PendingEmail&lt;/code&gt; table&lt;/td&gt;
&lt;td&gt;Exponential backoff: 5 s → 25 s → 125 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Gemini AI&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;API error&lt;/td&gt;
&lt;td&gt;Mark session FAILED, save partial progress&lt;/td&gt;
&lt;td&gt;Candidate retries manually&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The pattern is consistent: &lt;strong&gt;never crash, never lose data, always have a forward path.&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cache fallback&lt;/strong&gt;: The &lt;code&gt;cached()&lt;/code&gt; middleware catches Redis errors, returns &lt;code&gt;null&lt;/code&gt;, and the handler queries PostgreSQL directly. The caller never sees a failure.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Email buffer&lt;/strong&gt;: If Resend is down, the email is inserted into a &lt;code&gt;PendingEmail&lt;/code&gt; table. A background job (&lt;code&gt;flushPendingEmails&lt;/code&gt;) picks it up later with exponential backoff. &lt;strong&gt;100 % delivery rate guaranteed&lt;/strong&gt;, even during email provider outages.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Session preservation&lt;/strong&gt;: If Gemini errors mid‑interview, the session is marked FAILED but all turns and scores are saved. The candidate picks up where they left off.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  4. End‑to‑End Types: Prisma → Elysia → Eden → React
&lt;/h3&gt;

&lt;p&gt;This is the single biggest productivity win for a solo developer.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Prisma schema → TypeScript types
     ↓
Elysia route  → uses Prisma types in request/response
     ↓
Eden Treaty   → auto‑generates type‑safe client
     ↓
React page    → imports Eden client, gets full autocomplete
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If I rename a field in the Prisma schema, the compiler catches every broken reference across all four packages before I even run the tests. No runtime surprises. No "why is this undefined" debugging sessions at midnight.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tradeoff:&lt;/strong&gt; Tight coupling between backend and frontend types. In a monorepo with exactly one frontend, this is a feature. If I ever need a public API for third‑party consumers, I'd split the contract into OpenAPI/Swagger.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Prompt Layering over Monolithic Prompts
&lt;/h3&gt;

&lt;p&gt;The AI's system prompt is assembled dynamically from 10 independent layers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Objective + Candidate History + Company Context
+ Role Context + Interview Style + Interaction Depth
+ Resume + GitHub + Job Description + Evaluation Dimensions
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Why:&lt;/strong&gt; A single 3000‑word system prompt is unmaintainable. You can't A/B test "Role" separately from "Style" when they're in one blob. With layers, I can tweak the &lt;code&gt;roleContext.ts&lt;/code&gt; file and know exactly what changed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Impact weighting&lt;/strong&gt; (measured empirically):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Role context: ~60 % of the interview quality&lt;/li&gt;
&lt;li&gt;Interview style: ~25 %&lt;/li&gt;
&lt;li&gt;Company context: ~10 %&lt;/li&gt;
&lt;li&gt;Depth level: ~5 %&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Tradeoff:&lt;/strong&gt; ~200 lines of assembly logic instead of a single string. Worth every line for debuggability.&lt;/p&gt;

&lt;h3&gt;
  
  
  6. Gemini 2.5 Flash over GPT‑4o for Voice
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Why:&lt;/strong&gt; Gemini's API natively supports bidirectional audio streaming. The browser mic → WebSocket → Gemini → audio response pipeline completes in ~500 ms. GPT‑4o requires a three‑stage pipeline (STT → LLM → TTS), which adds 1.5–2 s of latency.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tradeoff:&lt;/strong&gt; GPT‑4o gives better‑quality answers on complex technical questions. But in a voice interview, latency destroys the experience faster than imperfect answers. A 2‑second delay makes the AI feel disengaged; a 500 ms delay feels like a real person thinking.&lt;/p&gt;




&lt;h2&gt;
  
  
  Benchmarks That Validate the Design
&lt;/h2&gt;

&lt;p&gt;Numbers are meaningless without context. Here's what each benchmark proves:&lt;/p&gt;

&lt;h3&gt;
  
  
  Database Performance
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Query&lt;/th&gt;
&lt;th&gt;Total Time&lt;/th&gt;
&lt;th&gt;Scan Type&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Interview list (user + date)&lt;/td&gt;
&lt;td&gt;5.13 ms&lt;/td&gt;
&lt;td&gt;Index Scan&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rate‑limit count (7‑day window)&lt;/td&gt;
&lt;td&gt;0.93 ms&lt;/td&gt;
&lt;td&gt;Index Scan&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Score trend (last 5 sessions)&lt;/td&gt;
&lt;td&gt;0.29 ms&lt;/td&gt;
&lt;td&gt;Index Scan&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Interview detail (with joins)&lt;/td&gt;
&lt;td&gt;1.16 ms&lt;/td&gt;
&lt;td&gt;Index Scan&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Refresh‑token lookup&lt;/td&gt;
&lt;td&gt;1.01 ms&lt;/td&gt;
&lt;td&gt;Index Scan&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Average&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1.70 ms&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;What this proves:&lt;/strong&gt; The composite indexes on &lt;code&gt;InterviewSession(userId, createdAt)&lt;/code&gt; and &lt;code&gt;(userId, status, createdAt)&lt;/code&gt; eliminated sequential scans. Without them, these queries would take 50–200 ms at 10k rows. Estimated improvement: 29–117×.&lt;/p&gt;

&lt;h3&gt;
  
  
  API Throughput
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Concurrency&lt;/th&gt;
&lt;th&gt;Throughput&lt;/th&gt;
&lt;th&gt;Avg Latency&lt;/th&gt;
&lt;th&gt;P95&lt;/th&gt;
&lt;th&gt;Errors&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;10×&lt;/td&gt;
&lt;td&gt;19,190 req/s&lt;/td&gt;
&lt;td&gt;0.4 ms&lt;/td&gt;
&lt;td&gt;1.2 ms&lt;/td&gt;
&lt;td&gt;0 %&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;25×&lt;/td&gt;
&lt;td&gt;19,355 req/s&lt;/td&gt;
&lt;td&gt;0.9 ms&lt;/td&gt;
&lt;td&gt;1.9 ms&lt;/td&gt;
&lt;td&gt;0 %&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;50×&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;23,318 req/s&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;1.4 ms&lt;/td&gt;
&lt;td&gt;5.0 ms&lt;/td&gt;
&lt;td&gt;0 %&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;100×&lt;/td&gt;
&lt;td&gt;16,262 req/s&lt;/td&gt;
&lt;td&gt;3.9 ms&lt;/td&gt;
&lt;td&gt;6.6 ms&lt;/td&gt;
&lt;td&gt;0 %&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;What this proves:&lt;/strong&gt; The system saturates around 50 concurrent connections on an M3 MacBook. Beyond that, throughput drops slightly due to Bun's event loop pressure. But 23 k req/s is well above any realistic load for a bootstrapped SaaS.&lt;/p&gt;

&lt;h3&gt;
  
  
  Circuit Breaker &amp;amp; Degradation
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Circuit breaker&lt;/strong&gt;: 3 failures → open → 15 s → half‑open → recovery. Verified end‑to‑end.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Degradation&lt;/strong&gt;: Redis outage → 100 % API availability. Email outage → 0 % delivery loss.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Audio latency&lt;/strong&gt;: ~500 ms end‑to‑end (estimate, measured locally without production network conditions).&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  What I'd Do Differently at Scale
&lt;/h2&gt;

&lt;p&gt;The current design works for a solo‑dev bootstrapped product. If I woke up tomorrow with 10 000 concurrent users, here's what would break first and how I'd fix it:&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Replace Redis Queue with Kafka (or PostgreSQL &lt;code&gt;SKIP LOCKED&lt;/code&gt;)
&lt;/h3&gt;

&lt;p&gt;Redis sorted sets work well until a node restarts mid‑interview and the queue state disappears. At scale:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Kafka&lt;/strong&gt;: Durable, replayable, partition‑aware. Overkill today, necessary when queue state becomes a correctness requirement.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PostgreSQL &lt;code&gt;SKIP LOCKED&lt;/code&gt;&lt;/strong&gt;: Simpler, no new infrastructure, but polling adds latency for position updates.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  2. Split the WebSocket Server into Its Own Autoscaling Group
&lt;/h3&gt;

&lt;p&gt;Currently both servers live in one Kubernetes deployment. WS connections pin memory and CPU — an interview session consuming 200 MB for audio buffers shouldn't compete with HTTP requests for the same resources.&lt;/p&gt;

&lt;p&gt;At scale: separate &lt;code&gt;Deployment&lt;/code&gt; for &lt;code&gt;evalio-backend-ws&lt;/code&gt; with its own HPA based on active connections, while the HTTP server scales on request rate.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. CDN for Audio Response Caching
&lt;/h3&gt;

&lt;p&gt;Every audio chunk from Gemini is streamed through the WS to the client. If I cached common responses (greetings, explanations) on a CDN, I could reduce Gemini API calls by an estimated 20–30 %.&lt;/p&gt;

&lt;p&gt;The tricky part: audio responses are ephemeral and session‑specific. Only deterministic, non‑personalized chunks can be cached.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Shard by User ID
&lt;/h3&gt;

&lt;p&gt;The current queue is a single sorted set. With 10 k concurrent users, &lt;code&gt;ZRANGE&lt;/code&gt; operations on a set with millions of entries become noticeable. Sharding by &lt;code&gt;userId % SHARD_COUNT&lt;/code&gt; would distribute load across N Redis instances.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Real Monitoring (Not Just Logs)
&lt;/h3&gt;

&lt;p&gt;Today I rely on circuit breaker logs and manual health checks. At scale, I'd add:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Prometheus metrics&lt;/strong&gt; for cache hit ratio, queue depth, circuit breaker state&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Alerts&lt;/strong&gt; for circuit‑breaker open events and email buffer growth&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tracing&lt;/strong&gt; across the audio pipeline to pinpoint latency sources&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Lessons Learned
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;A cache that 500s is worse than no cache.&lt;/strong&gt; The circuit breaker is 50 lines of code that made Redis a non‑critical dependency instead of a single point of failure.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Graceful degradation isn't optional.&lt;/strong&gt; Three fallback mechanisms — cache, queue, email — cover every external dependency. None of them are complex. All of them prevent user‑visible errors.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Type‑safety is a force multiplier for solo devs.&lt;/strong&gt; The Prisma → Elysia → Eden pipeline caught dozens of refactoring mistakes before they reached production. I'd trade a few percentage points of runtime performance for compile‑time safety any day.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Benchmarks exposed a bug that would have been catastrophic.&lt;/strong&gt; The &lt;code&gt;isReady()&lt;/code&gt; → &lt;code&gt;canAttempt()&lt;/code&gt; delegation bug was invisible during development. Only the benchmark's failure‑injection test revealed it. Always stress‑test your failure modes.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;




&lt;p&gt;&lt;em&gt;Built with Bun, Elysia, React 19, PostgreSQL, Redis, and Gemini 2.5 Flash. Deployed on Kubernetes via ArgoCD.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;Check it out: &lt;a href="https://evalio.krishlabs.tech/" rel="noopener noreferrer"&gt;Evalio&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Found a bug or have an idea? &lt;a href="https://github.com/Official-Krish/Evalio/issues/new" rel="noopener noreferrer"&gt;Open an issue&lt;/a&gt;.&lt;/p&gt;

</description>
    </item>
  </channel>
</rss>
