<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Rohit Saini</title><link>https://rohitsaini.me/</link><description>Backend engineer. Distributed systems, search infrastructure, streaming pipelines, and applied AI.</description><language>en-us</language><lastBuildDate>Mon, 03 Aug 2026 03:10:56 +0530</lastBuildDate><atom:link href="https://rohitsaini.me/rss.xml" rel="self" type="application/rss+xml"/><item><title>Blindspot, Part 3: $0.0215 a Report, and the Things That Broke</title><link>https://rohitsaini.me/writing/blindspot-running-it/</link><guid>https://rohitsaini.me/writing/blindspot-running-it/</guid><pubDate>Mon, 03 Aug 2026 12:00:00 +0530</pubDate><description>Budgets checked between stages, idempotent Instagram DM delivery, and two production failures: a gitignore comment that ignored nothing, and exit code 139 from an architecture mismatch.</description><content:encoded xmlns:content="http://purl.org/rss/1.0/modules/content/">&lt;p&gt;&lt;em&gt;Part 3 of three on &lt;a href="https://blindspot.buzz"&gt;Blindspot&lt;/a&gt;. &lt;a href="https://rohitsaini.me/writing/blindspot-reading-the-video/"&gt;Part 1: reading the video&lt;/a&gt; · &lt;a href="https://rohitsaini.me/writing/blindspot-extraction-and-evidence/"&gt;Part 2: extraction and evidence&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;The analysis pipeline is the interesting half. The half that decides whether it survives contact with real traffic is cheaper to describe and more expensive to get wrong.&lt;/p&gt;
&lt;h3 id="check-the-budget-between-stages-not-after"&gt;Check the budget between stages, not after&lt;/h3&gt;
&lt;p&gt;Every model call records usage and estimated cost, and a per-report budget is checked &lt;em&gt;between&lt;/em&gt; pipeline stages. If research exhausts it, the system stops gathering evidence and records that coverage may be incomplete. It does not keep going until the bill happens to stop.&lt;/p&gt;
&lt;p&gt;Measured on real Reels:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th style="text-align: right"&gt;cost&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Lean profile, per checkable Reel&lt;/td&gt;
&lt;td style="text-align: right"&gt;~$0.015&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Strong profile&lt;/td&gt;
&lt;td style="text-align: right"&gt;$0.20–0.25&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Built-in web search, per call&lt;/td&gt;
&lt;td style="text-align: right"&gt;~$0.01&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Evidence retrieval dominates, because retrieved documents arrive as context tokens.&lt;/p&gt;
&lt;p&gt;The local database currently holds 32 completed reports across 13 unique posts, plus one failed job. Those runs total &lt;strong&gt;$0.6884&lt;/strong&gt;, an average of &lt;strong&gt;$0.0215&lt;/strong&gt; per report.&lt;/p&gt;
&lt;p&gt;Everything else exists to keep that number down: exact-report caching, media caching so reposts aren&amp;rsquo;t retranscribed, triage stopping non-factual input early, a rolling daily budget that can pause the worker, per-user quotas, endpoint rate limits. Promptry records model, prompt version, token counts, latency, and cost for every invocation.&lt;/p&gt;
&lt;h3 id="a-crashed-job-should-not-crash-you-forever"&gt;A crashed job should not crash you forever&lt;/h3&gt;
&lt;p&gt;The worker polls a SQLite-backed job queue. On startup it marks orphaned running jobs as failed rather than replaying them.&lt;/p&gt;
&lt;p&gt;A job that reliably crashes the process must not be allowed to keep crashing it. Replay-on-boot turns one poison message into an outage.&lt;/p&gt;
&lt;h3 id="delivery-has-to-be-idempotent-because-the-platform-isnt"&gt;Delivery has to be idempotent, because the platform isn&amp;rsquo;t&lt;/h3&gt;
&lt;p&gt;Website and Instagram DM share one analysis pipeline but differ at admission and delivery. On a Meta message webhook, Blindspot validates and deduplicates the event, resolves the shared Reel or carousel, reserves the sender&amp;rsquo;s quota, acknowledges, enqueues, then sends a compact result and a public link when it finishes.&lt;/p&gt;
&lt;p&gt;Instagram delivers duplicate webhooks. Users share a second post while the first is still running. So message state is persisted and every step is idempotent: acknowledgement, processing, retry, final send. Failed delivery retries up to five times before being marked terminal.&lt;/p&gt;
&lt;p&gt;Report links carry opaque attribution tokens, which connect later feedback to a delivery without exposing the Instagram sender on a public page.&lt;/p&gt;
&lt;h3 id="boring-infrastructure-two-sharp-edges"&gt;Boring infrastructure, two sharp edges&lt;/h3&gt;
&lt;p&gt;It all runs on a shared Oracle ARM host behind Cloudflare and nginx: Next.js app, worker, FFmpeg, ingestion tools, and SQLite on one machine, because SQLite needs local shared storage.&lt;/p&gt;
&lt;p&gt;The worker doesn&amp;rsquo;t hot-reload. Editing pipeline code while the web app is running leaves the interface on new code and the worker on old code, which produces results that make no sense until you remember why. &lt;code&gt;serve.sh&lt;/code&gt; supervises both, but pipeline changes still need a worker restart.&lt;/p&gt;
&lt;p&gt;Two failures worth writing down.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Exit code 139, no error message.&lt;/strong&gt; A Rosetta x86 Node process was loading an arm64 &lt;code&gt;better-sqlite3&lt;/code&gt; binary. Scripts now run through &lt;code&gt;pnpm exec node&lt;/code&gt; so the Node architecture matches the installed native module.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;A &lt;code&gt;.gitignore&lt;/code&gt; that ignored nothing.&lt;/strong&gt; The file contained:&lt;/p&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt;/data # database
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Git treats the comment as part of the pattern, so it was looking for a directory literally named &lt;code&gt;data # database&lt;/code&gt;. The real database and secrets directories were never ignored. Comments go on their own lines now.&lt;/p&gt;
&lt;h3 id="what-i-dont-have-yet"&gt;What I don&amp;rsquo;t have yet&lt;/h3&gt;
&lt;p&gt;The evaluation harness scores stored reports without new API calls. Fixtures carry expected material claims, acceptable verdict sets, minimum evidence requirements, and known missing context.&lt;/p&gt;
&lt;p&gt;The target is 50 reviewed Reels, 10 each of directly false claims, real media in the wrong context, defensible-but-selective framing, health/scam/finance, and ambiguous developing stories.&lt;/p&gt;
&lt;p&gt;The fixture set currently contains four.&lt;/p&gt;
&lt;p&gt;Labels can&amp;rsquo;t come from Blindspot&amp;rsquo;s own reports; a reviewer has to establish them from primary sources, and anything political, communal, health, finance, or accusation-related needs a second reviewer. The comparison that matters is strong extraction plus lean research against all-lean, scored on material-claim recall, citation precision, source quality, verdict acceptability, confidence calibration, latency, cost, and unsafe overclaim rate. A confident unsafe error counts for more than an ordinary omission.&lt;/p&gt;
&lt;p&gt;Blindspot is live, the pipeline runs end to end, the reports are shareable. What I don&amp;rsquo;t have is a representative error rate.&lt;/p&gt;
&lt;p&gt;Four reviewed Reels can find bugs. They can&amp;rsquo;t establish trust.&lt;/p&gt;</content:encoded></item><item><title>Blindspot, Part 2: Extraction Sets the Ceiling</title><link>https://rohitsaini.me/writing/blindspot-extraction-and-evidence/</link><guid>https://rohitsaini.me/writing/blindspot-extraction-and-evidence/</guid><pubDate>Mon, 03 Aug 2026 11:00:00 +0530</pubDate><description>A weak extraction model found 1 of 7 checkable claims in a Reel. No amount of good research recovers a claim that never entered the request. Four rules about where the quality actually comes from.</description><content:encoded xmlns:content="http://purl.org/rss/1.0/modules/content/">&lt;p&gt;&lt;em&gt;Part 2 of three on &lt;a href="https://blindspot.buzz"&gt;Blindspot&lt;/a&gt;. &lt;a href="https://rohitsaini.me/writing/blindspot-reading-the-video/"&gt;Part 1: reading the video&lt;/a&gt; · &lt;a href="https://rohitsaini.me/writing/blindspot-running-it/"&gt;Part 3: cost, queues, and delivery&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;I expected evidence retrieval to be the main quality problem. The first measurements pointed one stage earlier.&lt;/p&gt;
&lt;p&gt;On a single Reel, a nano extraction model found 1 of 7 material claims. A stronger model found all seven, and flagged the central framing problem on top: the Reel presented a contested claim still under inquiry as conclusively established.&lt;/p&gt;
&lt;p&gt;No amount of excellent research recovers a claim that never entered the evidence request.&lt;/p&gt;
&lt;h3 id="rule-1-a-reel-is-not-a-spoken-article"&gt;Rule 1: a Reel is not a spoken article&lt;/h3&gt;
&lt;p&gt;The naive design treats a Reel as an article read aloud. Download, transcribe, fact-check the transcript.&lt;/p&gt;
&lt;p&gt;But meaning is split across speech, on-screen text, the caption, charts visible for one second, the choice of footage, the publication date, and editing that changes which comparison the viewer sees.&lt;/p&gt;
&lt;p&gt;A clip can be authentic while its caption supplies a false location. A number can be correct while the denominator is missing. One Reel can hold seven factual claims, two opinions, and a causal allegation no cited source establishes.&lt;/p&gt;
&lt;p&gt;So extraction is one multimodal structured call over transcript, caption, post metadata, and keyframes together, returning strict JSON: checkable claims, on-screen OCR, named entities, events worth retrieving on, and observable framing indicators.&lt;/p&gt;
&lt;p&gt;Parsed with &lt;code&gt;JSON.parse&lt;/code&gt;, never regex. Claims and framing stay in separate fields. &amp;ldquo;The payment was ₹10 crore&amp;rdquo; is checkable. &amp;ldquo;This proves negligence&amp;rdquo; is a different kind of claim, extracted only if the Reel actually asserts it as fact.&lt;/p&gt;
&lt;h3 id="rule-2-decide-what-not-to-spend-money-on"&gt;Rule 2: decide what not to spend money on&lt;/h3&gt;
&lt;p&gt;Most Instagram posts don&amp;rsquo;t need a factual investigation. Music, aesthetic edits, jokes, vlogs, and pure political opinion should not be forced into a fact-check verdict.&lt;/p&gt;
&lt;p&gt;A cheap triage stage runs after transcription and picks one of three routes:&lt;/p&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt;A no checkable claim ──► deterministic &amp;#34;nothing to check&amp;#34; report
B opinion / framing ──► observable framing only, skip retrieval
C factual claims ──► extraction, retrieval, synthesis
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Routes A and B are safety decisions before they are cost decisions. A system that always returns a decisive factual verdict will manufacture one for input that doesn&amp;rsquo;t support it.&lt;/p&gt;
&lt;h3 id="rule-3-retrieval-never-gets-a-verdict"&gt;Rule 3: retrieval never gets a verdict&lt;/h3&gt;
&lt;p&gt;Evidence is another provider seam: built-in web search, an external facts corpus of ~14,000 documents, or both. Blindspot batches every extracted signal into one request, and the service returns passages with provenance: source, URL, publication date, and which claim or keyframe they address.&lt;/p&gt;
&lt;p&gt;It is explicitly not allowed to decide whether the Reel is true.&lt;/p&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt;Blindspot Facts service
--------- -------------
read media, extract claims ───► retrieve relevant passages
send keyframes ───► match known images
synthesise report ◄─── return facts with provenance
assign verdicts never assigns a verdict
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;The point is debuggability. When a conclusion is wrong I can tell whether extraction missed the claim, retrieval returned weak evidence, or synthesis overreached. One end-to-end answer hides all three behind fluent prose.&lt;/p&gt;
&lt;h3 id="rule-4-tell-the-model-what-actually-happened"&gt;Rule 4: tell the model what actually happened&lt;/h3&gt;
&lt;p&gt;An early report claimed Instagram could not be accessed. Plausible-sounding limitation. Completely false. The pipeline had downloaded the video, extracted audio, read keyframes, and produced a transcript.&lt;/p&gt;
&lt;p&gt;What had actually happened was that web research found no corroboration. The model turned &amp;ldquo;search returned nothing&amp;rdquo; into &amp;ldquo;the media could not be fetched&amp;rdquo;, and filed non-evidence notes as sources.&lt;/p&gt;
&lt;p&gt;Synthesis input now carries &lt;code&gt;mediaAcquired&lt;/code&gt; and &lt;code&gt;keyframeCount&lt;/code&gt;, and the prompt states plainly what the pipeline completed. Missing external evidence and missing media are different sentences. Evidence entries must contain URLs; a null search result is represented as no external source found.&lt;/p&gt;
&lt;p&gt;Give the model operational state. Don&amp;rsquo;t expect it to infer operational state from incomplete context.&lt;/p&gt;
&lt;h3 id="verdicts-are-categories-not-a-score"&gt;Verdicts are categories, not a score&lt;/h3&gt;
&lt;p&gt;No truth percentage. A single number implies precision the evidence doesn&amp;rsquo;t have and erases disagreement between claims inside one Reel. Outcomes are &lt;code&gt;supported&lt;/code&gt;, &lt;code&gt;mixed_evidence&lt;/code&gt;, &lt;code&gt;misleading_context&lt;/code&gt;, &lt;code&gt;unsupported&lt;/code&gt;, &lt;code&gt;satire&lt;/code&gt;, &lt;code&gt;not_yet_verifiable&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Every factual conclusion points at cited evidence, and confidence is shown separately from the verdict. A supported claim resting on indirect reporting is not the same object as one backed by a primary document.&lt;/p&gt;
&lt;p&gt;The system is allowed to say it can&amp;rsquo;t verify something yet. Thin sourcing and developing events are normal input, not exceptions.&lt;/p&gt;
&lt;p&gt;Next: &lt;a href="https://rohitsaini.me/writing/blindspot-running-it/"&gt;what it costs, and the parts that break in production&lt;/a&gt;.&lt;/p&gt;</content:encoded></item><item><title>Blindspot, Part 1: Teaching a Pipeline to Watch the Whole Video</title><link>https://rohitsaini.me/writing/blindspot-reading-the-video/</link><guid>https://rohitsaini.me/writing/blindspot-reading-the-video/</guid><pubDate>Mon, 03 Aug 2026 10:00:00 +0530</pubDate><description>The first version processed a three-minute protest Reel successfully and missed the police crackdown at the end. Every frame it looked at came from the opening. Four rules came out of fixing that.</description><content:encoded xmlns:content="http://purl.org/rss/1.0/modules/content/">&lt;p&gt;&lt;em&gt;Part 1 of three on &lt;a href="https://blindspot.buzz"&gt;Blindspot&lt;/a&gt;. &lt;a href="https://rohitsaini.me/writing/blindspot-extraction-and-evidence/"&gt;Part 2: extraction and the evidence boundary&lt;/a&gt; · &lt;a href="https://rohitsaini.me/writing/blindspot-running-it/"&gt;Part 3: cost, queues, and delivery&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;I fed a three-minute protest Reel into the first version. It downloaded the video, transcribed the speech, pulled frames, searched the web, and produced a polished report.&lt;/p&gt;
&lt;p&gt;It had missed the police crackdown near the end.&lt;/p&gt;
&lt;p&gt;Every frame sent to the vision model came from the fast-cut opening. The pipeline reported success. It had never watched the second half, and nothing in the output said so.&lt;/p&gt;
&lt;p&gt;Blindspot turns a public Instagram Reel or post into a claim-level report with citations, missing context, and explicit confidence. It is an evidence-retrieval system, not a truth detector. This part covers the media stage, which is where that first failure lived.&lt;/p&gt;
&lt;h3 id="rule-1-never-leave-a-section-of-the-timeline-unseen"&gt;Rule 1: never leave a section of the timeline unseen&lt;/h3&gt;
&lt;p&gt;The original sampler took the first N scene changes. On a fast-cut intro the frame budget was gone before the video reached anything substantive, so the model saw twelve variations of the same three seconds.&lt;/p&gt;
&lt;p&gt;The fix was to stop looking for interesting frames and start guaranteeing coverage. &lt;code&gt;ffprobe&lt;/code&gt; reports duration, FFmpeg samples evenly across the whole thing:&lt;/p&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt;frame_count = clamp(round(duration_seconds / 14), 6, 16)
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Roughly one frame per 14 seconds, floor of 6, ceiling of 16, scaled to 768px wide. Scene detection survives only as a fallback when duration probing fails.&lt;/p&gt;
&lt;p&gt;The invariant changed from a heuristic about interestingness to a guarantee about coverage. Those fail very differently.&lt;/p&gt;
&lt;h3 id="rule-2-one-piece-of-content-has-two-identities"&gt;Rule 2: one piece of content has two identities&lt;/h3&gt;
&lt;p&gt;Blindspot keys its cache twice, because platform identity and content identity answer different questions.&lt;/p&gt;
&lt;p&gt;Platform identity is &lt;code&gt;instagram:&amp;lt;shortcode&amp;gt;&lt;/code&gt;. Exact post already analysed, return the report.&lt;/p&gt;
&lt;p&gt;Content identity is a SHA-256 of the media plus a 64-bit difference hash. SHA-256 catches byte-identical files. The perceptual hash survives re-encoding, resizing, and watermarks, so it recognises reposts of the same footage.&lt;/p&gt;
&lt;p&gt;On a media-cache hit, Blindspot reuses the transcript and the extracted visual claims. It re-analyses the caption, account, publication date, and framing every time.&lt;/p&gt;
&lt;p&gt;That split is the whole product. Authentic footage under a new false caption is the main case Blindspot exists to catch, so reusing the previous &lt;em&gt;verdict&lt;/em&gt; would defeat it. Reusing the transcript is free; reusing the conclusion is wrong.&lt;/p&gt;
&lt;p&gt;There&amp;rsquo;s also a &lt;code&gt;FORCE_REEXTRACT=1&lt;/code&gt; escape hatch. Without it, changing the extraction model and rerunning the same footage just benchmarks the old cached claims.&lt;/p&gt;
&lt;h3 id="rule-3-every-vendor-is-a-seam"&gt;Rule 3: every vendor is a seam&lt;/h3&gt;
&lt;p&gt;Local development runs &lt;code&gt;whisper.cpp&lt;/code&gt; with Metal acceleration: free per run, a few seconds per Reel on Apple Silicon. Production prefers Azure Speech for Hindi-English code-mixed audio, with Bhashini dual ASR able to run both language tracks in parallel for comparison. Groq and OpenAI sit behind them.&lt;/p&gt;
&lt;p&gt;All of it lives behind one transcription interface, selected by environment variable. Same for ingestion (&lt;code&gt;yt-dlp&lt;/code&gt;, then Apify), LLM calls, and evidence retrieval.&lt;/p&gt;
&lt;p&gt;This isn&amp;rsquo;t architectural taste. Instagram changes its access behaviour, speech vendors change models, rate limits move. Baking one vendor into the pipeline turns every provider problem into an application rewrite.&lt;/p&gt;
&lt;h3 id="rule-4-keep-the-derivatives-delete-the-source"&gt;Rule 4: keep the derivatives, delete the source&lt;/h3&gt;
&lt;p&gt;FFmpeg pulls 16kHz mono PCM audio alongside the frames. Reels with no audio track are valid input and must not fail the run. Photo and carousel posts take a parallel path where the slides are the keyframes.&lt;/p&gt;
&lt;p&gt;Once audio, keyframes, transcript, and hashes exist, the source video is deleted. Reports, transcripts, hashes, claims, citations, keyframes, and timings persist. The original media does not.&lt;/p&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt;URL ──► ingest ──► ffprobe ──► even-interval keyframes ──► hashes
│ │
└──► 16kHz mono audio ──► transcribe ───┴──► cache
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Cheap checks run before paid work, which is the only reason the cost numbers in Part 3 stay small.&lt;/p&gt;
&lt;p&gt;Next: &lt;a href="https://rohitsaini.me/writing/blindspot-extraction-and-evidence/"&gt;why extraction, not research, sets the ceiling on report quality&lt;/a&gt;.&lt;/p&gt;</content:encoded></item><item><title>Parakhi: Where Does Your ₹5 Actually Go?</title><link>https://rohitsaini.me/writing/parakhi/</link><guid>https://rohitsaini.me/writing/parakhi/</guid><pubDate>Sat, 01 Aug 2026 00:00:00 +0000</pubDate><description>Parakhi breaks an Indian product's MRP into what stays in India, what goes to tax, and what leaves the country. Every number is computed, sourced, and labelled with how much you should trust it.</description><content:encoded xmlns:content="http://purl.org/rss/1.0/modules/content/">&lt;p&gt;Type &amp;ldquo;Parle-G&amp;rdquo; into &lt;a href="https://parakhi.in"&gt;parakhi.in&lt;/a&gt; and you get a ₹5 biscuit packet taken apart: wheat, sugar, palm oil, the packaging film, GST at 18%, the retailer&amp;rsquo;s cut, the slice that leaves the country. Each row carries a source and a confidence dot.&lt;/p&gt;
&lt;p&gt;It works on 1002 products, 44 categories, and fuel across 12 states. Here is what it took to make those numbers defensible.&lt;/p&gt;
&lt;h3 id="the-rule-that-shaped-everything"&gt;The rule that shaped everything&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;The model never touches a number.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;That decision came first and everything else follows from it. An LLM can read a messy search box, guess which category a product belongs to, and normalise a brand name. It cannot be allowed to say &amp;ldquo;12% of this is imported&amp;rdquo;, because it will say it confidently, differently each time, with nothing behind it.&lt;/p&gt;
&lt;p&gt;So the arithmetic lives in &lt;code&gt;computeBaseline()&lt;/code&gt;, a pure function with no network calls in it. Same product in, same breakdown out, every time, forever.&lt;/p&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt; free text ─┐
barcode ──┼─► resolve BrandIndex fuzzy match
url ─┘ (LLM only as fallback)
│
▼
categorize keyword + HSN rules ──► one of 44
│ hand-written category templates
▼
┌──────────────────────┐
│ computeBaseline() │ pure function, no LLM
└──────────┬───────────┘
│
│ category template composition
│ + CBIC HSN→GST table (56 rows)
│ + Wikidata brand→parent→country
│ + Agmarknet daily mandi prices
│ + Open Food Facts label (confirms only)
▼
Postgres cache ──► ISR page
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;The 56-row GST table is typed by hand from CBIC notifications. The 44 category templates are each a couple of hours of reading trade-body reports. That work doesn&amp;rsquo;t scale and that is the point: it&amp;rsquo;s the part a model can&amp;rsquo;t do for you.&lt;/p&gt;
&lt;p&gt;Every number renders with a source tier and a confidence dot, so a figure backed by a government notification looks different on the page from one backed by an industry estimate. You can see which is which before you decide to believe it.&lt;/p&gt;
&lt;h3 id="problem-a-label-source-that-could-delete-an-ingredient"&gt;Problem: a label source that could delete an ingredient&lt;/h3&gt;
&lt;p&gt;Open Food Facts has real ingredient lists for a chunk of the catalog, which is better data than any template. The first version let the label override the template composition.&lt;/p&gt;
&lt;p&gt;Frooti broke it. Its mango pulp row didn&amp;rsquo;t fuzzy-match anything on the label, so it got dropped. The composition collapsed to sugar and water, both fully Indian, and the score shot up to 99%. The pipeline had made a product look &lt;em&gt;better&lt;/em&gt; by deleting its main ingredient.&lt;/p&gt;
&lt;p&gt;The fix is a one-way door. A label can add a &amp;ldquo;✓ on label&amp;rdquo; confirmation to a component. It can never remove one. On top of that, a generic-token blocklist, because &amp;ldquo;oil&amp;rdquo;, &amp;ldquo;powder&amp;rdquo; and &amp;ldquo;flavour&amp;rdquo; happily match across materials that have nothing to do with each other.&lt;/p&gt;
&lt;h3 id="problem-a-rate-limiter-that-limited-nothing"&gt;Problem: a rate limiter that limited nothing&lt;/h3&gt;
&lt;p&gt;Per-IP counts lived in an in-memory &lt;code&gt;Map&lt;/code&gt;. On Vercel each lambda gets its own memory, so the counter reset more or less at random.&lt;/p&gt;
&lt;p&gt;The global daily cap had a worse bug: it counted rows in the &lt;code&gt;LlmCall&lt;/code&gt; table, and the deterministic path never makes an LLM call. That counter sat at zero permanently. Anyone could hammer &lt;code&gt;/api/search&lt;/code&gt; and fill the database with junk products for free.&lt;/p&gt;
&lt;p&gt;Now there&amp;rsquo;s a &lt;code&gt;RateHit&lt;/code&gt; table in Postgres with an upsert-increment keyed on (scope, window). 10 new analyses per IP per hour, 500 site-wide per day, and the limit applies only on a cache miss. Looking at a product someone already analysed costs nothing and is never throttled.&lt;/p&gt;
&lt;h3 id="problem-migrations-through-a-connection-pooler"&gt;Problem: migrations through a connection pooler&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;prisma migrate deploy&lt;/code&gt; ran during the Vercel build, so schema changes shipped with the code that needed them. Neat, and it does not work. &lt;code&gt;migrate deploy&lt;/code&gt; can&amp;rsquo;t hold its advisory lock over Neon&amp;rsquo;s pgbouncer pooler, so every production deploy died on a P1002 timeout. One crashed run left lock &lt;code&gt;72707369&lt;/code&gt; held and blocked every migration after it. Migrating mid-build also kills the build&amp;rsquo;s own connection: &lt;code&gt;terminating connection due to administrator command&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Migrations now run in a GitHub Actions workflow over a direct, non-pooled connection, and they clear any stale backend sitting on the lock before starting. Deploys got boring again.&lt;/p&gt;
&lt;h3 id="problem-prerendering-1000-pages-through-a-pool-of-3"&gt;Problem: prerendering 1000 pages through a pool of 3&lt;/h3&gt;
&lt;p&gt;For SEO, &lt;code&gt;generateStaticParams&lt;/code&gt; returned every product slug. Prisma&amp;rsquo;s pool limit is 3 with a 10-second timeout. Builds crawled, then died around page 280, taking half an hour to fail.&lt;/p&gt;
&lt;p&gt;Hero products are prerendered now and everything else is on-demand ISR, cached in Postgres after the first hit. Builds finish in seconds, every page is still server-rendered and indexable, and the sitemap still lists all 1006 URLs.&lt;/p&gt;
&lt;h3 id="whats-live"&gt;What&amp;rsquo;s live&lt;/h3&gt;
&lt;p&gt;1002 products across 44 categories. 56 HSN→GST rows, ~140 brand-to-country mappings, 25 fuel buildup rows covering petrol and diesel in 12 states plus national LPG. Daily mandi prices from Agmarknet feed the agricultural inputs.&lt;/p&gt;
&lt;p&gt;The calibration harness runs against published anchors and currently reports GST exactness at 100%, label match at 84%, and every anchor inside its expected range.&lt;/p&gt;
&lt;p&gt;Cost per query is about $0.0001, with a hard $20/month circuit breaker that has never come close to tripping. The bill stays near zero because the expensive part, the arithmetic, doesn&amp;rsquo;t call anyone.&lt;/p&gt;
&lt;p&gt;Try it on something in your kitchen: &lt;a href="https://parakhi.in"&gt;parakhi.in&lt;/a&gt;.&lt;/p&gt;</content:encoded></item><item><title>ScamDB: The App Took a Weekend, The Data Took Three Months</title><link>https://rohitsaini.me/writing/scamdb/</link><guid>https://rohitsaini.me/writing/scamdb/</guid><pubDate>Wed, 29 Jul 2026 00:00:00 +0000</pubDate><description>I built a search engine for suspicious Indian phone numbers and UPI IDs. The hard part was never the app. It was that the number is always inside a screenshot.</description><content:encoded xmlns:content="http://purl.org/rss/1.0/modules/content/">&lt;p&gt;Someone in my family almost sent money to a &amp;ldquo;customs officer&amp;rdquo; who needed a clearance fee. The number was on WhatsApp. I googled it. Nothing. Two forums, one SEO farm, an answer from 2019.&lt;/p&gt;
&lt;p&gt;That&amp;rsquo;s the whole gap. Every scam number in India has already been reported by someone. It just isn&amp;rsquo;t searchable.&lt;/p&gt;
&lt;p&gt;So I built &lt;a href="https://scamdb.in"&gt;scamdb.in&lt;/a&gt;. Type a number or a UPI ID, see whether anyone has reported it.&lt;/p&gt;
&lt;p&gt;The app was a weekend. Next.js 16, Supabase, Google OAuth, moderation queue, SEO-friendly URLs like &lt;code&gt;/phone/9876543210&lt;/code&gt; so Google can actually index them. Boring, done, deployed.&lt;/p&gt;
&lt;p&gt;Then I opened it and it had 34 entries. A search engine with 34 rows is not a search engine. It&amp;rsquo;s a spreadsheet with a domain name.&lt;/p&gt;
&lt;p&gt;The next three months were entirely about data.&lt;/p&gt;
&lt;h3 id="the-pipeline-i-thought-i-was-building"&gt;The pipeline I thought I was building&lt;/h3&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt; r/indianscammers
│
▼
fetch posts ──► regex the number out ──► moderation ──► live
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Two hours of work. Ship it.&lt;/p&gt;
&lt;h3 id="what-actually-happened"&gt;What actually happened&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;~90% of posts came back &lt;code&gt;no_entities&lt;/code&gt;.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Not because the posts had no numbers. Because the number is in the screenshot.&lt;/p&gt;
&lt;p&gt;Nobody types &amp;ldquo;here is the number that scammed me: 98765 43210.&amp;rdquo; They post a WhatsApp screenshot. The number is 40 pixels tall, sitting in a green bubble, completely invisible to every regex I own.&lt;/p&gt;
&lt;p&gt;Text scraping was never going to work here. OCR wasn&amp;rsquo;t an optimization I could add later. It was the product.&lt;/p&gt;
&lt;h3 id="reddit-fought-back-on-every-front"&gt;Reddit fought back on every front&lt;/h3&gt;
&lt;p&gt;Before I could OCR anything I had to actually get the posts. This took an embarrassing number of attempts:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Anonymous &lt;code&gt;.json&lt;/code&gt; endpoints: &lt;strong&gt;403&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Browser-ish requests: you get the HTML webapp, not data.&lt;/li&gt;
&lt;li&gt;Move it to GitHub Actions so it runs on a schedule: datacenter IPs get rate-limited harder than my laptop did.&lt;/li&gt;
&lt;li&gt;RSS: works! Serves only new and hot. There is no historical anything.&lt;/li&gt;
&lt;li&gt;X/Twitter API free tier: &lt;strong&gt;402&lt;/strong&gt;. It&amp;rsquo;s write-only now. Reading costs $175/month.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The unblock was &lt;strong&gt;arctic-shift&lt;/strong&gt;, the Pushshift successor. Full Reddit archive, no auth, no rate limits, doesn&amp;rsquo;t care that I&amp;rsquo;m a datacenter, and it hands back direct full-res &lt;code&gt;i.redd.it&lt;/code&gt; URLs.&lt;/p&gt;
&lt;p&gt;First test: two screenshots in, nine numbers out. I stopped worrying about whether this would work.&lt;/p&gt;
&lt;h3 id="the-actual-pipeline"&gt;The actual pipeline&lt;/h3&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt; arctic-shift archive
│ paginate by created_utc
▼
image posts only ──► i.redd.it full-res URL
│
▼
┌──────────────┐
│ vision OCR │ Haiku, falls back to gpt-4o-mini
│ │ model output regex-validated, always
└──────┬───────┘
│
▼
raw_signals ──► deterministic extract ──► LLM enrich ──► moderation ──► live
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Every OCR result gets re-validated by regex on the way out. Vision models are very happy to hand you a number that is &lt;em&gt;almost&lt;/em&gt; the number in the image, and it looks fine until you dial it. So the transcription doesn&amp;rsquo;t count for anything on its own. Only what survives the pattern goes in.&lt;/p&gt;
&lt;h3 id="bugs-that-cost-me-real-weekends"&gt;Bugs that cost me real weekends&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Bug one: the crawler ran forever and made no progress.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Each run pulled the same images, OCR&amp;rsquo;d them, found nothing, threw them away, and did it again six hours later. The dedup check only knew about images that had &lt;em&gt;produced&lt;/em&gt; something. Dead images stayed permanently fresh.&lt;/p&gt;
&lt;p&gt;Fix: write a &amp;ldquo;tried this, got nothing&amp;rdquo; marker. You have to record the failures too, or you just keep paying for them.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Bug two: OpenAI&amp;rsquo;s 200K TPM limit.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Images are expensive in tokens. Bulk OCR hit the rate limit within a minute and spent the rest of the run 429-backing-off into a crawl. The correct fix was to pay for more throughput. I chose to let it accumulate slowly for free instead, which is a fix in the same way that &amp;ldquo;walking&amp;rdquo; is a fix for a broken car.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Bug three: my regex was importing America.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;The phone pattern matched the last 10 digits of &lt;em&gt;any&lt;/em&gt; number. &lt;code&gt;+1 863...&lt;/code&gt;, &lt;code&gt;+977...&lt;/code&gt;, all of it, cheerfully normalized into Indian mobile numbers. A US business had quietly become a suspicious Indian phone number in my database.&lt;/p&gt;
&lt;p&gt;Fix: a &lt;code&gt;(?&amp;lt;!\d)&lt;/code&gt; lookbehind so the match can&amp;rsquo;t start mid-number, and a hard reject on any &lt;code&gt;+&lt;/code&gt; prefix that isn&amp;rsquo;t &lt;code&gt;+91&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Bug four: the Twitter crawler, where every single entity was a false positive.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Not most. Every one. Flight numbers, order IDs, prices, dates. The signal-to-noise was so bad that the honest fix was &lt;code&gt;git rm&lt;/code&gt;. That commit message is still my favourite in the repo.&lt;/p&gt;
&lt;h3 id="where-it-is-now"&gt;Where it is now&lt;/h3&gt;
&lt;p&gt;1,124 entities. 1,115 phones, 9 UPI IDs, 2,798 raw signals collected, 525 reports approved.&lt;/p&gt;
&lt;p&gt;The 9 UPI IDs are not a bug. People screenshot the chat, not the payment page.&lt;/p&gt;
&lt;p&gt;Once the archive was swept clean, new posts dropped to single digits per week. That&amp;rsquo;s correct, and also boring, so I added two more sources.&lt;/p&gt;
&lt;p&gt;Reddit &lt;strong&gt;comments&lt;/strong&gt;: about 4% carry a number, pure text, zero vision cost. Every so often someone drops a bulk &amp;ldquo;here&amp;rsquo;s the list&amp;rdquo; comment, which is a very good day.&lt;/p&gt;
&lt;p&gt;Public aggregator pages: stored at low confidence, because they arrive with no story attached and I don&amp;rsquo;t know who put them there.&lt;/p&gt;
&lt;p&gt;Arctic-shift also 422s at random with &amp;ldquo;under maintenance.&amp;rdquo; Treat it exactly like a 429, retry, move on.&lt;/p&gt;
&lt;h3 id="the-honest-part"&gt;The honest part&lt;/h3&gt;
&lt;p&gt;999 reports are sitting in the moderation queue, unreviewed.&lt;/p&gt;
&lt;p&gt;I built the auto-publish safeguard, wrote the whole &amp;ldquo;every report needs a human&amp;rdquo; rule, then became the human. There has been no moderation action since June 7. The bottleneck in my carefully designed pipeline is a guy who also has a job.&lt;/p&gt;
&lt;p&gt;The other thing I&amp;rsquo;d say is that none of the interesting engineering was in the search. Search is a Postgres index. The interesting part was that the data had been sitting in public the entire time, in a format no computer could read.&lt;/p&gt;
&lt;p&gt;The number that started this is in there now. Third search result.&lt;/p&gt;</content:encoded></item><item><title>Brainrot: The Dumbest Video Format Taught Me the Most About Debugging</title><link>https://rohitsaini.me/writing/brainrot-debugging/</link><guid>https://rohitsaini.me/writing/brainrot-debugging/</guid><pubDate>Tue, 07 Jul 2026 00:00:00 +0000</pubDate><description>I automated those Reddit-story-over-Minecraft-parkour videos. The format is junk food. The bugs underneath were genuinely interesting.</description><content:encoded xmlns:content="http://purl.org/rss/1.0/modules/content/">&lt;p&gt;You&amp;rsquo;ve seen the format. Reddit story, robotic narrator, Minecraft parkour underneath, captions bouncing word by word. It floods Shorts because it works, and because someone, somewhere, is paying editors per video to make it.&lt;/p&gt;
&lt;p&gt;I automated the whole thing. Local TTS, free. Total cost per video: whatever one LLM call costs. Pennies.&lt;/p&gt;
&lt;h3 id="the-pipeline"&gt;The pipeline&lt;/h3&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt; mood (&amp;#34;funny&amp;#34;)
│
▼
┌─────────────┐
│ LLM writes │ 8-20s micro-story
│ the script │ two synced versions (spoken + display)
└──────┬──────┘
│
▼
┌─────────────┐
│ Kokoro │ 82M param TTS model
│ narrates │ Apache 2.0, runs on CPU, costs nothing
└──────┬──────┘
│
▼
┌─────────────┐
│ Whisper │ word-level timestamps
│ times it │ from the generated audio
└──────┬──────┘
│
▼
┌─────────────┐
│ libass │ karaoke captions
│ burns it │ word-by-word highlight
└──────┬──────┘
│
▼
┌─────────────┐
│ FFmpeg │ random gameplay slices
│ cuts it │ exact audio length, 1080x1920
└─────────────┘
&lt;/code&gt;&lt;/pre&gt;&lt;h3 id="the-one-design-decision-that-actually-mattered"&gt;The one design decision that actually mattered&lt;/h3&gt;
&lt;p&gt;Every story ships as &lt;strong&gt;two scripts&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;A spoken script where &amp;ldquo;$13.60&amp;rdquo; becomes &amp;ldquo;thirteen dollars and sixty cents&amp;rdquo;, because that&amp;rsquo;s what the narrator says. And a display script where it stays &amp;ldquo;$13.60&amp;rdquo;, because that&amp;rsquo;s what belongs on screen.&lt;/p&gt;
&lt;p&gt;Same sentences, same order, different formatting. Skip this and your captions drift the moment a number shows up.&lt;/p&gt;
&lt;h3 id="the-alignment-trick"&gt;The alignment trick&lt;/h3&gt;
&lt;p&gt;Trust Whisper&amp;rsquo;s ears, never its spelling.&lt;/p&gt;
&lt;p&gt;Whisper transcribed my narrator saying &amp;ldquo;al dente&amp;rdquo; as &amp;ldquo;al dent.&amp;rdquo; Doesn&amp;rsquo;t matter. I already know the script, I wrote it. So the transcript text gets thrown away entirely and only the timestamps survive. Those timestamps get matched back onto the real script with a diff.&lt;/p&gt;
&lt;p&gt;Sync stays word-perfect even when Whisper mishears.&lt;/p&gt;
&lt;p&gt;That was the fun part. Then the machine fought back.&lt;/p&gt;
&lt;h3 id="the-bugs-that-ate-my-weekend"&gt;The bugs that ate my weekend&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Bug one: the pipeline froze at 0% CPU.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Not slow. Parked. PyTorch (for the TTS) and CTranslate2 (for Whisper) each ship their own OpenMP runtime. When both load into one process, their thread pools deadlock. The famous &lt;code&gt;KMP_DUPLICATE_LIB_OK=TRUE&lt;/code&gt; workaround only silences the warning. macOS&amp;rsquo;s &lt;code&gt;sample&lt;/code&gt; tool showed the main thread suspended in &lt;code&gt;__kmp_suspend_64&lt;/code&gt;, waiting for a signal that never comes.&lt;/p&gt;
&lt;p&gt;Fix: force both libraries single-threaded before either one imports. Slower per step, but slow beats never.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Bug two: models I already downloaded still phoned home.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Hugging Face checks for updates on every load, and on my network that check just hung. &lt;code&gt;HF_HUB_OFFLINE=1&lt;/code&gt;, gone.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Bug three: Telegram delivery hung past its own timeout.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;The request had &lt;code&gt;timeout=120&lt;/code&gt;. It hung anyway. Turns out my network&amp;rsquo;s IPv6 route to &lt;code&gt;api.telegram.org&lt;/code&gt; is dead. TCP sits in &lt;code&gt;SYN_SENT&lt;/code&gt; at the OS level, below where Python&amp;rsquo;s timeout lives. &lt;code&gt;curl -6&lt;/code&gt;: hangs. &lt;code&gt;curl -4&lt;/code&gt;: instant. One monkeypatch to force IPv4 and delivery worked forever after.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;None of these bugs were in my code.&lt;/strong&gt; All of them ate hours. That&amp;rsquo;s the real cost of &amp;ldquo;local-first&amp;rdquo;: you inherit every layer under you.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;One more:&lt;/strong&gt; emoji in burned-in captions render as tofu boxes, because libass has no color-emoji fallback. They get stripped from the video and live on in the post caption instead. Nobody misses them.&lt;/p&gt;
&lt;h3 id="the-honest-part"&gt;The honest part&lt;/h3&gt;
&lt;p&gt;Renders take a couple of minutes each on CPU. The free LLM tier caps you around 20 videos a day. And the gameplay footage licensing is on you, &amp;ldquo;found it on YouTube&amp;rdquo; is not a license.&lt;/p&gt;
&lt;p&gt;Also, this format is junk food. I know. That&amp;rsquo;s why the interesting parts are underneath it.&lt;/p&gt;
&lt;p&gt;Code&amp;rsquo;s open, same repo as ReelGen: &lt;a href="https://github.com/rohitsaini1196/ReelGen"&gt;github.com/rohitsaini1196/ReelGen&lt;/a&gt;&lt;/p&gt;</content:encoded></item><item><title>ReelGen: Stop Scrolling Stock Footage, Start Shipping Reels</title><link>https://rohitsaini.me/writing/reelgen/</link><guid>https://rohitsaini.me/writing/reelgen/</guid><pubDate>Mon, 06 Jul 2026 00:00:00 +0000</pubDate><description>I got tired of AI reels that look like AI reels. So I built a pipeline that actually cares about color, pacing, and footage quality.</description><content:encoded xmlns:content="http://purl.org/rss/1.0/modules/content/">&lt;p&gt;Every AI-generated Reel looks the same. Same overused Pexels clips, same robotic zoom, same text that screams &amp;ldquo;a bot made this.&amp;rdquo;&lt;/p&gt;
&lt;p&gt;Got tired of it. Built something that doesn&amp;rsquo;t.&lt;/p&gt;
&lt;p&gt;Topic in, finished reel out. Color-graded, captioned, ready to post. About a penny per run.&lt;/p&gt;
&lt;h3 id="how-it-works"&gt;How it works&lt;/h3&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt; &amp;#34;Hidden cafes in Tokyo&amp;#34;
│
▼
┌──────────────┐
│ LLM Plans │ hook, beats, closer
│ the Story │ mood arc, color palette
└──────┬───────┘ caption + hashtags
│
▼
┌──────────────┐
│ CLIP Finds │ semantic match
│ the Footage │ not keyword luck
└──────┬───────┘
│
▼
┌──────────────┐
│ FFmpeg Does │ color grading
│ the Grading │ Ken Burns + handheld jitter
└──────┬───────┘
│
▼
┌──────────────┐
│ Pillow Adds │ animated text
│ the Text │ mood-matched font + style
└──────┬───────┘
│
▼
reel.mp4
1080×1920, IG-ready
~$0.01
&lt;/code&gt;&lt;/pre&gt;&lt;h3 id="the-actual-problem-with-ai-video-tools"&gt;The actual problem with AI video tools&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Zero taste.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Keyword search grabs the first &amp;ldquo;beach sunset&amp;rdquo; it finds. Warm or cold, sharp or mushy, doesn&amp;rsquo;t matter. No color consistency across clips. No pacing, either hard cuts everywhere or dissolves everywhere, never a mix that feels human-edited.&lt;/p&gt;
&lt;p&gt;Fixing that turned out to be a plumbing problem, not a model-size problem.&lt;/p&gt;
&lt;h3 id="what-i-did-differently"&gt;What I did differently&lt;/h3&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt;┌─────────────────────────────────────────────────┐
│ │
│ TWO HOOKS, ONE KEEPS │
│ LLM writes two opening angles. │
│ Whichever finds better footage wins. │
│ │
├─────────────────────────────────────────────────┤
│ │
│ SCORE EVERYTHING, FOUR WAYS │
│ │
│ Every candidate clip gets ranked on: │
│ │
│ ✓ Semantic match (CLIP) │
│ ✓ Sharpness │
│ ✓ Palette fit │
│ ✓ Motion stability │
│ │
│ Pick the winner BEFORE settling. │
│ │
├─────────────────────────────────────────────────┤
│ │
│ WEAK MATCH? TRY AGAIN, DIFFERENTLY │
│ │
│ Abstract caption like &amp;#34;you hit your limit&amp;#34; │
│ has no literal visual. LLM rewrites it to │
│ something more concrete. If footage still │
│ can&amp;#39;t carry it, text gets a stronger visual │
│ anchor automatically. │
│ │
└─────────────────────────────────────────────────┘
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;None of that needed a bigger model. Just better plumbing.&lt;/p&gt;
&lt;h3 id="the-honest-part"&gt;The honest part&lt;/h3&gt;
&lt;p&gt;Free-tier LLM quotas cap out fast. I burned a full day&amp;rsquo;s limit just testing this thing.&lt;/p&gt;
&lt;p&gt;And good editing doesn&amp;rsquo;t guarantee views. Posting cadence and trending audio still matter more than the cut. This makes reels you won&amp;rsquo;t be embarrassed to post. It doesn&amp;rsquo;t make them go viral for you.&lt;/p&gt;
&lt;h3 id="try-it"&gt;Try it&lt;/h3&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;python reelgen.py generate &lt;span class="se"&gt;\
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; --topic &lt;span class="s2"&gt;&amp;#34;Hidden cafes in Tokyo&amp;#39;s backstreets&amp;#34;&lt;/span&gt; &lt;span class="se"&gt;\
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; --mood calm &lt;span class="se"&gt;\
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; --style warm-analog
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Everything runs locally. No cloud rendering, no subscription.&lt;/p&gt;
&lt;p&gt;Code&amp;rsquo;s open: &lt;a href="https://github.com/rohitsaini1196/ReelGen"&gt;github.com/rohitsaini1196/ReelGen&lt;/a&gt;&lt;/p&gt;</content:encoded></item><item><title>The Claude Code Tabs You Forget About</title><link>https://rohitsaini.me/writing/claude-code-session-tracker/</link><guid>https://rohitsaini.me/writing/claude-code-session-tracker/</guid><pubDate>Sat, 23 May 2026 00:00:00 +0000</pubDate><description>I kept losing Claude Code sessions that were waiting on my reply. So I built a tiny local dashboard that surfaces them.</description><content:encoded xmlns:content="http://purl.org/rss/1.0/modules/content/">&lt;p&gt;If you run more than a couple of Claude Code sessions at once, you know the feeling. You kick off work in one tab, switch to another while it thinks, then a third, and twenty minutes later you&amp;rsquo;ve lost track of which one finished and is sitting there, quietly waiting on your reply.&lt;/p&gt;
&lt;p&gt;I kept losing those tabs. So I built &lt;a href="https://github.com/rohitsaini1196/claude-session-tracker"&gt;claude-session-tracker&lt;/a&gt;, a small, local dashboard that surfaces the sessions waiting on you or gone silent.&lt;/p&gt;
&lt;p&gt;&lt;img src="https://rohitsaini.me/demo.gif" alt="Claude Session Tracker Demo"&gt;&lt;/p&gt;
&lt;h3 id="why-not-just-use-agent-view"&gt;Why not just use Agent View?&lt;/h3&gt;
&lt;p&gt;Claude Code ships with Agent View (&lt;code&gt;claude agents&lt;/code&gt;), and it&amp;rsquo;s good, but it tracks &lt;strong&gt;background&lt;/strong&gt; sessions, the ones you launch with &lt;code&gt;--bg&lt;/code&gt;. It doesn&amp;rsquo;t see the normal interactive tabs you open in VS Code and forget about. Those are exactly the ones I lose.&lt;/p&gt;
&lt;p&gt;So this fills the gap: it watches the regular sessions, not the dispatched ones.&lt;/p&gt;
&lt;h3 id="the-mechanism-the-file-is-the-truth"&gt;The mechanism: the file is the truth&lt;/h3&gt;
&lt;p&gt;The whole thing rests on one decision: &lt;strong&gt;a local file is the source of truth, and the server only reads it.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Three Claude Code hooks each append a single JSON line to &lt;code&gt;~/.claude-sessions/events.jsonl&lt;/code&gt;:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;SessionStart&lt;/code&gt;: a session began&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Stop&lt;/code&gt;: Claude finished its turn, meaning it&amp;rsquo;s now waiting on you&lt;/li&gt;
&lt;li&gt;&lt;code&gt;PostToolUse&lt;/code&gt;: a liveness pulse to show the session is actively working&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;That&amp;rsquo;s it. The hooks do nothing but append one line, locally, with no network calls. The dashboard server reads that file and derives the state.&lt;/p&gt;
&lt;p&gt;The payoff is that the server is completely disposable. If it&amp;rsquo;s off, the hooks keep writing and it catches up on the next read. &lt;strong&gt;Zero data loss, zero added latency to your turns.&lt;/strong&gt; A network call inside a hook would have meant silent data loss whenever the server was down, which wasn&amp;rsquo;t a trade-off I was willing to make.&lt;/p&gt;
&lt;p&gt;It&amp;rsquo;s also privacy-minimized at the source: the hooks write only a session id, the working directory, an event type, and a timestamp. Never your prompts, your code, or Claude&amp;rsquo;s replies. It&amp;rsquo;s safe to point at a private repo.&lt;/p&gt;
&lt;h3 id="honest-about-what-it-cant-know"&gt;Honest about what it can&amp;rsquo;t know&lt;/h3&gt;
&lt;p&gt;The status isn&amp;rsquo;t binary, and that&amp;rsquo;s deliberate. A session &amp;ldquo;waiting on you&amp;rdquo; is reliable, because &lt;code&gt;Stop&lt;/code&gt; fires at the true end of a turn. But a session that&amp;rsquo;s gone quiet? It might be stalled, or it might just be busy inside one slow tool. You genuinely can&amp;rsquo;t tell those apart from the outside.&lt;/p&gt;
&lt;p&gt;So instead of guessing, the dashboard grades its confidence: &lt;code&gt;possibly stuck&lt;/code&gt; after a short quiet period, &lt;code&gt;likely stalled&lt;/code&gt; after a longer one. It tells you what it actually knows, and admits what it doesn&amp;rsquo;t.&lt;/p&gt;
&lt;h3 id="the-nice-touches"&gt;The nice touches&lt;/h3&gt;
&lt;p&gt;Once the data was there, a few things made it feel good to use. You can click a row to focus that specific VS Code window (only if it&amp;rsquo;s already open, so it never spawns a new one), see a 30-minute activity sparkline per session, and check a rough &amp;ldquo;engaged time&amp;rdquo; to see where your attention actually went.&lt;/p&gt;
&lt;p&gt;It&amp;rsquo;s open source, MIT, and runs on the Python standard library, meaning there&amp;rsquo;s absolutely nothing to &lt;code&gt;pip install&lt;/code&gt;.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;git clone https://github.com/rohitsaini1196/claude-session-tracker
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;cd&lt;/span&gt; claude-session-tracker
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;./install.sh &lt;span class="c1"&gt;# wires the hooks&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;./run.sh &lt;span class="c1"&gt;# dashboard at localhost:8787&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The dashboard is for the tabs you&amp;rsquo;d otherwise lose. Everything else can wait.&lt;/p&gt;</content:encoded></item><item><title>Meta-Dev-CLI: Stop Clicking, Start Typing</title><link>https://rohitsaini.me/writing/meta-dev-cli-stop-clicking-start-typing/</link><guid>https://rohitsaini.me/writing/meta-dev-cli-stop-clicking-start-typing/</guid><pubDate>Thu, 02 Apr 2026 00:00:00 +0000</pubDate><description>Why I built a CLI for Meta's WhatsApp API to keep developers (and AI agents) in their flow state.</description><content:encoded xmlns:content="http://purl.org/rss/1.0/modules/content/">&lt;p&gt;If you’ve ever had to work with Meta’s Developer Dashboard to manage WhatsApp Business Accounts (WABA), you know the &amp;ldquo;click-fest&amp;rdquo; I’m talking about.&lt;/p&gt;
&lt;p&gt;Finding a specific Phone Number ID, grabbing a temporary access token, or just sending a simple test message involves navigating through multiple tabs, models, and nested menus. It’s a context-switch nightmare.&lt;/p&gt;
&lt;p&gt;That’s why I built &lt;a href="https://github.com/rohitsaini1196/meta-dev-cli"&gt;meta-dev-cli&lt;/a&gt;.&lt;/p&gt;
&lt;h3 id="the-stay-in-the-terminal-philosophy"&gt;The &amp;ldquo;Stay in the Terminal&amp;rdquo; Philosophy&lt;/h3&gt;
&lt;p&gt;Developers (at least the ones I like talking to) want to stay in their flow state. That means staying in VS Code or the terminal.&lt;/p&gt;
&lt;p&gt;The goal of this CLI is simple: &lt;strong&gt;Whatever you can do in the dashboard, you should be able to do with one command.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Want to send a message?&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;meta wa send +1234567890 &lt;span class="s2"&gt;&amp;#34;Hey, it works! 🚀&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Need to check your WABA status or phone numbers?&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;meta wa phone-numbers
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;It’s fast, it’s low-friction, and it doesn&amp;rsquo;t require a browser.&lt;/p&gt;
&lt;h3 id="agent-first-design"&gt;Agent-First Design&lt;/h3&gt;
&lt;p&gt;One of the most important features of &lt;code&gt;meta-dev-cli&lt;/code&gt; is that it wasn&amp;rsquo;t just built for humans. I built it with &lt;strong&gt;AI agents&lt;/strong&gt; in mind—like Cursor, Claude Code, or GitHub Copilot.&lt;/p&gt;
&lt;p&gt;Every single command supports a &lt;code&gt;--json&lt;/code&gt; flag.&lt;/p&gt;
&lt;p&gt;When you run &lt;code&gt;meta apps list --json&lt;/code&gt;, you get a structured payload that an LLM can actually parse and act upon. This makes it incredibly easy to &amp;ldquo;hire&amp;rdquo; an agent to manage your Meta apps, automate deployments, or even build a custom monitoring dashboard.&lt;/p&gt;
&lt;h3 id="why-does-this-matter"&gt;Why does this matter?&lt;/h3&gt;
&lt;p&gt;Tools shouldn&amp;rsquo;t just provide functionality; they should reduce cognitive load.&lt;/p&gt;
&lt;p&gt;By pulling the Meta ecosystem into the terminal, we’re not just saving a few clicks—we’re making the entire development lifecycle more predictable and automatable.&lt;/p&gt;
&lt;p&gt;If you’re working with the WhatsApp Cloud API, give it a spin:
&lt;code&gt;pip install -e .&lt;/code&gt; (from the &lt;a href="https://github.com/rohitsaini1196/meta-dev-cli"&gt;repo&lt;/a&gt;)&lt;/p&gt;
&lt;p&gt;Let’s keep the dashboard for settings we change once a year, and use the terminal for everything else.&lt;/p&gt;</content:encoded></item><item><title>Live Kitchen Feeds: Transparency Without the Cloud Bill</title><link>https://rohitsaini.me/writing/live-kitchen-feeds-transparency-without-the-cloud-bill/</link><guid>https://rohitsaini.me/writing/live-kitchen-feeds-transparency-without-the-cloud-bill/</guid><pubDate>Tue, 31 Mar 2026 00:00:00 +0000</pubDate><description>Why food delivery apps need live streams, and how to build them cheaply with WebRTC.</description><content:encoded xmlns:content="http://purl.org/rss/1.0/modules/content/">&lt;p&gt;For the last 3-4 years, most of my meals have come from apps like Zomato and Swiggy. And honestly, I’m pretty sure that food is often prepared in ways I probably wouldn’t want to see.&lt;/p&gt;
&lt;p&gt;That got me thinking: What if customers could watch their food being cooked live? Not some polished marketing B-roll. Just a raw, live feed from the kitchen station while your order is actively being prepped.&lt;/p&gt;
&lt;p&gt;Transparency builds trust. Simple as that.&lt;/p&gt;
&lt;p&gt;The immediate pushback to an idea like this is always the infrastructure cost. If food delivery giants tried to stream thousands of kitchens using traditional cloud video architecture, their AWS bill would look like a phone number.&lt;/p&gt;
&lt;p&gt;But with today’s tech, it doesn&amp;rsquo;t have to be that way.&lt;/p&gt;
&lt;h3 id="the-low-cost-architecture"&gt;The Low-Cost Architecture&lt;/h3&gt;
&lt;p&gt;Instead of routing all that dense video data through centralized servers, we can just use peer-to-peer streaming via WebRTC.&lt;/p&gt;
&lt;p&gt;The setup is fairly basic:
You mount a cheap Android phone or a Raspberry Pi near the prep counter. It wakes up and automatically starts streaming only when an order drops.&lt;/p&gt;
&lt;p&gt;On the backend, you just need a lightweight signaling server (maybe Node.js or Go with WebSockets). This server&amp;rsquo;s only job is to introduce the customer&amp;rsquo;s phone to the kitchen&amp;rsquo;s camera so they can shake hands.&lt;/p&gt;
&lt;p&gt;Once they connect, it establishes a direct P2P link. That means the heavy video traffic &lt;strong&gt;never actually touches expensive cloud servers&lt;/strong&gt;. You&amp;rsquo;re mostly just paying a few bucks a month for the signaling server and a STUN/TURN fallback.&lt;/p&gt;
&lt;h3 id="what-about-millions-of-orders"&gt;What about millions of orders?&lt;/h3&gt;
&lt;p&gt;Scaling this is actually surprisingly chill. Since the heavy lifting, the video streaming, happens at the edge (between the two phones), your central servers are barely doing anything.&lt;/p&gt;
&lt;p&gt;A standard WebSocket signaling server can juggle tens of thousands of concurrent connections easily because it’s just passing tiny text tokens (SDPs) back and forth. To handle millions of daily orders, you just scale that signaling layer horizontally using something like Redis Pub/Sub. The cloud infrastructure cost barely flinches, even during the Friday night dinner rush.&lt;/p&gt;
&lt;h3 id="handling-the-dinner-rush"&gt;Handling the Dinner Rush&lt;/h3&gt;
&lt;p&gt;Okay, but what happens when a single cook is firing five different orders at the exact same time? You can&amp;rsquo;t force a budget kitchen tablet on a greasy Wi-Fi network to upload five separate video streams. It would instantly choke.&lt;/p&gt;
&lt;p&gt;The fix here is &lt;strong&gt;station-based multicasting&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;Instead of opening a 1:1 stream for every single order, the kitchen device captures one continuous video feed of the entire prep station. If five active orders are sitting there, the device pushes that &lt;em&gt;single&lt;/em&gt; stream to a lightweight SFU (Selective Forwarding Unit), which then mirrors it out to all five customers.&lt;/p&gt;
&lt;p&gt;But how do you know which burger is yours?&lt;/p&gt;
&lt;p&gt;This is where it gets fun. The kitchen app can run basic on-device object tracking or OCR to read the physical ticket numbers sitting on the counter. It then draws a simple AR overlay, like a glowing digital tag hovering over the plate reading “Order #1234”, directly onto the feed. You watch the wider station workflow, but you immediately spot your meal moving down the line.&lt;/p&gt;
&lt;h3 id="why-go-through-the-trouble"&gt;Why go through the trouble?&lt;/h3&gt;
&lt;p&gt;Beyond basic transparency, just having a camera on the line unlocks a lot of interesting data. You could automatically generate time-lapse cooking reels for social media, run lightweight AI models for hygiene detection, or even give restaurants a true, verifiable &amp;ldquo;Quality Score&amp;rdquo; based on how they actually handle the food.&lt;/p&gt;
&lt;p&gt;Food delivery crushed the convenience problem years ago. But it still completely lacks transparency. With WebRTC and dirt-cheap hardware, letting users watch their food being prepped might just be the most effective trust-building feature we could ship right now.&lt;/p&gt;
&lt;p&gt;And surprisingly, it wouldn&amp;rsquo;t even cost a fortune to build.&lt;/p&gt;</content:encoded></item><item><title>Designing for Scale</title><link>https://rohitsaini.me/writing/designing-for-scale/</link><guid>https://rohitsaini.me/writing/designing-for-scale/</guid><pubDate>Thu, 12 Mar 2026 00:00:00 +0000</pubDate><description>Scale is mostly about clarity — of data flow, ownership, failure handling, and observability.</description><content:encoded xmlns:content="http://purl.org/rss/1.0/modules/content/">&lt;p&gt;Modern systems rarely fail because of a single large mistake. They fail because of small assumptions that compound.&lt;/p&gt;
&lt;p&gt;A field that&amp;rsquo;s always populated — until it isn&amp;rsquo;t. A service that responds in 50ms — until traffic doubles. A queue that never backs up — until a downstream dependency has a bad day.&lt;/p&gt;
&lt;p&gt;When designing for scale, the first instinct is usually to reach for more infrastructure. More caches. More queues. More services. More replicas.&lt;/p&gt;
&lt;p&gt;That instinct is wrong about 80% of the time.&lt;/p&gt;
&lt;h2 id="scale-is-about-clarity"&gt;Scale Is About Clarity&lt;/h2&gt;
&lt;p&gt;Over the last few years, I&amp;rsquo;ve come to believe that scale is mostly about clarity. Not clever algorithms. Not exotic databases. Just clarity about four things.&lt;/p&gt;
&lt;h3 id="1-clarity-of-data-flow"&gt;1. Clarity of Data Flow&lt;/h3&gt;
&lt;p&gt;If you can&amp;rsquo;t draw how data moves through your system on a napkin, you&amp;rsquo;re going to have a bad time scaling it.&lt;/p&gt;
&lt;p&gt;Here&amp;rsquo;s a real scenario. We had a service that called three downstream APIs sequentially:&lt;/p&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt;Request → Service A → Service B → Service C → Response
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Total latency: sum of all three. At low traffic, this was fine — maybe 200ms total. At high traffic, one slow response from Service C would back up everything upstream.&lt;/p&gt;
&lt;p&gt;The fix wasn&amp;rsquo;t adding more instances of our service. It was restructuring the data flow so that we only needed Service A&amp;rsquo;s response synchronously. Services B and C could be handled asynchronously via events:&lt;/p&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt;Request → Service A → Response (sync, fast)
↓
Kafka Event
↓ ↓
Service B Service C (async, eventually consistent)
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Same outcome. Dramatically different scaling characteristics.&lt;/p&gt;
&lt;h3 id="2-clarity-of-ownership"&gt;2. Clarity of Ownership&lt;/h3&gt;
&lt;p&gt;One of the fastest ways to create scaling problems is shared ownership. Two teams writing to the same database table. Three services all maintaining their own copy of user preferences. Five consumers all computing the same aggregation differently.&lt;/p&gt;
&lt;p&gt;I follow a simple rule: &lt;strong&gt;every piece of data has exactly one owner.&lt;/strong&gt; The owner is the only service that writes to it. Everyone else reads, caches, or subscribes to change events.&lt;/p&gt;
&lt;p&gt;This sounds obvious, but in practice, it&amp;rsquo;s the first thing that gets violated when teams are moving fast. &amp;ldquo;We&amp;rsquo;ll just add a column to their table, it&amp;rsquo;s faster.&amp;rdquo; Three months later, you have two services with competing opinions about what a user&amp;rsquo;s status should be.&lt;/p&gt;
&lt;h3 id="3-clarity-of-failure-handling"&gt;3. Clarity of Failure Handling&lt;/h3&gt;
&lt;p&gt;At small scale, you can get away with &amp;ldquo;it probably won&amp;rsquo;t fail.&amp;rdquo; At large scale, everything that &lt;em&gt;can&lt;/em&gt; fail &lt;em&gt;will&lt;/em&gt; fail, and probably all at the same time on Friday evening.&lt;/p&gt;
&lt;p&gt;The question isn&amp;rsquo;t &amp;ldquo;will this fail?&amp;rdquo; but &amp;ldquo;when this fails, what happens?&amp;rdquo;&lt;/p&gt;
&lt;p&gt;Some patterns that have saved me:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Circuit breakers on every external call.&lt;/strong&gt; When a dependency goes down, fail fast instead of queueing up thousands of requests.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Timeouts everywhere.&lt;/strong&gt; Not just HTTP timeouts. Database query timeouts. Kafka consumer processing timeouts. Redis connection timeouts. I&amp;rsquo;ve seen systems grind to a halt because a single missing timeout let a slow query hold a connection pool hostage.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Dead-letter queues for every consumer.&lt;/strong&gt; When a message can&amp;rsquo;t be processed after N retries, park it somewhere you can inspect later instead of blocking the entire queue.&lt;/li&gt;
&lt;/ul&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# A timeout that actually works&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;signal&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;TimeoutError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ne"&gt;Exception&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;pass&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;timeout_handler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;signum&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;frame&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="ne"&gt;TimeoutError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;Operation timed out&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;with_timeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;seconds&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;signal&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;signal&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SIGALRM&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timeout_handler&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;signal&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;alarm&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;seconds&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;finally&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;signal&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;alarm&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;h3 id="4-clarity-of-observability"&gt;4. Clarity of Observability&lt;/h3&gt;
&lt;p&gt;You can&amp;rsquo;t scale what you can&amp;rsquo;t see.&lt;/p&gt;
&lt;p&gt;I&amp;rsquo;ve worked on systems where the only &amp;ldquo;monitoring&amp;rdquo; was someone checking if the API returned 200. Green meant good, right? Wrong. The API was returning 200 while silently dropping 30% of messages because a Kafka producer was failing and the error was being swallowed.&lt;/p&gt;
&lt;p&gt;Good observability at scale means:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Metrics that matter.&lt;/strong&gt; Not just CPU and memory. Business metrics. Messages processed per second. P99 latency per endpoint. Queue depth over time. Error rate by type.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Structured logging.&lt;/strong&gt; Key-value pairs, not strings. When you&amp;rsquo;re searching through millions of log lines, &lt;code&gt;{&amp;quot;thread_id&amp;quot;: &amp;quot;abc&amp;quot;, &amp;quot;action&amp;quot;: &amp;quot;send&amp;quot;, &amp;quot;latency_ms&amp;quot;: 342}&lt;/code&gt; is infinitely more useful than &lt;code&gt;Sent message to thread abc in 342ms&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Traces that connect the dots.&lt;/strong&gt; When a request touches five services, a correlation ID that flows through all of them is the difference between &amp;ldquo;something is slow&amp;rdquo; and &amp;ldquo;the Elasticsearch query in Service C is slow because the index isn&amp;rsquo;t optimized for this access pattern.&amp;rdquo;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="the-trap-of-premature-scaling"&gt;The Trap of Premature Scaling&lt;/h2&gt;
&lt;p&gt;The biggest trap I see: designing for scale you don&amp;rsquo;t have yet. Adding Kafka when you have 100 requests per minute. Splitting into microservices when your team is four people. Introducing a distributed cache before profiling where the actual bottleneck is.&lt;/p&gt;
&lt;p&gt;Every scaling mechanism you add is also a complexity mechanism. A cache means you now need cache invalidation strategy. A message queue means you need idempotency guarantees. Microservices mean you need service discovery, distributed tracing, and a deployment pipeline that doesn&amp;rsquo;t take 45 minutes.&lt;/p&gt;
&lt;p&gt;Start simple. Measure. Scale the parts that are actually slow.&lt;/p&gt;
&lt;h2 id="the-one-rule"&gt;The One Rule&lt;/h2&gt;
&lt;p&gt;If I had to condense everything into one rule, it would be this:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The simplest architecture that clearly defines boundaries will almost always outperform a complex one that doesn&amp;rsquo;t.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Scaling is less about adding components and more about removing ambiguity. When every engineer on the team can look at the system and immediately understand where data flows, who owns what, how failures are handled, and where to look when things go wrong — that system is ready to scale.&lt;/p&gt;
&lt;p&gt;Not because the infrastructure is impressive. Because the thinking is clear.&lt;/p&gt;</content:encoded></item><item><title>Event-Driven Systems Are Not Magic</title><link>https://rohitsaini.me/writing/event-driven-systems-are-not-magic/</link><guid>https://rohitsaini.me/writing/event-driven-systems-are-not-magic/</guid><pubDate>Fri, 20 Feb 2026 00:00:00 +0000</pubDate><description>Event-driven systems are powerful, but they redistribute complexity rather than eliminating it.</description><content:encoded xmlns:content="http://purl.org/rss/1.0/modules/content/">&lt;p&gt;There&amp;rsquo;s a moment in every growing system&amp;rsquo;s life where someone says, &amp;ldquo;We should make this event-driven.&amp;rdquo; And most of the time, they&amp;rsquo;re right. Decoupling producers from consumers is genuinely powerful. It lets teams work independently, services scale independently, and the overall system becomes more resilient.&lt;/p&gt;
&lt;p&gt;But here&amp;rsquo;s what nobody mentions in the conference talks: events don&amp;rsquo;t eliminate complexity. They redistribute it.&lt;/p&gt;
&lt;p&gt;And if you&amp;rsquo;re not prepared for where that complexity lands, you&amp;rsquo;re going to have a worse time than you did with synchronous calls.&lt;/p&gt;
&lt;h2 id="what-event-driven-actually-gives-you"&gt;What Event-Driven Actually Gives You&lt;/h2&gt;
&lt;p&gt;Let me start with the good parts, because there are genuinely good parts.&lt;/p&gt;
&lt;p&gt;Say you have a system where creating a new user account needs to:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Store the user in the database&lt;/li&gt;
&lt;li&gt;Send a welcome email&lt;/li&gt;
&lt;li&gt;Create a default workspace&lt;/li&gt;
&lt;li&gt;Notify the analytics service&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The synchronous version looks like this:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;create_user&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;create_user&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;email_service&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;send_welcome&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;workspace_service&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;create_default&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;analytics&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;track&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;user_created&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;This is simple to read and simple to debug. But it has problems. If the email service is slow, the API response is slow. If the analytics service is down, user creation fails. Every dependency is a single point of failure.&lt;/p&gt;
&lt;p&gt;The event-driven version:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;create_user&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;create_user&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;event_bus&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;publish&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;user.created&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;user_id&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;email&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# Separate consumers, running independently&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;on_user_created_send_email&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;email_service&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;send_welcome&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;email&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;on_user_created_setup_workspace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;workspace_service&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;create_default&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;user_id&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;on_user_created_track_analytics&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;analytics&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;track&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;user_created&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;user_id&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Now the API is fast — it does one thing and publishes an event. Each side effect runs independently. If the analytics service is having a bad day, users still get created and emails still go out.&lt;/p&gt;
&lt;p&gt;This is the pitch. And it&amp;rsquo;s a real improvement.&lt;/p&gt;
&lt;p&gt;But now let&amp;rsquo;s talk about what comes next.&lt;/p&gt;
&lt;h2 id="problem-1-ordering"&gt;Problem 1: Ordering&lt;/h2&gt;
&lt;p&gt;Events in a queue aren&amp;rsquo;t guaranteed to arrive in the order you think.&lt;/p&gt;
&lt;p&gt;Say a user creates an account and immediately updates their profile. You now have two events:&lt;/p&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt;Event 1: user.created { user_id: 42, name: &amp;#34;Rohit&amp;#34; }
Event 2: user.updated { user_id: 42, name: &amp;#34;Rohit S.&amp;#34; }
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;If both events land on different partitions, or if Consumer A processes events faster than Consumer B, you might process the &lt;code&gt;updated&lt;/code&gt; event before the &lt;code&gt;created&lt;/code&gt; event. Now your downstream service is trying to update a user that doesn&amp;rsquo;t exist yet.&lt;/p&gt;
&lt;p&gt;In Kafka, you can solve this by using the &lt;code&gt;user_id&lt;/code&gt; as the partition key. All events for the same user land on the same partition and are processed in order. But this only works within a single topic. Across topics? You&amp;rsquo;re on your own.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# Producing with a partition key&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;producer&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;topic&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;user-events&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="c1"&gt;# same user → same partition → ordered&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;serialize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;There&amp;rsquo;s no universal ordering across an event-driven system. You need to design for partial ordering and be very explicit about which ordering guarantees you actually need.&lt;/p&gt;
&lt;h2 id="problem-2-idempotency"&gt;Problem 2: Idempotency&lt;/h2&gt;
&lt;p&gt;Events will be delivered more than once. This isn&amp;rsquo;t a maybe — it&amp;rsquo;s a guarantee. Network blips, consumer restarts, rebalances — all of these cause redelivery.&lt;/p&gt;
&lt;p&gt;If your consumer does this:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;on_payment_received&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;add_credit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;user_id&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;amount&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;And the event gets delivered twice, the user just got double credit. Congratulations, you now have a financial accounting bug.&lt;/p&gt;
&lt;p&gt;The fix is making every consumer idempotent. The simplest way is to track which events you&amp;rsquo;ve already processed:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;on_payment_received&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;event_already_processed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;event_id&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;]):&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="c1"&gt;# skip duplicate&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;add_credit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;user_id&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;amount&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;mark_event_processed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;event_id&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;This works, but now you have an extra database table, extra queries on every event, and you need to think about cleanup (that table grows forever). For simpler cases, you can use natural idempotency — operations like &lt;code&gt;SET status = 'active'&lt;/code&gt; are inherently idempotent, while &lt;code&gt;INCREMENT balance BY 100&lt;/code&gt; is not.&lt;/p&gt;
&lt;h2 id="problem-3-schema-evolution"&gt;Problem 3: Schema Evolution&lt;/h2&gt;
&lt;p&gt;Your event schema &lt;em&gt;will&lt;/em&gt; change. New fields get added. Old fields get deprecated. The structure of the payload evolves as the product evolves.&lt;/p&gt;
&lt;p&gt;But here&amp;rsquo;s the catch: producers and consumers are deployed independently. When you add a new field to the &lt;code&gt;user.created&lt;/code&gt; event, there&amp;rsquo;s a window where the producer is sending the new schema but the consumer is still running the old code.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# Version 1 of the event&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;user_id&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;42&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;email&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;rohit@example.com&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# Version 2, six months later&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;user_id&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;42&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;email&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;rohit@example.com&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;plan&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;premium&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;source&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;referral&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Consumers need to handle both versions gracefully. This means:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Always use optional fields for new data.&lt;/strong&gt; Never require a field that didn&amp;rsquo;t exist in v1.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Never remove or rename fields.&lt;/strong&gt; Add new ones, deprecate old ones.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Version your events.&lt;/strong&gt; Include a &lt;code&gt;schema_version&lt;/code&gt; field so consumers can branch on it if needed.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Some teams use schema registries (like Confluent Schema Registry for Kafka) to enforce compatibility. That adds infrastructure but catches breaking changes before they hit production.&lt;/p&gt;
&lt;h2 id="problem-4-debugging-across-services"&gt;Problem 4: Debugging Across Services&lt;/h2&gt;
&lt;p&gt;This is the one that hurts the most. In a monolith, debugging is straightforward. You add a breakpoint, step through the code, and follow the logic. In an event-driven system, the &amp;ldquo;logic&amp;rdquo; spans multiple services, multiple queues, and multiple timelines.&lt;/p&gt;
&lt;p&gt;A user reports: &amp;ldquo;I created an account but never got my welcome email.&amp;rdquo;&lt;/p&gt;
&lt;p&gt;In the monolith: check &lt;code&gt;create_user&lt;/code&gt;, find the bug. Ten minutes.&lt;/p&gt;
&lt;p&gt;In the event-driven system:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Did the &lt;code&gt;user.created&lt;/code&gt; event get published? Check producer logs.&lt;/li&gt;
&lt;li&gt;Did Kafka receive it? Check the topic.&lt;/li&gt;
&lt;li&gt;Did the email consumer pick it up? Check consumer group lag.&lt;/li&gt;
&lt;li&gt;Did the consumer process it successfully? Check consumer logs.&lt;/li&gt;
&lt;li&gt;Did the email service actually send the email? Check that service&amp;rsquo;s logs.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;You&amp;rsquo;re now debugging across five different log streams, and you need a correlation ID to tie them all together.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# Always propagate a correlation ID through your events&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;event&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;event_id&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;uuid4&lt;/span&gt;&lt;span class="p"&gt;()),&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;correlation_id&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;X-Correlation-ID&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;uuid4&lt;/span&gt;&lt;span class="p"&gt;())),&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;user_id&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;email&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;timestamp&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;utcnow&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;isoformat&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;If you don&amp;rsquo;t set this up from day one, you&amp;rsquo;ll wish you had by month two.&lt;/p&gt;
&lt;h2 id="so-when-should-you-use-events"&gt;So When Should You Use Events?&lt;/h2&gt;
&lt;p&gt;Events are great when:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;The producer doesn&amp;rsquo;t need to know the result of the consumer&amp;rsquo;s work&lt;/li&gt;
&lt;li&gt;You have multiple independent reactions to the same trigger&lt;/li&gt;
&lt;li&gt;You need resilience — consumers can retry independently without blocking each other&lt;/li&gt;
&lt;li&gt;Workloads are bursty and you need a buffer&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Events are overkill when:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;You have a simple request-response flow&lt;/li&gt;
&lt;li&gt;You need strong consistency and the result immediately&lt;/li&gt;
&lt;li&gt;Your system is small enough that the operational overhead of queues outweighs the benefit&lt;/li&gt;
&lt;li&gt;You only have one consumer (just call it directly)&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="the-real-work"&gt;The Real Work&lt;/h2&gt;
&lt;p&gt;Using events isn&amp;rsquo;t the hard part. &lt;em&gt;Anyone&lt;/em&gt; can publish a message to Kafka. The real work is everything that comes after:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Designing for out-of-order delivery&lt;/li&gt;
&lt;li&gt;Making every consumer idempotent&lt;/li&gt;
&lt;li&gt;Evolving schemas without breaking things&lt;/li&gt;
&lt;li&gt;Building observability that spans service boundaries&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The first message you publish is satisfying. The hundredth production bug you debug across an event-driven pipeline is humbling.&lt;/p&gt;
&lt;p&gt;Events are a tool. A powerful one. But they&amp;rsquo;re not magic. Treat them with the respect they deserve, and they&amp;rsquo;ll serve you well. Treat them as a silver bullet, and they&amp;rsquo;ll redistribute your complexity to places you weren&amp;rsquo;t looking.&lt;/p&gt;</content:encoded></item><item><title>Debugging Distributed Systems</title><link>https://rohitsaini.me/writing/debugging-distributed-systems/</link><guid>https://rohitsaini.me/writing/debugging-distributed-systems/</guid><pubDate>Thu, 05 Feb 2026 00:00:00 +0000</pubDate><description>Debugging distributed systems is fundamentally different. You're reading timelines, not stack traces.</description><content:encoded xmlns:content="http://purl.org/rss/1.0/modules/content/">&lt;p&gt;In a monolith, debugging is straightforward. Something breaks, you get a stack trace, you find the line, you fix it. The entire execution happened in one process, on one machine, in one timeline.&lt;/p&gt;
&lt;p&gt;Distributed systems blow all of that up. The &amp;ldquo;stack trace&amp;rdquo; is scattered across five services, three message queues, and two databases. The bug might not even be a bug — it might be a timing issue that only happens under specific load patterns on the third Tuesday of the month.&lt;/p&gt;
&lt;p&gt;I&amp;rsquo;ve spent enough time debugging distributed systems that I&amp;rsquo;ve developed a set of habits and patterns that consistently save me time. None of them are groundbreaking. They&amp;rsquo;re just &lt;em&gt;discipline&lt;/em&gt; applied before things break.&lt;/p&gt;
&lt;h2 id="correlation-ids-your-lifeline"&gt;Correlation IDs: Your Lifeline&lt;/h2&gt;
&lt;p&gt;The single most valuable piece of infrastructure in any distributed system is a correlation ID. It&amp;rsquo;s a unique identifier generated at the entry point of a request that flows through every service, every queue, every log line.&lt;/p&gt;
&lt;p&gt;Without it, debugging looks like this:&lt;/p&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt;Service A logs: &amp;#34;Processed request for user 42&amp;#34;
Service B logs: &amp;#34;Received event for user 42&amp;#34;
Service C logs: &amp;#34;Sent notification for user 42&amp;#34;
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Seems traceable, right? Now imagine 500 requests per second for user 42. Which log line in Service B corresponds to which log line in Service A?&lt;/p&gt;
&lt;p&gt;With a correlation ID:&lt;/p&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt;Service A: {&amp;#34;correlation_id&amp;#34;: &amp;#34;abc-123&amp;#34;, &amp;#34;action&amp;#34;: &amp;#34;process_request&amp;#34;, &amp;#34;user_id&amp;#34;: 42}
Service B: {&amp;#34;correlation_id&amp;#34;: &amp;#34;abc-123&amp;#34;, &amp;#34;action&amp;#34;: &amp;#34;handle_event&amp;#34;, &amp;#34;user_id&amp;#34;: 42}
Service C: {&amp;#34;correlation_id&amp;#34;: &amp;#34;abc-123&amp;#34;, &amp;#34;action&amp;#34;: &amp;#34;send_notification&amp;#34;, &amp;#34;user_id&amp;#34;: 42}
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Now you can grep for &lt;code&gt;abc-123&lt;/code&gt; across all your services and reconstruct the exact path of a single request. This is non-negotiable. If you don&amp;rsquo;t have this, add it today.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# Middleware that generates or propagates correlation IDs&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;correlation_middleware&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;next_handler&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;correlation_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;X-Correlation-ID&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;uuid4&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="c1"&gt;# Attach to thread-local context for logging&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;correlation_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;correlation_id&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;next_handler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;X-Correlation-ID&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;correlation_id&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The key rule: every time you publish a message, make an HTTP call, or write a log line, include the correlation ID. It should propagate like a virus through your entire system.&lt;/p&gt;
&lt;h2 id="structured-logging-not-string-logging"&gt;Structured Logging, Not String Logging&lt;/h2&gt;
&lt;p&gt;When you&amp;rsquo;re debugging a monolith with 100 log lines, &lt;code&gt;printf&lt;/code&gt; debugging works fine. When you&amp;rsquo;re debugging a distributed system with 10 million log lines, you need structured logging.&lt;/p&gt;
&lt;p&gt;The difference:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# Bad: stringly-typed and impossible to query&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;info&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;Processing message &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;msg_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; for thread &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;thread_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;, took &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;latency&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;ms&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# Good: structured, filterable, aggregatable&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;info&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;message_processed&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;extra&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;message_id&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;msg_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;thread_id&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;thread_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;latency_ms&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;latency&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;consumer_group&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;notification-sender&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;partition&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;partition&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;offset&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;offset&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;With structured logs, you can do things like:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Find all messages where &lt;code&gt;latency_ms &amp;gt; 5000&lt;/code&gt; across all services&lt;/li&gt;
&lt;li&gt;Filter by &lt;code&gt;consumer_group&lt;/code&gt; to see only one consumer&amp;rsquo;s perspective&lt;/li&gt;
&lt;li&gt;Aggregate &lt;code&gt;latency_ms&lt;/code&gt; by &lt;code&gt;thread_id&lt;/code&gt; to find hot threads&lt;/li&gt;
&lt;li&gt;Correlate high latency with specific Kafka partitions&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This turns debugging from &amp;ldquo;reading log files&amp;rdquo; into &amp;ldquo;querying a database.&amp;rdquo; Most log aggregation tools (Datadog, Kibana, CloudWatch) work dramatically better with structured data.&lt;/p&gt;
&lt;h2 id="think-in-timelines-not-call-stacks"&gt;Think in Timelines, Not Call Stacks&lt;/h2&gt;
&lt;p&gt;This is the biggest mental shift. In a monolith, execution is vertical — function A calls function B calls function C. You read a stack trace top to bottom.&lt;/p&gt;
&lt;p&gt;In a distributed system, execution is horizontal — things happen &lt;em&gt;across time&lt;/em&gt; on &lt;em&gt;different machines&lt;/em&gt;. The order matters, the timing matters, and the gaps between events often tell you more than the events themselves.&lt;/p&gt;
&lt;p&gt;When debugging, I build a timeline:&lt;/p&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt;10:00:00.000 Service A Received request correlation_id=abc-123
10:00:00.015 Service A Published event to Kafka correlation_id=abc-123
10:00:00.015 Service A Returned 200 to client correlation_id=abc-123
10:00:02.340 Service B Consumed event correlation_id=abc-123
^^^ 2.3 second gap — why? Consumer lag? Slow deserialization?
10:00:02.380 Service B Called Service C correlation_id=abc-123
10:00:07.380 Service B Timeout from Service C correlation_id=abc-123
^^^ 5 second timeout — Service C is the problem
10:00:07.400 Service B Sent to dead-letter queue correlation_id=abc-123
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;The timeline immediately reveals the problem: Service C took too long to respond. But without seeing the full timeline, you might have spent an hour looking at Service B&amp;rsquo;s code trying to figure out why it sent the message to the dead-letter queue.&lt;/p&gt;
&lt;h2 id="replay-events-to-reproduce-bugs"&gt;Replay Events to Reproduce Bugs&lt;/h2&gt;
&lt;p&gt;One of the advantages of event-driven systems is that events are stored (at least for a while). This means you can reproduce bugs by replaying events — something you can&amp;rsquo;t do with synchronous HTTP calls.&lt;/p&gt;
&lt;p&gt;In Kafka, you can reset a consumer group&amp;rsquo;s offset to a specific timestamp:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# Reset consumer to replay events from a specific time&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;kafka-consumer-groups.sh &lt;span class="se"&gt;\
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; --bootstrap-server localhost:9092 &lt;span class="se"&gt;\
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; --group my-consumer-group &lt;span class="se"&gt;\
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; --topic my-topic &lt;span class="se"&gt;\
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; --reset-offsets &lt;span class="se"&gt;\
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; --to-datetime 2026-02-05T10:00:00.000 &lt;span class="se"&gt;\
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; --execute
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;I&amp;rsquo;ve used this many times to reproduce production bugs in a staging environment. Copy the events from around the time of the bug, replay them against a staging consumer, and watch what happens. It&amp;rsquo;s like having a time machine for your system.&lt;/p&gt;
&lt;p&gt;Of course, be careful with this. Replaying events against a production database can cause duplicate processing. Always replay against an isolated environment.&lt;/p&gt;
&lt;h2 id="the-dashboard-that-actually-helps"&gt;The Dashboard That Actually Helps&lt;/h2&gt;
&lt;p&gt;Most monitoring dashboards show you CPU, memory, and request counts. These are useful for capacity planning but nearly useless for debugging distributed systems.&lt;/p&gt;
&lt;p&gt;The dashboard I actually look at during an incident has:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Consumer lag per consumer group.&lt;/strong&gt; If a consumer is falling behind, something is slow or failing.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;P99 latency per service, per endpoint.&lt;/strong&gt; Not average latency (which hides problems) but the 99th percentile.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Error rate by error type.&lt;/strong&gt; Not just &amp;ldquo;errors went up&amp;rdquo; but &amp;ldquo;TimeoutError to Service C went from 0.1% to 15%.&amp;rdquo;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Queue depth over time.&lt;/strong&gt; Is the queue growing? At what rate? This tells you if your consumers can&amp;rsquo;t keep up.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Dead-letter queue count.&lt;/strong&gt; If messages are landing here, something is wrong and needs human attention.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt;┌─────────────────────────────────────────────┐
│ Consumer Lag │
│ notification-sender: 0 (healthy) │
│ analytics-writer: 45,000 (⚠️ behind) │
│ search-indexer: 0 (healthy) │
├─────────────────────────────────────────────┤
│ DLQ Messages (last 24h) │
│ notification-sender: 3 │
│ analytics-writer: 0 │
│ search-indexer: 127 (🔴 investigate) │
└─────────────────────────────────────────────┘
&lt;/code&gt;&lt;/pre&gt;&lt;h2 id="embrace-the-uncertainty"&gt;Embrace the Uncertainty&lt;/h2&gt;
&lt;p&gt;Here&amp;rsquo;s the hardest part about debugging distributed systems: sometimes you won&amp;rsquo;t find a clean root cause. Sometimes the answer is &amp;ldquo;there was a 200ms network partition between zone A and zone B that caused a cascade of timeouts.&amp;rdquo; There&amp;rsquo;s no line of code to fix. There&amp;rsquo;s no bug to close.&lt;/p&gt;
&lt;p&gt;In those cases, the fix isn&amp;rsquo;t fixing the bug — it&amp;rsquo;s making the system resilient to that class of failure. Add retries. Add circuit breakers. Increase timeouts. Add fallbacks. Accept that partial failures are normal and design for them.&lt;/p&gt;
&lt;p&gt;Distributed systems are fundamentally about trade-offs. You trade simplicity for scalability, consistency for availability, debuggability for independence. The trick is knowing which trade-offs you&amp;rsquo;ve made, and being prepared when they come to collect.&lt;/p&gt;
&lt;p&gt;The best distributed systems debuggers I know aren&amp;rsquo;t the ones who find bugs the fastest. They&amp;rsquo;re the ones who build systems that explain themselves when things go wrong.&lt;/p&gt;
&lt;p&gt;Invest in correlation IDs, structured logging, and good dashboards. You&amp;rsquo;re building the tools your 3 AM self will thank you for.&lt;/p&gt;</content:encoded></item><item><title>Production-Ready AI</title><link>https://rohitsaini.me/writing/production-ready-ai/</link><guid>https://rohitsaini.me/writing/production-ready-ai/</guid><pubDate>Sun, 18 Jan 2026 00:00:00 +0000</pubDate><description>Adding AI to a system is easy. Making it reliable is not.</description><content:encoded xmlns:content="http://purl.org/rss/1.0/modules/content/">&lt;p&gt;Adding AI to a system is easy. You grab an API key, write a prompt, call the model, and show the result. It works in the demo. The stakeholders nod approvingly.&lt;/p&gt;
&lt;p&gt;Then you deploy to production, and everything falls apart in ways you didn&amp;rsquo;t anticipate.&lt;/p&gt;
&lt;p&gt;The model hallucinates a response that contradicts your own data. Latency spikes to 8 seconds because the model is thinking really hard about a simple question. Your monthly bill triples because someone left a retry loop running against a large-context model. A customer sees a response that&amp;rsquo;s technically correct but completely wrong for their context.&lt;/p&gt;
&lt;p&gt;The gap between demo and production AI is enormous. And most of it has nothing to do with the model itself.&lt;/p&gt;
&lt;h2 id="the-things-that-actually-matter"&gt;The Things That Actually Matter&lt;/h2&gt;
&lt;h3 id="1-deterministic-fallbacks"&gt;1. Deterministic Fallbacks&lt;/h3&gt;
&lt;p&gt;Here&amp;rsquo;s something I learned early: your AI feature needs to work &lt;em&gt;without&lt;/em&gt; the AI.&lt;/p&gt;
&lt;p&gt;That sounds contradictory, but it&amp;rsquo;s the most important design decision you&amp;rsquo;ll make. If the AI model is unavailable — the API is down, latency is too high, the response is garbage — your system should degrade to something useful, not crash or show a blank page.&lt;/p&gt;
&lt;p&gt;For a search system, that means falling back to keyword search when semantic search fails. For an auto-reply feature, it means sending a canned response instead of nothing. For a classification pipeline, it means defaulting to a &amp;ldquo;needs human review&amp;rdquo; bucket.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;get_ai_response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;llm&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;complete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;prompt&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;3.0&lt;/span&gt; &lt;span class="c1"&gt;# hard timeout, non-negotiable&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;confidence&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mf"&gt;0.7&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;fallback_response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ne"&gt;TimeoutError&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;APIError&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;fallback_response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The fallback isn&amp;rsquo;t a failure — it&amp;rsquo;s a feature. Users care far less about &amp;ldquo;AI-powered&amp;rdquo; than they care about &amp;ldquo;it works.&amp;rdquo;&lt;/p&gt;
&lt;h3 id="2-observability"&gt;2. Observability&lt;/h3&gt;
&lt;p&gt;AI systems are black boxes by default. The model takes an input, does mysterious computation, and produces an output. If the output is wrong, you have almost no traditional debugging tools.&lt;/p&gt;
&lt;p&gt;This is why observability for AI isn&amp;rsquo;t optional — it&amp;rsquo;s critical. For every AI call, I log:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;The full prompt&lt;/strong&gt; (or a hash of it if it contains sensitive data)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The model&amp;rsquo;s response&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Latency&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Token count&lt;/strong&gt; (input + output)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Confidence score&lt;/strong&gt; (if available)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Whether the fallback was triggered&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The user&amp;rsquo;s reaction&lt;/strong&gt; (did they accept the suggestion? dismiss it? edit it?)&lt;/li&gt;
&lt;/ul&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;log_ai_interaction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;info&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;ai_interaction&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;extra&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;prompt_hash&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;hash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;response_length&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;model&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;metadata&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;latency_ms&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;metadata&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;latency_ms&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;input_tokens&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;metadata&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;input_tokens&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;output_tokens&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;metadata&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;output_tokens&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;fallback_used&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;metadata&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;fallback_used&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;correlation_id&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;metadata&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;correlation_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;This lets you answer questions like: &amp;ldquo;Why did our AI accuracy drop last Tuesday?&amp;rdquo; Maybe a prompt template was deployed with a typo. Maybe the context window was being exceeded for longer inputs. Maybe the model provider quietly changed their default model version. Without these logs, you&amp;rsquo;re guessing.&lt;/p&gt;
&lt;h3 id="3-cost-boundaries"&gt;3. Cost Boundaries&lt;/h3&gt;
&lt;p&gt;AI API calls cost real money, and the cost model is different from anything else in your infrastructure. It&amp;rsquo;s not per-request or per-second — it&amp;rsquo;s per-token. A complex prompt with a large context can cost 100x more than a simple one.&lt;/p&gt;
&lt;p&gt;I set cost boundaries at multiple levels:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Per-request budget.&lt;/strong&gt; If a single prompt would cost more than $0.05, truncate the context or use a smaller model.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Per-user daily budget.&lt;/strong&gt; Prevents a single heavy user from blowing up your bill.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Per-service monthly budget.&lt;/strong&gt; Hard alerts when spend exceeds projections.&lt;/li&gt;
&lt;/ul&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;estimate_cost&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;gpt-4&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;token_count&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;tokenizer&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;cost_per_token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;MODEL_COSTS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;estimated_cost&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;token_count&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;cost_per_token&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;estimated_cost&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;MAX_COST_PER_REQUEST&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="c1"&gt;# Switch to cheaper model or truncate context&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;use_lighter_model&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;call_model&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The most expensive bug I&amp;rsquo;ve seen in an AI system wasn&amp;rsquo;t a logic error — it was a retry loop that kept sending the same large prompt to GPT-4. The team didn&amp;rsquo;t notice for two days. The bill was not pleasant.&lt;/p&gt;
&lt;h3 id="4-latency-budgets"&gt;4. Latency Budgets&lt;/h3&gt;
&lt;p&gt;AI model calls are slow compared to a database query. An LLM might take 2-5 seconds for a reasonable response. If your user expects a page load in under a second, you can&amp;rsquo;t synchronously call an LLM in the request path.&lt;/p&gt;
&lt;p&gt;Strategies that work:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Async processing.&lt;/strong&gt; Queue the AI call and show results when ready (works for background jobs like classification).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Streaming.&lt;/strong&gt; Stream tokens as they arrive so the user sees something happening (works for chat-like interfaces).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Pre-computation.&lt;/strong&gt; Run AI during off-peak hours and cache results (works for recommendations, summaries).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Model selection based on latency.&lt;/strong&gt; Use a fast, smaller model for real-time features and a large model for batch jobs.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The key is deciding your latency budget upfront and designing around it, rather than adding a loading spinner and hoping nobody complains.&lt;/p&gt;
&lt;h2 id="ai-should-never-be-a-single-point-of-failure"&gt;AI Should Never Be a Single Point of Failure&lt;/h2&gt;
&lt;p&gt;This is my one non-negotiable rule. AI features should enhance the experience, not gate it. If the AI is down, the core product should still work. If the AI gives bad output, there should be a way for the system (or the user) to recover gracefully.&lt;/p&gt;
&lt;p&gt;Think of AI like a suggestion engine:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Suggestions can be wrong. That&amp;rsquo;s fine, as long as the user can override them.&lt;/li&gt;
&lt;li&gt;Suggestions can be slow. That&amp;rsquo;s fine, as long as the rest of the system doesn&amp;rsquo;t wait.&lt;/li&gt;
&lt;li&gt;Suggestions can be expensive. That&amp;rsquo;s fine, as long as you have guardrails.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The best AI features I&amp;rsquo;ve shipped are the ones where users don&amp;rsquo;t even realize AI is involved. It just feels like the product is smart. That&amp;rsquo;s the bar — not flashy &amp;ldquo;Powered by AI&amp;rdquo; badges, but quietly useful features that degrade gracefully and cost predictably.&lt;/p&gt;
&lt;p&gt;Making AI work in a demo takes an afternoon. Making it production-ready takes weeks. The difference is everything that surrounds the model — fallbacks, logging, cost controls, latency management, and the humble acceptance that sometimes the best AI response is no AI response at all.&lt;/p&gt;</content:encoded></item><item><title>Why Queues Matter More Than You Think</title><link>https://rohitsaini.me/writing/why-queues-matter-more-than-you-think/</link><guid>https://rohitsaini.me/writing/why-queues-matter-more-than-you-think/</guid><pubDate>Mon, 05 Jan 2026 00:00:00 +0000</pubDate><description>Queues are the unsung heroes of reliable systems. They absorb traffic spikes, decouple services, and turn fragile sync calls into resilient pipelines.</description><content:encoded xmlns:content="http://purl.org/rss/1.0/modules/content/">&lt;p&gt;Early in my career, I thought queues were just a way to &amp;ldquo;do stuff later.&amp;rdquo; Background jobs. Email sending. That kind of thing. They felt like an optimization — nice to have, not essential.&lt;/p&gt;
&lt;p&gt;I was wrong. These days, I think queues are the single most underappreciated piece of infrastructure in backend systems. Not because they&amp;rsquo;re complex (they&amp;rsquo;re not), but because of how many problems they quietly solve.&lt;/p&gt;
&lt;h2 id="the-problem-with-synchronous-calls"&gt;The Problem with Synchronous Calls&lt;/h2&gt;
&lt;p&gt;Let&amp;rsquo;s start with a common pattern. Service A needs to tell Service B that something happened.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# Service A&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;process_order&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;save_order&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;http://service-b/notify&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;to_dict&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="ne"&gt;Exception&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;Service B failed&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;This works fine when both services are healthy. But now consider what happens when they&amp;rsquo;re not:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Service B is down.&lt;/strong&gt; Your order processing fails even though it has nothing to do with notifications.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Service B is slow.&lt;/strong&gt; Your request hangs, your thread pool fills up, and Service A starts rejecting &lt;em&gt;all&lt;/em&gt; requests.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Traffic spikes.&lt;/strong&gt; Both services get hammered. Service A can scale, but Service B can&amp;rsquo;t keep up. Requests start timing out.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The fundamental problem: synchronous calls create tight coupling. Service A&amp;rsquo;s availability depends on Service B&amp;rsquo;s availability, and there&amp;rsquo;s no buffer for traffic differences.&lt;/p&gt;
&lt;h2 id="add-a-queue-between-them"&gt;Add a Queue Between Them&lt;/h2&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# Service A — just publish and move on&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;process_order&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;save_order&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;queue&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;publish&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;order.processed&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;to_dict&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# Service B — consume at its own pace&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;handle_order_event&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;send_notification&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;That&amp;rsquo;s it. One small change, and you get:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Decoupling.&lt;/strong&gt; Service A doesn&amp;rsquo;t know or care about Service B. It publishes an event and moves on.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Resilience.&lt;/strong&gt; If Service B goes down, messages pile up in the queue. When it comes back, it processes the backlog. No data loss.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Backpressure.&lt;/strong&gt; Service B consumes at whatever rate it can handle. If traffic spikes, the queue absorbs the burst.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="backpressure-is-the-key"&gt;Backpressure Is the Key&lt;/h2&gt;
&lt;p&gt;Backpressure is the concept I wish someone had explained to me earlier. It&amp;rsquo;s simple: when a downstream system can&amp;rsquo;t keep up, the pressure needs to go &lt;em&gt;somewhere&lt;/em&gt;. Without a queue, it goes to the caller — in the form of timeouts, errors, and cascading failures.&lt;/p&gt;
&lt;p&gt;With a queue, the pressure goes to the queue itself. Messages stack up, but nobody crashes. The consumer chews through the backlog at a sustainable rate.&lt;/p&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt;Traffic spike:
┌──────────┐
Requests → │ Queue │ → Consumer (steady rate)
100/s │ (buffer) │ 10/s
│ depth: │
│ growing │
└──────────┘
After spike:
┌──────────┐
Requests → │ Queue │ → Consumer (catches up)
5/s │ (buffer) │ 10/s
│ depth: │
│ draining │
└──────────┘
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;I&amp;rsquo;ve seen this pattern save production systems multiple times. A marketing email goes out, traffic spikes 10x, and the system survives because there&amp;rsquo;s a queue between the API and the heavy processing. Without the queue, the whole thing would fall over.&lt;/p&gt;
&lt;h2 id="retry-semantics-come-free"&gt;Retry Semantics Come Free&lt;/h2&gt;
&lt;p&gt;One of the best things about queues: retries are built into the model.&lt;/p&gt;
&lt;p&gt;With synchronous calls, you have to build retry logic yourself — exponential backoff, jitter, max attempts, circuit breakers. It&amp;rsquo;s a lot of code, and it&amp;rsquo;s easy to get wrong.&lt;/p&gt;
&lt;p&gt;With a queue (especially Kafka), if a consumer fails to process a message, it simply doesn&amp;rsquo;t commit the offset. The message stays in the queue and gets reprocessed. You don&amp;rsquo;t need to build retry infrastructure — it&amp;rsquo;s inherent to how consumers work.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# Kafka consumer — automatic retry via offset management&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;consume&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;consumer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;process&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;consumer&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;commit&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="c1"&gt;# success, move on&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;TransientError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="c1"&gt;# don&amp;#39;t commit — message will be redelivered&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;warning&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;Will retry: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;PermanentError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="c1"&gt;# send to dead-letter queue, commit, move on&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;dead_letter&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;publish&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;consumer&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;commit&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Of course, this means your consumers need to be idempotent. Processing the same message twice should have the same result as processing it once. That&amp;rsquo;s the trade-off — but it&amp;rsquo;s a much easier problem to solve than building a full retry framework.&lt;/p&gt;
&lt;h2 id="when-queues-arent-the-answer"&gt;When Queues Aren&amp;rsquo;t the Answer&lt;/h2&gt;
&lt;p&gt;Queues aren&amp;rsquo;t always right. Here&amp;rsquo;s when I &lt;em&gt;don&amp;rsquo;t&lt;/em&gt; use them:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;When you need an immediate response.&lt;/strong&gt; If the user is waiting for the result of an operation, a queue adds latency that a direct call doesn&amp;rsquo;t. You can&amp;rsquo;t tell a user &amp;ldquo;your payment is being processed, results in 30 seconds&amp;rdquo; for an e-commerce checkout.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;When the system is simple.&lt;/strong&gt; Two services, low traffic, both maintained by the same team? A direct HTTP call is fine. Don&amp;rsquo;t add Kafka for a system that handles 50 requests per minute.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;When ordering is critical and complex.&lt;/strong&gt; Queues can preserve ordering within a partition, but if you need global ordering across multiple data types, you might be adding more complexity than you&amp;rsquo;re solving.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="the-mental-model"&gt;The Mental Model&lt;/h2&gt;
&lt;p&gt;I think of queues as shock absorbers. In a car, the road is uneven — bumps, potholes, sudden drops. Without shock absorbers, every bump transfers directly to the passengers. With them, the ride is smooth even when the road isn&amp;rsquo;t.&lt;/p&gt;
&lt;p&gt;Your production traffic is the road. It&amp;rsquo;s uneven — spikes, bursts, sudden surges from a marketing campaign or a bot crawling your API. Without a queue, every spike transfers directly to your services. With a queue, the spike is absorbed, and your consumers keep processing at a steady, healthy rate.&lt;/p&gt;
&lt;p&gt;The best part? This is boring infrastructure. Kafka, RabbitMQ, SQS — all battle-tested, well-documented, and understood by thousands of engineers. You&amp;rsquo;re not doing anything clever. You&amp;rsquo;re just putting a buffer in the right place.&lt;/p&gt;
&lt;p&gt;And sometimes, that&amp;rsquo;s all it takes to turn a fragile system into a reliable one.&lt;/p&gt;</content:encoded></item><item><title>Boring Systems Win</title><link>https://rohitsaini.me/writing/boring-systems-win/</link><guid>https://rohitsaini.me/writing/boring-systems-win/</guid><pubDate>Wed, 10 Dec 2025 00:00:00 +0000</pubDate><description>The most reliable systems are boring. They use proven databases, avoid unnecessary abstraction, and log properly.</description><content:encoded xmlns:content="http://purl.org/rss/1.0/modules/content/">&lt;p&gt;Every few months, something shiny shows up. A new database that &amp;ldquo;scales infinitely.&amp;rdquo; A framework that promises to solve distributed consensus in 10 lines. A storage engine written in Rust that&amp;rsquo;s &amp;ldquo;blazingly fast.&amp;rdquo;&lt;/p&gt;
&lt;p&gt;And every time, I have the same thought: will this thing still be maintained when I need to debug it at 2 AM on a Saturday?&lt;/p&gt;
&lt;h2 id="the-case-for-boring"&gt;The Case for Boring&lt;/h2&gt;
&lt;p&gt;The most reliable systems I&amp;rsquo;ve built — and more importantly, the ones I&amp;rsquo;ve &lt;em&gt;operated&lt;/em&gt; — are boring. Not boring as in &amp;ldquo;nobody thought about them.&amp;rdquo; Boring as in the choices were so obvious they didn&amp;rsquo;t generate any debate.&lt;/p&gt;
&lt;p&gt;Postgres over the latest NoSQL flavor. A simple REST API over a GraphQL layer nobody asked for. Cron jobs over a custom scheduler. A monolith before microservices.&lt;/p&gt;
&lt;p&gt;These aren&amp;rsquo;t sexy choices. But here&amp;rsquo;s what they buy you:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Debugging is straightforward.&lt;/strong&gt; Google your error and there are 400 Stack Overflow answers from 2014.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Onboarding is fast.&lt;/strong&gt; New engineers don&amp;rsquo;t need a PhD in your custom abstraction layer.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Failure modes are well-understood.&lt;/strong&gt; Postgres connection pooling issues? There&amp;rsquo;s a playbook. Your bespoke event mesh melting down? Good luck.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="a-real-example"&gt;A Real Example&lt;/h2&gt;
&lt;p&gt;I once worked on a system that processed millions of messages a day. The original design had four message queues chained together, each with its own retry logic, dead-letter handling, and serialization format.&lt;/p&gt;
&lt;p&gt;It was impressive on a whiteboard.&lt;/p&gt;
&lt;p&gt;In production, debugging a single failed message meant jumping across four dashboards, correlating timestamps, and praying the logs hadn&amp;rsquo;t rotated.&lt;/p&gt;
&lt;p&gt;We rewrote it with a single Kafka topic and a consumer that wrote to Postgres. One queue, one database, one log stream. The whole pipeline became something a new hire could trace end-to-end in 30 minutes.&lt;/p&gt;
&lt;p&gt;Was it less &amp;ldquo;architecturally elegant&amp;rdquo;? Sure. But incidents that used to take hours to debug now took minutes.&lt;/p&gt;
&lt;h2 id="when-boring-gets-hard"&gt;When Boring Gets Hard&lt;/h2&gt;
&lt;p&gt;The tricky part is that boring takes discipline. It&amp;rsquo;s easy to say &amp;ldquo;just use Postgres&amp;rdquo; when you&amp;rsquo;re starting fresh. It&amp;rsquo;s harder when someone brings a compelling argument for a new technology in a design review.&lt;/p&gt;
&lt;p&gt;Here&amp;rsquo;s my mental filter:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Will someone besides me operate this at 3 AM?&lt;/strong&gt; If yes, pick the thing they already know.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Does this solve a problem we actually have, or one we might have?&lt;/strong&gt; Premature optimization isn&amp;rsquo;t just about code — it applies to architecture too.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;How many people in the world have debugged this in production?&lt;/strong&gt; The size of that community is directly proportional to how fast you&amp;rsquo;ll recover from outages.&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 id="boring-scales"&gt;Boring Scales&lt;/h2&gt;
&lt;p&gt;There&amp;rsquo;s a misconception that boring tech doesn&amp;rsquo;t scale. That&amp;rsquo;s backwards. Boring tech has &lt;em&gt;already&lt;/em&gt; scaled — that&amp;rsquo;s why it&amp;rsquo;s boring. Postgres handles more production traffic globally than most hyped databases ever will. Kafka has been battle-tested at LinkedIn, Netflix, and thousands of companies that never wrote blog posts about it.&lt;/p&gt;
&lt;p&gt;The systems that survive aren&amp;rsquo;t the ones that trended on Hacker News. They&amp;rsquo;re the ones that kept running quietly while everything around them caught fire.&lt;/p&gt;
&lt;p&gt;Boring systems don&amp;rsquo;t get applause.&lt;/p&gt;
&lt;p&gt;But they don&amp;rsquo;t page you at 3 AM either.&lt;/p&gt;</content:encoded></item><item><title>Data Flow Is Everything</title><link>https://rohitsaini.me/writing/data-flow-is-everything/</link><guid>https://rohitsaini.me/writing/data-flow-is-everything/</guid><pubDate>Sat, 22 Nov 2025 00:00:00 +0000</pubDate><description>Every backend problem eventually becomes a data flow problem.</description><content:encoded xmlns:content="http://purl.org/rss/1.0/modules/content/">&lt;p&gt;I&amp;rsquo;ve noticed a pattern over the years. Whenever a system starts behaving weirdly — data shows up late, duplicates appear, something silently goes stale — the root cause is almost never a bug in the traditional sense.&lt;/p&gt;
&lt;p&gt;It&amp;rsquo;s a data flow problem.&lt;/p&gt;
&lt;p&gt;Someone didn&amp;rsquo;t think about where the data comes from, who transforms it, who owns the authoritative copy, and what happens when something in that chain fails.&lt;/p&gt;
&lt;h2 id="start-with-a-map"&gt;Start With a Map&lt;/h2&gt;
&lt;p&gt;Before writing any code for a new system, I draw the data flow. Not a UML diagram — nobody has time for that. Just boxes and arrows on a whiteboard (or, honestly, a notes app). Something like:&lt;/p&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt;User Request
↓
API Gateway → Validation → Service A
↓
Kafka Topic
↓ ↓
Consumer B Consumer C
↓ ↓
Postgres Elasticsearch
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;This simple sketch already surfaces questions you&amp;rsquo;d otherwise discover in production:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Consumer B and Consumer C both process the same event. Are they idempotent? What if one succeeds and the other fails?&lt;/li&gt;
&lt;li&gt;Postgres and Elasticsearch will eventually diverge. Which one is the source of truth? How do you reconcile?&lt;/li&gt;
&lt;li&gt;If the API Gateway retries a failed request, does Service A handle duplicates?&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;I can&amp;rsquo;t count the number of times a 10-minute whiteboard session like this prevented weeks of debugging later.&lt;/p&gt;
&lt;h2 id="the-four-questions"&gt;The Four Questions&lt;/h2&gt;
&lt;p&gt;Whenever I look at any data flowing through a system, I ask four questions:&lt;/p&gt;
&lt;h3 id="1-where-does-it-originate"&gt;1. Where does it originate?&lt;/h3&gt;
&lt;p&gt;Every piece of data has a birth place. A user action, an external API, a scheduled job. Knowing the origin tells you about reliability — user inputs are messy, external APIs are flaky, scheduled jobs can overlap.&lt;/p&gt;
&lt;h3 id="2-who-transforms-it"&gt;2. Who transforms it?&lt;/h3&gt;
&lt;p&gt;Data rarely stays in its original form. It gets validated, enriched, aggregated, filtered. Each transformation is a place where bugs hide. The more transformations, the harder it is to trace a value back to its origin.&lt;/p&gt;
&lt;p&gt;Here&amp;rsquo;s a pattern that bites people: Service A publishes an event with a raw user input. Service B enriches it by calling an external API. Service C receives the enriched version. Now, when something&amp;rsquo;s wrong with the enriched data, you need to check three services to find the problem. Sometimes the external API returned bad data. Sometimes Service B had a caching bug. Sometimes Service A sent the wrong field.&lt;/p&gt;
&lt;h3 id="3-who-owns-it"&gt;3. Who owns it?&lt;/h3&gt;
&lt;p&gt;This is the question teams skip most often, and it causes the most pain. Ownership means: who is responsible when this data is wrong? If two services both write to the same row, you don&amp;rsquo;t have shared ownership — you have &lt;em&gt;no&lt;/em&gt; ownership.&lt;/p&gt;
&lt;p&gt;A rule I follow: &lt;strong&gt;one writer per data entity.&lt;/strong&gt; If &lt;code&gt;orders&lt;/code&gt; service writes order status, nobody else does. Other services read it, react to it, cache it — but they don&amp;rsquo;t write to it. This single constraint prevents an enormous class of data inconsistency bugs.&lt;/p&gt;
&lt;h3 id="4-how-does-it-fail"&gt;4. How does it fail?&lt;/h3&gt;
&lt;p&gt;This is where it gets interesting. Data flow failures are rarely boolean (works/doesn&amp;rsquo;t work). They&amp;rsquo;re usually partial, delayed, or silent:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;A Kafka consumer falls behind. Data is technically flowing, but 4 hours late.&lt;/li&gt;
&lt;li&gt;An API times out and a retry publishes a duplicate event.&lt;/li&gt;
&lt;li&gt;A database migration adds a new column, but the producer hasn&amp;rsquo;t been updated. Now half the events have &lt;code&gt;null&lt;/code&gt; in a field downstream services expect.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Drawing the failure paths — not just the happy paths — is what separates design docs that look good from systems that actually survive production.&lt;/p&gt;
&lt;h2 id="an-example-the-accidental-dual-write"&gt;An Example: The Accidental Dual Write&lt;/h2&gt;
&lt;p&gt;A while back, I worked on a messaging system where we needed to update both a thread&amp;rsquo;s status and send a notification whenever a new message arrived.&lt;/p&gt;
&lt;p&gt;The first implementation was simple:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;handle_new_message&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;update_thread_status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;thread_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;active&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;notification_service&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;thread_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;new_message&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;What could go wrong?&lt;/p&gt;
&lt;p&gt;Everything.&lt;/p&gt;
&lt;p&gt;If the database update succeeds but the notification call fails, the thread is marked active but nobody gets notified. If I wrapped both in a try-except and retried, I&amp;rsquo;d potentially send duplicate notifications. If I used a database transaction, it wouldn&amp;rsquo;t matter because &lt;code&gt;notification_service&lt;/code&gt; is a separate system — you can&amp;rsquo;t transact across two different services.&lt;/p&gt;
&lt;p&gt;The fix was to stop trying to do both at once. Instead, we leaned on the data flow:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;handle_new_message&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;update_thread_status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;thread_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;active&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="c1"&gt;# That&amp;#39;s it. The DB change triggers a CDC event.&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# Separate consumer picks up the change event&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;on_thread_status_changed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;new_status&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;active&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;notification_service&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;thread_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;new_message&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Now each step does exactly one thing. The data flow is clear: message arrives → DB update → change event → notification. If the notification fails, we retry the event. No dual writes, no partial states.&lt;/p&gt;
&lt;h2 id="why-this-matters"&gt;Why This Matters&lt;/h2&gt;
&lt;p&gt;Most architecture debates — monolith vs. microservices, SQL vs. NoSQL, sync vs. async — are actually data flow debates in disguise. Once you draw the flow clearly, the right architecture usually becomes obvious.&lt;/p&gt;
&lt;p&gt;The system&amp;rsquo;s behavior isn&amp;rsquo;t defined by its components. It&amp;rsquo;s defined by how data moves between them.&lt;/p&gt;
&lt;p&gt;Start with the flow. The code follows.&lt;/p&gt;</content:encoded></item><item><title>Elasticsearch Sharding, Done Right</title><link>https://rohitsaini.me/writing/elasticsearch-sharding-done-right/</link><guid>https://rohitsaini.me/writing/elasticsearch-sharding-done-right/</guid><pubDate>Sun, 10 Nov 2024 00:00:00 +0000</pubDate><description>Sharding and index mappings are the foundation of a healthy Elasticsearch cluster. Get them wrong, and no amount of hardware will save you.</description><content:encoded xmlns:content="http://purl.org/rss/1.0/modules/content/">&lt;p&gt;Elasticsearch is deceptively easy to get started with. Throw some JSON at it, and it indexes. Query it, and you get results. It feels like magic — until your cluster has 500 million documents and everything is on fire.&lt;/p&gt;
&lt;p&gt;I&amp;rsquo;ve spent a lot of time operating Elasticsearch (well, OpenSearch these days, but the concepts are the same). And the lesson I keep learning is that the problems you hit at scale are almost never about Elasticsearch itself. They&amp;rsquo;re about decisions you made early — sharding strategy and index mappings — that seemed harmless at the time.&lt;/p&gt;
&lt;h2 id="what-sharding-actually-is"&gt;What Sharding Actually Is&lt;/h2&gt;
&lt;p&gt;Let&amp;rsquo;s start from the basics, because I&amp;rsquo;ve seen too many engineers use shards without really understanding what they are.&lt;/p&gt;
&lt;p&gt;An Elasticsearch index is not a single blob of data sitting on a single machine. It&amp;rsquo;s split into &lt;strong&gt;shards&lt;/strong&gt;, and each shard is an independent Lucene index. When you create an index with 5 primary shards, you&amp;rsquo;re essentially creating 5 smaller search engines that work together.&lt;/p&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt;Index: &amp;#34;messages&amp;#34;
├── Shard 0 → Node A
├── Shard 1 → Node B
├── Shard 2 → Node A
├── Shard 3 → Node C
└── Shard 4 → Node B
Each shard is a complete, independent Lucene index.
A search query hits ALL shards in parallel, results are merged.
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Why does this matter? Because:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Writes are distributed.&lt;/strong&gt; Each document gets routed to one shard (by default, based on a hash of the document ID). More shards = writes spread across more nodes.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Reads are parallelized.&lt;/strong&gt; A search query runs on every shard simultaneously. The results from each shard are merged and returned.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Each shard has overhead.&lt;/strong&gt; Memory for segment metadata, file handles, thread pools. Shards aren&amp;rsquo;t free.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;That last point is the one most people learn too late.&lt;/p&gt;
&lt;h2 id="the-oversharding-problem"&gt;The Oversharding Problem&lt;/h2&gt;
&lt;p&gt;The most common mistake I see: too many shards. Someone creates an index with 20 primary shards &amp;ldquo;just in case&amp;rdquo; on a 3-node cluster. Each shard has a replica. That&amp;rsquo;s 40 shards for a single index across 3 nodes — roughly 13 shards per node.&lt;/p&gt;
&lt;p&gt;Now multiply that by 30 indices (one per day, for a month of logs). That&amp;rsquo;s 1,200 shards on a 3-node cluster. Each shard consumes heap memory, file descriptors, and thread pool resources. The cluster spends more time managing shard metadata than actually searching.&lt;/p&gt;
&lt;p&gt;Symptoms of oversharding:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Cluster state updates become slow&lt;/li&gt;
&lt;li&gt;JVM heap pressure is high even with small indices&lt;/li&gt;
&lt;li&gt;Search latency increases despite low query volume&lt;/li&gt;
&lt;li&gt;Node startup takes forever because it&amp;rsquo;s recovering hundreds of shards&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id="how-many-shards-do-you-actually-need"&gt;How Many Shards Do You Actually Need?&lt;/h3&gt;
&lt;p&gt;My rule of thumb:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Target 20-40 GB per shard.&lt;/strong&gt; This is the sweet spot Elastic themselves recommend. Small enough to recover quickly, large enough to be efficient.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Keep total shards per node under 600-800.&lt;/strong&gt; Beyond this, cluster state management becomes a bottleneck.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Start with 1 shard&lt;/strong&gt; for small indices. Seriously. If your index is under 10 GB, one primary shard is fine.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt;Example calculation:
Expected index size: 100 GB
Target shard size: 30 GB
Primary shards needed: ceil(100 / 30) = 4
With 1 replica: 4 × 2 = 8 total shards
On a 3-node cluster: ~2-3 shards per node ✓
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;You can always add more shards later via reindexing or the split API. You can&amp;rsquo;t easily reduce them. Start small.&lt;/p&gt;
&lt;h2 id="index-mappings-the-thing-you-cant-fix-later"&gt;Index Mappings: The Thing You Can&amp;rsquo;t Fix Later&lt;/h2&gt;
&lt;p&gt;If sharding is the physical layout, mappings are the logical layout. They define how Elasticsearch interprets and stores each field in your documents.&lt;/p&gt;
&lt;p&gt;And here&amp;rsquo;s the thing people don&amp;rsquo;t realize: &lt;strong&gt;once a field is mapped, you can&amp;rsquo;t change its type without reindexing.&lt;/strong&gt; If you accidentally let Elasticsearch auto-detect that a field is &lt;code&gt;text&lt;/code&gt; when it should be &lt;code&gt;keyword&lt;/code&gt;, you&amp;rsquo;re stuck with it until you create a new index and migrate your data.&lt;/p&gt;
&lt;h3 id="text-vs-keyword"&gt;Text vs Keyword&lt;/h3&gt;
&lt;p&gt;This is the single most important mapping decision:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;text&lt;/code&gt;&lt;/strong&gt;: The field is analyzed — broken into tokens, lowercased, stemmed. Good for full-text search. &amp;ldquo;Quick brown fox&amp;rdquo; becomes &lt;code&gt;[&amp;quot;quick&amp;quot;, &amp;quot;brown&amp;quot;, &amp;quot;fox&amp;quot;]&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;keyword&lt;/code&gt;&lt;/strong&gt;: The field is stored as-is. Good for exact matches, aggregations, sorting. &amp;ldquo;Quick brown fox&amp;rdquo; stays &lt;code&gt;&amp;quot;Quick brown fox&amp;quot;&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-json" data-lang="json"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;#34;mappings&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;#34;properties&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;#34;message_body&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;#34;type&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;text&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;#34;analyzer&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;standard&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;#34;status&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;#34;type&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;keyword&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;#34;user_email&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;#34;type&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;keyword&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;#34;created_at&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;#34;type&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;date&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;#34;format&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;strict_date_optional_time||epoch_millis&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Common mistakes I&amp;rsquo;ve seen:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Email addresses mapped as &lt;code&gt;text&lt;/code&gt;.&lt;/strong&gt; Elasticsearch tokenizes &lt;code&gt;rohit@example.com&lt;/code&gt; into &lt;code&gt;[&amp;quot;rohit&amp;quot;, &amp;quot;example.com&amp;quot;]&lt;/code&gt;. Now searching for the exact email doesn&amp;rsquo;t work as expected, and aggregations on email are meaningless.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Status fields mapped as &lt;code&gt;text&lt;/code&gt;.&lt;/strong&gt; You want to filter by &lt;code&gt;status: &amp;quot;active&amp;quot;&lt;/code&gt;, but because it&amp;rsquo;s analyzed, you also match documents containing the word &amp;ldquo;active&amp;rdquo; anywhere in a text field.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Numeric IDs mapped as &lt;code&gt;text&lt;/code&gt;.&lt;/strong&gt; Thread IDs, user IDs — these should be &lt;code&gt;keyword&lt;/code&gt;. You&amp;rsquo;re never going to full-text search an ID.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id="turn-off-dynamic-mapping"&gt;Turn Off Dynamic Mapping&lt;/h3&gt;
&lt;p&gt;By default, Elasticsearch will auto-detect field types when it sees a new field. This sounds helpful but causes problems:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;A field that looks like a number gets mapped as &lt;code&gt;long&lt;/code&gt;. Later you send a string value for the same field. Indexing fails.&lt;/li&gt;
&lt;li&gt;String fields default to both &lt;code&gt;text&lt;/code&gt; and &lt;code&gt;keyword&lt;/code&gt; (a multi-field mapping), doubling the storage.&lt;/li&gt;
&lt;li&gt;You end up with hundreds of fields you didn&amp;rsquo;t plan for, all consuming resources.&lt;/li&gt;
&lt;/ul&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-json" data-lang="json"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;#34;mappings&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;#34;dynamic&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;strict&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;#34;properties&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;#34;thread_id&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nt"&gt;&amp;#34;type&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;keyword&amp;#34;&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;#34;message&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nt"&gt;&amp;#34;type&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;text&amp;#34;&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;#34;timestamp&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nt"&gt;&amp;#34;type&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;date&amp;#34;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;With &lt;code&gt;&amp;quot;dynamic&amp;quot;: &amp;quot;strict&amp;quot;&lt;/code&gt;, Elasticsearch rejects any document with a field not in your mapping. It feels restrictive, but it prevents mapping surprises in production.&lt;/p&gt;
&lt;h2 id="managing-shards-over-time"&gt;Managing Shards Over Time&lt;/h2&gt;
&lt;p&gt;For time-series data (logs, events, metrics), the standard approach is &lt;strong&gt;time-based indices&lt;/strong&gt; — one index per day or per week. This gives you natural data lifecycle management: old indices can be closed, frozen, or deleted without affecting current data.&lt;/p&gt;
&lt;p&gt;But time-based indices can lead to oversharding if you&amp;rsquo;re not careful. 365 days × 5 shards × 2 (with replicas) = 3,650 shards per year for a single data type.&lt;/p&gt;
&lt;h3 id="index-lifecycle-management-ilm"&gt;Index Lifecycle Management (ILM)&lt;/h3&gt;
&lt;p&gt;ILM automates the lifecycle of your indices through phases:&lt;/p&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt;Hot → Warm → Cold → Delete
&lt;/code&gt;&lt;/pre&gt;&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Hot&lt;/strong&gt;: Actively writing. Fast storage. Full replicas.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Warm&lt;/strong&gt;: No longer writing. Can reduce replicas. Move to cheaper storage.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Cold&lt;/strong&gt;: Rarely queried. Frozen indices use minimal resources.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Delete&lt;/strong&gt;: Data retention period expired.&lt;/li&gt;
&lt;/ul&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-json" data-lang="json"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;#34;policy&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;#34;phases&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;#34;hot&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;#34;actions&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;#34;rollover&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;#34;max_size&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;30gb&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;#34;max_age&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;1d&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;#34;warm&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;#34;min_age&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;7d&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;#34;actions&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;#34;shrink&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nt"&gt;&amp;#34;number_of_shards&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;#34;forcemerge&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nt"&gt;&amp;#34;max_num_segments&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;#34;delete&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;#34;min_age&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;90d&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;#34;actions&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nt"&gt;&amp;#34;delete&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The &lt;code&gt;shrink&lt;/code&gt; action in the warm phase is particularly useful — it reduces the number of shards in an index. That 5-shard index that made sense during active writes can become a 1-shard index once it&amp;rsquo;s read-only. This keeps your total shard count manageable.&lt;/p&gt;
&lt;h3 id="rollover-instead-of-date-based-names"&gt;Rollover Instead of Date-Based Names&lt;/h3&gt;
&lt;p&gt;Instead of creating &lt;code&gt;logs-2024-11-10&lt;/code&gt;, &lt;code&gt;logs-2024-11-11&lt;/code&gt;, etc., use rollover aliases. Elasticsearch creates a new index when the current one hits a size or age threshold.&lt;/p&gt;
&lt;p&gt;This means your shards stay at a consistent, healthy size regardless of traffic patterns. On a busy day, you might roll over twice. On a quiet day, not at all. The data drives the sharding, not the calendar.&lt;/p&gt;
&lt;h2 id="the-cheat-sheet"&gt;The Cheat Sheet&lt;/h2&gt;
&lt;p&gt;Here&amp;rsquo;s what I wish someone had told me early on:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Decision&lt;/th&gt;
&lt;th&gt;Recommendation&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Shard size&lt;/td&gt;
&lt;td&gt;Target 20-40 GB per shard&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Starting shards&lt;/td&gt;
&lt;td&gt;1 for small indices, calculate for large ones&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Replicas&lt;/td&gt;
&lt;td&gt;At least 1 for production, 0 for dev&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dynamic mapping&lt;/td&gt;
&lt;td&gt;Set to &lt;code&gt;strict&lt;/code&gt; — define your fields explicitly&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;IDs and status fields&lt;/td&gt;
&lt;td&gt;&lt;code&gt;keyword&lt;/code&gt;, never &lt;code&gt;text&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Free-text content&lt;/td&gt;
&lt;td&gt;&lt;code&gt;text&lt;/code&gt; with an appropriate analyzer&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Time-series data&lt;/td&gt;
&lt;td&gt;Use ILM with rollover&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Old indices&lt;/td&gt;
&lt;td&gt;Shrink, then freeze or delete&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The boring truth about Elasticsearch is that the clusters I&amp;rsquo;ve seen run smoothly aren&amp;rsquo;t the ones with the most nodes or the cleverest queries. They&amp;rsquo;re the ones where someone thought carefully about shards and mappings before the first document was indexed.&lt;/p&gt;
&lt;p&gt;Get the foundation right, and everything else follows. Get it wrong, and no amount of hardware will save you.&lt;/p&gt;</content:encoded></item><item><title>You Probably Don't Need Microservices</title><link>https://rohitsaini.me/writing/you-probably-dont-need-microservices/</link><guid>https://rohitsaini.me/writing/you-probably-dont-need-microservices/</guid><pubDate>Thu, 15 Jun 2023 00:00:00 +0000</pubDate><description>The microservices hype led teams to split too early. Most systems would be better off as a well-structured monolith.</description><content:encoded xmlns:content="http://purl.org/rss/1.0/modules/content/">&lt;p&gt;In 2023, Amazon&amp;rsquo;s Prime Video team published a blog post that broke the internet — at least the engineering corner of it. They moved a monitoring service from a distributed microservices architecture back to a monolith. The result? 90% cost reduction.&lt;/p&gt;
&lt;p&gt;The reaction was predictable. Half the internet said &amp;ldquo;see, microservices are a scam.&amp;rdquo; The other half said &amp;ldquo;you&amp;rsquo;re missing the context.&amp;rdquo; Both were partially right.&lt;/p&gt;
&lt;p&gt;But the real takeaway wasn&amp;rsquo;t about Amazon at all. It was about the rest of us — the teams that adopted microservices not because they needed to, but because it felt like the right thing to do.&lt;/p&gt;
&lt;h2 id="how-we-got-here"&gt;How We Got Here&lt;/h2&gt;
&lt;p&gt;Around 2015-2018, microservices became the default answer to every architecture question. Netflix talked about them. Uber talked about them. Conference talks were wall-to-wall microservices. If you were building a monolith, you were doing it wrong.&lt;/p&gt;
&lt;p&gt;The pitch was compelling:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Independent deployment&lt;/li&gt;
&lt;li&gt;Teams can move fast without stepping on each other&lt;/li&gt;
&lt;li&gt;Scale individual services based on demand&lt;/li&gt;
&lt;li&gt;Use the best language/framework for each service&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;All true. All real benefits. For organizations with hundreds of engineers deploying dozens of times a day.&lt;/p&gt;
&lt;p&gt;The problem? Most of us aren&amp;rsquo;t Netflix. Most teams have 5-15 engineers, deploy a few times a week, and are building products where the biggest bottleneck is figuring out what to build — not how to scale it.&lt;/p&gt;
&lt;h2 id="the-hidden-costs"&gt;The Hidden Costs&lt;/h2&gt;
&lt;p&gt;What nobody talked about in those conference talks was the operational overhead. When you split a monolith into 10 services, you don&amp;rsquo;t just get 10 independent deployables. You get:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;10 CI/CD pipelines&lt;/strong&gt; to maintain&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;10 sets of logs&lt;/strong&gt; to search through when something breaks&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Network calls&lt;/strong&gt; where you used to have function calls — each one a potential failure point&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Distributed transactions&lt;/strong&gt; where you used to have database transactions&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Service discovery, load balancing, circuit breakers&lt;/strong&gt; — infrastructure that didn&amp;rsquo;t exist before because it didn&amp;rsquo;t need to&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Data consistency problems&lt;/strong&gt; because you now have 10 databases instead of one&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;I&amp;rsquo;ve seen teams spend more time debugging inter-service communication than building features. The service mesh becomes the product, and the actual product becomes an afterthought.&lt;/p&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt;Monolith:
function_a() → function_b() → function_c()
Total failure points: 0 network calls
Microservices:
Service A --HTTP-→ Service B --gRPC-→ Service C
Total failure points: 2 network calls, 2 serializations,
2 deserializations, load balancer hops,
DNS resolution, TLS handshakes...
&lt;/code&gt;&lt;/pre&gt;&lt;h2 id="the-monolith-isnt-the-problem"&gt;The Monolith Isn&amp;rsquo;t the Problem&lt;/h2&gt;
&lt;p&gt;When teams hit scaling issues with a monolith, the instinct is to break it apart. But most of the time, the monolith isn&amp;rsquo;t the problem — the &lt;em&gt;structure&lt;/em&gt; of the monolith is.&lt;/p&gt;
&lt;p&gt;A well-structured monolith has clear module boundaries, separated concerns, and defined interfaces between components. It&amp;rsquo;s basically microservices in a single process, without the network overhead.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# A well-structured monolith — clear boundaries, single process&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# orders/service.py&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;OrderService&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;create_order&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="bp"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;items&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;order&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;order_repo&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;save&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;items&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="bp"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;event_bus&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;emit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;order.created&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;# in-process event&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# notifications/handlers.py&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;NotificationHandler&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;on_order_created&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="bp"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="bp"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;email_service&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;send_confirmation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# billing/handlers.py &lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;BillingHandler&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;on_order_created&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="bp"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="bp"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;invoice_service&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;generate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Same separation of concerns. Same independent logic. But function calls instead of HTTP calls. Shared transactions instead of distributed ones. One deployment instead of three.&lt;/p&gt;
&lt;p&gt;If the day comes when the notifications team needs to deploy 10 times a day independently, or the billing module needs to scale to 100x the traffic of orders — &lt;em&gt;then&lt;/em&gt; you extract it. Not before. Not because a conference talk said so.&lt;/p&gt;
&lt;h2 id="when-microservices-actually-make-sense"&gt;When Microservices Actually Make Sense&lt;/h2&gt;
&lt;p&gt;I&amp;rsquo;m not saying microservices are always wrong. They make sense when:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;You have multiple teams&lt;/strong&gt; that need to deploy independently and you&amp;rsquo;re stepping on each other&amp;rsquo;s toes&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;One part of the system has fundamentally different scaling needs&lt;/strong&gt; — a real-time chat feature vs. a batch report generator&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;You&amp;rsquo;re integrating systems across different technology stacks&lt;/strong&gt; — an ML pipeline in Python talking to a web app in Go&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Organizational boundaries align with service boundaries&lt;/strong&gt; — Conway&amp;rsquo;s Law is real&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The key word is &lt;em&gt;need&lt;/em&gt;. Not &amp;ldquo;might need someday&amp;rdquo; or &amp;ldquo;Netflix needs this.&amp;rdquo; Actual, present-tense need backed by real pain.&lt;/p&gt;
&lt;h2 id="my-rule-of-thumb"&gt;My Rule of Thumb&lt;/h2&gt;
&lt;p&gt;Start with a monolith. Structure it well. When a specific module has a clear, demonstrable reason to be independent — different scaling needs, different team ownership, different deployment cadence — extract it.&lt;/p&gt;
&lt;p&gt;This isn&amp;rsquo;t revolutionary advice. It&amp;rsquo;s boring advice. And by now you know how I feel about boring.&lt;/p&gt;
&lt;p&gt;The best architecture isn&amp;rsquo;t the one with the most boxes on the diagram. It&amp;rsquo;s the one where every box earns its operational overhead. For most teams, most of the time, that means fewer boxes than you think.&lt;/p&gt;</content:encoded></item></channel></rss>