<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Arnav Sharma</title>
    <description>The latest articles on DEV Community by Arnav Sharma (@arnavsharma2711).</description>
    <link>https://dev.to/arnavsharma2711</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F2687372%2Fa440aef5-9cc5-4d6c-ab13-3bbbe77f4c6e.jpg</url>
      <title>DEV Community: Arnav Sharma</title>
      <link>https://dev.to/arnavsharma2711</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/arnavsharma2711"/>
    <language>en</language>
    <item>
      <title>How to Secure OAuth 2.0 in Production</title>
      <dc:creator>Arnav Sharma</dc:creator>
      <pubDate>Fri, 14 Aug 2026 10:17:11 +0000</pubDate>
      <link>https://dev.to/arnavsharma2711/how-to-secure-oauth-20-in-production-5fc9</link>
      <guid>https://dev.to/arnavsharma2711/how-to-secure-oauth-20-in-production-5fc9</guid>
      <description>&lt;p&gt;You got OAuth working. Users click "Login with Google," a token comes back, your API accepts it. Shipped.&lt;/p&gt;

&lt;p&gt;And then someone steals that token from the URL fragment. Or intercepts the authorization code on a mobile device. Or crafts a redirect URI that funnels credentials to their domain. These aren't theoretical attacks. They're documented in RFC 9700 with section numbers and mitigation steps, and they work against implementations that looked fine on the happy path.&lt;/p&gt;

&lt;p&gt;If you've read my &lt;a href="https://www.arnavsharma.dev/blogs/oauth-openid-connect" rel="noopener noreferrer"&gt;intro to OAuth and OpenID Connect&lt;/a&gt;, you know the basic flow. This post picks up where that one left off. Here's what you need to actually harden it.&lt;/p&gt;




&lt;h2&gt;
  
  
  The flows you need to stop using
&lt;/h2&gt;

&lt;p&gt;Two OAuth grants are officially dead. Not deprecated-but-still-kinda-fine. Dead.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Implicit flow&lt;/strong&gt; (&lt;code&gt;response_type=token&lt;/code&gt;) puts access tokens directly in the URL fragment. That means they show up in browser history, leak through Referer headers to any third-party resource on your callback page, and get exposed by open redirectors. You can't sender-constrain them. You can't rotate them. RFC 9700 Section 2.1.2 says SHOULD NOT. OAuth 2.1 removes it entirely.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Resource Owner Password Credentials (ROPC)&lt;/strong&gt; hands the user's actual password to your client application. This breaks the entire point of delegated authorization. It's also incompatible with MFA, WebAuthn, passkeys, or any modern authentication mechanism. RFC 9700 Section 2.4 says MUST NOT. Gone in 2.1.&lt;/p&gt;

&lt;p&gt;So what do you use? Authorization Code flow. For everything. Public clients, confidential clients, SPAs, mobile apps. One flow to rule them all, protected by PKCE.&lt;/p&gt;

&lt;h2&gt;
  
  
  🔑 PKCE is non-negotiable
&lt;/h2&gt;

&lt;p&gt;PKCE (Proof Key for Code Exchange, &lt;a href="https://www.rfc-editor.org/info/rfc7636" rel="noopener noreferrer"&gt;RFC 7636&lt;/a&gt;) solves three problems at once: code interception, code injection, and CSRF.&lt;/p&gt;

&lt;p&gt;Here's the attack without it. Your app redirects the user to the authorization server. The AS issues a code and redirects back. But on mobile, multiple apps can register the same custom URI scheme. A malicious app intercepts the redirect, grabs the code, and exchanges it for tokens. Game over.&lt;/p&gt;

&lt;p&gt;With PKCE, your app generates a random &lt;code&gt;code_verifier&lt;/code&gt; before starting the flow, computes a &lt;code&gt;code_challenge&lt;/code&gt; from it (SHA-256 hash, base64url-encoded), and sends only the challenge to the AS. When exchanging the code for tokens, you prove possession of the original verifier. The attacker has the code but not the verifier. Useless.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;node:crypto&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;generatePKCE&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;verifier&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;randomBytes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;32&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;base64url&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;challenge&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createHash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;sha256&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;verifier&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;base64url&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;code_verifier&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;verifier&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;code_challenge&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;challenge&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;S256&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Always use S256. The &lt;code&gt;plain&lt;/code&gt; method sends the verifier as the challenge itself, which means anyone who can read the authorization request already has it. Defeats the purpose.&lt;/p&gt;

&lt;p&gt;RFC 9700 Section 2.1.1 makes PKCE mandatory for public clients. But honestly, use it for confidential clients too. OAuth 2.1 will require it for all client types. There's no downside.&lt;/p&gt;

&lt;h2&gt;
  
  
  🎯 Redirect URI: exact match or get wrecked
&lt;/h2&gt;

&lt;p&gt;Your authorization server must validate redirect URIs with &lt;strong&gt;exact string matching&lt;/strong&gt;. Not pattern matching. Not wildcard subdomains. Exact.&lt;/p&gt;

&lt;p&gt;Why? Because &lt;code&gt;https://*.myapp.com/callback&lt;/code&gt; also matches &lt;code&gt;https://evil.myapp.com/callback&lt;/code&gt;. And subdomain takeovers are common. An attacker claims an abandoned subdomain, registers it as a redirect URI, and now authorization codes flow directly to them.&lt;/p&gt;

&lt;p&gt;It gets worse with naive pattern matching. A poorly implemented check for &lt;code&gt;https://myapp.com&lt;/code&gt; might also accept &lt;code&gt;https://attacker.com/.myapp.com&lt;/code&gt;. Combined with open redirectors, this lets attackers steal tokens without needing a subdomain takeover at all.&lt;/p&gt;

&lt;p&gt;The only exception RFC 9700 allows: localhost with variable ports for native desktop apps during development (RFC 8252 Section 7.3). Everything else? Exact strings.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where to store tokens in the browser
&lt;/h2&gt;

&lt;p&gt;This is probably the most debated topic in OAuth security. Every option has problems.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;localStorage&lt;/strong&gt;: Accessible to any JavaScript on your page. One XSS vulnerability and every token is gone. Not great.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Memory only&lt;/strong&gt;: Safe from XSS-based theft, but tokens vanish on page refresh. Terrible UX.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;httpOnly secure cookies&lt;/strong&gt;: JavaScript can't read them, so XSS can't steal them. But now you need CSRF protection on every request. And you're back to dealing with cookie semantics, SameSite attributes, and cross-origin headaches.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The BFF pattern&lt;/strong&gt; (Backend For Frontend): This is what RFC 9700 recommends for browser-based apps. Your frontend never touches tokens at all. A server-side component handles the OAuth flow, stores tokens in a server session, and issues a plain httpOnly session cookie to the browser. The browser sends the cookie, the BFF attaches the access token before forwarding to your API.&lt;/p&gt;

&lt;p&gt;Tokens never exist in JavaScript. XSS can't steal what isn't there. It adds a component to your architecture, but it's the only approach where a single XSS doesn't mean full token compromise.&lt;/p&gt;

&lt;h2&gt;
  
  
  🧠 Refresh token rotation and reuse detection
&lt;/h2&gt;

&lt;p&gt;Access tokens should live for 5-15 minutes. Short enough that a stolen one has limited blast radius. But you need refresh tokens for session continuity.&lt;/p&gt;

&lt;p&gt;For public clients, RFC 9700 Section 4.14.2 requires either sender-constraining (DPoP or mTLS) or refresh token rotation. Rotation means every time a client uses a refresh token, the AS issues a new one and invalidates the old one. Each refresh token is single-use.&lt;/p&gt;

&lt;p&gt;The important part: &lt;strong&gt;reuse detection&lt;/strong&gt;. If someone presents an already-used refresh token, that's a compromise signal. The AS should immediately revoke the entire token family. Not just that one token. Everything associated with that session.&lt;/p&gt;

&lt;p&gt;So if an attacker steals a refresh token and races the legitimate client, one of them will present the invalidated token. The AS catches it and nukes both sessions. Aggressive, but correct.&lt;/p&gt;

&lt;h2&gt;
  
  
  ⚡ Validating tokens on your resource server
&lt;/h2&gt;

&lt;p&gt;If you're accepting JWTs as access tokens, validation isn't optional. Every request needs these checks:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;jwt&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;jsonwebtoken&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;jwksClient&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;jwks-rsa&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;jwksClient&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;jwksUri&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://as.example/.well-known/jwks.json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;validateAccessToken&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;header&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;jwt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;complete&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getSigningKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;header&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;kid&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;jwt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;verify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getPublicKey&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;issuer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://as.example&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;audience&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://api.example.com&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;algorithms&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;RS256&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Check the signature against the AS's published JWKS. Verify &lt;code&gt;iss&lt;/code&gt; matches your expected authorization server. Confirm &lt;code&gt;aud&lt;/code&gt; includes your resource server's identifier. Reject if &lt;code&gt;exp&lt;/code&gt; is in the past. Fail on any one of these and you reject the token. No partial credit.&lt;/p&gt;

&lt;p&gt;And one thing I see constantly: &lt;strong&gt;don't use ID Tokens as access tokens&lt;/strong&gt;. An ID Token's audience is the client application. It's an authentication assertion, not an authorization credential. Sending it as a Bearer token to your API is wrong, even if it "works" because your API doesn't check the audience claim. If you need a refresher on how JWTs work and why claims matter, I wrote about that in my &lt;a href="https://www.arnavsharma.dev/blogs/understanding-jwt" rel="noopener noreferrer"&gt;JWT deep dive&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The state parameter still matters
&lt;/h2&gt;

&lt;p&gt;Even with PKCE handling CSRF protection, you should still use &lt;code&gt;state&lt;/code&gt;. It's cheap insurance.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;node:crypto&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// Before redirecting to AS&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;randomBytes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;base64url&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;oauthState&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// On callback&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;oauthState&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;CSRF detected&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;delete&lt;/span&gt; &lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;oauthState&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// one-time use&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Bind it to the user's session. Make it one-time-use. And if your AS supports the &lt;code&gt;iss&lt;/code&gt; response parameter (RFC 9207), validate that too. It prevents mix-up attacks where a malicious authorization server tricks your client into sending codes to the wrong token endpoint.&lt;/p&gt;




&lt;h2&gt;
  
  
  📌 What to actually do
&lt;/h2&gt;

&lt;p&gt;Stop reading specs for a second. Here's the concrete checklist:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Use Authorization Code + PKCE (S256) for every client type. No exceptions&lt;/li&gt;
&lt;li&gt;Register redirect URIs as exact strings. Kill any wildcard patterns&lt;/li&gt;
&lt;li&gt;Store tokens server-side (BFF pattern) for browser apps. localStorage is a liability&lt;/li&gt;
&lt;li&gt;Set access token lifetime to 5-15 minutes. Use refresh token rotation with reuse detection&lt;/li&gt;
&lt;li&gt;Validate JWT access tokens fully: signature, issuer, audience, expiration&lt;/li&gt;
&lt;li&gt;Never send ID Tokens to your API as bearer credentials&lt;/li&gt;
&lt;li&gt;Include &lt;code&gt;state&lt;/code&gt; in every authorization request. One-time, session-bound, unpredictable&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;RFC 9700 is 80+ pages, but those seven points cover 90% of what will actually get you owned in production. The spec exists because people shipped without them and got burned.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;If you haven't read the basics yet, start with my post on &lt;a href="https://www.arnavsharma.dev/blogs/oauth-openid-connect" rel="noopener noreferrer"&gt;how OAuth and OpenID Connect work together&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  More from me
&lt;/h2&gt;

&lt;p&gt;I write about backend systems, auth, and developer tooling at &lt;a href="https://www.arnavsharma.dev" rel="noopener noreferrer"&gt;arnavsharma.dev&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>oauth</category>
      <category>security</category>
      <category>backend</category>
    </item>
    <item>
      <title>How Nginx Handles Thousands of Connections With One Thread</title>
      <dc:creator>Arnav Sharma</dc:creator>
      <pubDate>Fri, 14 Aug 2026 10:16:03 +0000</pubDate>
      <link>https://dev.to/arnavsharma2711/how-nginx-handles-thousands-of-connections-with-one-thread-4pjk</link>
      <guid>https://dev.to/arnavsharma2711/how-nginx-handles-thousands-of-connections-with-one-thread-4pjk</guid>
      <description>&lt;p&gt;You've copied an nginx config from Stack Overflow. It works. You have no idea why.&lt;/p&gt;

&lt;p&gt;Maybe you added a &lt;code&gt;location&lt;/code&gt; block that broke something else. Maybe you spent an hour debugging a 502 that turned out to be a missing trailing slash on &lt;code&gt;proxy_pass&lt;/code&gt;. Maybe you're just stacking directives you found in blog posts, hoping nothing conflicts.&lt;/p&gt;

&lt;p&gt;That's fine. Everyone starts there. But the moment you need to debug a production routing issue at midnight, "it works, don't touch it" stops being a strategy. So let's actually understand what this thing does under the hood.&lt;/p&gt;




&lt;h2&gt;
  
  
  🧠 The process model
&lt;/h2&gt;

&lt;p&gt;When you start nginx, you get one &lt;strong&gt;master process&lt;/strong&gt; and several &lt;strong&gt;worker processes&lt;/strong&gt;. That's it. No thread pools per request, no spawning child processes for each connection.&lt;/p&gt;

&lt;p&gt;The master runs as root. It reads your config, binds to ports 80 and 443, and spawns workers. Then it basically sits there managing the lifecycle: starting new workers, gracefully shutting down old ones during config reloads, and restarting them if they crash.&lt;/p&gt;

&lt;p&gt;Workers do all the real work. Every connection, every request, every byte of response goes through a worker. By default, &lt;code&gt;worker_processes&lt;/code&gt; is set to &lt;code&gt;1&lt;/code&gt;, but basically everyone sets it to &lt;code&gt;auto&lt;/code&gt;, which spawns one worker per CPU core.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="c1"&gt;# /etc/nginx/nginx.conf (top-level context)&lt;/span&gt;
&lt;span class="k"&gt;worker_processes&lt;/span&gt; &lt;span class="s"&gt;auto&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;worker_rlimit_nofile&lt;/span&gt; &lt;span class="mi"&gt;65535&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;events&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;worker_connections&lt;/span&gt; &lt;span class="mi"&gt;4096&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;multi_accept&lt;/span&gt; &lt;span class="no"&gt;on&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each worker handles connections independently. They don't share memory or coordinate on who takes which request. The OS kernel distributes incoming connections across the listening workers.&lt;/p&gt;

&lt;p&gt;And here's where it gets interesting.&lt;/p&gt;

&lt;h2&gt;
  
  
  ⚡ Why the event loop wins
&lt;/h2&gt;

&lt;p&gt;Apache's traditional model (prefork) spawns one process per request. Its worker MPM uses one thread per request. Either way, you're paying for an OS-level context switch and a chunk of memory for every single connection sitting there waiting for data.&lt;/p&gt;

&lt;p&gt;Nginx does something different. Each worker runs a &lt;strong&gt;single-threaded event loop&lt;/strong&gt;. One thread, thousands of connections.&lt;/p&gt;

&lt;p&gt;The worker registers all its sockets with the kernel using &lt;code&gt;epoll&lt;/code&gt; (Linux) or &lt;code&gt;kqueue&lt;/code&gt; (macOS/BSD). Then it waits. When any socket has data ready, the kernel tells the worker which ones. The worker processes those events, fires off responses, and goes back to waiting. Never blocks. Never sits idle consuming resources for a connection that isn't doing anything right now.&lt;/p&gt;

&lt;p&gt;This is why nginx can handle 10,000+ concurrent connections on hardware where Apache would fall over. It was literally built to solve the C10K problem back in 2002.&lt;/p&gt;

&lt;p&gt;But there's a catch. If anything in the event loop blocks, like reading a large file from a slow disk, the &lt;em&gt;entire&lt;/em&gt; worker stalls. Every other connection on that worker just waits. That's why nginx added thread pools (&lt;code&gt;aio threads;&lt;/code&gt;) to offload blocking disk I/O to a pool of 32 threads by default. The event loop stays responsive.&lt;/p&gt;

&lt;h2&gt;
  
  
  How config blocks map to requests
&lt;/h2&gt;

&lt;p&gt;The config is hierarchical: &lt;code&gt;http&lt;/code&gt; wraps &lt;code&gt;server&lt;/code&gt; wraps &lt;code&gt;location&lt;/code&gt;. Each level inherits from its parent and can override.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;http&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;# applies to all virtual hosts&lt;/span&gt;
    &lt;span class="kn"&gt;gzip&lt;/span&gt; &lt;span class="no"&gt;on&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kn"&gt;server&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kn"&gt;listen&lt;/span&gt; &lt;span class="mi"&gt;80&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;server_name&lt;/span&gt; &lt;span class="s"&gt;api.myapp.com&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="c1"&gt;# this server block handles requests to api.myapp.com&lt;/span&gt;

        &lt;span class="kn"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/users&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="kn"&gt;proxy_pass&lt;/span&gt; &lt;span class="s"&gt;http://127.0.0.1:3000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="kn"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/static&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="kn"&gt;root&lt;/span&gt; &lt;span class="n"&gt;/var/www&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 class="kn"&gt;server&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kn"&gt;listen&lt;/span&gt; &lt;span class="mi"&gt;80&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;server_name&lt;/span&gt; &lt;span class="s"&gt;admin.myapp.com&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="c1"&gt;# different hostname, different routing&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When a request arrives, nginx picks the right &lt;code&gt;server&lt;/code&gt; block by matching the &lt;code&gt;Host&lt;/code&gt; header against &lt;code&gt;server_name&lt;/code&gt;. Priority: exact match first, then longest leading wildcard (&lt;code&gt;*.example.com&lt;/code&gt;), then longest trailing wildcard (&lt;code&gt;mail.*&lt;/code&gt;), then first matching regex. No match? Falls back to &lt;code&gt;default_server&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Then it finds the right &lt;code&gt;location&lt;/code&gt; block. This is where people get tripped up.&lt;/p&gt;

&lt;h2&gt;
  
  
  🎯 Location matching precedence
&lt;/h2&gt;

&lt;p&gt;Location matching isn't first-match-wins. It has a specific priority order that ignores the sequence in your config file:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;= /exact&lt;/code&gt; - Exact match. Stops immediately if matched.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;^~ /prefix&lt;/code&gt; - Longest prefix match with the "stop searching" modifier. Skips regex.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;~ /regex&lt;/code&gt; or &lt;code&gt;~* /regex&lt;/code&gt; - Regular expressions (case-sensitive and case-insensitive). First regex match in config order wins.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;/prefix&lt;/code&gt; - Longest prefix match without the modifier. Used only if no regex matched.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;So a request to &lt;code&gt;/api/users/123&lt;/code&gt; checks all prefix locations, finds the longest match, then checks regexes. If a regex matches, it wins over the prefix. Unless that prefix had &lt;code&gt;^~&lt;/code&gt;, which blocks regex from overriding it.&lt;/p&gt;

&lt;p&gt;Confusing? Yeah. In my experience, most routing bugs come from people assuming locations are evaluated top-to-bottom. They're not.&lt;/p&gt;

&lt;h2&gt;
  
  
  The six jobs nginx actually does
&lt;/h2&gt;

&lt;p&gt;Nginx wears a lot of hats. But they all boil down to six things:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Static file serving&lt;/strong&gt; with &lt;code&gt;root&lt;/code&gt; or &lt;code&gt;alias&lt;/code&gt;, &lt;code&gt;try_files&lt;/code&gt; for SPA fallbacks, and &lt;code&gt;sendfile on&lt;/code&gt; for zero-copy delivery straight from kernel space. Fast.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reverse proxying&lt;/strong&gt; with &lt;code&gt;proxy_pass&lt;/code&gt;. Takes a client request, forwards it to your app server, and returns the response. Your app never sees the internet directly. I've written more about &lt;a href="https://www.arnavsharma.dev/blogs/reverse-proxy-vs-forward-proxy" rel="noopener noreferrer"&gt;what reverse proxies do and how they differ from forward proxies&lt;/a&gt; &amp;lt;!-- not published yet, author can decide whether to include --&amp;gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Load balancing&lt;/strong&gt; via &lt;code&gt;upstream&lt;/code&gt; blocks. Round-robin by default, or &lt;code&gt;least_conn&lt;/code&gt;, &lt;code&gt;ip_hash&lt;/code&gt;, and weighted distribution. If you want the full breakdown of &lt;a href="https://www.arnavsharma.dev/blogs/load-balancing-algorithms" rel="noopener noreferrer"&gt;how these algorithms compare&lt;/a&gt;, I covered that separately.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TLS termination&lt;/strong&gt;. Nginx handles the SSL/TLS handshake, decrypts traffic, and forwards plain HTTP internally. Your app servers don't need certificates or crypto overhead.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Caching&lt;/strong&gt; with &lt;code&gt;proxy_cache_path&lt;/code&gt;. Stores upstream responses on disk and serves them directly for repeat requests without hitting your backend.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rate limiting&lt;/strong&gt; using &lt;code&gt;limit_req_zone&lt;/code&gt; and &lt;code&gt;limit_req&lt;/code&gt;. Throttle by IP, by endpoint, whatever you need.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;upstream&lt;/span&gt; &lt;span class="s"&gt;backend&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;least_conn&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;server&lt;/span&gt; &lt;span class="nf"&gt;10.0.0.1&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;8080&lt;/span&gt; &lt;span class="s"&gt;weight=3&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;server&lt;/span&gt; &lt;span class="nf"&gt;10.0.0.2&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;8080&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;server&lt;/span&gt; &lt;span class="nf"&gt;10.0.0.3&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;8080&lt;/span&gt; &lt;span class="s"&gt;backup&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;server&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;listen&lt;/span&gt; &lt;span class="mi"&gt;443&lt;/span&gt; &lt;span class="s"&gt;ssl&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;server_name&lt;/span&gt; &lt;span class="s"&gt;app.example.com&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;ssl_certificate&lt;/span&gt; &lt;span class="n"&gt;/etc/ssl/cert.pem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;ssl_certificate_key&lt;/span&gt; &lt;span class="n"&gt;/etc/ssl/key.pem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kn"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_pass&lt;/span&gt; &lt;span class="s"&gt;http://backend&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;Host&lt;/span&gt; &lt;span class="nv"&gt;$host&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;X-Real-IP&lt;/span&gt; &lt;span class="nv"&gt;$remote_addr&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;X-Forwarded-For&lt;/span&gt; &lt;span class="nv"&gt;$proxy_add_x_forwarded_for&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's TLS termination, load balancing, and reverse proxying in 15 lines.&lt;/p&gt;

&lt;h2&gt;
  
  
  🛠️ Gotchas that bite everyone
&lt;/h2&gt;

&lt;p&gt;These are the ones I see constantly. Bookmark this section.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The trailing slash on proxy_pass.&lt;/strong&gt; This single character changes everything. &lt;code&gt;proxy_pass http://backend;&lt;/code&gt; (no trailing slash) passes the full original URI. &lt;code&gt;proxy_pass http://backend/;&lt;/code&gt; (with trailing slash) strips the matched location prefix. So if your location is &lt;code&gt;/api/&lt;/code&gt; and the request is &lt;code&gt;/api/users&lt;/code&gt;, the first forwards &lt;code&gt;/api/users&lt;/code&gt;, the second forwards &lt;code&gt;/users&lt;/code&gt;. Mismatching this causes mystery 404s.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Missing &lt;code&gt;proxy_set_header Host&lt;/code&gt;.&lt;/strong&gt; Without it, your upstream sees the internal hostname (like &lt;code&gt;127.0.0.1:3000&lt;/code&gt;) instead of the original &lt;code&gt;Host&lt;/code&gt; header. And without &lt;code&gt;X-Forwarded-For&lt;/code&gt;, your app has no idea what the client's actual IP address is. Every proxy config needs these headers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;413 Request Entity Too Large.&lt;/strong&gt; The default &lt;code&gt;client_max_body_size&lt;/code&gt; is &lt;code&gt;1m&lt;/code&gt;. One megabyte. Any file upload over that gets rejected with a 413 before your app even sees it. Set it explicitly: &lt;code&gt;client_max_body_size 50m;&lt;/code&gt; or &lt;code&gt;0&lt;/code&gt; to disable the limit entirely.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;502 versus 504.&lt;/strong&gt; A 502 means nginx connected to your upstream but got garbage back (or the connection was refused). Your app probably crashed. A 504 means nginx waited for a response and gave up after &lt;code&gt;proxy_read_timeout&lt;/code&gt; (default 60s). Your app is alive but slow. Different problems, different fixes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Never skip &lt;code&gt;nginx -t&lt;/code&gt; before reload.&lt;/strong&gt; Always run &lt;code&gt;nginx -t&lt;/code&gt; to test config syntax. Then &lt;code&gt;nginx -s reload&lt;/code&gt; for a graceful reload where old workers finish their current requests. A full restart drops every active connection. I've seen teams push broken configs to production because they reloaded without testing first. Don't be that team.&lt;/p&gt;




&lt;h2&gt;
  
  
  📌 Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Nginx's event-driven workers handle thousands of connections each without spawning threads per request&lt;/li&gt;
&lt;li&gt;Config hierarchy is &lt;code&gt;http&lt;/code&gt; &amp;gt; &lt;code&gt;server&lt;/code&gt; &amp;gt; &lt;code&gt;location&lt;/code&gt;, with inheritance flowing down&lt;/li&gt;
&lt;li&gt;Location matching has a fixed priority order that ignores config file sequence&lt;/li&gt;
&lt;li&gt;Most production nginx issues come from trailing slashes, missing proxy headers, and the 1MB body size default&lt;/li&gt;
&lt;li&gt;Always &lt;code&gt;nginx -t&lt;/code&gt; before you reload. Always.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;If you're running nginx as a load balancer, the post on &lt;a href="https://www.arnavsharma.dev/blogs/load-balancing-algorithms" rel="noopener noreferrer"&gt;load balancing algorithms&lt;/a&gt; goes deeper on the strategies behind &lt;code&gt;upstream&lt;/code&gt; blocks.&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  More from me
&lt;/h2&gt;

&lt;p&gt;More posts on this kind of thing at &lt;a href="https://www.arnavsharma.dev" rel="noopener noreferrer"&gt;arnavsharma.dev&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>nginx</category>
      <category>backend</category>
      <category>devops</category>
    </item>
    <item>
      <title>Microservices vs Monolith: Why You Should Probably Start With One Big App</title>
      <dc:creator>Arnav Sharma</dc:creator>
      <pubDate>Fri, 14 Aug 2026 10:15:14 +0000</pubDate>
      <link>https://dev.to/arnavsharma2711/microservices-vs-monolith-why-you-should-probably-start-with-one-big-app-34b6</link>
      <guid>https://dev.to/arnavsharma2711/microservices-vs-monolith-why-you-should-probably-start-with-one-big-app-34b6</guid>
      <description>&lt;p&gt;What if the architecture that "doesn't scale" is the one that gets you to production fastest?&lt;/p&gt;

&lt;p&gt;I keep seeing the same pattern. A dev starts a side project, draws 8 services on a whiteboard before writing a single line of code, spends three weekends wiring up service discovery and inter-service auth, and the actual product logic is 200 lines across all of them. The project dies in a Docker Compose file.&lt;/p&gt;

&lt;p&gt;Microservices aren't wrong. But they solve problems most early-stage projects don't have. And the cost of splitting too early is way higher than the cost of splitting too late.&lt;/p&gt;




&lt;h2&gt;
  
  
  🎯 Four options, not two
&lt;/h2&gt;

&lt;p&gt;People talk about this like it's binary. Monolith or microservices. But there are actually four architecture patterns on the spectrum, and two of them don't get enough attention.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The monolith.&lt;/strong&gt; One deployable unit. All modules share a process, a database, and a release cycle. Not a dirty word. Just means you ship one thing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The modular monolith.&lt;/strong&gt; Still one deployment, but with enforced internal boundaries. Modules have explicit public APIs and can't reach into each other's guts. Shopify runs a 2.8 million line Rails app this way. They use a tool called Packwerk to enforce dependency rules between components. One deploy, but internally decoupled. This is honestly where most teams should land.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Microservices.&lt;/strong&gt; Independently deployable services, each owning its own data, talking over the network. The defining trait isn't size. It's that you can deploy service A without touching service B.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The distributed monolith.&lt;/strong&gt; The failure mode nobody plans for. You split your code into 15 services, but they still have to be deployed together, tested together, and they fail together. You've got all the operational complexity of a distributed system with none of the independence benefits. The worst of both worlds. And honestly? This is what most failed microservices migrations actually produce.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you gain when you split
&lt;/h2&gt;

&lt;p&gt;I'll be fair to the microservices side. When the split is done well, you get real benefits:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Dimension&lt;/th&gt;
&lt;th&gt;Monolith&lt;/th&gt;
&lt;th&gt;Microservices&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Deployment&lt;/td&gt;
&lt;td&gt;Ship everything together&lt;/td&gt;
&lt;td&gt;Deploy one service independently&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Team ownership&lt;/td&gt;
&lt;td&gt;Coordinate releases across teams&lt;/td&gt;
&lt;td&gt;Each team owns full lifecycle&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fault isolation&lt;/td&gt;
&lt;td&gt;One bug can bring down the whole app&lt;/td&gt;
&lt;td&gt;Failures stay contained per service&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Scaling&lt;/td&gt;
&lt;td&gt;Scale the entire unit&lt;/td&gt;
&lt;td&gt;Scale only the hot path&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tech flexibility&lt;/td&gt;
&lt;td&gt;One stack for everything&lt;/td&gt;
&lt;td&gt;Each service picks its own tools&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;These are real. At Amazon's scale, with thousands of teams, independent deployment isn't a nice-to-have. It's survival. But Amazon has the org structure to back it up.&lt;/p&gt;

&lt;h2&gt;
  
  
  🧠 What gets worse (the hidden costs)
&lt;/h2&gt;

&lt;p&gt;Here's what people skip over when they're excited about service boundaries.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Network calls replace function calls.&lt;/strong&gt; An in-process method call takes nanoseconds. A network request takes milliseconds plus serialization overhead. Chain five services together and your p99 latency floor is 250ms before your code even does anything. That latency budget adds up fast, especially when you're trying to hit sub-second response times. If you want more on how traffic flows through these layers, the post on &lt;a href="https://www.arnavsharma.dev/blogs/api-gateway" rel="noopener noreferrer"&gt;API gateways&lt;/a&gt; covers the routing side.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No cross-service transactions.&lt;/strong&gt; Forget ACID. You're in saga territory now, dealing with eventual consistency and compensation logic. For a lot of apps, this complexity just isn't worth it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;CI/CD per service.&lt;/strong&gt; N services times M environments equals an explosion of build pipelines. Each one needs monitoring, alerting, and someone who knows what to do when it breaks at 3am.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Observability costs more than the services themselves.&lt;/strong&gt; Distributed tracing, log aggregation, correlation IDs, and service meshes. You need all of it just to answer "why did this request fail?" Something that was a stack trace in a monolith becomes a detective hunt across 6 services.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;On-call surface multiplies.&lt;/strong&gt; Each service needs an owner. With 50 services you need 50 runbooks, and you'd better hope those runbooks are up to date.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cognitive load.&lt;/strong&gt; Developers now need to understand network failure modes, retries, idempotency, and circuit breakers. That's a lot of accidental complexity for a team that just wants to ship features.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conway's law is not optional
&lt;/h2&gt;

&lt;p&gt;Here's a thing that doesn't get said enough. Your architecture will mirror your org structure whether you plan for it or not. Conway's Law from 1967. It's not a suggestion.&lt;/p&gt;

&lt;p&gt;Amazon's two-pizza team model works because one team equals one service equals clear ownership. But if you're a 20-person company with 3 teams and you've drawn 50 microservices on a diagram? You don't have the people to support that. You'll end up with roughly as many real services as you have real teams, no matter what the architecture doc says.&lt;/p&gt;

&lt;p&gt;So match your architecture to your org. Not your aspirations.&lt;/p&gt;

&lt;h2&gt;
  
  
  📌 Signals it's time to split
&lt;/h2&gt;

&lt;p&gt;Start with a monolith. But stay alert for these signals:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Deployments require cross-team coordination&lt;/strong&gt; and are becoming a bottleneck. Teams are waiting on each other to ship.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Teams keep stepping on each other's code.&lt;/strong&gt; Merge conflicts every sprint, broken features from unrelated changes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One component needs to scale 100x&lt;/strong&gt; while the rest stays flat. Your checkout service gets hammered during sales but your admin panel sits idle.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Compliance or security requires data isolation.&lt;/strong&gt; PCI, PII regulations that demand certain data lives in its own boundary.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Build times are so long they destroy productivity.&lt;/strong&gt; If your CI takes 45 minutes, people stop running it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these signals mean "rewrite everything into 30 services overnight." They mean: identify one bounded context, extract it using the strangler fig pattern, and see if your life gets better. One at a time.&lt;/p&gt;

&lt;h2&gt;
  
  
  ⚡ The playbook (short version)
&lt;/h2&gt;

&lt;p&gt;If you're going to split, do it in this order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Start modular.&lt;/strong&gt; Enforce boundaries inside the monolith first. Shopify's approach. Separate your modules with clear interfaces, even if they still deploy together. This is cheap and reversible.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Identify the bounded context.&lt;/strong&gt; Which module has a genuinely different scaling profile, team ownership, or data isolation need?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Strangler fig.&lt;/strong&gt; Route traffic to the new service incrementally. Old code handles what's left. No big-bang rewrite. Martin Fowler described this pattern back in 2004, and it remains the safest way to extract.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Own the operational cost.&lt;/strong&gt; Before you extract, make sure you have CI/CD, monitoring, tracing, and an on-call rotation for the new service. If you don't have these, you're not ready.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you're partitioning data across services and using event-driven communication, understanding &lt;a href="https://www.arnavsharma.dev/blogs/kafka-partitions-consumer-groups" rel="noopener noreferrer"&gt;how Kafka partitions and consumer groups work&lt;/a&gt; will save you some grief on the messaging side.&lt;/p&gt;




&lt;h2&gt;
  
  
  Real-world evidence
&lt;/h2&gt;

&lt;p&gt;I'm not just making this up. The pattern "start monolith, split later" has strong backing:&lt;/p&gt;

&lt;p&gt;Martin Fowler wrote in 2015: "Almost all the successful microservice stories have started with a monolith that got too big and was broken up." His explicit advice is don't start a new project with microservices, even if you're sure it'll be big enough to justify them eventually.&lt;/p&gt;

&lt;p&gt;Segment had 140+ microservices (one per integration partner) and consolidated them back into a single service. Three engineers were spending most of their time keeping the system alive instead of building features. After consolidating, their shared library improvement rate jumped from 32 to 46 per year and on-call paging for load spikes disappeared entirely.&lt;/p&gt;

&lt;p&gt;Amazon's Prime Video team moved a video quality monitoring tool from Step Functions plus Lambda back into a single ECS process and cut that tool's infrastructure cost by over 90%. But here's the thing people get wrong about this one: it was one monitoring tool, not Prime Video's entire streaming platform. Prime Video still runs hundreds of services. The 90% figure applies to this specific pipeline's cost, not Amazon's bill.&lt;/p&gt;

&lt;p&gt;DHH has been running Basecamp on a single Rails monolith serving millions of users with a small team since 2016. His argument: microservices add complexity that only pays off at organizational scale most companies never reach. Hard to argue with the results.&lt;/p&gt;




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

&lt;p&gt;Start with a monolith. Make it modular. Split when a real signal appears, not when a conference talk makes you feel behind. The distributed monolith is the actual enemy here, and you create it by splitting before you understand your domain boundaries.&lt;/p&gt;

&lt;p&gt;Most of us aren't building the next Prime Video. We're building apps that need to ship, iterate, and not collapse under the weight of their own infrastructure.&lt;/p&gt;




&lt;h2&gt;
  
  
  Where else to find me
&lt;/h2&gt;

&lt;p&gt;I write about system design and architecture decisions at &lt;a href="https://www.arnavsharma.dev" rel="noopener noreferrer"&gt;arnavsharma.dev&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>backend</category>
      <category>programming</category>
      <category>productivity</category>
    </item>
    <item>
      <title>How Message Queues Work: The Architecture Your Signup Endpoint Needs</title>
      <dc:creator>Arnav Sharma</dc:creator>
      <pubDate>Fri, 14 Aug 2026 10:14:07 +0000</pubDate>
      <link>https://dev.to/arnavsharma2711/how-message-queues-work-the-architecture-your-signup-endpoint-needs-103h</link>
      <guid>https://dev.to/arnavsharma2711/how-message-queues-work-the-architecture-your-signup-endpoint-needs-103h</guid>
      <description>&lt;p&gt;Your signup endpoint takes four seconds. The user clicks "Create Account," stares at a spinner, and you're just sitting there sending a welcome email synchronously. The password got hashed in 200ms. The database write took 50ms. The remaining 3.7 seconds? Your SMTP provider thinking about life.&lt;/p&gt;

&lt;p&gt;And the user doesn't care about that email. They just want in.&lt;/p&gt;

&lt;p&gt;This is the exact problem message queues solve. You take the slow work, shove it into a queue, and respond to the user immediately. Something else picks it up later. Probably within milliseconds. But the point is: not on the request path.&lt;/p&gt;

&lt;p&gt;Same story with image resizing, PDF generation, webhook delivery. Anything that's slow and doesn't need to happen before you respond to the user? Queue it.&lt;/p&gt;




&lt;h2&gt;
  
  
  🎯 What a queue actually is
&lt;/h2&gt;

&lt;p&gt;A message queue is a buffer sitting between a producer and a consumer, managed by a broker. The producer drops a message in. The consumer pulls it out and processes it. That's it.&lt;/p&gt;

&lt;p&gt;The producer doesn't know or care who processes the message. The consumer doesn't know or care who sent it. They're decoupled. If your email service goes down for thirty seconds, messages pile up in the queue and get processed when it recovers. No lost signups. No retries from the client.&lt;/p&gt;

&lt;p&gt;This is point-to-point messaging: one message goes to one consumer. If you've got three workers pulling from the same queue, the broker hands each message to exactly one of them. Competing consumers. More workers means faster drain.&lt;/p&gt;

&lt;p&gt;Here's what enqueueing looks like with BullMQ (a Redis-backed queue for Node.js):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Queue&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;bullmq&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;emailQueue&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Queue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;welcome-emails&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// In your signup handler - takes ~1ms instead of 4 seconds&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;emailQueue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;send-welcome&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;usr_abc123&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;newuser@example.com&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the worker that picks it up:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Worker&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;bullmq&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;worker&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Worker&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;welcome-emails&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;job&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// This runs outside the request path&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;sendWelcomeEmail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Welcome email sent to &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&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 class="na"&gt;connection&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;host&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;127.0.0.1&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;6379&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nx"&gt;worker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;failed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;job&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;job&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; failed: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Your signup endpoint now returns in 250ms. The email gets sent a few hundred milliseconds later by a separate process. The user never notices.&lt;/p&gt;

&lt;h2&gt;
  
  
  ⚡ Ack, visibility timeout, and redelivery
&lt;/h2&gt;

&lt;p&gt;So what happens when a worker crashes mid-processing? The message just disappears? No.&lt;/p&gt;

&lt;p&gt;Queues don't delete a message when it's picked up. They hide it. In SQS, this is called a &lt;strong&gt;visibility timeout&lt;/strong&gt;: the message becomes invisible to other consumers for N seconds (default 30). If your worker finishes and deletes the message, great. If it dies, the timeout expires and the message reappears for another worker to grab.&lt;/p&gt;

&lt;p&gt;RabbitMQ does the same thing differently. Your consumer sends a manual &lt;code&gt;ack&lt;/code&gt; when it's done. No ack? The broker requeues.&lt;/p&gt;

&lt;p&gt;But here's where it gets tricky. Say processing takes 35 seconds and your visibility timeout is 30. The message reappears while the first worker is still on it. Now two workers are processing the same job. Duplicates.&lt;/p&gt;

&lt;p&gt;This is why queues give you at-least-once delivery, not exactly-once. Your consumers need to be idempotent. Processing the same message twice should produce the same result. Check if the email was already sent before sending it again.&lt;/p&gt;

&lt;h2&gt;
  
  
  🛠️ Dead letter queues and what to alert on
&lt;/h2&gt;

&lt;p&gt;Sometimes a message is just bad. Malformed payload, referencing a deleted user, hitting a bug that'll never self-heal. It fails, requeues, fails again. Forever.&lt;/p&gt;

&lt;p&gt;A &lt;strong&gt;dead letter queue&lt;/strong&gt; (DLQ) catches these. You configure a max receive count, say 3. After three failed attempts, the broker moves the message to a separate DLQ instead of requeuing it. Your main queue stays healthy. You check the DLQ during working hours, figure out what went wrong, fix the bug or data issue, and replay the messages. No one got paged at 2am over a malformed payload.&lt;/p&gt;

&lt;p&gt;Now, the metric that actually matters: &lt;strong&gt;queue depth&lt;/strong&gt;. Specifically, the number of messages waiting to be processed. In SQS, that's &lt;code&gt;ApproximateNumberOfMessagesVisible&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;If depth is growing, your consumers can't keep up. Maybe one crashed. Maybe traffic spiked. Maybe a downstream service is slow. Whatever the cause, a growing queue is the canary. Set an alarm on it. Auto-scale your consumers based on it. A flat or zero depth means things are healthy.&lt;/p&gt;

&lt;p&gt;Quick note on brokers: RabbitMQ gives you routing, priorities, and complex topologies. SQS gives you zero ops and infinite scale. BullMQ is great for Node.js apps already running Redis. Kafka is a distributed commit log designed for high-throughput streaming — it's a different animal entirely, and I wrote about &lt;a href="https://www.arnavsharma.dev/blogs/kafka-partitions-consumer-groups" rel="noopener noreferrer"&gt;how its partitions and consumer groups work&lt;/a&gt; separately. Pick based on what you're already running.&lt;/p&gt;




&lt;h2&gt;
  
  
  More writing
&lt;/h2&gt;

&lt;p&gt;The rest of my writing lives at &lt;a href="https://www.arnavsharma.dev" rel="noopener noreferrer"&gt;arnavsharma.dev&lt;/a&gt;, if this was useful.&lt;/p&gt;

</description>
      <category>backend</category>
      <category>webdev</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Kafka vs RabbitMQ: Which Message Broker Should You Actually Pick?</title>
      <dc:creator>Arnav Sharma</dc:creator>
      <pubDate>Fri, 14 Aug 2026 10:12:53 +0000</pubDate>
      <link>https://dev.to/arnavsharma2711/kafka-vs-rabbitmq-which-message-broker-should-you-actually-pick-853</link>
      <guid>https://dev.to/arnavsharma2711/kafka-vs-rabbitmq-which-message-broker-should-you-actually-pick-853</guid>
      <description>&lt;h1&gt;
  
  
  Kafka vs RabbitMQ: Which Message Broker Should You Actually Pick?
&lt;/h1&gt;

&lt;p&gt;Why is every "Kafka vs RabbitMQ" article just a feature table and some made-up throughput numbers?&lt;/p&gt;

&lt;p&gt;You don't pick a broker from a spreadsheet. You pick it because one of them matches how your data actually flows and the other one fights you the entire time. The difference isn't speed or popularity. It's architecture. And once you see it, the rest of the decision becomes obvious.&lt;/p&gt;




&lt;h2&gt;
  
  
  🧠 One is a log, the other is a router
&lt;/h2&gt;

&lt;p&gt;Kafka is a distributed append-only commit log. Producers write messages to the end of a partition. That's it. Messages sit there, indexed by offset, until a retention policy (time or size) eventually cleans them up. Nobody deletes them when they're "done."&lt;/p&gt;

&lt;p&gt;Consumers pull. They track their own position in the log by committing offsets back to Kafka. The broker doesn't know or care whether you've processed a message. It just stores segments and serves reads.&lt;/p&gt;

&lt;p&gt;RabbitMQ is the opposite. It's a smart broker. Messages arrive at an &lt;strong&gt;exchange&lt;/strong&gt;, get routed through &lt;strong&gt;bindings&lt;/strong&gt; to one or more &lt;strong&gt;queues&lt;/strong&gt;, and the broker pushes them to consumers. When a consumer sends an ack, the message is gone. Deleted. The broker owns delivery state.&lt;/p&gt;

&lt;p&gt;So Kafka says: "here's the log, read wherever you want." RabbitMQ says: "tell me where things should go and I'll deliver them." Everything else follows from this split.&lt;/p&gt;

&lt;h2&gt;
  
  
  ⚡ RabbitMQ's exchange model
&lt;/h2&gt;

&lt;p&gt;Kafka has topics and partitions. You publish to a topic, and the partition key decides which partition it lands in. Simple. But there's no broker-side routing logic. Consumers get everything on their assigned partitions and filter client-side if needed.&lt;/p&gt;

&lt;p&gt;RabbitMQ gives you four exchange types, each with different routing rules:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Exchange type&lt;/th&gt;
&lt;th&gt;What it does&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Direct&lt;/td&gt;
&lt;td&gt;Routes to queues whose binding key exactly matches the message's routing key&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Topic&lt;/td&gt;
&lt;td&gt;Wildcard matching on routing key patterns (&lt;code&gt;order.*.created&lt;/code&gt;, &lt;code&gt;#.error&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fanout&lt;/td&gt;
&lt;td&gt;Broadcasts to every bound queue. No filtering.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Headers&lt;/td&gt;
&lt;td&gt;Matches on message header attributes instead of routing key&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;This means the broker itself decides who gets what. You can have one publisher sending order events and the broker splits them across an invoice queue, a notification queue, and an analytics queue based on routing keys. No consumer-side logic. No duplicated subscriptions.&lt;/p&gt;

&lt;p&gt;If your system needs complex message routing, RabbitMQ handles it natively. With Kafka, you'd either use multiple topics or build filtering into every consumer. Not the end of the world, but it's work you don't have to do with RabbitMQ.&lt;/p&gt;

&lt;h2&gt;
  
  
  Replay vs delete-on-ack
&lt;/h2&gt;

&lt;p&gt;This is where the architectural choice really bites.&lt;/p&gt;

&lt;p&gt;Kafka retains messages. Period. A consumer can rewind to offset zero and reprocess everything from the beginning. Deployed a bug that corrupted downstream data? Reset the consumer group offset and replay. Need a second service to read the same events independently? Just add another consumer group. The data's still there.&lt;/p&gt;

&lt;p&gt;I wrote about &lt;a href="https://www.arnavsharma.dev/blogs/kafka-partitions-consumer-groups" rel="noopener noreferrer"&gt;how consumer groups and partition assignment work&lt;/a&gt; in a previous post, so I won't re-explain the mechanics here.&lt;/p&gt;

&lt;p&gt;RabbitMQ's classic and quorum queues delete messages on ack. Gone forever. If you need reprocessing, you're out of luck unless you built your own archival system. Dead-letter exchanges catch rejected messages, but that's error handling, not replay.&lt;/p&gt;

&lt;p&gt;But here's the thing. RabbitMQ added &lt;strong&gt;Streams&lt;/strong&gt; back in version 3.9. Streams are an append-only replicated log with offset-based consumption. Basically Kafka semantics inside RabbitMQ. They support replay, time-based seeking, and fan-out without re-delivery. So the line is blurring. Still, if replay is your primary use case, Kafka was built for it from day one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Ordering guarantees
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Kafka&lt;/strong&gt;: Total order within a partition. Messages sharing the same key hash to the same partition, so per-key ordering is strict. Want ordered processing across multiple consumers? That's what consumer groups give you, with each partition going to exactly one consumer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;RabbitMQ&lt;/strong&gt;: FIFO within a single queue. Clean and simple. But the moment you add competing consumers (multiple consumers on one queue for parallelism), messages get dispatched round-robin and ordering breaks. You either accept that or run single-consumer queues, which limits throughput.&lt;/p&gt;

&lt;h2&gt;
  
  
  Delivery semantics
&lt;/h2&gt;

&lt;p&gt;Both default to &lt;strong&gt;at-least-once&lt;/strong&gt; delivery. Your consumer might see the same message twice if something crashes mid-processing. But how they handle stronger guarantees differs:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;At-least-once&lt;/th&gt;
&lt;th&gt;Effectively-once&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Kafka&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;acks=all&lt;/code&gt; + retries (default)&lt;/td&gt;
&lt;td&gt;Idempotent producer (default since Kafka 3.0) deduplicates retries via sequence numbers. Transactions give atomic read-process-write across partitions.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;RabbitMQ&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Publisher confirms + consumer ack&lt;/td&gt;
&lt;td&gt;No built-in exactly-once. Deduplication is your problem. Quorum queues guarantee replication, but not dedup.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;So if you need end-to-end exactly-once (or close to it), Kafka has first-party support. With RabbitMQ you're implementing idempotency yourself. Not impossible, just extra work.&lt;/p&gt;

&lt;h2&gt;
  
  
  Ops in 2025-2026
&lt;/h2&gt;

&lt;p&gt;A quick update on where both projects stand operationally, because a lot of older comparison posts are outdated:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Kafka 4.0&lt;/strong&gt; (March 2025) fully removed ZooKeeper. It's gone. KRaft mode is the only option now. This cuts a huge operational dependency. No more separate ZK cluster to babysit. Easier to deploy, fewer moving parts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;RabbitMQ 4.0&lt;/strong&gt; removed classic mirrored queues entirely. Quorum queues (Raft-based replication) are the only replicated queue type going forward. Better consistency, better throughput under replication. If you're reading guides that mention &lt;code&gt;ha-mode&lt;/code&gt; policies, they're obsolete.&lt;/p&gt;

&lt;h2&gt;
  
  
  📌 I'm not quoting throughput numbers
&lt;/h2&gt;

&lt;p&gt;Every comparison post throws around "Kafka does 2 million msgs/sec" or "RabbitMQ maxes out at 50K." I'm not doing that.&lt;/p&gt;

&lt;p&gt;Why? Because those numbers mean nothing without context. Message size, persistence settings, replication factor, ack mode, batch size, hardware, network. Change any one variable and the number changes by 10x. A benchmark where Kafka batches 1KB messages with &lt;code&gt;acks=1&lt;/code&gt; and RabbitMQ uses durable quorum queues with per-message confirms isn't a comparison. It's fiction dressed up as data.&lt;/p&gt;

&lt;p&gt;Here's what I'll say: Kafka is architecturally optimized for throughput (sequential disk writes, zero-copy with sendfile, batching). RabbitMQ optimizes for per-message routing flexibility and low latency at moderate scale. That's the shape of it. Actual numbers depend entirely on your deployment.&lt;/p&gt;




&lt;h2&gt;
  
  
  So which one do you pick?
&lt;/h2&gt;

&lt;p&gt;This is the decision guide. Be honest about your actual requirements.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pick Kafka when&lt;/strong&gt; you need event streaming, event sourcing, or replay. When you have high throughput requirements (think hundreds of thousands of messages per second). When multiple independent services need to consume the same data stream. Log aggregation, change data capture, CQRS. All Kafka territory.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pick RabbitMQ when&lt;/strong&gt; you need complex routing logic at the broker level. When you're doing request-reply patterns or RPC. When you need per-message priority, TTL, or dead-letter handling. When you want polyglot protocol support (AMQP, MQTT, STOMP) without running separate brokers. Moderate scale, task queues, workflow orchestration.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pick neither&lt;/strong&gt; when you don't want to operate a message broker at all. If your system has low throughput, simple fan-out, and you're running on AWS anyway, just use SQS. Or SNS+SQS for pub/sub. Zero cluster management, no replication to configure, no disk monitoring. I talked about &lt;a href="https://www.arnavsharma.dev/blogs/api-gateway" rel="noopener noreferrer"&gt;how API gateways sit in front of these services&lt;/a&gt; if you're building event-driven architectures on managed infrastructure.&lt;/p&gt;

&lt;p&gt;Honestly, for a lot of teams shipping their first async system, SQS is the right answer. No shame in it. You can always migrate to Kafka or RabbitMQ when you outgrow it. Operating a broker cluster before you need one is just yak shaving.&lt;/p&gt;




&lt;h2&gt;
  
  
  Where else to find me
&lt;/h2&gt;

&lt;p&gt;My other posts, plus what I'm building right now, are at &lt;a href="https://www.arnavsharma.dev" rel="noopener noreferrer"&gt;arnavsharma.dev&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>kafka</category>
      <category>eventdriven</category>
      <category>architecture</category>
    </item>
    <item>
      <title>What Is Idempotency? A Practical Guide for API Developers</title>
      <dc:creator>Arnav Sharma</dc:creator>
      <pubDate>Fri, 14 Aug 2026 10:11:51 +0000</pubDate>
      <link>https://dev.to/arnavsharma2711/what-is-idempotency-a-practical-guide-for-api-developers-40ip</link>
      <guid>https://dev.to/arnavsharma2711/what-is-idempotency-a-practical-guide-for-api-developers-40ip</guid>
      <description>&lt;p&gt;A user clicks "Pay Now." The request times out. No confirmation screen. So they click again. Totally reasonable thing to do. And now their card has been charged twice.&lt;/p&gt;

&lt;p&gt;This isn't some weird edge case. It happens all the time in production. Network blips, client retries, at-least-once message delivery, webhook providers re-firing because they didn't get an ACK fast enough. The same request hits your server more than once, and your code happily processes it each time.&lt;/p&gt;

&lt;p&gt;The fix has a name: idempotency.&lt;/p&gt;




&lt;h2&gt;
  
  
  🛠️ What idempotent actually means
&lt;/h2&gt;

&lt;p&gt;An operation is &lt;strong&gt;idempotent&lt;/strong&gt; if running it multiple times produces the same server-side effect as running it once. That's it. From RFC 9110 §9.2.2.&lt;/p&gt;

&lt;p&gt;The response can differ though. A DELETE that returns 200 the first time and 404 the second is still idempotent. The resource is gone either way. Same effect, different status code.&lt;/p&gt;

&lt;p&gt;People mix this up with &lt;strong&gt;safe&lt;/strong&gt;. A safe method doesn't change state at all. GET is safe. PUT is not safe (it changes stuff) but it is idempotent (doing the same PUT ten times leaves you in the same state as doing it once).&lt;/p&gt;

&lt;p&gt;So: all safe methods are idempotent. Not all idempotent methods are safe.&lt;/p&gt;

&lt;p&gt;Here's the HTTP method breakdown per RFC 9110 §9.2.2:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Method&lt;/th&gt;
&lt;th&gt;Safe&lt;/th&gt;
&lt;th&gt;Idempotent&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;GET&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PUT&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DELETE&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;POST&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;no&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;POST is the odd one out. Each call can create a new resource or fire a new side effect. There's nothing in the protocol that prevents it. And that's exactly why payment endpoints (almost always POST) need extra work to become idempotent.&lt;/p&gt;

&lt;h2&gt;
  
  
  ⚡ The idempotency key pattern
&lt;/h2&gt;

&lt;p&gt;Stripe popularized this approach and it's become the industry standard for making POST endpoints safe to retry.&lt;/p&gt;

&lt;p&gt;The idea: the client generates a unique key (a UUIDv4 works fine) &lt;em&gt;before&lt;/em&gt; sending the request and passes it in an &lt;code&gt;Idempotency-Key&lt;/code&gt; header. The server uses that key to detect replays.&lt;/p&gt;

&lt;p&gt;On the first request, the server processes normally and stores the key alongside the response. On a retry with the same key and same parameters, it returns the stored response without re-executing anything. No double charge.&lt;/p&gt;

&lt;p&gt;But what if someone sends the same key with &lt;em&gt;different&lt;/em&gt; parameters? That's a bug on the client side, and the server returns a 409 Conflict. You don't want to silently return a cached response when the request body doesn't match. That would mask real errors.&lt;/p&gt;

&lt;p&gt;Stripe expires keys after 24 hours. After that, a new execution happens.&lt;/p&gt;

&lt;p&gt;Here's a stripped-down middleware that does this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;idempotency&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;NextFunction&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;idempotency-key&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;next&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;fingerprint&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;hash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cached&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s2"&gt;`SELECT fingerprint, status, body FROM idempotency_keys
     WHERE key = $1 AND created_at &amp;gt; NOW() - INTERVAL '24h'`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cached&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cached&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rows&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 class="nx"&gt;fingerprint&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;fingerprint&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;409&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Key reused with different params&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cached&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rows&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 class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cached&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rows&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 class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;// Wrap res.json to capture the response for storage&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;origJson&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;bind&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;json&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;any&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="s2"&gt;`INSERT INTO idempotency_keys (key, fingerprint, status, body)
       VALUES ($1, $2, $3, $4) ON CONFLICT (key) DO NOTHING`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;fingerprint&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;statusCode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;origJson&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="nf"&gt;next&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notice the &lt;code&gt;ON CONFLICT (key) DO NOTHING&lt;/code&gt; at the bottom. That's doing the real heavy lifting.&lt;/p&gt;

&lt;h2&gt;
  
  
  🧠 The database constraint is the actual guard
&lt;/h2&gt;

&lt;p&gt;Here's the mistake I see constantly. People write logic like this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Check if the key exists (&lt;code&gt;SELECT&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;If not, process the request&lt;/li&gt;
&lt;li&gt;Insert the key&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Looks reasonable. Totally broken in practice.&lt;/p&gt;

&lt;p&gt;Two identical requests arrive 5ms apart. Both hit step 1, both see "no key exists," both proceed to step 2. You've just processed the payment twice. This is a classic TOCTOU race — time-of-check to time-of-use.&lt;/p&gt;

&lt;p&gt;The fix is to let the database handle atomicity. A &lt;code&gt;UNIQUE&lt;/code&gt; constraint on the idempotency key column means only one insert can ever succeed. The second one fails, and your code catches that conflict:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;payments&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;idempotency_key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;customer_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'ord_123_pay'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;4999&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'cus_abc'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'completed'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;CONFLICT&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;idempotency_key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;DO&lt;/span&gt; &lt;span class="k"&gt;NOTHING&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One statement. Atomic. No race condition. The database's locking mechanism handles concurrent access for you. If you're building anything that processes payments, webhook events, or queue messages, this pattern should be your default.&lt;/p&gt;

&lt;p&gt;And yeah, retry strategies (exponential backoff, jitter, circuit breakers) and distributed transactions are their own topics. They work &lt;em&gt;alongside&lt;/em&gt; idempotency but don't replace it. Separate posts for those.&lt;/p&gt;

&lt;p&gt;If your API sits behind an &lt;a href="https://www.arnavsharma.dev/blogs/api-gateway" rel="noopener noreferrer"&gt;API gateway&lt;/a&gt;, the gateway might handle retries automatically. Another reason your handlers need to be idempotent even when you don't think you're retrying.&lt;/p&gt;




&lt;h2&gt;
  
  
  More writing
&lt;/h2&gt;

&lt;p&gt;Everything else I've written is over at &lt;a href="https://www.arnavsharma.dev" rel="noopener noreferrer"&gt;arnavsharma.dev&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>api</category>
      <category>architecture</category>
      <category>backend</category>
      <category>softwareengineering</category>
    </item>
    <item>
      <title>How Database Indexes Work (And Why Yours Might Be Useless)</title>
      <dc:creator>Arnav Sharma</dc:creator>
      <pubDate>Thu, 13 Aug 2026 07:59:30 +0000</pubDate>
      <link>https://dev.to/arnavsharma2711/how-database-indexes-work-and-why-yours-might-be-useless-3e8i</link>
      <guid>https://dev.to/arnavsharma2711/how-database-indexes-work-and-why-yours-might-be-useless-3e8i</guid>
      <description>&lt;h1&gt;
  
  
  How database indexes work (and why yours might be useless)
&lt;/h1&gt;

&lt;p&gt;You added an index on &lt;code&gt;customer_id&lt;/code&gt;. The query still does a sequential scan. You added another index on &lt;code&gt;status&lt;/code&gt;. Same thing. You now have five indexes on a table, writes are slower, and the planner hasn't touched a single one of them.&lt;/p&gt;

&lt;p&gt;Why?&lt;/p&gt;

&lt;p&gt;Because an index isn't magic. It's a trade. And if you don't understand what you're trading — or when the database decides the trade isn't worth it — you'll keep throwing indexes at problems they can't solve.&lt;/p&gt;




&lt;h2&gt;
  
  
  🧠 What an index actually stores
&lt;/h2&gt;

&lt;p&gt;Forget the "it makes queries faster" hand-wave. An index is a separate data structure, sorted by your chosen column, where each entry holds two things: the column value (the key) and a &lt;strong&gt;row locator&lt;/strong&gt; that tells the database where the full row lives.&lt;/p&gt;

&lt;p&gt;That row locator is where Postgres and MySQL diverge in a way that matters.&lt;/p&gt;

&lt;p&gt;In Postgres, tables are heap-organized. Rows sit in an unordered pile. The locator is a &lt;strong&gt;ctid&lt;/strong&gt;, basically a (page number, slot offset) pair. A physical address. The index says "the row with &lt;code&gt;customer_id = 42&lt;/code&gt; is at page 87, slot 3." Direct jump.&lt;/p&gt;

&lt;p&gt;InnoDB does it differently. The table itself &lt;em&gt;is&lt;/em&gt; a B-tree, organized by primary key. They call this a clustered index. Secondary indexes don't store a physical address. They store the primary key value. So when you look up &lt;code&gt;customer_id = 42&lt;/code&gt; through a secondary index, InnoDB finds the PK in the index leaf, then descends the clustered index B-tree a second time to reach the actual row.&lt;/p&gt;

&lt;p&gt;Two lookups instead of one. That second descent is cheap for a single row. It gets expensive in bulk.&lt;/p&gt;




&lt;h2&gt;
  
  
  🔑 The two-step lookup and when sequential scan wins
&lt;/h2&gt;

&lt;p&gt;Here's the thing people miss. Every index lookup that returns a full row is a two-step process:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Walk the index structure to find the matching entries.&lt;/li&gt;
&lt;li&gt;For each entry, follow the locator to fetch the full row from the table.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Step 1 is fast. Sorted structure, logarithmic depth. Step 2 is the problem. Each row fetch is a random I/O to a potentially different page on disk. Fetch 10 rows, you might hit 10 different pages. Fetch 10,000 rows, that's potentially 10,000 random page reads scattered across the table.&lt;/p&gt;

&lt;p&gt;A sequential scan, by contrast, reads pages in order. One continuous stream. Sequential I/O is dramatically faster than random I/O, even on SSDs.&lt;/p&gt;

&lt;p&gt;So the database planner does math. If your query returns a small fraction of the table, say 50 rows out of a million, the index wins easily. But as that fraction grows, random I/O piles up until a single sequential pass through the whole table is genuinely cheaper.&lt;/p&gt;

&lt;p&gt;Where's the crossover? Roughly 5-15% of the table, depending on row width, storage speed, and planner settings like &lt;code&gt;random_page_cost&lt;/code&gt;. Not a fixed number. A rule of thumb. But it means an index on a column where most queries match 20% of rows is dead weight.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- This query returns ~30% of a million-row table.&lt;/span&gt;
&lt;span class="c1"&gt;-- The planner will almost certainly ignore your index on status.&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'completed'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  ⚡ Selectivity, cardinality, and the column that never gets indexed
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Selectivity&lt;/strong&gt; is the fraction of rows a predicate matches. Low selectivity (few rows match) means the index pays off. High selectivity (many rows match) means it doesn't.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cardinality&lt;/strong&gt; is how many distinct values a column has. A boolean column has cardinality 2. An email column might have cardinality in the millions.&lt;/p&gt;

&lt;p&gt;Low cardinality columns are the classic trap. You index &lt;code&gt;is_active&lt;/code&gt; (true/false). Half the table is &lt;code&gt;true&lt;/code&gt;. The planner won't use that index for &lt;code&gt;WHERE is_active = true&lt;/code&gt; because fetching 500,000 rows via random I/O is worse than scanning the whole million-row table sequentially.&lt;/p&gt;

&lt;p&gt;Same story with a &lt;code&gt;status&lt;/code&gt; column holding three values. Each value matches roughly 33% of rows. The index exists, takes up space, slows every write, and the planner ignores it.&lt;/p&gt;

&lt;p&gt;But here's a trick. If you only ever query the &lt;em&gt;rare&lt;/em&gt; value:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- Partial index: only indexes rows where status = 'pending'&lt;/span&gt;
&lt;span class="c1"&gt;-- (maybe 2% of the table)&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;idx_orders_pending&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'pending'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- The planner will happily use this for:&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'pending'&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;NOW&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;INTERVAL&lt;/span&gt; &lt;span class="s1"&gt;'7 days'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Smaller index, higher selectivity, actually gets used. And cheaper to maintain because it only tracks the rows that match the predicate.&lt;/p&gt;

&lt;h3&gt;
  
  
  Things that silently prevent index usage
&lt;/h3&gt;

&lt;p&gt;Even with good selectivity, the planner might still ignore your index. Common blockers:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Function wrapping the column.&lt;/strong&gt; &lt;code&gt;WHERE LOWER(email) = 'bob@test.com'&lt;/code&gt; can't use a plain index on &lt;code&gt;email&lt;/code&gt;. The index is sorted by raw values, not lowercased ones. Fix: expression index. &lt;code&gt;CREATE INDEX ON users (LOWER(email))&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Type mismatch.&lt;/strong&gt; &lt;code&gt;WHERE int_column = '42'&lt;/code&gt; forces a cast. The planner can't match the index. Fix: use the right literal type.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Leading wildcard.&lt;/strong&gt; &lt;code&gt;WHERE name LIKE '%smith'&lt;/code&gt; needs to scan every entry because the prefix is unknown. Fix: a trigram index with &lt;code&gt;pg_trgm&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;OR across different columns.&lt;/strong&gt; &lt;code&gt;WHERE a = 1 OR b = 2&lt;/code&gt; can't walk a single index cleanly. Fix: separate indexes on each column (Postgres can bitmap-OR them), or restructure as a UNION.&lt;/p&gt;




&lt;h2&gt;
  
  
  The write cost nobody talks about
&lt;/h2&gt;

&lt;p&gt;Every index you add is a promise: "I will maintain this sorted structure on every single write."&lt;/p&gt;

&lt;p&gt;INSERT a row? The database updates every index on that table. UPDATE an indexed column? Old entry removed, new entry inserted. DELETE a row? Every index gets cleaned.&lt;/p&gt;

&lt;p&gt;That's per-write, per-index overhead. A table with 8 indexes means every INSERT does 8 additional B-tree modifications. And if you have expression indexes, the database recomputes the expression on each write too.&lt;/p&gt;

&lt;p&gt;Then there's bloat. Postgres uses MVCC, so old row versions stick around until VACUUM cleans them up. But those dead tuples also exist in the index. On a heavily-updated table, indexes can bloat to 2-5x their ideal size. VACUUM reclaims the space inside the index, but the file on disk doesn't shrink. You end up with a 4GB index that's half dead entries, slowing scans through the index structure itself.&lt;/p&gt;

&lt;p&gt;So every index is a bet. You're betting that the read performance gain outweighs the write cost and the maintenance burden. For a read-heavy table with highly selective queries, that bet pays off. For a write-heavy table where the indexed column has low cardinality? You're paying the cost with zero benefit.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- Check index usage: are your indexes actually being used?&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;indexrelname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;idx_scan&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;idx_tup_read&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_stat_user_indexes&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;schemaname&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'public'&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;idx_scan&lt;/span&gt; &lt;span class="k"&gt;ASC&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;-- Indexes with idx_scan = 0 are candidates for removal.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  What this post doesn't cover
&lt;/h2&gt;

&lt;p&gt;The internal node layout of B+ trees (fanout, splits, how height stays low) gets its own post. Same for multi-column index ordering strategy and index-only scans where the database never touches the table at all. Those are different problems with different mental models.&lt;/p&gt;

&lt;p&gt;If you're building systems that route traffic between services, the trade-off thinking here is similar to decisions you'd make at the &lt;a href="https://www.arnavsharma.dev/blogs/api-gateway" rel="noopener noreferrer"&gt;API gateway layer&lt;/a&gt;. And if you want another example of hidden costs in layered systems, the write amplification story in &lt;a href="https://www.arnavsharma.dev/blogs/docker-images-layers-containers" rel="noopener noreferrer"&gt;Docker image layers&lt;/a&gt; rhymes with index bloat more than you'd expect.&lt;/p&gt;




&lt;h2&gt;
  
  
  📌 Key takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;An index stores the key plus a row locator. In Postgres that's a ctid (physical address). In InnoDB it's the primary key, requiring a second tree descent.&lt;/li&gt;
&lt;li&gt;The second fetch (random I/O per row) is why the planner ignores your index past roughly 5-15% of the table. Not a fixed threshold, but a useful mental benchmark.&lt;/li&gt;
&lt;li&gt;Low-cardinality columns (booleans, status fields) produce indexes the planner will never use. Partial indexes targeting the rare value are the fix.&lt;/li&gt;
&lt;li&gt;Every index costs you on writes: maintenance overhead, expression recomputation, and MVCC-driven bloat in Postgres.&lt;/li&gt;
&lt;li&gt;Functions on columns, type mismatches, leading wildcards, and OR across columns all silently block index usage. Each has a specific fix.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Where else to find me
&lt;/h2&gt;

&lt;p&gt;Plenty more posts at &lt;a href="https://www.arnavsharma.dev" rel="noopener noreferrer"&gt;arnavsharma.dev&lt;/a&gt; if this helped.&lt;/p&gt;

</description>
      <category>database</category>
      <category>programming</category>
      <category>sql</category>
    </item>
    <item>
      <title>Exactly-Once vs At-Least-Once: What Kafka's Guarantee Actually Covers</title>
      <dc:creator>Arnav Sharma</dc:creator>
      <pubDate>Thu, 13 Aug 2026 07:57:57 +0000</pubDate>
      <link>https://dev.to/arnavsharma2711/exactly-once-vs-at-least-once-what-kafkas-guarantee-actually-covers-4jbc</link>
      <guid>https://dev.to/arnavsharma2711/exactly-once-vs-at-least-once-what-kafkas-guarantee-actually-covers-4jbc</guid>
      <description>&lt;h1&gt;
  
  
  Exactly-once delivery is a lie: what your broker actually guarantees
&lt;/h1&gt;

&lt;p&gt;Your message broker's docs say "exactly-once semantics." You read that, nod, and assume your consumer handler runs exactly one time per message. Ship it.&lt;/p&gt;

&lt;p&gt;Then you get duplicate charges in production. Or a user's order gets created twice. And you're staring at the logs thinking: I thought this was supposed to be exactly-once?&lt;/p&gt;

&lt;p&gt;Here's the thing. The broker isn't lying to you. But it's not promising what you think it's promising. And the gap between what "exactly-once" means on the marketing page and what it means in the protocol spec is where your bugs live.&lt;/p&gt;




&lt;h2&gt;
  
  
  ⚡ The three delivery semantics
&lt;/h2&gt;

&lt;p&gt;Before we untangle the confusion, let's get precise about what the three guarantees actually mean. They look different depending on whether you're the producer or the consumer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;At-most-once.&lt;/strong&gt; Producer fires a message and doesn't retry on failure. If the network drops it, it's gone. On the consumer side, you commit your offset &lt;em&gt;before&lt;/em&gt; processing. If your process crashes after committing but before finishing work, that message is skipped forever. No duplicates, but you lose data.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;At-least-once.&lt;/strong&gt; Producer retries until it gets an acknowledgment. If the ack gets lost but the broker already wrote the message, you get a duplicate in the log. Consumer side: you process the message first, then commit the offset. Crash after processing but before committing? You'll reprocess that message on restart. Duplicates happen. Guaranteed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Exactly-once.&lt;/strong&gt; Each message appears in the log once and its effect is applied once. Sounds perfect. But there's a catch.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why exactly-once delivery is impossible
&lt;/h3&gt;

&lt;p&gt;Think about what happens at the network level. Your producer sends message M to the broker. The broker writes it. The broker sends an ACK back. The ACK gets lost.&lt;/p&gt;

&lt;p&gt;Now your producer is stuck. It can't tell the difference between "the broker never received M" and "the broker received M but the ACK didn't make it back." Two completely different situations, identical from the producer's perspective.&lt;/p&gt;

&lt;p&gt;So it has to choose. Retry and risk a duplicate? Or don't retry and risk losing the message? There's no third option. This is the Two Generals Problem (1975): no finite protocol can guarantee two parties reach agreement over an unreliable channel. The sender can never know its last message arrived.&lt;/p&gt;

&lt;p&gt;Not a Kafka limitation. Not a broker limitation. A mathematical impossibility for any system communicating over a network that can drop packets.&lt;/p&gt;

&lt;p&gt;So when someone says "exactly-once delivery," they're either wrong or they're talking about something else.&lt;/p&gt;




&lt;h2&gt;
  
  
  🎯 Exactly-once processing: the achievable goal
&lt;/h2&gt;

&lt;p&gt;Here's the pivot. You can't guarantee a message is &lt;em&gt;delivered&lt;/em&gt; exactly once. But you can guarantee its &lt;em&gt;effect&lt;/em&gt; is applied exactly once. Different thing entirely.&lt;/p&gt;

&lt;p&gt;The industry sometimes calls this "effectively-once." Your consumer might receive the same message three times. Doesn't matter, as long as the business result only happens once. Two ways to get there:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Idempotent processing.&lt;/strong&gt; Design your writes so that applying the same operation twice produces the same result. An upsert keyed on a deterministic message ID. A dedup check before inserting. If the same message shows up again, the second write is a no-op.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Atomic offset + effect commit.&lt;/strong&gt; In a single database transaction, write your business result AND advance your consumer's offset/cursor. On restart, you read the last committed offset from your DB. You might reprocess a message, but the dedup check inside the transaction rejects it.&lt;/p&gt;

&lt;p&gt;Here's what that looks like in practice. An at-least-once consumer with a PostgreSQL dedup table:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;handleMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="nx"&gt;pool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Pool&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;pool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;BEGIN&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="c1"&gt;// Insert message ID — ON CONFLICT means we've seen this before&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;rowCount&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="s2"&gt;`INSERT INTO processed_messages (message_id) VALUES ($1) ON CONFLICT DO NOTHING`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rowCount&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ROLLBACK&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// duplicate, skip it&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="c1"&gt;// Business logic: only runs once per message ID&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="s2"&gt;`INSERT INTO orders (id, data) VALUES ($1, $2)`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;COMMIT&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ROLLBACK&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;finally&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;release&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="c1"&gt;// ACK to broker ONLY after commit succeeds&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The consumer uses at-least-once delivery from the broker. Messages might arrive more than once. But the &lt;code&gt;ON CONFLICT DO NOTHING&lt;/code&gt; clause on &lt;code&gt;processed_messages&lt;/code&gt; means the business write only happens once. Exactly-once processing on top of at-least-once delivery.&lt;/p&gt;

&lt;h3&gt;
  
  
  What Kafka's "exactly-once" actually covers
&lt;/h3&gt;

&lt;p&gt;Kafka does offer exactly-once semantics. But with a hard boundary.&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;idempotent producer&lt;/strong&gt; (&lt;code&gt;enable.idempotence=true&lt;/code&gt;, default since Kafka 3.0) assigns each producer a PID and attaches monotonic sequence numbers per partition. The broker deduplicates by rejecting messages with a sequence number it's already seen. Scope: one producer session, one partition.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Transactions&lt;/strong&gt; (&lt;code&gt;transactional.id&lt;/code&gt;) go further. They atomically write to multiple partitions &lt;em&gt;and&lt;/em&gt; commit consumer offsets in a single operation. Consumers with &lt;code&gt;isolation.level=read_committed&lt;/code&gt; only see committed messages. Zombie producers with the same transactional ID get fenced off.&lt;/p&gt;

&lt;p&gt;Here's a transactional produce-and-commit using KafkaJS:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;producer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;kafka&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;producer&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;transactionalId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;my-app-topicA-0&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;maxInFlightRequests&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 class="na"&gt;idempotent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;producer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;txn&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;producer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;transaction&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;txn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;topic&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;output&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;result&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}]&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;txn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sendOffsets&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;consumerGroupId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;my-group&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;topics&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;topic&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;input&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;partitions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;partition&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 class="na"&gt;offset&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;42&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}]&lt;/span&gt; &lt;span class="p"&gt;}],&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;txn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;commit&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;txn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;abort&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This gives you exactly-once from input topic to output topic. Read a message, produce a result, commit the offset, all atomically. Powerful stuff.&lt;/p&gt;

&lt;p&gt;But here's the boundary everyone misses. This only works when &lt;strong&gt;both source and sink are Kafka topics&lt;/strong&gt;. The moment your consumer writes to an external database, calls an HTTP API, sends an email, anything outside Kafka's transaction fence, you're back to at-least-once. The transaction can't wrap your PostgreSQL insert or your Stripe API call.&lt;/p&gt;

&lt;p&gt;So yes, Kafka has exactly-once. For Kafka-to-Kafka pipelines. For everything else, you need the patterns from the previous section.&lt;/p&gt;




&lt;h2&gt;
  
  
  The practical playbook
&lt;/h2&gt;

&lt;p&gt;You've got a consumer that writes to a database. Messages will arrive more than once. Here's what actually works:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Dedup table.&lt;/strong&gt; Store &lt;code&gt;(message_id, processed_at)&lt;/code&gt; in the same transaction as your business write. Reject on conflict. Simplest pattern, works everywhere.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Idempotent upserts.&lt;/strong&gt; If your write is naturally idempotent (setting a user's email to a value, not incrementing a counter), just use &lt;code&gt;ON CONFLICT DO UPDATE&lt;/code&gt; or equivalent. No separate dedup table needed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Atomic offset commit.&lt;/strong&gt; Store the consumer offset in your application database, not in Kafka's &lt;code&gt;__consumer_offsets&lt;/code&gt; topic. On restart, seek to the stored offset. Combined with a dedup check, this closes the gap completely.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Dedup windows.&lt;/strong&gt; Keep a TTL-bounded set of seen message IDs in Redis or memory. Cheaper than a DB check, but only works within the window. Messages replayed after the TTL expires will be processed again. Partial fix.&lt;/p&gt;

&lt;p&gt;And honestly? Sometimes at-most-once is the right call. Metrics counters where a missing data point is fine but a duplicate inflates your graphs. Fire-and-forget telemetry. Log shipping. Not everything needs exactly-once processing.&lt;/p&gt;

&lt;p&gt;If you're running &lt;a href="https://www.arnavsharma.dev/blogs/kafka-partitions-consumer-groups" rel="noopener noreferrer"&gt;Kafka with consumer groups&lt;/a&gt;, the rebalancing protocol already causes redelivery on partition reassignment. Your consumers need to handle duplicates regardless of what guarantee you think you've configured.&lt;/p&gt;




&lt;h2&gt;
  
  
  📌 Key takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Exactly-once delivery is impossible&lt;/strong&gt; over an unreliable network. The Two Generals Problem proves it. Your broker can't fix physics.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Exactly-once processing is achievable.&lt;/strong&gt; Idempotent handlers + dedup tables turn at-least-once delivery into effectively-once effects.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Kafka's exactly-once covers Kafka-to-Kafka only.&lt;/strong&gt; External writes (databases, APIs, emails) fall outside the transaction boundary.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Design for at-least-once.&lt;/strong&gt; Assume every message arrives more than once and make your handlers safe for it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The dedup table pattern works everywhere.&lt;/strong&gt; Single transaction, message ID check, business write. Hard to mess up.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;For how Kafka's partitioning and consumer groups handle parallelism and rebalancing, see &lt;a href="https://www.arnavsharma.dev/blogs/kafka-partitions-consumer-groups" rel="noopener noreferrer"&gt;Kafka partitions and consumer groups&lt;/a&gt;. And if you're routing traffic to your consumers through an &lt;a href="https://www.arnavsharma.dev/blogs/api-gateway" rel="noopener noreferrer"&gt;API gateway&lt;/a&gt;, the retry behavior at the gateway layer adds another source of duplicates to plan for.&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  More writing
&lt;/h2&gt;

&lt;p&gt;If this was useful, there's more where it came from at &lt;a href="https://www.arnavsharma.dev" rel="noopener noreferrer"&gt;arnavsharma.dev&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>backend</category>
      <category>kafka</category>
      <category>distributedsystems</category>
    </item>
    <item>
      <title>Event-Driven Architecture Explained: Events, Commands, and the Tradeoffs Nobody Mentions</title>
      <dc:creator>Arnav Sharma</dc:creator>
      <pubDate>Thu, 13 Aug 2026 07:56:56 +0000</pubDate>
      <link>https://dev.to/arnavsharma2711/event-driven-architecture-explained-events-commands-and-the-tradeoffs-nobody-mentions-2ki4</link>
      <guid>https://dev.to/arnavsharma2711/event-driven-architecture-explained-events-commands-and-the-tradeoffs-nobody-mentions-2ki4</guid>
      <description>&lt;h1&gt;
  
  
  Event-driven architecture: what happens after "payment succeeded"
&lt;/h1&gt;

&lt;p&gt;Your checkout handler used to do two things. Charge the card, save the order. Ship it.&lt;/p&gt;

&lt;p&gt;Then product asked for a confirmation email. Fine, three things. Then analytics wanted a purchase event. Then the warehouse needed a stock reservation. Then the loyalty team added points accrual. Then fraud detection wanted a copy. Then someone said "we should notify the seller too."&lt;/p&gt;

&lt;p&gt;Now your handler does seven things after payment succeeds, and if the email service is slow, the customer stares at a spinner while all seven finish in sequence. One fails? The whole request blows up. Not great.&lt;/p&gt;

&lt;p&gt;This is the exact pain point that pushes teams toward event-driven architecture. Instead of one handler calling seven services synchronously, you announce "hey, payment succeeded" and let each service react on its own.&lt;/p&gt;




&lt;h2&gt;
  
  
  Events vs commands
&lt;/h2&gt;

&lt;p&gt;First distinction that matters: an &lt;strong&gt;event&lt;/strong&gt; is a fact about something that already happened. "OrderPlaced." Immutable. Past tense. The producer doesn't know or care who's listening.&lt;/p&gt;

&lt;p&gt;A &lt;strong&gt;command&lt;/strong&gt; is an imperative. "PlaceOrder." It's directed at a specific service and expects a result.&lt;/p&gt;

&lt;p&gt;The difference sounds academic until you realize it changes coupling entirely. A command ties you to the receiver. An event doesn't. Your checkout service publishes "PaymentSucceeded" and moves on. Whether three services or thirty react to it, the checkout service doesn't change.&lt;/p&gt;

&lt;p&gt;And the envelope carrying either one? That's just a &lt;strong&gt;message&lt;/strong&gt;. The broker (Kafka, SQS, EventBridge, whatever) routes messages. It doesn't care about the semantics inside.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three flavors of event
&lt;/h2&gt;

&lt;p&gt;Martin Fowler identified four patterns that people lump under "event-driven." Three of them are actually about events, and they solve different problems.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Event notification&lt;/strong&gt; is the thin version. The event carries just an ID and a type: &lt;code&gt;{ "type": "OrderPlaced", "orderId": "abc-123" }&lt;/code&gt;. Receivers call back to the source if they need details. Low coupling, but chatty.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Event-carried state transfer&lt;/strong&gt; is the fat version. The event includes the full state delta: order total, line items, shipping address. Receivers cache this locally and never call back. Great for resilience. But now every consumer is coupled to your schema.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Event sourcing&lt;/strong&gt; goes further. You don't store current state at all. You store every event that ever happened, in order, and rebuild state by replaying them. Think git commits. You can reconstruct any past state, get a full audit trail, and answer "what did this look like last Tuesday?" The cost is complexity and storage.&lt;/p&gt;

&lt;p&gt;Which flavor you pick depends on the problem. Most teams start with notification, move to state transfer when the callback traffic gets annoying, and reach for event sourcing only when audit or temporal queries are a hard requirement.&lt;/p&gt;

&lt;h2&gt;
  
  
  ⚡ Choreography vs orchestration
&lt;/h2&gt;

&lt;p&gt;Once events flow, you need a topology. Two options.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Choreography&lt;/strong&gt; is decentralized. Services react to events and emit their own events. Nobody's in charge. OrderPlaced triggers PaymentService, which emits PaymentConfirmed, which triggers InventoryService, which emits StockReserved. It's like a dance where everyone knows their part.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Each service just listens and reacts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;SNSClient&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;PublishCommand&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@aws-sdk/client-sns&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sns&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;SNSClient&lt;/span&gt;&lt;span class="p"&gt;({});&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;onPaymentConfirmed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;PaymentEvent&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;reservation&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;reserveStock&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;orderId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// announce what happened, move on&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;sns&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;PublishCommand&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;TopicArn&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;INVENTORY_TOPIC_ARN&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;Message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;eventId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;randomUUID&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
      &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;StockReserved&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;orderId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;orderId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;warehouseId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;reservation&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;warehouseId&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 class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Simple fanouts? Choreography works beautifully. But the flow is implicit. When something breaks, figuring out what happened means tracing events across five services with correlation IDs. Painful.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Orchestration&lt;/strong&gt; puts a central coordinator in charge. Step Functions, Temporal, Conductor. The orchestrator calls each step, handles retries, and owns the rollback logic. You get a visible workflow graph and explicit error handling. But now that coordinator is a single point of logic (though not necessarily a single point of failure).&lt;/p&gt;

&lt;p&gt;So: choreography for simple fan-outs with few steps. Orchestration for complex multi-step flows where ordering matters or where you need compensation logic. Most real systems use both. The order fan-out is choreographed, but the payment+inventory+shipping sequence might be orchestrated.&lt;/p&gt;

&lt;h2&gt;
  
  
  🎯 What actually gets harder
&lt;/h2&gt;

&lt;p&gt;I'm not going to pretend this is all upside. Event-driven architecture trades one set of problems for another.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Debugging causality.&lt;/strong&gt; There's no request trace anymore. A customer says "my order didn't go through" and you're grepping across six services for a correlation ID. You need distributed tracing (OpenTelemetry) and correlation IDs on every event from day one. Retrofitting this is miserable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Eventual consistency the user can see.&lt;/strong&gt; "I placed an order but it's not showing up." The event hasn't propagated yet. Your UI needs to handle this honestly — optimistic updates, polling, or just telling the user "processing, check back in a moment." Pretending it's instantaneous will create support tickets.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Testing gets weird.&lt;/strong&gt; Async delivery, non-deterministic ordering, retries firing at random intervals. Integration tests for event-driven flows are harder to write and flakier to maintain. Contract tests on event schemas help, but they're not a substitute for end-to-end verification.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Monitoring is different.&lt;/strong&gt; Consumer lag, dead letter queue depth, processing latency. These are your new first-class metrics. If you don't watch them, you'll find out about problems from angry users instead of dashboards.&lt;/p&gt;

&lt;h2&gt;
  
  
  🛠️ The dual-write problem
&lt;/h2&gt;

&lt;p&gt;Here's a trap that bites almost every team the first time. Your service needs to save an order to the database AND publish an "OrderPlaced" event. Two writes to two different systems.&lt;/p&gt;

&lt;p&gt;What if the DB write succeeds but the publish fails? You've got an order with no event. Downstream services never find out. What if you publish first and the DB write fails? You've announced something that didn't actually happen.&lt;/p&gt;

&lt;p&gt;This is the &lt;strong&gt;dual-write problem&lt;/strong&gt;, and "just retry" doesn't fix it. You can't get atomicity across a database and a message broker without some pattern.&lt;/p&gt;

&lt;p&gt;The standard fix is the &lt;strong&gt;transactional outbox&lt;/strong&gt;. Instead of publishing directly, you write the event to an &lt;code&gt;outbox&lt;/code&gt; table in the same database transaction as your business data. One atomic write. A separate relay process polls the outbox (or uses CDC to tail the transaction log) and publishes events to the broker.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Same DB transaction = atomic&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;placeOrder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;order&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Order&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Pool&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;BEGIN&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;INSERT INTO orders (id, user_id, total, status) VALUES ($1, $2, $3, $4)&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;total&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;confirmed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="c1"&gt;// event goes in the same transaction&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;INSERT INTO outbox (event_id, event_type, payload) VALUES ($1, $2, $3)&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;randomUUID&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;OrderPlaced&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;order&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;COMMIT&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ROLLBACK&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;finally&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;release&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both rows land or neither does. The relay picks up new outbox rows and publishes them. If the relay fails, it retries from where it left off. Consumers still need to be idempotent (since at-least-once delivery means duplicates are normal), but that's a topic for a dedicated post on &lt;a href="https://www.arnavsharma.dev/blogs/kafka-partitions-consumer-groups" rel="noopener noreferrer"&gt;idempotency patterns&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Scope and where to go from here
&lt;/h2&gt;

&lt;p&gt;I've intentionally kept this post at the conceptual level. There are rabbit holes everywhere.&lt;/p&gt;

&lt;p&gt;Message queues (Kafka, RabbitMQ, SQS) each have different delivery guarantees and ordering behavior. Choosing between them matters. Idempotent consumers need a deduplication strategy. Retries need backoff and dead letter queues. And when multiple services need to coordinate rollbacks, you're in saga territory — a pattern that's been around since Garcia-Molina and Salem named it in 1987.&lt;/p&gt;

&lt;p&gt;Each of those deserves its own post. For now, the mental model is what matters: events decouple, topology shapes your failure modes, and the dual-write problem will bite you if you don't address it upfront.&lt;/p&gt;

&lt;p&gt;If you're already using Kafka and want to understand how partitions and consumer groups affect your event flow, I wrote about that in depth &lt;a href="https://www.arnavsharma.dev/blogs/kafka-partitions-consumer-groups" rel="noopener noreferrer"&gt;here&lt;/a&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  📌 Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Events are facts (past tense, immutable). Commands are directives. Don't confuse them.&lt;/li&gt;
&lt;li&gt;Pick the right event flavor: notification for low coupling, state transfer for resilience, sourcing for audit trails&lt;/li&gt;
&lt;li&gt;Choreography works for simple fan-outs. Orchestration wins for complex multi-step flows with rollback needs.&lt;/li&gt;
&lt;li&gt;The dual-write problem is real. Use a transactional outbox.&lt;/li&gt;
&lt;li&gt;Eventual consistency isn't a bug to hide. Design your UI around it.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  More from me
&lt;/h2&gt;

&lt;p&gt;More posts on distributed systems and backend architecture at &lt;a href="https://www.arnavsharma.dev" rel="noopener noreferrer"&gt;arnavsharma.dev&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>backend</category>
      <category>architecture</category>
      <category>distributedsystems</category>
      <category>eventdriven</category>
    </item>
    <item>
      <title>The Dual-Write Problem and How to Actually Fix It</title>
      <dc:creator>Arnav Sharma</dc:creator>
      <pubDate>Thu, 13 Aug 2026 07:55:44 +0000</pubDate>
      <link>https://dev.to/arnavsharma2711/the-dual-write-problem-and-how-to-actually-fix-it-3i48</link>
      <guid>https://dev.to/arnavsharma2711/the-dual-write-problem-and-how-to-actually-fix-it-3i48</guid>
      <description>&lt;h1&gt;
  
  
  Distributed transactions: why BEGIN/COMMIT can't save you across services
&lt;/h1&gt;

&lt;p&gt;Someone splits the monolith into services. Yesterday, orders, payments, and inventory all lived in one database. You wrapped everything in a transaction, committed, done. ACID handled the rest.&lt;/p&gt;

&lt;p&gt;Now the order lives in one database, the payment in another, inventory in a third. You still write &lt;code&gt;BEGIN&lt;/code&gt; and &lt;code&gt;COMMIT&lt;/code&gt;. But there's no shared transaction manager anymore. That &lt;code&gt;COMMIT&lt;/code&gt; only applies to the database you're talking to right now. The other two? They don't know. They don't care.&lt;/p&gt;

&lt;p&gt;This is where things get ugly.&lt;/p&gt;




&lt;h2&gt;
  
  
  🛠️ The dual-write problem
&lt;/h2&gt;

&lt;p&gt;Here's the situation. Your Order Service needs to do two things when an order is created: write to its database, and publish an event so downstream services (payment, inventory, notifications) can react. Two systems. Two writes. No shared transaction between them.&lt;/p&gt;

&lt;p&gt;Three ways this blows up:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;DB commits, event publish fails.&lt;/strong&gt; Your database has the order. But the message broker never got the event. Payment never gets charged. Inventory never gets reserved. The systems silently diverge and nobody notices until a customer complains.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Event publishes, DB rolls back.&lt;/strong&gt; The broker accepted your event. Downstream services start processing. But your database transaction failed. Now payment is charging for an order that doesn't exist.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Process crashes between the two writes.&lt;/strong&gt; One write landed. Which one? Depends on the order you wrote them. Either way, you're inconsistent.&lt;/p&gt;

&lt;p&gt;No retry logic fixes this. Retrying the publish doesn't help if the DB already rolled back. Retrying the DB write doesn't help if the event already fired. The problem isn't failure handling. The problem is that two independent systems can't commit atomically.&lt;/p&gt;




&lt;h2&gt;
  
  
  🧠 Two-phase commit: the textbook answer nobody uses
&lt;/h2&gt;

&lt;p&gt;The academic solution is &lt;strong&gt;two-phase commit (2PC)&lt;/strong&gt;. A coordinator asks every participant "can you commit?" in Phase 1. Each participant acquires locks, writes to durable storage, and votes YES or NO. In Phase 2, if everyone voted YES, the coordinator sends COMMIT. Otherwise, ABORT.&lt;/p&gt;

&lt;p&gt;Sounds clean. In practice, teams avoid it for good reasons.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It blocks.&lt;/strong&gt; If the coordinator crashes between phases, every participant that voted YES holds its locks indefinitely. They can't commit (they don't know the decision). They can't abort (maybe the coordinator will come back and say COMMIT). They just wait. With locks held. Blocking every other transaction that touches those rows.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It requires XA.&lt;/strong&gt; Every participant needs to implement the XA interface (prepare, commit, rollback). Your PostgreSQL supports it. Your message broker probably doesn't. Your managed cloud datastore? Almost certainly not. So you can't even use 2PC across the systems you actually have.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It kills availability.&lt;/strong&gt; All participants must be online simultaneously. One slow service and the entire transaction is stuck. In a distributed system where partial failures are the norm, this is a non-starter.&lt;/p&gt;

&lt;p&gt;So 2PC works in theory and in tightly controlled environments (like a single vendor database cluster). But for services communicating over a network? Not practical. 3PC exists as an academic improvement that adds a pre-commit phase to reduce blocking, but it still can't handle network partitions and almost nobody implements it.&lt;/p&gt;




&lt;h2&gt;
  
  
  ⚡ The transactional outbox
&lt;/h2&gt;

&lt;p&gt;Here's the trick. You can't atomically write to a database and a message broker. But you &lt;em&gt;can&lt;/em&gt; atomically write to a database twice. Same database, same transaction.&lt;/p&gt;

&lt;p&gt;Instead of publishing the event directly, you write it to an &lt;strong&gt;outbox table&lt;/strong&gt; inside the same transaction as your business data:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;BEGIN&lt;/span&gt; &lt;span class="n"&gt;TRANSACTION&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;customer_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'ord-123'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'cust-1'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'PENDING'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;outbox&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;aggregate_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;event_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;gen_random_uuid&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="s1"&gt;'ord-123'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'OrderCreated'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="s1"&gt;'{"orderId":"ord-123","customerId":"cust-1"}'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;NOW&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;

&lt;span class="k"&gt;COMMIT&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One transaction. Both writes succeed or both fail. No dual-write problem.&lt;/p&gt;

&lt;p&gt;A separate relay process picks up committed outbox rows and publishes them to your broker, &lt;a href="https://www.arnavsharma.dev/blogs/kafka-partitions-consumer-groups" rel="noopener noreferrer"&gt;Kafka&lt;/a&gt;, SQS, whatever you're using. The relay can poll the table on an interval, or you can use change data capture (CDC) to tail the database's transaction log directly.&lt;/p&gt;

&lt;p&gt;If the relay crashes mid-publish, it restarts and re-publishes. This means consumers might see the same event twice. That's fine. They need to be idempotent anyway (a topic for its own post).&lt;/p&gt;

&lt;p&gt;Simple pattern. Genuinely reliable. And it works with whatever broker you already have.&lt;/p&gt;




&lt;h2&gt;
  
  
  Sagas and compensating actions
&lt;/h2&gt;

&lt;p&gt;The outbox solves "write + publish" atomicity. But what about operations that actually span multiple services? An order that needs to reserve inventory &lt;em&gt;and&lt;/em&gt; charge payment &lt;em&gt;and&lt;/em&gt; confirm the order?&lt;/p&gt;

&lt;p&gt;That's where &lt;strong&gt;sagas&lt;/strong&gt; come in. A saga is a sequence of local transactions, each in its own service. Each step publishes an event or command that triggers the next step. No global transaction. No distributed locks.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;CreateOrder saga (orchestrated):

1. OrderService.createOrder(PENDING)
   → on failure: OrderService.rejectOrder()

2. PaymentService.authorizePayment()
   → on failure: PaymentService.refundPayment()

3. InventoryService.reserveStock()
   → on failure: InventoryService.releaseReservation()

4. OrderService.confirmOrder(CONFIRMED)
   → terminal step, no compensation needed

If Step 3 fails:
  → run compensate(Step 2): refund the payment
  → run compensate(Step 1): reject the order
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two flavors exist. &lt;strong&gt;Choreography&lt;/strong&gt;: services react to each other's events with no coordinator. Works well for 2-3 steps, gets tangled fast after that. &lt;strong&gt;Orchestration&lt;/strong&gt;: a central saga orchestrator sends commands and decides what to do next. Easier to trace and reason about.&lt;/p&gt;

&lt;p&gt;But here's the thing people miss about sagas. Compensation is not rollback.&lt;/p&gt;

&lt;p&gt;A database rollback erases the write. It never happened. A compensating action is a &lt;em&gt;new forward operation&lt;/em&gt;. A refund is not an un-charge — the money moved to the merchant, now it moves back. There are processing fees, accounting entries, customer notifications. It takes time. The original charge still shows up in logs.&lt;/p&gt;

&lt;p&gt;You have to design compensating actions explicitly for every step. And they have their own failure modes. What if the refund fails? Now you need retry logic for your compensations too.&lt;/p&gt;

&lt;p&gt;I'll write a full saga implementation walkthrough in a future post. For now, the key insight: sagas give you eventual consistency across services, but they trade the simplicity of ACID for a lot of explicit failure handling.&lt;/p&gt;




&lt;h2&gt;
  
  
  The best first move is often the simplest
&lt;/h2&gt;

&lt;p&gt;Before you reach for outbox patterns or saga orchestrators, ask yourself: does this operation actually need to span multiple services?&lt;/p&gt;

&lt;p&gt;If Order Service and Payment Service always transact together, maybe they shouldn't be separate services. Maybe you split too early. A single database transaction is simpler, faster, and more reliable than any distributed pattern.&lt;/p&gt;

&lt;p&gt;Redraw your service boundaries so the transaction stays local. That's not a failure of architecture. That's good design.&lt;/p&gt;

&lt;p&gt;When you genuinely need cross-service coordination:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Outbox pattern&lt;/strong&gt; for reliable event publishing from a single service&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Saga&lt;/strong&gt; when the operation truly spans multiple autonomous services with independent datastores&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And whatever pattern you pick, idempotent consumers aren't optional. Every message will be delivered at least once. Design for it.&lt;/p&gt;




&lt;h2&gt;
  
  
  📌 Quick reference
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Dual-write problem&lt;/strong&gt;: you can't atomically write to two systems without a shared transaction&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;2PC&lt;/strong&gt;: blocks on coordinator failure, requires XA, reduces availability. Avoid for service-to-service coordination&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Transactional outbox&lt;/strong&gt;: write the event to the same DB transaction as the business data; relay publishes it later&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sagas&lt;/strong&gt;: sequence of local transactions with compensating actions on failure. Compensation is a new operation, not a rollback&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Best first move&lt;/strong&gt;: keep the transaction local by redrawing service boundaries&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;If you're working with event-driven services, the post on &lt;a href="https://www.arnavsharma.dev/blogs/kafka-partitions-consumer-groups" rel="noopener noreferrer"&gt;Kafka partitions and consumer groups&lt;/a&gt; covers how the messaging layer handles ordering and parallelism. And for routing requests to the right service in the first place, see &lt;a href="https://www.arnavsharma.dev/blogs/api-gateway" rel="noopener noreferrer"&gt;API gateways&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Where else to find me
&lt;/h2&gt;

&lt;p&gt;My writing and side projects all live at &lt;a href="https://www.arnavsharma.dev" rel="noopener noreferrer"&gt;arnavsharma.dev&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>backend</category>
      <category>microservices</category>
      <category>database</category>
      <category>distributedsystems</category>
    </item>
    <item>
      <title>CQRS Explained: It's Just Two Models (Not a Whole Architecture)</title>
      <dc:creator>Arnav Sharma</dc:creator>
      <pubDate>Thu, 13 Aug 2026 07:54:21 +0000</pubDate>
      <link>https://dev.to/arnavsharma2711/cqrs-explained-its-just-two-models-not-a-whole-architecture-1g89</link>
      <guid>https://dev.to/arnavsharma2711/cqrs-explained-its-just-two-models-not-a-whole-architecture-1g89</guid>
      <description>&lt;h1&gt;
  
  
  CQRS explained: it's just two models (not a whole architecture)
&lt;/h1&gt;

&lt;p&gt;Why does a pattern whose entire content is "use a different model for reads than for writes" end up meaning event sourcing, two databases, and a message bus in every conference talk? How did we get from "split your objects" to an architecture diagram with fifteen boxes?&lt;/p&gt;

&lt;p&gt;I think we cargo-culted it. Someone saw a Greg Young talk where CQRS happened to sit next to event sourcing, and the two got welded together in collective memory. The actual pattern is almost disappointingly simple.&lt;/p&gt;

&lt;p&gt;Let me un-weld them.&lt;/p&gt;




&lt;h2&gt;
  
  
  🔑 The minimal claim
&lt;/h2&gt;

&lt;p&gt;Bertrand Meyer gave us &lt;strong&gt;Command-Query Separation&lt;/strong&gt; in 1988. Method-level rule: a method either changes state (command) or returns data (query). Never both.&lt;/p&gt;

&lt;p&gt;Greg Young extended that to the model level in 2010 and called it CQRS. His definition: "simply the creation of two objects where there was previously only one." You have a write model that handles commands and enforces business rules. You have a read model shaped for queries. That's it.&lt;/p&gt;

&lt;p&gt;Not two databases. Not a message bus. Not eventual consistency. Young said this explicitly: "CQRS is not eventual consistency, it is not eventing, it is not messaging, it is not having separated models for reading and writing, nor is it using event sourcing."&lt;/p&gt;

&lt;p&gt;So what is it? Separation at the model boundary. Your write side validates and persists. Your read side queries and returns. They don't share the same object or the same shape.&lt;/p&gt;

&lt;h2&gt;
  
  
  🧠 Four things CQRS is not
&lt;/h2&gt;

&lt;p&gt;Let me kill these early because they're the reason people over-architect their first attempt.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It doesn't require event sourcing.&lt;/strong&gt; Event sourcing is a persistence strategy where you store every state change as an event. CQRS works with a plain Postgres table. They pair well together, but neither requires the other. You can do CQRS without events. You can do event sourcing without separating read/write models.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It doesn't require a second database.&lt;/strong&gt; Both models can live in the same database. Different tables, different views, even just different queries on the same table. A separate read store is one point on the spectrum, not the definition.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It doesn't require a message bus.&lt;/strong&gt; You can call the write model, wait for it to finish, then query the read model synchronously. No Kafka, no RabbitMQ, no SQS. Messaging is an infrastructure choice you make when you need async decoupling, not a prerequisite for CQRS.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It's not a system-wide architecture.&lt;/strong&gt; Fowler is blunt about this: "CQRS should only be used on specific portions of a system (a Bounded Context in DDD lingo) and not the system as a whole." Udi Dahan agrees. You apply it to the bounded context where read/write shapes have genuinely diverged. Your user settings page probably doesn't need it.&lt;/p&gt;




&lt;h2&gt;
  
  
  The implementation spectrum
&lt;/h2&gt;

&lt;p&gt;Here's where it gets practical. CQRS exists on a spectrum from cheap to expensive.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tier 1: same model, separate methods.&lt;/strong&gt; You split your service into command handlers and query handlers. They might even hit the same database table. The separation is in your code organization, not your infrastructure. Cost: almost nothing.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Command handler: validates, mutates, persists&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;makeCustomerPreferred&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;customerId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;repo&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;CustomerRepo&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;customer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;repo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;load&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;customerId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;customer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;orderCount&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Customer doesn't qualify for preferred status&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nx"&gt;customer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;preferred&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;customer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;preferredSince&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;repo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;customer&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Query handler: reads a flat view, zero domain logic&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getPreferredCustomers&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Pool&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;since&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;SELECT id, name, preferred_since FROM customers WHERE status = 'preferred'&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Tier 2: separate DTOs.&lt;/strong&gt; The write side uses rich domain entities. The read side uses flat DTOs shaped for the screen. Same database, but the query handler doesn't hydrate your full domain model just to render a list.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tier 3: separate read models, same database.&lt;/strong&gt; You add a materialized view or a denormalized table that's optimized for reads. The write side updates the canonical tables; a trigger or background job refreshes the read view. Still one database. But now your read queries are fast without N+1 problems or complex joins.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tier 4: separate read database, async sync.&lt;/strong&gt; Dedicated read store (Elasticsearch for search, Redis for hot data, a read replica shaped differently from the primary). Kept in sync via domain events or CDC. This is where eventual consistency enters the picture. And this is where most of the complexity lives.&lt;/p&gt;

&lt;p&gt;Most teams should stop at tier 2 or 3. Tier 4 earns its cost only when you have genuine read/write asymmetry (thousands of reads per write) or when your read shapes are so different from your write schema that joins become the bottleneck.&lt;/p&gt;




&lt;h2&gt;
  
  
  ⚡ The eventual-consistency cost
&lt;/h2&gt;

&lt;p&gt;Once you cross into tier 4, a user can write something and immediately read stale data. The projection hasn't caught up yet. "I updated my profile but it still shows the old name." Sound familiar?&lt;/p&gt;

&lt;p&gt;This is the &lt;strong&gt;read-your-own-writes&lt;/strong&gt; problem, and you can't wish it away. But people have solved it repeatedly. Four approaches that actually work in production:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Route to primary after write.&lt;/strong&gt; After a user mutates data, pin their reads to the write-side database for a few seconds (session flag or cookie). Everyone else reads from the eventual-consistent projection. Simple. Works.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Version tokens.&lt;/strong&gt; The write returns a version number or logical timestamp. The client sends it on the next read. The routing layer picks a replica that's at or past that version. If none qualifies yet, it falls back to primary.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Optimistic UI merge.&lt;/strong&gt; The client knows what it just wrote. It merges that pending state into whatever the read model returns until the projection catches up. React Query's &lt;code&gt;optimisticUpdate&lt;/code&gt; is basically this.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Read from write model for "own" data.&lt;/strong&gt; A user's own profile or cart reads directly from the write model. Other users' views of that data come from the projection. You're routing by ownership, not by time.&lt;/p&gt;

&lt;p&gt;None of these are free. Each adds code paths and edge cases. But they're well-understood patterns, not research problems.&lt;/p&gt;




&lt;h2&gt;
  
  
  When to actually use it (and my default answer)
&lt;/h2&gt;

&lt;p&gt;Fowler's warning is worth quoting: "you should be very cautious about using CQRS … the majority of cases I've run into have not been so good, with CQRS seen as a significant force for getting a software system into serious difficulties."&lt;/p&gt;

&lt;p&gt;Strong words from someone who usually hedges.&lt;/p&gt;

&lt;p&gt;My position: the default answer is don't. Start with a single model. If your read shapes are almost identical to your write shapes — which they are in most CRUD apps — separated models just double your code for no benefit.&lt;/p&gt;

&lt;p&gt;CQRS earns its complexity when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You have genuinely different read and write shapes (a dashboard aggregating data from multiple write models)&lt;/li&gt;
&lt;li&gt;Read/write traffic is wildly asymmetric (analytics dashboards hit 10,000x more than the admin panel writing data)&lt;/li&gt;
&lt;li&gt;Write-side invariants are complex enough that polluting the domain model with display concerns makes it worse&lt;/li&gt;
&lt;li&gt;You're already in an event-driven system where projecting events into read models is natural&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last point is where CQRS pairs with the event-driven patterns I'll cover in a dedicated post on event-driven architecture. If your system already publishes domain events, building read projections from those events is a small incremental step. But adopting events &lt;em&gt;and&lt;/em&gt; CQRS &lt;em&gt;and&lt;/em&gt; a message bus all at once for a CRUD app? That's resume-driven development.&lt;/p&gt;

&lt;p&gt;If you're working with &lt;a href="https://www.arnavsharma.dev/blogs/api-gateway" rel="noopener noreferrer"&gt;API gateways&lt;/a&gt; that route between services, CQRS might make sense at the service boundary. And if you're already running &lt;a href="https://www.arnavsharma.dev/blogs/kafka-partitions-consumer-groups" rel="noopener noreferrer"&gt;Kafka with partitioned consumers&lt;/a&gt;, projecting events into a read store is straightforward. But those are preconditions, not reasons to adopt CQRS from scratch.&lt;/p&gt;




&lt;h2&gt;
  
  
  📌 Key takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;CQRS is two models. One for writes, one for reads. That's the whole pattern.&lt;/li&gt;
&lt;li&gt;It doesn't require event sourcing, a second database, or a message bus. Those are infrastructure choices you might make, not prerequisites.&lt;/li&gt;
&lt;li&gt;The spectrum runs from "separate methods on one class" to "fully async separate databases." Most apps should stay on the cheap end.&lt;/li&gt;
&lt;li&gt;Eventual consistency is only a problem at tier 4. If you're there, use route-to-primary, version tokens, or optimistic UI to handle read-your-own-writes.&lt;/li&gt;
&lt;li&gt;Default answer: don't use it. Wait until you feel the pain of divergent read/write shapes before you split the model.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  More writing
&lt;/h2&gt;

&lt;p&gt;I park all my writing at &lt;a href="https://www.arnavsharma.dev" rel="noopener noreferrer"&gt;arnavsharma.dev&lt;/a&gt; if you want to read more.&lt;/p&gt;

</description>
      <category>backend</category>
      <category>architecture</category>
      <category>database</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Your Index Only Scan Is Lying: Covering Indexes and the Visibility Map</title>
      <dc:creator>Arnav Sharma</dc:creator>
      <pubDate>Thu, 13 Aug 2026 07:53:12 +0000</pubDate>
      <link>https://dev.to/arnavsharma2711/your-index-only-scan-is-lying-covering-indexes-and-the-visibility-map-43nd</link>
      <guid>https://dev.to/arnavsharma2711/your-index-only-scan-is-lying-covering-indexes-and-the-visibility-map-43nd</guid>
      <description>&lt;h1&gt;
  
  
  Covering Indexes Explained: Why "Index Only Scan" Still Hits the Heap
&lt;/h1&gt;

&lt;p&gt;You ran EXPLAIN ANALYZE. You saw &lt;code&gt;Index Only Scan&lt;/code&gt; in the output. You assumed the query never touched the table. Case closed, right?&lt;/p&gt;

&lt;p&gt;Then you looked one line lower. &lt;code&gt;Heap Fetches: 4,827&lt;/code&gt;. Your "index only" scan hit the heap almost five thousand times. Not so index-only after all.&lt;/p&gt;

&lt;p&gt;This trips up a lot of developers. The plan node says one thing, the runtime metric says another. I won't rehash what an index is or how B+ trees work (I've touched on index fundamentals &lt;a href="https://www.arnavsharma.dev/blogs/git-internals" rel="noopener noreferrer"&gt;before&lt;/a&gt;). This post is about the covering trick and the gotcha that makes it unreliable on write-heavy tables.&lt;/p&gt;




&lt;h2&gt;
  
  
  ⚡ What covering means and the fetch it skips
&lt;/h2&gt;

&lt;p&gt;A covering index is one that contains every column a query needs. SELECT columns, WHERE columns, ORDER BY columns. All of them. When the engine can answer entirely from the index, it skips the table (the heap).&lt;/p&gt;

&lt;p&gt;That heap visit is expensive. Random I/O. The index gives you a pointer to a row, then the engine jumps to that heap page, pulls the row, extracts the column. Ten thousand rows means ten thousand random fetches.&lt;/p&gt;

&lt;p&gt;A covering index kills that step. Everything lives in the index's leaf pages already.&lt;/p&gt;

&lt;p&gt;In Postgres 11+, you get the &lt;code&gt;INCLUDE&lt;/code&gt; clause for this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- Query: SELECT amount FROM orders WHERE customer_id = 42 AND status = 'shipped';&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;idx_orders_cover&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;customer_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;INCLUDE&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;customer_id&lt;/code&gt; and &lt;code&gt;status&lt;/code&gt; are &lt;strong&gt;key columns&lt;/strong&gt;. They live in both internal and leaf pages, they're searchable, and they determine sort order. Column order still matters here. The leftmost prefix rule applies to keys exactly as it does for any composite index.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;amount&lt;/code&gt; is a &lt;strong&gt;payload column&lt;/strong&gt;. It's stored only in leaf pages. You can't filter on it. You can't sort by it. It's just along for the ride so the engine doesn't have to go back to the heap. Think of it as a stowaway.&lt;/p&gt;

&lt;p&gt;InnoDB doesn't have INCLUDE. You'd tack &lt;code&gt;amount&lt;/code&gt; on as a trailing key column. But InnoDB gives you something free: every secondary index already carries the primary key columns in its leaf entries. PK columns are always "covered" without you doing anything.&lt;/p&gt;




&lt;h2&gt;
  
  
  🔑 The visibility map problem
&lt;/h2&gt;

&lt;p&gt;Here's the part most people miss. Postgres can't actually guarantee a pure index-only scan even with a perfect covering index.&lt;/p&gt;

&lt;p&gt;Why? MVCC. Every row has visibility rules: which transactions can see it, whether it's been deleted but not yet vacuumed. That info lives on the heap page, not in the index. So Postgres needs a way to answer "is this tuple visible to my transaction?" without going to the heap.&lt;/p&gt;

&lt;p&gt;The answer is the &lt;strong&gt;visibility map&lt;/strong&gt;. A bitmap with one bit per heap page. When VACUUM confirms every tuple on a page is visible to all current transactions, it sets that page's all-visible bit. During an index-only scan, Postgres checks the VM bit:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Bit set → return data from the index. No heap access.&lt;/li&gt;
&lt;li&gt;Bit not set → fetch the heap page anyway to check visibility.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And that second case is your &lt;code&gt;Heap Fetches&lt;/code&gt; number. Every recently-modified page that VACUUM hasn't caught yet forces a heap visit. On a write-heavy table where pages are constantly dirtied, those bits get cleared faster than VACUUM can set them. Your covering index is technically correct but practically useless.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Index Only Scan using idx_orders_cover on orders
  Index Cond: (customer_id = 42 AND status = 'shipped')
  Heap Fetches: 42
  Buffers: shared hit=15
  Planning Time: 0.08 ms
  Execution Time: 1.2 ms
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;(illustrative numbers)&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;Heap Fetches: 0&lt;/code&gt; means you got a true index-only scan. Any positive number means degraded pages forced heap access. The diagnostic is simple: run EXPLAIN ANALYZE, check Heap Fetches. If it's high relative to rows returned, VACUUM isn't keeping up.&lt;/p&gt;

&lt;p&gt;But MySQL doesn't have this problem. InnoDB stores row versions inline using undo logs, and secondary indexes point to the clustered index via the PK. No separate visibility check needed. When MySQL's EXPLAIN shows &lt;code&gt;Using index&lt;/code&gt; in the Extra column, it really does mean "table not accessed."&lt;/p&gt;

&lt;p&gt;Don't confuse that with &lt;code&gt;Using index condition&lt;/code&gt;. Different thing entirely:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;+----+------+---------------+---------+------+-------------+
| id | type | key           | key_len | rows | Extra       |
+----+------+---------------+---------+------+-------------+
|  1 | ref  | idx_status_amt| 5       |  120 | Using index |
+----+------+---------------+---------+------+-------------+
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;(illustrative)&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;Using index&lt;/code&gt; = covering, query answered from index alone. &lt;code&gt;Using index condition&lt;/code&gt; = Index Condition Pushdown (ICP), where a filter is pushed to the storage engine to evaluate against the index, but the table is still fetched for non-indexed columns. Similar names, different behaviour.&lt;/p&gt;




&lt;h2&gt;
  
  
  📌 Key takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A covering index holds every column the query needs, skipping heap fetches entirely. In Postgres, &lt;code&gt;INCLUDE&lt;/code&gt; lets you add payload columns that aren't searchable or sortable.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Index Only Scan&lt;/code&gt; in the plan doesn't guarantee zero table access. Check &lt;code&gt;Heap Fetches&lt;/code&gt; in EXPLAIN ANALYZE. That's the real metric. High numbers mean VACUUM hasn't caught up with writes.&lt;/li&gt;
&lt;li&gt;In InnoDB, secondary indexes carry the PK for free. &lt;code&gt;Using index&lt;/code&gt; means covering; &lt;code&gt;Using index condition&lt;/code&gt; means ICP. They aren't the same.&lt;/li&gt;
&lt;li&gt;Wide covering indexes cost you: bigger pages, more memory pressure, slower writes. Worth it for hot read paths on stable tables. Not worth it when the visibility map can't stay ahead.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you're tuning a slow read query and EXPLAIN already shows an index scan, adding one or two columns via INCLUDE is often the cheapest win. But only if your table is vacuumed regularly. Otherwise you're paying index maintenance cost for a benefit you never get.&lt;/p&gt;

&lt;p&gt;Somewhat related — &lt;a href="https://www.arnavsharma.dev/blogs/api-gateway" rel="noopener noreferrer"&gt;API gateways&lt;/a&gt; solve a similar "one extra hop" problem at the network layer, where caching at the edge saves the round-trip to the origin the same way a covering index saves the trip to the heap.&lt;/p&gt;




&lt;h2&gt;
  
  
  Where else to find me
&lt;/h2&gt;

&lt;p&gt;You'll find my other posts and projects at &lt;a href="https://www.arnavsharma.dev" rel="noopener noreferrer"&gt;arnavsharma.dev&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>database</category>
      <category>postgres</category>
      <category>sql</category>
      <category>performance</category>
    </item>
  </channel>
</rss>
