<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
  <title>Tautik Agrahari</title>
  <link href="https://tautik.me/"/>
  <link rel="self" href="https://tautik.me/feed/index.xml"/>
  <id>https://tautik.me/</id>
  <updated>2026-05-14T00:00:00Z</updated>
  <author><name>Tautik Agrahari</name></author>
  <entry>
    <title>geohash vs quadtree vs r-tree - three ways to find what's near you</title>
    <link href="https://tautik.me/near-you/"/>
    <id>https://tautik.me/near-you/</id>
    <updated>2026-05-14T00:00:00Z</updated>
    <content type="html">&lt;p&gt;every time your phone shows &amp;quot;5 ubers within a mile&amp;quot; or yelp pulls up restaurants near your dot on the map, somewhere in the backend a system is solving the same problem: given a point on earth, find all the other points close to it, fast.&lt;/p&gt;
&lt;p&gt;the naive answer is to scan every row in your database, compute the haversine distance to the query point, and return everything within some radius. this works when you have 100 points. it absolutely melts when you have 100 million.&lt;/p&gt;
&lt;p&gt;so we use spatial indexes. three come up over and over: &lt;strong&gt;geohash&lt;/strong&gt;, &lt;strong&gt;quadtree&lt;/strong&gt;, and &lt;strong&gt;r-tree&lt;/strong&gt;. they all partition space, but they do it in fundamentally different ways — geohash encodes points into sortable strings, quadtree carves space into rigid quadrants, r-tree wraps data in flexible overlapping rectangles. let&amp;#39;s walk through each, end with a concrete yelp example, and figure out which one to reach for when.&lt;/p&gt;
&lt;h2 id="what-you-39-ll-take-away"&gt;what you&amp;#39;ll take away&lt;/h2&gt;
&lt;p&gt;quick pointers so you know what to look for as you read:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;geohash, quadtree, and r-tree are three bets on the same narrowing problem.&lt;/strong&gt; encode points into sortable strings, carve space into a rigid grid, or wrap data in flexible overlapping rectangles.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;the index never gives you the answer — it gives you a candidate set.&lt;/strong&gt; narrow with the structure, refine with exact haversine. skip the second step and you ship wrong results.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;always query the cell AND its 8 neighbors.&lt;/strong&gt; two points 10 meters apart can land in cells that don&amp;#39;t even share a prefix.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;pick by deployment, not elegance.&lt;/strong&gt; geohash for redis-fast point-only lookups, quadtree for wildly uneven density, r-tree inside real databases mixing points and polygons.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;r-tree is what production databases actually run.&lt;/strong&gt; postgis, mysql spatial, oracle — overlap is the tax, splitting heuristics are the tuning knob.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="the-problem-they-solve"&gt;the problem they solve&lt;/h2&gt;
&lt;p&gt;okay. concrete. you&amp;#39;re building yelp. user opens the app at lat &lt;code&gt;40.7128&lt;/code&gt;, lon &lt;code&gt;-74.0060&lt;/code&gt; (manhattan). they want &amp;quot;coffee shops within 1 mile&amp;quot;. you have 10 million coffee shops in your db.&lt;/p&gt;
&lt;p&gt;a brute-force &lt;code&gt;SELECT * FROM shops WHERE distance(lat, lon, 40.7128, -74.0060) &amp;lt; 1&lt;/code&gt; does 10 million distance computations per query. dead. you&amp;#39;d burn through a server cluster before lunch.&lt;/p&gt;
&lt;p&gt;what you actually want is: &lt;strong&gt;narrow the search to ~50 candidate shops in O(log n), then run the precise distance check on just those.&lt;/strong&gt; that &amp;quot;narrowing&amp;quot; step is what spatial indexes do.&lt;/p&gt;
&lt;p&gt;geohash, quadtree, and r-tree are three different bets on how to do the narrowing.&lt;/p&gt;
&lt;h2 id="geohash-encode-space-into-a-string"&gt;geohash — encode space into a string&lt;/h2&gt;
&lt;p&gt;geohash converts a &lt;code&gt;(lat, lon)&lt;/code&gt; pair into a short base32 string like &lt;code&gt;dr5ru7p&lt;/code&gt;. the magic is the &lt;strong&gt;prefix property&lt;/strong&gt;: two points that are close on earth will, most of the time, share a long common prefix.&lt;/p&gt;
&lt;p&gt;so &lt;code&gt;dr5ru7p&lt;/code&gt; and &lt;code&gt;dr5ru3z&lt;/code&gt; are nearby (both share &lt;code&gt;dr5ru&lt;/code&gt;). &lt;code&gt;dr5ru7p&lt;/code&gt; and &lt;code&gt;9q8yzn&lt;/code&gt; are very far (no shared prefix — different sides of the country).&lt;/p&gt;
&lt;h3 id="how-it-actually-encodes"&gt;how it actually encodes&lt;/h3&gt;
&lt;p&gt;geohash interleaves the binary representations of latitude and longitude. you take lat (range &lt;code&gt;-90..90&lt;/code&gt;), repeatedly split it in half, write down whether your target is in the lower or upper half. same for lon (range &lt;code&gt;-180..180&lt;/code&gt;). interleave the bits, encode in base32. each character adds about 5 bits of precision.&lt;/p&gt;
&lt;p&gt;&lt;img src="https://raw.githubusercontent.com/tautik/image-assets/main/gq-geohash-encoding.png" alt="geohash encoding — lat/lon get recursively halved and bit-interleaved, then base32-encoded into a short string; longer strings = more precision; nearby points share a prefix"&gt;&lt;/p&gt;
&lt;p&gt;precision by length:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;length&lt;/th&gt;
&lt;th&gt;cell size (approx)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;4 chars&lt;/td&gt;
&lt;td&gt;~39 km × 19 km&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5 chars&lt;/td&gt;
&lt;td&gt;~5 km × 5 km&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6 chars&lt;/td&gt;
&lt;td&gt;~1.2 km × 0.6 km&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;7 chars&lt;/td&gt;
&lt;td&gt;~150 m × 150 m&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;8 chars&lt;/td&gt;
&lt;td&gt;~38 m × 19 m&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;so for &amp;quot;coffee shops within ~1 mile&amp;quot;, a 5-character geohash bucket is about right. you store every shop pre-computed with its 5-char geohash, then the query becomes &amp;quot;give me all shops where &lt;code&gt;geohash = &amp;#39;dr5ru&amp;#39;&lt;/code&gt;.&amp;quot;&lt;/p&gt;
&lt;h3 id="why-redis-loves-this"&gt;why redis loves this&lt;/h3&gt;
&lt;p&gt;geohashes are &lt;em&gt;sorted strings&lt;/em&gt;. they fit perfectly into a redis sorted set, or any b-tree index. redis actually has built-in commands for this — under the hood it&amp;#39;s just a sorted set of geohash-encoded scores.&lt;/p&gt;
&lt;div class="codeblock"&gt;&lt;pre&gt;&lt;code class="hljs"&gt;GEOADD shops -74.0060 40.7128 "blue-bottle"
GEOADD shops -74.0050 40.7130 "joe-coffee"

GEOSEARCH shops FROMLONLAT -74.0060 40.7128 BYRADIUS 1 mi&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;dead simple. no postgis, no kd-tree, no custom service. just a sorted set with magic strings.&lt;/p&gt;
&lt;h3 id="the-edge-case-nobody-mentions-on-day-1"&gt;the edge case nobody mentions on day 1&lt;/h3&gt;
&lt;p&gt;geohash has one big gotcha: &lt;strong&gt;points right next to each other can fall in different cells&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;imagine the cell boundary runs right through times square. a hotdog stand 5 meters east of the boundary has geohash &lt;code&gt;dr5ru7&lt;/code&gt;. a coffee shop 5 meters west has geohash &lt;code&gt;dr5rgz&lt;/code&gt;. they&amp;#39;re 10 meters apart, but their geohashes don&amp;#39;t even share the second character.&lt;/p&gt;
&lt;p&gt;&lt;img src="https://raw.githubusercontent.com/tautik/image-assets/main/gq-geohash-edge-case.png" alt="geohash boundary problem — two nearby points on opposite sides of a cell boundary get totally different prefixes; fix is to query the user&amp;#39;s cell + its 8 neighbors"&gt;&lt;/p&gt;
&lt;p&gt;the fix is mandatory: &lt;strong&gt;always query the user&amp;#39;s cell AND its 8 neighboring cells.&lt;/strong&gt; there are well-known formulas to compute the 8 neighbors of any geohash, so this is cheap. you end up querying 9 cells instead of 1, then filtering the result with exact haversine distance.&lt;/p&gt;
&lt;p&gt;so the real query loop is:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;compute the user&amp;#39;s geohash at the right precision&lt;/li&gt;
&lt;li&gt;compute its 8 neighbors → 9 cells total&lt;/li&gt;
&lt;li&gt;fetch all points whose geohash is in that set&lt;/li&gt;
&lt;li&gt;compute exact haversine distance for each, filter to radius&lt;/li&gt;
&lt;li&gt;return&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;step 4 is the unavoidable &amp;quot;refinement&amp;quot; step. the index narrows you from 10 million to ~50–200 candidates. the haversine check filters those to the final answer.&lt;/p&gt;
&lt;details class="recall"&gt;&lt;summary&gt;&lt;span class="recall-mark"&gt;&amp;#10045; RECALL&lt;/span&gt; what property of geohash lets you build a &amp;quot;nearby&amp;quot; feature on a plain sorted set or b-tree index, with no spatial extension at all?&lt;/summary&gt;&lt;p&gt;the prefix property — interleaving lat/lon bits and base32-encoding them means nearby points (usually) share a long common prefix, and the resulting strings &lt;em&gt;sort&lt;/em&gt;. proximity search becomes a prefix/range lookup on any ordered structure, which is exactly why redis ships &lt;code&gt;GEOADD&lt;/code&gt;/&lt;code&gt;GEOSEARCH&lt;/code&gt; as a thin layer over a sorted set.&lt;/p&gt;&lt;/details&gt;

&lt;details class="recall"&gt;&lt;summary&gt;&lt;span class="recall-mark"&gt;&amp;#10045; RECALL&lt;/span&gt; two coffee shops sit 10 meters apart but their geohashes don&amp;#39;t even share a second character. what happened, and what&amp;#39;s the mandatory fix?&lt;/summary&gt;&lt;p&gt;a cell boundary runs between them — the prefix property breaks at boundaries, so closeness on earth doesn&amp;#39;t guarantee closeness in the encoding. the fix is to always query the user&amp;#39;s cell plus its 8 neighbors (cheap, well-known formulas exist), then filter the candidates with exact haversine distance. query one cell and you&amp;#39;ll silently drop results.&lt;/p&gt;&lt;/details&gt;

&lt;h2 id="quadtree-recursive-2d-split"&gt;quadtree — recursive 2D split&lt;/h2&gt;
&lt;p&gt;quadtree comes at it differently. instead of encoding each point, it organizes &lt;em&gt;space itself&lt;/em&gt; into a tree.&lt;/p&gt;
&lt;p&gt;start with the whole 2D map as a single box. that&amp;#39;s the root. when too many points are in one box (say, more than 4), split the box into 4 equal quadrants — NW, NE, SW, SE. each quadrant becomes a child node. if any of those children gets too crowded, split that one into 4 more. recurse.&lt;/p&gt;
&lt;p&gt;&lt;img src="https://raw.githubusercontent.com/tautik/image-assets/main/gq-quadtree-recursive-split.png" alt="quadtree structure — root is the full map, busy areas get split deeper while empty areas stay shallow; downtown nyc has lots of small leaves, the atlantic ocean stays one big block"&gt;&lt;/p&gt;
&lt;p&gt;the beauty is &lt;strong&gt;density-adaptive&lt;/strong&gt;: downtown manhattan ends up with tiny leaf nodes (lots of recursive splits) while the middle of nebraska might be a single huge leaf. you&amp;#39;re not wasting tree depth on empty space, and you&amp;#39;re not letting busy areas get overcrowded.&lt;/p&gt;
&lt;h3 id="the-search"&gt;the search&lt;/h3&gt;
&lt;p&gt;to find points near &lt;code&gt;(lat, lon)&lt;/code&gt;:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;start at the root&lt;/li&gt;
&lt;li&gt;descend into whichever quadrant contains your query point&lt;/li&gt;
&lt;li&gt;recurse until you&amp;#39;re in a leaf&lt;/li&gt;
&lt;li&gt;that leaf contains the candidate points (and you check neighboring leaves too — same boundary problem as geohash, different mechanics)&lt;/li&gt;
&lt;li&gt;exact haversine check, return&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;each step picks 1 of 4 children, so search is &lt;code&gt;O(log₄ n)&lt;/code&gt; — same shape as &lt;code&gt;O(log n)&lt;/code&gt;, just a tighter constant.&lt;/p&gt;
&lt;h3 id="tradeoffs-vs-geohash"&gt;tradeoffs vs geohash&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;density adapts automatically.&lt;/strong&gt; if nyc has 100k coffee shops and nebraska has 50, the quadtree just &lt;em&gt;handles&lt;/em&gt; it. with geohash, you have to manually pick a precision that works everywhere.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;memory overhead.&lt;/strong&gt; every internal node, every pointer. for uniform-density data, geohash is way cheaper.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;harder to distribute.&lt;/strong&gt; geohash strings shard naturally across machines (just hash the prefix). quadtrees need careful partitioning.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;rebalancing is real.&lt;/strong&gt; as drivers move (uber!) or points get added/removed, the tree shape changes. geohash, since it&amp;#39;s just a string per point, sidesteps this entirely.&lt;/li&gt;
&lt;/ul&gt;
&lt;details class="recall"&gt;&lt;summary&gt;&lt;span class="recall-mark"&gt;&amp;#10045; RECALL&lt;/span&gt; why does a quadtree handle manhattan-vs-nebraska density gracefully where geohash forces an awkward global compromise — and what does that grace cost you?&lt;/summary&gt;&lt;p&gt;a quadtree splits a box only when it gets crowded, so dense areas grow deep tiny leaves while empty space stays one big block; geohash makes you pick a single precision that&amp;#39;s wrong somewhere. the cost: node and pointer overhead, harder distribution across machines, and real rebalancing as points move — geohash sidesteps all of that because it&amp;#39;s just a string per point.&lt;/p&gt;&lt;/details&gt;

&lt;h2 id="r-tree-flexible-overlapping-rectangles"&gt;r-tree — flexible, overlapping rectangles&lt;/h2&gt;
&lt;p&gt;both geohash and quadtree split space on &lt;em&gt;fixed grid lines&lt;/em&gt; — every cell or quadrant has a predetermined position and size. r-trees throw that constraint out. instead, they wrap the data in &lt;strong&gt;flexible, overlapping bounding rectangles&lt;/strong&gt; that adapt to wherever the data actually clusters.&lt;/p&gt;
&lt;p&gt;think of organizing a pile of photos on a table. quadtree would force you to draw a fixed 2×2 grid and keep subdividing. r-tree just lets you draw natural rectangles around clumps of nearby photos, even if those rectangles overlap a bit.&lt;/p&gt;
&lt;p&gt;&lt;img src="https://raw.githubusercontent.com/tautik/image-assets/main/gq-rtree-structure.png" alt="r-tree — flexible overlapping rectangles that adapt to data; left shows MBRs on a 2d map, right shows the tree of bounding boxes"&gt;&lt;/p&gt;
&lt;p&gt;each rectangle is a &lt;strong&gt;minimum bounding rectangle (MBR)&lt;/strong&gt; — the smallest axis-aligned box that contains all its children. the root has a few wide MBRs, each child has tighter ones, all the way down to leaves that hold actual data items.&lt;/p&gt;
&lt;h3 id="why-this-matters"&gt;why this matters&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;r-tree is the default in production databases.&lt;/strong&gt; postgresql&amp;#39;s gist indexes (and &lt;code&gt;postgis&lt;/code&gt;) use r-trees. so does mysql&amp;#39;s spatial index, oracle spatial, and basically every commercial spatial database. there&amp;#39;s a reason for that.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;mixed data types.&lt;/strong&gt; r-tree indexes points, polygons, road networks, delivery zones — anything with a bounding box — in the same index. quadtree struggles here because non-point shapes mess up the rigid grid; r-tree just expands the rectangle to fit.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;adapts to data clusters.&lt;/strong&gt; rectangles hug the data rather than slicing space arbitrarily. fewer empty cells, fewer wasted traversals.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;disk-friendly.&lt;/strong&gt; modern r-tree variants (r*-tree, hilbert r-tree) tune their splits for how databases actually read pages off disk. when you call &lt;code&gt;ST_DWithin&lt;/code&gt; in postgis, it&amp;#39;s hitting an r-tree.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id="the-trade-off"&gt;the trade-off&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;rectangles can overlap.&lt;/strong&gt; when a query point sits inside two overlapping rectangles, you have to descend into both branches and union the results. lots of overlap means the index does extra work.&lt;/p&gt;
&lt;p&gt;production r-tree implementations spend a lot of cleverness on the &lt;strong&gt;splitting heuristic&lt;/strong&gt; — when a node fills up and has to split, how do you divide its children into two new MBRs that minimize overlap? this is where r-tree, r±tree, r*-tree, and hilbert r-tree differ. all the same shape, different splitting strategies.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;in interviews and at the whiteboard: just say &amp;quot;r-tree&amp;quot; and you&amp;#39;re fine. the variants matter for production tuning but not for the architectural conversation.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;details class="recall"&gt;&lt;summary&gt;&lt;span class="recall-mark"&gt;&amp;#10045; RECALL&lt;/span&gt; why do postgis, mysql spatial, and oracle all default to r-trees instead of geohash or quadtrees — and what&amp;#39;s the tax you pay for it?&lt;/summary&gt;&lt;p&gt;minimum bounding rectangles hug the data instead of slicing space on fixed grid lines, and they can wrap anything with a bounding box — points, polygons, road networks — in one index. the tax is overlap: a query point inside two rectangles forces you down both branches, which is why production variants pour their cleverness into splitting heuristics that minimize overlap.&lt;/p&gt;&lt;/details&gt;

&lt;h2 id="a-concrete-example-yelp-quot-coffee-near-me-quot"&gt;a concrete example — yelp &amp;quot;coffee near me&amp;quot;&lt;/h2&gt;
&lt;p&gt;let&amp;#39;s walk through the same query with both approaches.&lt;/p&gt;
&lt;p&gt;user at &lt;code&gt;(40.7128, -74.0060)&lt;/code&gt; asks for coffee shops within 1 mile.&lt;/p&gt;
&lt;h3 id="geohash-version"&gt;geohash version&lt;/h3&gt;
&lt;div class="codeblock"&gt;&lt;pre&gt;&lt;code class="hljs"&gt;# precompute (done once per shop, stored in db / redis)
shop "Blue Bottle"    → geohash dr5ru7p
shop "Joe Coffee"     → geohash dr5ru3z
shop "Stumptown SoHo" → geohash dr5rsbk
shop "...10M others"

# query time
user_geohash = encode(40.7128, -74.0060, length=5) = "dr5ru"
neighbors    = ["dr5ru",  # the user's cell
                "dr5rg", "dr5rs", "dr5rv",
                "dr5rt",          "dr5rw",
                "dr5rh", "dr5rk", "dr5re"]  # 8 neighbors

# fetch all shops whose geohash starts with one of these 9 prefixes
candidates = SELECT * FROM shops WHERE LEFT(geohash, 5) IN (...9 values...);
# ~50–200 results

# exact distance filter
results = [shop for shop in candidates
           if haversine(user, shop) &amp;lt;= 1.0]&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;simple. redis-friendly. one round trip. ship it.&lt;/p&gt;
&lt;h3 id="quadtree-version"&gt;quadtree version&lt;/h3&gt;
&lt;div class="codeblock"&gt;&lt;pre&gt;&lt;code class="hljs"&gt;# precompute (built in memory or stored in postgis-style index)
quadtree = build_quadtree(all_10M_shops)

# query time
user_point = (40.7128, -74.0060)
leaf       = quadtree.find_leaf(user_point)
candidates = leaf.points + leaf.neighbors_within(1_mile)

# exact distance filter
results = [c for c in candidates if haversine(user, c) &amp;lt;= 1.0]&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;same overall shape, different data structure. the quadtree gracefully handles the fact that manhattan has 100x the shop density of staten island, while geohash needed you to pick a single global precision.&lt;/p&gt;
&lt;p&gt;&lt;img src="https://raw.githubusercontent.com/tautik/image-assets/main/gq-nearby-query-flow.png" alt="both flows side-by-side — spatial index narrows candidates from 10M to ~100, then exact haversine distance filter yields the final answer"&gt;&lt;/p&gt;
&lt;h2 id="when-to-pick-which"&gt;when to pick which&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;use case&lt;/th&gt;
&lt;th&gt;better fit&lt;/th&gt;
&lt;th&gt;why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;uber nearby drivers&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;geohash&lt;/td&gt;
&lt;td&gt;drivers move every few seconds; just update the geohash string in redis. classic pattern.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;tinder location matching&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;geohash&lt;/td&gt;
&lt;td&gt;users update location periodically, sorted set, done&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;anything you want in redis quickly&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;geohash&lt;/td&gt;
&lt;td&gt;it&amp;#39;s literally a built-in&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;mapping with wildly varying density&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;quadtree&lt;/td&gt;
&lt;td&gt;density-adaptive splits give better worst-case performance&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;physics sim, collision detection&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;quadtree&lt;/td&gt;
&lt;td&gt;spatial reasoning over points matters more than encoding&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;yelp restaurant search at production scale&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;r-tree (via postgis)&lt;/td&gt;
&lt;td&gt;mixed data — points + polygons (delivery zones, neighborhoods) — and you want a real db query, not just a redis lookup&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;mapping app indexing roads + POIs together&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;r-tree&lt;/td&gt;
&lt;td&gt;only structure that gracefully handles points + polygons + linestrings in one index&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;postgis, mysql spatial, oracle spatial&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;r-tree&lt;/td&gt;
&lt;td&gt;they literally use r-tree — that&amp;#39;s the default&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;blockquote&gt;
&lt;p&gt;rule of thumb:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;geohash&lt;/strong&gt; when you need redis-fast, point-only, simple-as-possible.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;quadtree&lt;/strong&gt; when density is wildly uneven and points are the only thing you index.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;r-tree&lt;/strong&gt; when you&amp;#39;re in a real database (postgis, mysql) and need to mix data types.&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;details class="recall"&gt;&lt;summary&gt;&lt;span class="recall-mark"&gt;&amp;#10045; RECALL&lt;/span&gt; you&amp;#39;re building &amp;quot;drivers near me&amp;quot; for an uber clone and a yelp-style search with delivery zones. which index for each, and why?&lt;/summary&gt;&lt;p&gt;drivers → geohash in redis: moving points are just string updates in a sorted set, no tree to rebalance every few seconds. yelp → r-tree via postgis: you&amp;#39;re mixing points with polygons (delivery zones, neighborhoods) and want a real database query, not just a key lookup. quadtree only enters when density is wildly uneven and points are the only thing you index.&lt;/p&gt;&lt;/details&gt;

&lt;h2 id="the-universal-pattern"&gt;the universal pattern&lt;/h2&gt;
&lt;p&gt;both approaches share the same two-step structure:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;index → narrow.&lt;/strong&gt; use the spatial structure to go from millions of candidates to a few hundred. cheap and fast.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;exact distance → refine.&lt;/strong&gt; for each candidate, compute the real haversine distance. more expensive per-row, but only running on the narrowed set.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;the index doesn&amp;#39;t give you the right answer. it gives you a &lt;em&gt;candidate set small enough that the exact answer is cheap to compute&lt;/em&gt;. forgetting step 2 is how you ship a &amp;quot;nearby&amp;quot; feature that includes shops 10 miles away just because they happened to share a geohash cell.&lt;/p&gt;
&lt;details class="recall"&gt;&lt;summary&gt;&lt;span class="recall-mark"&gt;&amp;#10045; RECALL&lt;/span&gt; what does a spatial index actually give you — and how do you ship a bug if you treat its output as the answer?&lt;/summary&gt;&lt;p&gt;a candidate set small enough that computing the exact answer is cheap: narrow from millions to a few hundred in O(log n), then haversine-filter to the radius. skip the refine step and you return shops 10 miles away just because they shared a cell — the index narrows, the distance check decides.&lt;/p&gt;&lt;/details&gt;

&lt;h2 id="what-i-39-d-remember"&gt;what i&amp;#39;d remember&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;three different bets on the same problem.&lt;/strong&gt; geohash = encoding, quadtree = rigid grid, r-tree = flexible overlapping rectangles.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;geohash is the right default&lt;/strong&gt; for most &amp;quot;nearby&amp;quot; features built on redis or a sorted index.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;quadtree wins when point density varies a lot&lt;/strong&gt; across your map.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;r-tree wins inside real databases&lt;/strong&gt; (postgis, mysql) — and it&amp;#39;s the only structure that gracefully indexes mixed shapes.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;always query neighbors, never just one cell.&lt;/strong&gt; boundary edge cases will burn you otherwise.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;always do the exact distance check after the index.&lt;/strong&gt; the index narrows; the distance check gives you the right answer.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;the &amp;quot;narrow then refine&amp;quot; two-step is universal.&lt;/strong&gt; whichever index you pick, the second step is the same haversine filter.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;nothing here is novel. redis has &lt;code&gt;GEOADD&lt;/code&gt;, postgis has &lt;code&gt;ST_DWithin&lt;/code&gt;, mongodb has &lt;code&gt;2dsphere&lt;/code&gt; indexes. the design is in picking the right tool, remembering the boundary fix, and not over-engineering. &lt;strong&gt;you&amp;#39;re paid to solve a problem, not to ship the fanciest architecture.&lt;/strong&gt;&lt;/p&gt;
</content>
  </entry>
  <entry>
    <title>computer use agent story</title>
    <link href="https://tautik.me/cua/"/>
    <id>https://tautik.me/cua/</id>
    <updated>2026-05-12T00:00:00Z</updated>
    <content type="html">&lt;p&gt;a cua (computer use agent) is just an llm that controls a browser. you give it a task like &amp;quot;log into some saas dashboard and screenshot the encryption settings&amp;quot;, it takes a screenshot, sends the screenshot to claude with a list of tools (&lt;code&gt;click&lt;/code&gt;, &lt;code&gt;type&lt;/code&gt;, &lt;code&gt;scroll&lt;/code&gt;), claude returns a tool call like &lt;code&gt;click(x=420, y=180)&lt;/code&gt;, you apply that to the browser, take another screenshot, and loop. that&amp;#39;s the whole thing. it&amp;#39;s a &lt;code&gt;while not done: screenshot → llm → tool call&lt;/code&gt; loop with a real browser on the other end.&lt;/p&gt;
&lt;p&gt;the part that&amp;#39;s not obvious is &lt;strong&gt;where the browser actually runs&lt;/strong&gt;. it can&amp;#39;t run on the user&amp;#39;s laptop (you&amp;#39;d need them online and giving you access). it can&amp;#39;t run on your api server (you&amp;#39;d be sharing one chrome between every request). so it has to run on a remote linux box somewhere, with chrome inside it, and your backend talks to that chrome over the network. that &amp;quot;remote linux box with chrome&amp;quot; is the whole infra problem. everything else — the prompts, the planner, the auth, the recording — is built around that one primitive.&lt;/p&gt;
&lt;h2 id="what-you-39-ll-take-away"&gt;what you&amp;#39;ll take away&lt;/h2&gt;
&lt;p&gt;quick pointers so you know what to look for as you read:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;a cua is just a screenshot loop.&lt;/strong&gt; screenshot → llm → tool → screenshot. everything else is plumbing.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;the hard part is &amp;quot;where chrome runs.&amp;quot;&lt;/strong&gt; rented browser apis are easy; raw vms are powerful but you build the stack yourself.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;you don&amp;#39;t need to invent a remote desktop.&lt;/strong&gt; xvfb + x11vnc + novnc has been doing this since the 90s.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;auth = &lt;code&gt;storage_state&lt;/code&gt;, not oauth.&lt;/strong&gt; capture cookies once, replay forever.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;bot detection isn&amp;#39;t really solvable.&lt;/strong&gt; proxies + atomic typing + bail on captchas, that&amp;#39;s it.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;a &lt;code&gt;Computer&lt;/code&gt; protocol with ~10 methods&lt;/strong&gt; is the seam that lets you swap providers without rewriting the agent.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;traditional unit tests are theatre for agents.&lt;/strong&gt; trace everything, benchmark periodically, manually verify on real flows.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;scaling = retuning gunicorn, not magic.&lt;/strong&gt; long timeouts, worker recycling, locked + shielded cleanup, pre-warmed templates.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="the-screenshot-loop-slightly-less-hand-wavy"&gt;the screenshot loop, slightly less hand-wavy&lt;/h2&gt;
&lt;p&gt;claude (and openai&amp;#39;s models, and gemini) ship &amp;quot;computer use&amp;quot; tools — basically a function spec that says &amp;quot;i can call &lt;code&gt;click(x, y)&lt;/code&gt;, &lt;code&gt;type(text)&lt;/code&gt;, &lt;code&gt;scroll(direction)&lt;/code&gt; etc.&amp;quot; you wire that up like any other tool call. the whole loop is maybe 15 lines of pseudocode:&lt;/p&gt;
&lt;div class="codeblock"&gt;&lt;span class="code-lang"&gt;python&lt;/span&gt;&lt;pre&gt;&lt;code class="hljs language-python"&gt;&lt;span class="hljs-keyword"&gt;async&lt;/span&gt; &lt;span class="hljs-keyword"&gt;def&lt;/span&gt; &lt;span class="hljs-title function_"&gt;run_task&lt;/span&gt;(&lt;span class="hljs-params"&gt;task: &lt;span class="hljs-built_in"&gt;str&lt;/span&gt;, browser: Computer&lt;/span&gt;):
    history = []
    &lt;span class="hljs-keyword"&gt;for&lt;/span&gt; turn &lt;span class="hljs-keyword"&gt;in&lt;/span&gt; &lt;span class="hljs-built_in"&gt;range&lt;/span&gt;(MAX_TURNS):
        screenshot = &lt;span class="hljs-keyword"&gt;await&lt;/span&gt; browser.screenshot()
        response = &lt;span class="hljs-keyword"&gt;await&lt;/span&gt; claude.complete(
            task=task,
            screenshot=screenshot,
            tools=COMPUTER_TOOLS,   &lt;span class="hljs-comment"&gt;# click, type, scroll, ...&lt;/span&gt;
            history=history,
        )
        &lt;span class="hljs-keyword"&gt;if&lt;/span&gt; &lt;span class="hljs-string"&gt;&amp;quot;&amp;lt;&amp;lt;TASK_COMPLETE&amp;gt;&amp;gt;&amp;quot;&lt;/span&gt; &lt;span class="hljs-keyword"&gt;in&lt;/span&gt; response.text:
            &lt;span class="hljs-keyword"&gt;return&lt;/span&gt; response
        tool_call = response.tool_use         &lt;span class="hljs-comment"&gt;# e.g. click(x=420, y=180)&lt;/span&gt;
        &lt;span class="hljs-keyword"&gt;await&lt;/span&gt; browser.execute(tool_call)      &lt;span class="hljs-comment"&gt;# applied to chrome via cdp&lt;/span&gt;
        history.append((screenshot, tool_call))&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;quick aside on &lt;code&gt;&amp;lt;&amp;lt;TASK_COMPLETE&amp;gt;&amp;gt;&lt;/code&gt; — that&amp;#39;s not a magic constant from anthropic. it&amp;#39;s just a sentinel string &lt;em&gt;you&lt;/em&gt; put in the system prompt: &amp;quot;when you&amp;#39;re done with the task, output the literal text &lt;code&gt;&amp;lt;&amp;lt;TASK_COMPLETE&amp;gt;&amp;gt;&lt;/code&gt; followed by a one-line summary.&amp;quot; the model emits it, your loop greps for it, you break. could be &lt;code&gt;[DONE]&lt;/code&gt;, doesn&amp;#39;t matter — pick something the model is unlikely to output by accident. same trick for the captcha bail-out later: prompt says &amp;quot;if you see a captcha, output &lt;code&gt;&amp;lt;&amp;lt;TASK_COMPLETE&amp;gt;&amp;gt;&lt;/code&gt; with reason &lt;code&gt;CAPTCHA&lt;/code&gt;.&amp;quot;&lt;/p&gt;
&lt;p&gt;that&amp;#39;s it. the model is doing all the perception (where&amp;#39;s the button? what does the screen say?) and all the planning (what do i click next?). you&amp;#39;re just the hands.&lt;/p&gt;
&lt;p&gt;&lt;img src="https://raw.githubusercontent.com/tautik/image-assets/main/cua-screenshot-loop.png" alt="the screenshot loop"&gt;&lt;/p&gt;
&lt;p&gt;you might ask, tautik — what drives the browser? &lt;code&gt;playwright&lt;/code&gt;. python library that speaks &lt;code&gt;cdp&lt;/code&gt; (chrome devtools protocol — same thing your devtools panel uses when you press f12). when chrome launches with &lt;code&gt;--remote-debugging-port=9222&lt;/code&gt;, anyone with the websocket url can connect and drive it — click, type, screenshot, run js, intercept network. so your backend does &lt;code&gt;await playwright.chromium.connect_over_cdp(url)&lt;/code&gt; and from then on it&amp;#39;s &lt;em&gt;as if&lt;/em&gt; the chrome is on your laptop, except the actual browser is on a vm somewhere.&lt;/p&gt;
&lt;details class="recall"&gt;&lt;summary&gt;&lt;span class="recall-mark"&gt;&amp;#10045; RECALL&lt;/span&gt; in the cua loop, what is your code responsible for vs the model — and how does the loop know when to stop?&lt;/summary&gt;&lt;p&gt;the model does all the perception (what&amp;#39;s on screen) and all the planning (what to click next); your code is just the hands — apply the tool call to chrome over cdp, screenshot, send it back, loop. termination is a sentinel string &lt;em&gt;you&lt;/em&gt; define in the system prompt (&amp;quot;output &lt;code&gt;&amp;lt;&amp;lt;TASK_COMPLETE&amp;gt;&amp;gt;&lt;/code&gt; when done&amp;quot;) that the loop greps for — nothing magic, and the same trick handles the captcha bail-out.&lt;/p&gt;&lt;/details&gt;

&lt;h2 id="where-chrome-runs-the-actual-hard-problem"&gt;where chrome runs — the actual hard problem&lt;/h2&gt;
&lt;p&gt;aight so two paths.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;option 1: rented browser-as-a-service.&lt;/strong&gt; vendors like browserbase give you back a &lt;code&gt;cdp_url&lt;/code&gt; and a &lt;code&gt;stream_url&lt;/code&gt; and call it a day. their problem to figure out where chrome runs and how to keep it alive. easy day one. cost: they decide which features get exposed (recordings? proxies? auth storage?) and how their pricing scales. you get whatever they ship.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;option 2: raw linux vm + install chrome yourself.&lt;/strong&gt; services like e2b give you firecracker-as-a-service — &lt;code&gt;AsyncSandbox.create()&lt;/code&gt; returns a root vm with &lt;code&gt;commands.run()&lt;/code&gt;, &lt;code&gt;files.read/write&lt;/code&gt;, port exposure. &lt;strong&gt;no browser, no display, no vnc, no recording.&lt;/strong&gt; you trade up on control, you trade down on convenience.&lt;/p&gt;
&lt;p&gt;most teams should start with option 1. you&amp;#39;ll know when it&amp;#39;s time to move down a layer — usually when you keep hitting the vendor&amp;#39;s ceiling on something (recording, proxies, custom flags, snapshot timing). don&amp;#39;t migrate prematurely; you&amp;#39;re signing up for a stack you now own end to end.&lt;/p&gt;
&lt;details class="recall"&gt;&lt;summary&gt;&lt;span class="recall-mark"&gt;&amp;#10045; RECALL&lt;/span&gt; rented browser-as-a-service vs raw vm with chrome — when is it time to move down a layer, and what do you sign up for?&lt;/summary&gt;&lt;p&gt;start rented; migrate only when you keep hitting the vendor&amp;#39;s ceiling on something you need — recording, proxies, custom chrome flags, snapshot timing. the raw vm hands you a root box and nothing else: no browser, no display, no vnc, no recording. you trade up on control and sign up to own the entire stack end to end, so don&amp;#39;t do it prematurely.&lt;/p&gt;&lt;/details&gt;

&lt;h2 id="the-remote-display-stack-demystified"&gt;the remote display stack, demystified&lt;/h2&gt;
&lt;p&gt;if you go option 2, here&amp;#39;s what you actually need on the vm. linux servers don&amp;#39;t have monitors, but chrome (when not running headless) wants to draw to a screen. so:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;xvfb&lt;/code&gt;&lt;/strong&gt; is a fake screen — gives chrome an &lt;code&gt;x11&lt;/code&gt; display in ram&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;xfce4&lt;/code&gt;&lt;/strong&gt; is a window manager that puts borders and a taskbar around chrome&amp;#39;s window&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;x11vnc&lt;/code&gt;&lt;/strong&gt; watches the fake screen and rebroadcasts it as &lt;code&gt;vnc&lt;/code&gt; (a 90s screen-sharing protocol)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;novnc&lt;/code&gt;&lt;/strong&gt; translates the vnc stream into websockets so a normal browser tab can render it without any plugin (i wrote a deeper &lt;a href="https://tautik.me/novnc/"&gt;novnc walkthrough here&lt;/a&gt; if you want to actually understand what&amp;#39;s happening over the wire)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;so the chain looks like:&lt;/p&gt;
&lt;p&gt;&lt;img src="https://raw.githubusercontent.com/tautik/image-assets/main/cua-display-stack.png" alt="the remote display stack"&gt;&lt;/p&gt;
&lt;p&gt;none of this is novel — vnc has been around forever and people have been remote-debugging headless linux for two decades. the only thing you have to do is orchestrate the startup order (xvfb → xfce → x11vnc → novnc → chrome) and wait for cdp to respond on port 9222 before the agent connects.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;apt install xvfb xfce4 x11vnc novnc chromium-browser&lt;/code&gt; and you&amp;#39;re ~80% there. there are even pre-built ubuntu templates floating around with all of this preinstalled, which saves a lot of fiddling.&lt;/p&gt;
&lt;details class="recall"&gt;&lt;summary&gt;&lt;span class="recall-mark"&gt;&amp;#10045; RECALL&lt;/span&gt; chrome on a headless linux vm needs a screen to draw on, and you need to watch it from a browser tab. what&amp;#39;s the chain, and why is none of it novel?&lt;/summary&gt;&lt;p&gt;&lt;code&gt;xvfb&lt;/code&gt; fakes a display in ram, &lt;code&gt;xfce4&lt;/code&gt; puts a window manager around chrome, &lt;code&gt;x11vnc&lt;/code&gt; rebroadcasts the fake screen as vnc, and &lt;code&gt;novnc&lt;/code&gt; translates vnc into websockets a browser tab can render. all of it is decades-old remote-desktop tech — your only real work is the startup ordering (xvfb → xfce → x11vnc → novnc → chrome) and waiting for cdp to answer before the agent connects.&lt;/p&gt;&lt;/details&gt;

&lt;h2 id="the-computer-protocol-the-small-trick-that-buys-you-everything"&gt;the &lt;code&gt;Computer&lt;/code&gt; protocol — the small trick that buys you everything&lt;/h2&gt;
&lt;p&gt;this is the smartest 30 lines of code you&amp;#39;ll write. before introducing it, your agent loop will have provider-specific calls baked in — &lt;code&gt;vendor.start_browser()&lt;/code&gt;, &lt;code&gt;vendor.click()&lt;/code&gt; — and swapping providers later means a rewrite.&lt;/p&gt;
&lt;p&gt;so define a &lt;code&gt;typing.Protocol&lt;/code&gt; called &lt;code&gt;Computer&lt;/code&gt; with the methods the loop actually uses — &lt;code&gt;screenshot&lt;/code&gt;, &lt;code&gt;click&lt;/code&gt;, &lt;code&gt;type&lt;/code&gt;, &lt;code&gt;scroll&lt;/code&gt;, &lt;code&gt;keypress&lt;/code&gt;, &lt;code&gt;drag&lt;/code&gt;, &lt;code&gt;wait&lt;/code&gt;, &lt;code&gt;move&lt;/code&gt;, &lt;code&gt;double_click&lt;/code&gt;, &lt;code&gt;get_environment&lt;/code&gt;, &lt;code&gt;get_dimensions&lt;/code&gt;. the loop calls those. anything that &lt;em&gt;implements&lt;/em&gt; those methods is a valid &lt;code&gt;Computer&lt;/code&gt;.&lt;/p&gt;
&lt;div class="codeblock"&gt;&lt;span class="code-lang"&gt;python&lt;/span&gt;&lt;pre&gt;&lt;code class="hljs language-python"&gt;&lt;span class="hljs-keyword"&gt;class&lt;/span&gt; &lt;span class="hljs-title class_"&gt;Computer&lt;/span&gt;(&lt;span class="hljs-title class_ inherited__"&gt;Protocol&lt;/span&gt;):
    &lt;span class="hljs-keyword"&gt;async&lt;/span&gt; &lt;span class="hljs-keyword"&gt;def&lt;/span&gt; &lt;span class="hljs-title function_"&gt;screenshot&lt;/span&gt;(&lt;span class="hljs-params"&gt;self&lt;/span&gt;) -&amp;gt; &lt;span class="hljs-built_in"&gt;bytes&lt;/span&gt;: ...
    &lt;span class="hljs-keyword"&gt;async&lt;/span&gt; &lt;span class="hljs-keyword"&gt;def&lt;/span&gt; &lt;span class="hljs-title function_"&gt;click&lt;/span&gt;(&lt;span class="hljs-params"&gt;self, x: &lt;span class="hljs-built_in"&gt;int&lt;/span&gt;, y: &lt;span class="hljs-built_in"&gt;int&lt;/span&gt;&lt;/span&gt;) -&amp;gt; &lt;span class="hljs-literal"&gt;None&lt;/span&gt;: ...
    &lt;span class="hljs-keyword"&gt;async&lt;/span&gt; &lt;span class="hljs-keyword"&gt;def&lt;/span&gt; &lt;span class="hljs-title function_"&gt;type&lt;/span&gt;(&lt;span class="hljs-params"&gt;self, text: &lt;span class="hljs-built_in"&gt;str&lt;/span&gt;&lt;/span&gt;) -&amp;gt; &lt;span class="hljs-literal"&gt;None&lt;/span&gt;: ...
    &lt;span class="hljs-comment"&gt;# ... ~8 more&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;&lt;code&gt;VendorAClient&lt;/code&gt; is a &lt;code&gt;Computer&lt;/code&gt;. &lt;code&gt;RawVMChromeWrapper&lt;/code&gt; is a &lt;code&gt;Computer&lt;/code&gt;. tomorrow if you want a third provider, that&amp;#39;s another &lt;code&gt;Computer&lt;/code&gt;. the loop doesn&amp;#39;t know or care. and you can mock a &lt;code&gt;Computer&lt;/code&gt; with 30 lines for a unit test.&lt;/p&gt;
&lt;p&gt;&lt;img src="https://raw.githubusercontent.com/tautik/image-assets/main/cua-computer-protocol.png" alt="the Computer protocol seam"&gt;&lt;/p&gt;
&lt;p&gt;this is just the strategy pattern with python&amp;#39;s structural typing. nothing fancy. but it&amp;#39;s the difference between &amp;quot;ship a new provider, swap a factory&amp;quot; and &amp;quot;rewrite the agent.&amp;quot;&lt;/p&gt;
&lt;details class="recall"&gt;&lt;summary&gt;&lt;span class="recall-mark"&gt;&amp;#10045; RECALL&lt;/span&gt; what does the &lt;code&gt;Computer&lt;/code&gt; protocol buy you, and why structural typing instead of an inheritance hierarchy?&lt;/summary&gt;&lt;p&gt;the loop only ever calls ~10 methods — screenshot, click, type, scroll, and friends — so anything implementing them is a valid &lt;code&gt;Computer&lt;/code&gt;: a vendor client, a raw-vm wrapper, a 30-line mock for tests. swapping providers becomes a factory change instead of an agent rewrite. structural typing means providers never need to know about your base class; the protocol is just the seam where they plug in.&lt;/p&gt;&lt;/details&gt;

&lt;h2 id="the-stuff-you-actually-have-to-write"&gt;the stuff you actually have to write&lt;/h2&gt;
&lt;p&gt;ok so the vm is up, chrome is running, the loop is talking to it. &lt;strong&gt;what&amp;#39;s still left?&lt;/strong&gt;&lt;/p&gt;
&lt;h3 id="auth"&gt;auth&lt;/h3&gt;
&lt;p&gt;big one. when a user says &amp;quot;log into my snowflake instance and pull the audit logs&amp;quot; — how does the agent log in?&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;you don&amp;#39;t do oauth.&lt;/strong&gt; trying to script through &amp;quot;sign in with google → enter password → 2fa&amp;quot; with an llm is a nightmare and most enterprise saas blocks it as suspicious activity anyway.&lt;/p&gt;
&lt;p&gt;what you do instead is &lt;strong&gt;capture once, replay forever&lt;/strong&gt;. once, during auth setup, a real user logs in interactively while watching a vnc stream of your vm. when they confirm &amp;quot;yes i&amp;#39;m logged in&amp;quot;, you call playwright&amp;#39;s &lt;code&gt;context.storage_state()&lt;/code&gt; which returns a json blob of cookies + localstorage + sessionstorage. you store that json (encrypted, scoped to the org). every future task that needs that platform pulls the json and injects cookies + storage into a fresh playwright context. the user appears already logged in. &lt;strong&gt;no oauth, no passwords stored, no refresh token handling.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;if the customer&amp;#39;s site uses oauth (sign in with google), the user does the dance once during capture and you keep the resulting session cookies. you never see refresh tokens.&lt;/p&gt;
&lt;p&gt;why not use a vendor&amp;#39;s &amp;quot;auth as a service&amp;quot;? because vendor auth blobs are opaque, in their proprietary format, and lock you in. &lt;code&gt;storage_state&lt;/code&gt; is playwright&amp;#39;s native primitive — works on any vm, anywhere playwright runs. when you swap providers, the auth blobs come with you untouched. &lt;strong&gt;when a vendor offers a fancy feature wrapping a primitive you already know, take the primitive.&lt;/strong&gt;&lt;/p&gt;
&lt;details class="recall"&gt;&lt;summary&gt;&lt;span class="recall-mark"&gt;&amp;#10045; RECALL&lt;/span&gt; how does the agent log into a customer&amp;#39;s saas without doing oauth, and why prefer playwright&amp;#39;s primitive over a vendor&amp;#39;s auth-as-a-service?&lt;/summary&gt;&lt;p&gt;capture once, replay forever — a real user logs in interactively over vnc, you call &lt;code&gt;context.storage_state()&lt;/code&gt; to snapshot cookies + localstorage + sessionstorage as json (encrypted, org-scoped), and every future task injects it into a fresh context so the agent appears already logged in. no scripted oauth (fragile, flagged as suspicious), no stored passwords, no refresh tokens. and &lt;code&gt;storage_state&lt;/code&gt; is playwright-native, so the blobs move with you across providers — vendor auth blobs are opaque lock-in. when a vendor wraps a primitive you know, take the primitive.&lt;/p&gt;&lt;/details&gt;

&lt;h3 id="proxies"&gt;proxies&lt;/h3&gt;
&lt;p&gt;datacenter ips get flagged by anti-bot waf instantly. cloudflare, akamai, datadome — they all maintain ip reputation lists and your aws/gcp/e2b egress ip is on every one of them. options:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;residential proxies&lt;/strong&gt; (bright data etc) — expensive and noisy but actually work&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;isp proxies&lt;/strong&gt; — middle ground, cheaper, decent reputation&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;curated static rotating list&lt;/strong&gt; — self-managed pool of clean ips, cheapest, but you maintain it&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;most teams end up with a tri-state mode: &lt;code&gt;off&lt;/code&gt; (default datacenter ip), &lt;code&gt;on&lt;/code&gt; (rotate through your pool), &lt;code&gt;forced&lt;/code&gt; (always use a specific clean ip for sensitive flows). pre-install &lt;code&gt;tinyproxy&lt;/code&gt; or similar in your template and pass &lt;code&gt;--proxy-server=...&lt;/code&gt; when chrome launches.&lt;/p&gt;
&lt;h3 id="recording"&gt;recording&lt;/h3&gt;
&lt;p&gt;your customers will ask &amp;quot;show me what the agent did&amp;quot; and you&amp;#39;ll need video + network logs. neither comes free.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;screen capture:&lt;/strong&gt; &lt;code&gt;ffmpeg&lt;/code&gt; reading the x11 framebuffer → mp4 written to a path on the vm like &lt;code&gt;/tmp/recording.mp4&lt;/code&gt;. one ffmpeg process per task, kicked off when the loop starts.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;network capture:&lt;/strong&gt; &lt;code&gt;tcpdump&lt;/code&gt; with a bpf filter that excludes port 9222 (so you don&amp;#39;t record your own cdp traffic) → pcap on the vm → har conversion → zstd compression.&lt;/p&gt;
&lt;p&gt;now the obvious question — those files live &lt;em&gt;on the vm&lt;/em&gt;, your backend lives somewhere else, how do they physically end up in s3? you ask the sandbox sdk to read them. e2b (and most vm-as-a-service sdks) expose a &lt;code&gt;files.read(path)&lt;/code&gt; that streams bytes from the vm filesystem back to your backend over their control channel. so cleanup looks like: stop ffmpeg → &lt;code&gt;bytes = await sandbox.files.read(&amp;quot;/tmp/recording.mp4&amp;quot;)&lt;/code&gt; → &lt;code&gt;await s3.put_object(Body=bytes)&lt;/code&gt; → repeat for the pcap → &lt;em&gt;then&lt;/em&gt; kill the sandbox. order matters — once the sandbox is killed, the filesystem is gone.&lt;/p&gt;
&lt;p&gt;both uploads must be &lt;strong&gt;non-fatal&lt;/strong&gt; — if ffmpeg crashes mid-task or the file read times out, log a warning, move on, kill the sandbox anyway. observability shouldn&amp;#39;t be on the critical path, and one stuck recorder cannot be allowed to leak vms.&lt;/p&gt;
&lt;h3 id="custom-vm-template"&gt;custom vm template&lt;/h3&gt;
&lt;p&gt;stock templates are fine but cold start sucks (~30s to boot xvfb + xfce + x11vnc + novnc + chrome). the trick is to &lt;strong&gt;pre-start the entire stack at build time&lt;/strong&gt; and snapshot the running state. when a sandbox boots from the snapshot, everything is already running. cold start drops to ~7s.&lt;/p&gt;
&lt;p&gt;bonus optimization: pre-warm chrome&amp;#39;s profile during build. run a headless chrome at build time loading &lt;code&gt;google.com&lt;/code&gt;, sleep 8s so all the sqlite databases get created (cookies, history, web data, preferences), then kill it. saves 15–20s per cold start because chrome doesn&amp;#39;t need to bootstrap profile dbs at runtime.&lt;/p&gt;
&lt;h2 id="bot-detection-the-day-1-problem-you-won-39-t-solve"&gt;bot detection — the day-1 problem you won&amp;#39;t solve&lt;/h2&gt;
&lt;p&gt;honestly. five layers of &amp;quot;avoid getting flagged&amp;quot; is the best most teams do:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;atomic typing into the url bar&lt;/strong&gt; (&lt;code&gt;ctrl+l&lt;/code&gt;, type, enter — three separate tool calls, no combining). real humans don&amp;#39;t paste-and-press at machine speed.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;residential or isp proxies&lt;/strong&gt;, never raw datacenter ips&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;real headful chrome&lt;/strong&gt;, not puppeteer-style headless (the user-agent string and window props are obvious tells)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;browser-realistic http headers&lt;/strong&gt; on any side fetches your backend makes&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;captcha bail-out&lt;/strong&gt; — when the model sees an image-grid challenge, return &lt;code&gt;&amp;lt;&amp;lt;TASK_COMPLETE&amp;gt;&amp;gt;&lt;/code&gt; with reason &lt;code&gt;CAPTCHA&lt;/code&gt;. don&amp;#39;t pretend to solve them.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;you&amp;#39;re not going to beat cloudflare&amp;#39;s bot detection. nobody does, including the bot-detection vendors who claim to. plan around it — surface the captcha bail to the customer, let them decide whether to retry from a residential ip or hand off to a human.&lt;/p&gt;
&lt;details class="recall"&gt;&lt;summary&gt;&lt;span class="recall-mark"&gt;&amp;#10045; RECALL&lt;/span&gt; you&amp;#39;re not going to beat cloudflare&amp;#39;s bot detection. so what do you actually do?&lt;/summary&gt;&lt;p&gt;stack mitigations and plan for failure: residential or isp proxies (datacenter ips are pre-flagged everywhere), real headful chrome (headless tells are obvious), atomic human-paced typing instead of machine-speed pastes, browser-realistic headers on side fetches — and when a captcha appears, bail via the sentinel with reason &lt;code&gt;CAPTCHA&lt;/code&gt; and surface it to the customer, who decides whether to retry from a cleaner ip or hand off to a human. don&amp;#39;t pretend to solve captchas; nobody does.&lt;/p&gt;&lt;/details&gt;

&lt;h2 id="how-do-you-even-test-this"&gt;how do you even test this&lt;/h2&gt;
&lt;p&gt;honest answer: &lt;strong&gt;not as well as a textbook would tell you to&lt;/strong&gt;. unit tests for an agent are theatre. mock the screenshot, mock claude&amp;#39;s response, mock the click — congrats, you&amp;#39;ve tested that python can call &lt;code&gt;mock.assert_called&lt;/code&gt;. you haven&amp;#39;t tested that the agent can actually log in anywhere.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;the failure modes that matter are at the screenshot + llm + browser interaction layer&lt;/strong&gt;, and you can&amp;#39;t unit-test those — you need a real browser, a real model, a real website. so skip the theatre.&lt;/p&gt;
&lt;p&gt;what actually catches regressions:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;smoke-test the streaming endpoint, not the loop.&lt;/strong&gt; one &lt;code&gt;pytest&lt;/code&gt; test that posts a real task to &lt;code&gt;/execute_task&lt;/code&gt;, opens the sse stream, and asserts you get a &lt;code&gt;stream_url&lt;/code&gt; event followed by a &lt;code&gt;result&lt;/code&gt; or &lt;code&gt;error&lt;/code&gt; event. that&amp;#39;s it. no mocks. it catches &amp;quot;did anything wire up correctly&amp;quot; — which is the only question a unit test could meaningfully answer here anyway.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;@traced&lt;/code&gt; on every method that&amp;#39;s not a fat blob.&lt;/strong&gt; decorate your tool implementations (&lt;code&gt;click&lt;/code&gt;, &lt;code&gt;type&lt;/code&gt;, &lt;code&gt;scroll&lt;/code&gt;, llm calls, prompt builders) with braintrust&amp;#39;s &lt;code&gt;@traced&lt;/code&gt; so spans show up automatically. &lt;strong&gt;don&amp;#39;t trace methods that return base64 screenshots&lt;/strong&gt; — span payload inflates and your dashboard becomes useless. exception, not the rule.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;make tracing non-fatal.&lt;/strong&gt; if the tracing init fails or the export crashes, prod shouldn&amp;#39;t go down. wrap it in graceful degradation — observability is never on the critical path.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;trace everything in prod that&amp;#39;s left.&lt;/strong&gt; every screenshot reference (not the bytes), every tool call, every llm response, every cost in dollars, every latency number. pull up a failed run and watch it frame by frame: &amp;quot;ok at turn 14 the model clicked the wrong button because the dropdown hadn&amp;#39;t loaded yet.&amp;quot; that&amp;#39;s the core feedback loop.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;fail loud, fail typed.&lt;/strong&gt; structured error subclasses with context so failures group cleanly in your dashboard.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;benchmark periodically.&lt;/strong&gt; &lt;code&gt;webvoyager&lt;/code&gt; is a public dataset of 642 web tasks across 15 sites — not perfect, but it&amp;#39;s a real number you can quote, and every cua vendor benchmarks against it.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;manually verify on real flows.&lt;/strong&gt; ngl, this is most of how regressions get caught. actually run the agent against the real customer portal.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;swap models based on signal, not vibes.&lt;/strong&gt; when traces say sonnet is winning over opus on your shape of task, ship sonnet. don&amp;#39;t run formal traffic-split a/b tests for this — offline batch eval is enough.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;is this rigorous? not by traditional standards. but agents fail in ways traditional tests can&amp;#39;t catch — flaky vendor pages, css that changed yesterday, a captcha the model hadn&amp;#39;t seen before, a button label that became an icon. &lt;strong&gt;the only honest test is running the thing.&lt;/strong&gt; so focus your infra on running the thing well and watching it carefully.&lt;/p&gt;
&lt;p&gt;once you have customers, build a small internal dataset of tasks shaped like real customer flows (anonymized) and run them as a webvoyager-style nightly job with traced scoring. that&amp;#39;s the obvious next move past the prototype phase.&lt;/p&gt;
&lt;details class="recall"&gt;&lt;summary&gt;&lt;span class="recall-mark"&gt;&amp;#10045; RECALL&lt;/span&gt; why are unit tests theatre for a cua, and what actually catches regressions?&lt;/summary&gt;&lt;p&gt;the failure modes live at the screenshot + llm + real-website layer — mock all three and you&amp;#39;ve only proven python can call a mock. instead: one real smoke test against the streaming endpoint (post a task, assert &lt;code&gt;stream_url&lt;/code&gt; then &lt;code&gt;result&lt;/code&gt;/&lt;code&gt;error&lt;/code&gt; events), &lt;code&gt;@traced&lt;/code&gt; on every tool and llm call (never the screenshot bytes — span bloat), typed structured errors, periodic benchmarks like webvoyager, and — honestly — manually running real customer flows. the only honest test is running the thing and watching failed runs frame by frame.&lt;/p&gt;&lt;/details&gt;

&lt;h2 id="scaling-what-actually-breaks-at-volume"&gt;scaling — what actually breaks at volume&lt;/h2&gt;
&lt;p&gt;a cua task isn&amp;#39;t a 50ms api call. it&amp;#39;s a 60-second-to-30-minute browser session holding an sse stream open and a vm warm on the other end. the python web-server defaults are tuned for the opposite shape — short requests, lots of them — so you have to retune.&lt;/p&gt;
&lt;p&gt;the things that bite you:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;gunicorn timeouts kill the worker mid-task.&lt;/strong&gt; default timeout is 30s. an enterprise task can run 30 minutes. set &lt;code&gt;timeout = 3600&lt;/code&gt; (one hour) and &lt;code&gt;keepalive = 3600&lt;/code&gt; so the sse connection doesn&amp;#39;t get aggressively closed. &lt;code&gt;graceful_timeout = 45&lt;/code&gt; so deploys can drain in flight without taking forever. yes you&amp;#39;re abusing the worker model — long-running tasks really belong in a background queue, but honestly for v1 the abused-worker pattern works.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;uvicorn workers leak memory.&lt;/strong&gt; chrome client objects, screenshot bytes, llm response cache, &lt;code&gt;playwright&lt;/code&gt; contexts that didn&amp;#39;t fully gc — it adds up. set &lt;code&gt;max_requests = 500&lt;/code&gt; with &lt;code&gt;max_requests_jitter = 100&lt;/code&gt; so workers cycle automatically and the leak resets every ~500 tasks. cheap fix, very effective.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;set &lt;code&gt;preload_app = True&lt;/code&gt;.&lt;/strong&gt; with multiple workers you don&amp;#39;t want each one re-initing your singletons (db pools, llm clients, braintrust logger, otel exporter). preload the app in the master process and let workers fork from it. shared code, separate state — exactly what you want.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;worker count = &lt;code&gt;min(2 × cpu_cores, ram_ceiling)&lt;/code&gt;.&lt;/strong&gt; cua tasks are io-bound (waiting on llms, waiting on chrome), so 2 workers per core is fine. but each worker holds a chrome connection + a tracing buffer + sse state, so cap at whatever your ram budget allows.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;the vm-side concurrency is &lt;em&gt;not&lt;/em&gt; your bottleneck.&lt;/strong&gt; vendors like e2b&amp;#39;s sdk pool ~2000 concurrent commands per sandbox. one sandbox running a task can fire screen-recording, network-recording, and chrome-launch commands in parallel via &lt;code&gt;asyncio.gather&lt;/code&gt; without breaking a sweat. the bottleneck is your egress proxy pool and your llm rate limits.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;cleanup is where things get spicy at scale. when a task ends — successfully or via cancellation — you have an ffmpeg process, a tcpdump process, a chrome instance, a playwright context, and a vm sandbox to tear down. &lt;strong&gt;in that order.&lt;/strong&gt; if you &lt;code&gt;kill()&lt;/code&gt; the sandbox first, the filesystem is gone and your recordings never upload.&lt;/p&gt;
&lt;p&gt;a few rules that will save you:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;wrap cleanup in an &lt;code&gt;asyncio.Lock&lt;/code&gt;.&lt;/strong&gt; if a task gets cancelled while cleanup is already running, you don&amp;#39;t want two coroutines trying to kill the same sandbox at the same time. one lock, atomic teardown.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;asyncio.shield&lt;/code&gt; the sandbox kill.&lt;/strong&gt; the most critical step is &amp;quot;tell e2b to stop billing me.&amp;quot; if a parent cancellation interrupts that call, the sandbox keeps running and you&amp;#39;re paying for it. shield it.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;flush recordings to s3 &lt;em&gt;before&lt;/em&gt; killing the sandbox.&lt;/strong&gt; after kill, the filesystem is destroyed. there is no second chance.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;make every cleanup step non-fatal.&lt;/strong&gt; if ffmpeg refuses to die, log it, move on, kill the sandbox anyway. you cannot afford one stuck recorder to leak vms.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;track an &lt;code&gt;_cleanup_completed&lt;/code&gt; flag.&lt;/strong&gt; so a re-entered cleanup short-circuits instead of running twice and crashing on a closed handle.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;if cold start matters — and at any real volume, it does — pre-warm the entire desktop stack at template build time. xvfb, xfce, x11vnc, novnc, chrome all running before the snapshot is taken. boot from snapshot drops cold start from ~30s to ~7s. you&amp;#39;re paying once at build time so every task pays nothing at runtime. one of the highest-leverage optimizations in the whole stack.&lt;/p&gt;
&lt;details class="recall"&gt;&lt;summary&gt;&lt;span class="recall-mark"&gt;&amp;#10045; RECALL&lt;/span&gt; what breaks when 30-minute browser tasks hit a web server tuned for 50ms requests, and what&amp;#39;s the teardown rule that protects both your bill and your recordings?&lt;/summary&gt;&lt;p&gt;the defaults kill you: a 30s gunicorn timeout murders workers mid-task (set it to ~an hour with matching keepalive for the sse stream), leaked chrome clients and screenshot bytes accumulate (recycle workers via &lt;code&gt;max_requests&lt;/code&gt;), and per-worker re-init wastes ram (preload the app, fork from master). teardown order: flush recordings to s3 &lt;em&gt;before&lt;/em&gt; killing the sandbox — the filesystem dies with it — under an &lt;code&gt;asyncio.Lock&lt;/code&gt; so cleanup never runs twice, with the sandbox kill shielded from cancellation so you stop paying, and every step non-fatal so one stuck recorder can&amp;#39;t leak vms.&lt;/p&gt;&lt;/details&gt;

&lt;h2 id="the-three-things-to-take-away"&gt;the three things to take away&lt;/h2&gt;
&lt;p&gt;first, &lt;strong&gt;the &lt;code&gt;Computer&lt;/code&gt; protocol.&lt;/strong&gt; ~30 lines of structural typing. the seam that lets you swap providers and run multiple orchestrators on top of the same vm. structural typing &amp;gt; inheritance for plug-in surfaces.&lt;/p&gt;
&lt;p&gt;second, &lt;strong&gt;&lt;code&gt;storage_state&lt;/code&gt; over oauth.&lt;/strong&gt; you don&amp;#39;t do oauth; you capture sessions. portable across providers, opaque-vendor-blob-free, plays nicely with playwright. when a vendor offers a fancy feature wrapping a primitive you already know, take the primitive.&lt;/p&gt;
&lt;p&gt;third, &lt;strong&gt;fork-vs-depend.&lt;/strong&gt; for a layer this load-bearing — the layer between your agent and the world — owning it pays off. cost is the bugs you inherit (templates carry their own quirks). benefit is total control over what gets exposed and when. on a primitive like this, control wins.&lt;/p&gt;
&lt;p&gt;aight that&amp;#39;s the cua story. nothing here is magic. it&amp;#39;s a screenshot loop, a remote desktop stack from 1998, a protocol seam, and a lot of patience for bot detection. &lt;strong&gt;you&amp;#39;re paid to solve a problem, not to ship the fanciest architecture.&lt;/strong&gt;&lt;/p&gt;
</content>
  </entry>
  <entry>
    <title>noVNC and websockify</title>
    <link href="https://tautik.me/novnc/"/>
    <id>https://tautik.me/novnc/</id>
    <updated>2026-05-11T00:00:00Z</updated>
    <content type="html">&lt;p&gt;aight, let&amp;#39;s talk about remote desktops and why the web makes everything complicated (but also kinda cool).&lt;/p&gt;
&lt;h2 id="what-you-39-ll-take-away"&gt;what you&amp;#39;ll take away&lt;/h2&gt;
&lt;p&gt;quick pointers so you know what to look for as you read:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;vnc is just a protocol over tcp.&lt;/strong&gt; server captures the screen, client sends input — and only the changed pixels ship.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;browsers can&amp;#39;t open raw tcp connections.&lt;/strong&gt; sandboxed to http and websockets — that one constraint shapes the whole architecture.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;websockify is a dumb translator, and that&amp;#39;s the point.&lt;/strong&gt; unwrap websocket frames, forward raw tcp, wrap responses back.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;neither end knows the other&amp;#39;s world exists.&lt;/strong&gt; the vnc server sees plain tcp, novnc speaks pure websocket.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;build bridges, don&amp;#39;t rebuild systems.&lt;/strong&gt; respecting each layer&amp;#39;s constraints beats changing everything.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="what-even-is-vnc"&gt;what even is vnc?&lt;/h2&gt;
&lt;p&gt;your first question should hit you: what is vnc? well, it&amp;#39;s a remote desktop protocol - think of it like http or websocket but for controlling computers remotely. &lt;strong&gt;vnc&lt;/strong&gt; (virtual network computing) lets you control one computer from another over a network. dead simple concept.&lt;/p&gt;
&lt;p&gt;so basically you get this magic remote control for any computer. wanna fix your mom&amp;#39;s laptop from across the country? vnc. need to check on that long-running script on your server while you&amp;#39;re at starbucks? vnc. it&amp;#39;s been around since like the 90s and just works.&lt;/p&gt;
&lt;h2 id="how-does-vnc-work"&gt;how does vnc work?&lt;/h2&gt;
&lt;p&gt;the setup is straightforward. you&amp;#39;ve got a &lt;strong&gt;vnc server&lt;/strong&gt; running on the remote computer - the one you want to control. this server captures everything: screen updates, what&amp;#39;s happening on the desktop, all that visual stuff. then on your local machine, you run a &lt;strong&gt;vnc client&lt;/strong&gt; that displays those screen updates and sends your keyboard/mouse actions back to the server.&lt;/p&gt;
&lt;p&gt;here&amp;#39;s the important bit: all this communication happens over a &lt;strong&gt;tcp connection&lt;/strong&gt;. remember that - yep fr remember that. the protocol itself is pretty efficient too. instead of sending the entire screen every time, vnc just sends what changed. move a window? only that rectangle gets updated. type some text? just those pixels change.&lt;/p&gt;
&lt;p&gt;&lt;img src="https://raw.githubusercontent.com/tautik/image-assets/main/45pm-1.webp" alt="/static/img/45pm-1.webp"&gt;&lt;/p&gt;
&lt;details class="recall"&gt;&lt;summary&gt;&lt;span class="recall-mark"&gt;&amp;#10045; RECALL&lt;/span&gt; how does vnc keep remote-desktop traffic small enough to be usable?&lt;/summary&gt;&lt;p&gt;it never resends the whole screen — only the regions that changed. move a window and just that rectangle ships; type and only those pixels update. all of it flows over one persistent tcp connection: the server captures and diffs the screen, the client renders updates and sends keyboard/mouse input back.&lt;/p&gt;&lt;/details&gt;

&lt;h2 id="then-what-is-novnc"&gt;then what is novnc?&lt;/h2&gt;
&lt;p&gt;so here&amp;#39;s where things get interesting. what if you want to access that remote desktop from a web browser? seems reasonable right? well, browsers have this annoying (but important) limitation: &lt;strong&gt;they can&amp;#39;t make direct tcp connections&lt;/strong&gt;. security reasons. browsers can only do http requests and websocket connections.&lt;/p&gt;
&lt;p&gt;this is where &lt;strong&gt;novnc&lt;/strong&gt; enters the chat. it&amp;#39;s a javascript library that implements a full vnc client entirely within your web browser. uses html5 canvas for display, handles all the vnc protocol stuff in javascript. pretty impressive tbh. but wait - if browsers can&amp;#39;t do tcp and vnc servers only speak tcp, how does novnc connect to anything?&lt;/p&gt;
&lt;p&gt;spoiler: it can&amp;#39;t. not directly anyway.&lt;/p&gt;
&lt;p&gt;&lt;img src="https://raw.githubusercontent.com/tautik/image-assets/main/50pm.webp" alt="/static/img/50pm.webp"&gt;&lt;/p&gt;
&lt;details class="recall"&gt;&lt;summary&gt;&lt;span class="recall-mark"&gt;&amp;#10045; RECALL&lt;/span&gt; novnc implements a complete vnc client in javascript — so why can&amp;#39;t it talk to a vnc server on its own?&lt;/summary&gt;&lt;p&gt;browsers can&amp;#39;t open raw tcp connections — the sandbox limits them to http requests and websockets, for good security reasons. vnc servers only speak tcp. so you&amp;#39;ve got a fully capable client and a fully capable server with no shared language between them; something in the middle has to translate.&lt;/p&gt;&lt;/details&gt;

&lt;h2 id="enter-websockify-the-protocol-translator"&gt;enter websockify: the protocol translator&lt;/h2&gt;
&lt;p&gt;the solution is &lt;strong&gt;websockify&lt;/strong&gt; - a proxy server that acts as a translator between the web world and traditional networking. think of it as a universal adapter but for protocols.&lt;/p&gt;
&lt;p&gt;websockify sits in the middle doing this translation dance:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;receives websocket connections from browsers running novnc&lt;/li&gt;
&lt;li&gt;extracts the vnc protocol data from those websocket frames&lt;/li&gt;
&lt;li&gt;forwards it as raw tcp packets to the actual vnc server&lt;/li&gt;
&lt;li&gt;when the vnc server responds with screen updates, websockify wraps that data back into websocket frames&lt;/li&gt;
&lt;li&gt;sends them back to the browser&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;img src="https://raw.githubusercontent.com/tautik/image-assets/main/53pm-3.webp" alt="/static/img/53pm-3.webp"&gt;&lt;/p&gt;
&lt;p&gt;the beautiful thing is neither end needs to know about the other&amp;#39;s world. the vnc server has no idea websockets even exist - it just sees regular tcp connections. novnc doesn&amp;#39;t need to worry about tcp sockets - it just speaks websocket to websockify.&lt;/p&gt;
&lt;details class="recall"&gt;&lt;summary&gt;&lt;span class="recall-mark"&gt;&amp;#10045; RECALL&lt;/span&gt; what does websockify actually do with each message, and why does neither end need modification?&lt;/summary&gt;&lt;p&gt;it unwraps vnc data from incoming websocket frames and forwards it as raw tcp to the vnc server; responses get wrapped back into websocket frames for the browser. the vnc server just sees ordinary tcp connections, novnc just speaks websocket — each side stays in its native world while the proxy quietly translates between them.&lt;/p&gt;&lt;/details&gt;

&lt;h2 id="putting-it-all-together"&gt;putting it all together&lt;/h2&gt;
&lt;p&gt;so when you set up web-based remote desktop, here&amp;#39;s what you&amp;#39;re actually running:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;vnc server&lt;/strong&gt; on the target machine (the one you want to control)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;websockify&lt;/strong&gt; as a proxy - often runs on the same server, listening on port 6080&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;novnc html/javascript files&lt;/strong&gt; served to users&amp;#39; browsers&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;when a user connects, their browser loads novnc, which opens a websocket to websockify. websockify then connects to the vnc server via tcp. boom - you&amp;#39;re controlling a remote desktop through nothing but a web browser. no plugins, no java applets (remember those? lol), no special software to install.&lt;/p&gt;
&lt;p&gt;the whole setup enables some pretty powerful use cases. cloud providers use this for console access to vms. support teams can help users without installing anything. you can access your home lab from any computer with a browser. hell, you could probably run it on your phone if you hate yourself enough.&lt;/p&gt;
&lt;p&gt;what i find neat about this architecture is how it respects the constraints of each layer. browsers stay in their sandbox, vnc servers don&amp;#39;t need modifications, and websockify just quietly translates between them. &lt;strong&gt;it&amp;#39;s a reminder that sometimes the best solution isn&amp;#39;t changing everything - it&amp;#39;s building a good bridge.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;nothing revolutionary here, just good engineering solving a real problem. and now you can remote desktop from literally anywhere with a browser. pretty cool for technology that&amp;#39;s essentially duct-taping protocols together.&lt;/p&gt;
&lt;details class="recall"&gt;&lt;summary&gt;&lt;span class="recall-mark"&gt;&amp;#10045; RECALL&lt;/span&gt; you need browser access to a system that only speaks a raw tcp protocol — what&amp;#39;s the general pattern?&lt;/summary&gt;&lt;p&gt;put a protocol-translating proxy in the middle, the websockify way: the browser speaks websocket to the proxy, the proxy speaks tcp to the legacy system. it respects each layer&amp;#39;s constraints — the browser stays sandboxed, the server stays unmodified — which usually beats rewriting either end. build a good bridge instead of changing everything.&lt;/p&gt;&lt;/details&gt;
</content>
  </entry>
  <entry>
    <title>how dropbox handles uploads, downloads and sync</title>
    <link href="https://tautik.me/dropbox/"/>
    <id>https://tautik.me/dropbox/</id>
    <updated>2026-05-07T00:00:00Z</updated>
    <content type="html">&lt;p&gt;dropbox is one of those apps that feels totally simple until you start designing it. you upload a file, it shows up on your other devices. share it, someone else sees it. but every step here has a real engineering question buried in it. 50GB uploads can&amp;#39;t just be a single POST. sharing 100k files across users can&amp;#39;t just be a list scan. and getting bytes from a virginia data center to a tokyo client without making them wait 30 minutes? that&amp;#39;s a whole separate problem.&lt;/p&gt;
&lt;p&gt;this post walks through how i&amp;#39;d actually build it.&lt;/p&gt;
&lt;h2 id="what-you-39-ll-take-away"&gt;what you&amp;#39;ll take away&lt;/h2&gt;
&lt;p&gt;quick pointers so you know what to look for as you read:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;never upload large files through your own servers.&lt;/strong&gt; presigned URLs let the client upload directly to S3.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;chunk + fingerprint = resumable uploads.&lt;/strong&gt; a hash of each chunk gives you a unique id that works across sessions.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;trust but verify.&lt;/strong&gt; clients report progress, but the server confirms via S3&amp;#39;s &lt;code&gt;ListParts&lt;/code&gt; before marking done.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;sharing is a graph problem, not a list problem.&lt;/strong&gt; put &lt;code&gt;(userId, fileId)&lt;/code&gt; in its own indexed table.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;sync is a hybrid.&lt;/strong&gt; WebSocket push for near-real-time, polling as the safety net.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;CDN for downloads, not uploads.&lt;/strong&gt; downloads benefit from edge caching; uploads don&amp;#39;t.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;content-defined chunking is the secret of delta sync.&lt;/strong&gt; fixed boundaries break the moment you insert a byte at the start.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="what-we-39-re-building"&gt;what we&amp;#39;re building&lt;/h2&gt;
&lt;p&gt;a cloud file storage service. user uploads a file, downloads it from any device, shares with others, and sees automatic sync across all their connected devices.&lt;/p&gt;
&lt;p&gt;scope:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;upload, download, share, automatic sync across devices&lt;/li&gt;
&lt;li&gt;support files up to 50GB&lt;/li&gt;
&lt;li&gt;low latency, secure, available&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;out of scope:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;file editing, in-app preview, virus scanning, versioning, per-user storage limits&lt;/li&gt;
&lt;li&gt;rolling our own blob storage (we&amp;#39;ll use S3 / equivalent and call it done)&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="the-cap-call"&gt;the cap call&lt;/h2&gt;
&lt;p&gt;availability &amp;gt; consistency. user uploads a file in germany; user in america seeing the old version for a few seconds is fine. dropbox is not a stock exchange. eventual consistency, no problem.&lt;/p&gt;
&lt;h2 id="core-entities"&gt;core entities&lt;/h2&gt;
&lt;p&gt;just two real entities and a user wrapper:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;File&lt;/strong&gt; — the raw bytes&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;FileMetadata&lt;/strong&gt; — id, name, size, mime type, owner, status, s3 link, chunks&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;User&lt;/strong&gt; — auxiliary, identified via session token / JWT in headers&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="the-api"&gt;the api&lt;/h2&gt;
&lt;p&gt;four endpoints, one per feature:&lt;/p&gt;
&lt;div class="codeblock"&gt;&lt;pre&gt;&lt;code class="hljs"&gt;POST /files/presigned-url      → returns a presigned URL to upload to
GET  /files/{fileId}            → returns metadata + presigned download URL
POST /files/{fileId}/share      → body: { users: [...] }
GET  /files/changes?since=...   → returns ChangeEvent[] for delta sync&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;users come from headers, never from request bodies — keep auth out of payloads.&lt;/p&gt;
&lt;h2 id="upload-the-presigned-url-trick"&gt;upload — the presigned URL trick&lt;/h2&gt;
&lt;p&gt;the obvious way to handle uploads is wrong, and it&amp;#39;s worth saying why.&lt;/p&gt;
&lt;p&gt;if a user POSTs a 50GB file to your server, two bad things happen. first, you&amp;#39;ve burned bandwidth uploading the file twice — once from client to your server, once from your server to S3. second, your API gateway probably has a 10MB request body limit (looking at you, AWS API Gateway). you can&amp;#39;t even get the bytes through the gate.&lt;/p&gt;
&lt;p&gt;the fix is &lt;strong&gt;presigned URLs&lt;/strong&gt;. instead of the file flowing through your server, the client requests a permission slip from your server, then uploads directly to S3 using that slip.&lt;/p&gt;
&lt;p&gt;three steps:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;client POSTs to &lt;code&gt;/files/presigned-url&lt;/code&gt; with just the metadata (name, size, mimeType). server creates a row in &lt;code&gt;FileMetadata&lt;/code&gt; with &lt;code&gt;status: uploading&lt;/code&gt;, generates a presigned URL via the S3 SDK, returns the URL. &lt;em&gt;no file bytes touch your server.&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;client PUTs the file directly to the presigned URL. S3 stores it.&lt;/li&gt;
&lt;li&gt;S3 fires an event notification to your backend on completion. backend flips &lt;code&gt;status: uploaded&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;now your server never holds the bytes. the file goes client → S3 directly. cheap, fast, no payload limits.&lt;/p&gt;
&lt;p&gt;&lt;img src="https://raw.githubusercontent.com/tautik/image-assets/main/upload-flow.png" alt="upload via presigned URL — client requests URL from server, uploads directly to S3, S3 notifies server on completion"&gt;&lt;/p&gt;
&lt;h2 id="download-the-same-trick-in-reverse"&gt;download — the same trick, in reverse&lt;/h2&gt;
&lt;p&gt;same idea. client requests metadata from your server, server returns a CDN-signed URL pointing to the file. client fetches from the CDN; CDN serves from cache or pulls from S3 on first miss.&lt;/p&gt;
&lt;div class="codeblock"&gt;&lt;pre&gt;&lt;code class="hljs"&gt;GET /files/{fileId} → { metadata, downloadUrl }&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;the &lt;code&gt;downloadUrl&lt;/code&gt; is a signed CDN URL with a short expiration (5 minutes is typical). without that signature, anyone with the link could download. with it, the CDN verifies the signature and serves only authorized requests.&lt;/p&gt;
&lt;details class="recall"&gt;&lt;summary&gt;&lt;span class="recall-mark"&gt;&amp;#10045; RECALL&lt;/span&gt; why should neither uploads nor downloads of file bytes flow through your api servers — and why does the download path add a CDN while the upload path doesn&amp;#39;t?&lt;/summary&gt;&lt;p&gt;proxying a 50GB upload burns the bandwidth twice (client → your server, your server → S3) and your api gateway&amp;#39;s request body limit blocks the bytes anyway. so the server issues a presigned URL, the client PUTs directly to S3, and an S3 event notification flips the metadata to &lt;code&gt;uploaded&lt;/code&gt;. downloads reverse the trick: the server returns a short-TTL signed CDN url, because hot files benefit from edge caching. uploads skip the CDN because fresh unique bytes have nothing to cache.&lt;/p&gt;&lt;/details&gt;

&lt;h2 id="large-files-chunk-fingerprint-resume"&gt;large files — chunk, fingerprint, resume&lt;/h2&gt;
&lt;p&gt;50GB on a 100Mbps connection takes ~1 hour. asking the user to start over if their wifi drops 45 minutes in is cruel. and honestly, most browsers and servers won&amp;#39;t even accept a single request that large.&lt;/p&gt;
&lt;p&gt;the answer is &lt;strong&gt;chunking on the client&lt;/strong&gt;. break the file into 5–10MB pieces. upload each chunk separately. track progress by counting completed chunks. resume by skipping chunks already uploaded.&lt;/p&gt;
&lt;p&gt;but how do you know which chunks have been uploaded? you can&amp;#39;t go by file name — two users can upload files with identical names. you need a content-derived id. that&amp;#39;s a &lt;strong&gt;fingerprint&lt;/strong&gt; — a SHA-256 (or similar) hash of the bytes.&lt;/p&gt;
&lt;p&gt;so the metadata grows:&lt;/p&gt;
&lt;div class="codeblock"&gt;&lt;span class="code-lang"&gt;json&lt;/span&gt;&lt;pre&gt;&lt;code class="hljs language-json"&gt;&lt;span class="hljs-punctuation"&gt;{&lt;/span&gt;
  &lt;span class="hljs-attr"&gt;&amp;quot;id&amp;quot;&lt;/span&gt;&lt;span class="hljs-punctuation"&gt;:&lt;/span&gt; &lt;span class="hljs-string"&gt;&amp;quot;uuid-123&amp;quot;&lt;/span&gt;&lt;span class="hljs-punctuation"&gt;,&lt;/span&gt;
  &lt;span class="hljs-attr"&gt;&amp;quot;fingerprint&amp;quot;&lt;/span&gt;&lt;span class="hljs-punctuation"&gt;:&lt;/span&gt; &lt;span class="hljs-string"&gt;&amp;quot;sha256:ab12...&amp;quot;&lt;/span&gt;&lt;span class="hljs-punctuation"&gt;,&lt;/span&gt;
  &lt;span class="hljs-attr"&gt;&amp;quot;name&amp;quot;&lt;/span&gt;&lt;span class="hljs-punctuation"&gt;:&lt;/span&gt; &lt;span class="hljs-string"&gt;&amp;quot;movie.mp4&amp;quot;&lt;/span&gt;&lt;span class="hljs-punctuation"&gt;,&lt;/span&gt;
  &lt;span class="hljs-attr"&gt;&amp;quot;status&amp;quot;&lt;/span&gt;&lt;span class="hljs-punctuation"&gt;:&lt;/span&gt; &lt;span class="hljs-string"&gt;&amp;quot;uploading&amp;quot;&lt;/span&gt;&lt;span class="hljs-punctuation"&gt;,&lt;/span&gt;
  &lt;span class="hljs-attr"&gt;&amp;quot;chunks&amp;quot;&lt;/span&gt;&lt;span class="hljs-punctuation"&gt;:&lt;/span&gt; &lt;span class="hljs-punctuation"&gt;[&lt;/span&gt;
    &lt;span class="hljs-punctuation"&gt;{&lt;/span&gt; &lt;span class="hljs-attr"&gt;&amp;quot;id&amp;quot;&lt;/span&gt;&lt;span class="hljs-punctuation"&gt;:&lt;/span&gt; &lt;span class="hljs-string"&gt;&amp;quot;chunk-1-fp&amp;quot;&lt;/span&gt;&lt;span class="hljs-punctuation"&gt;,&lt;/span&gt; &lt;span class="hljs-attr"&gt;&amp;quot;status&amp;quot;&lt;/span&gt;&lt;span class="hljs-punctuation"&gt;:&lt;/span&gt; &lt;span class="hljs-string"&gt;&amp;quot;uploaded&amp;quot;&lt;/span&gt;&lt;span class="hljs-punctuation"&gt;,&lt;/span&gt; &lt;span class="hljs-attr"&gt;&amp;quot;etag&amp;quot;&lt;/span&gt;&lt;span class="hljs-punctuation"&gt;:&lt;/span&gt; &lt;span class="hljs-string"&gt;&amp;quot;...&amp;quot;&lt;/span&gt; &lt;span class="hljs-punctuation"&gt;}&lt;/span&gt;&lt;span class="hljs-punctuation"&gt;,&lt;/span&gt;
    &lt;span class="hljs-punctuation"&gt;{&lt;/span&gt; &lt;span class="hljs-attr"&gt;&amp;quot;id&amp;quot;&lt;/span&gt;&lt;span class="hljs-punctuation"&gt;:&lt;/span&gt; &lt;span class="hljs-string"&gt;&amp;quot;chunk-2-fp&amp;quot;&lt;/span&gt;&lt;span class="hljs-punctuation"&gt;,&lt;/span&gt; &lt;span class="hljs-attr"&gt;&amp;quot;status&amp;quot;&lt;/span&gt;&lt;span class="hljs-punctuation"&gt;:&lt;/span&gt; &lt;span class="hljs-string"&gt;&amp;quot;uploading&amp;quot;&lt;/span&gt; &lt;span class="hljs-punctuation"&gt;}&lt;/span&gt;&lt;span class="hljs-punctuation"&gt;,&lt;/span&gt;
    &lt;span class="hljs-punctuation"&gt;{&lt;/span&gt; &lt;span class="hljs-attr"&gt;&amp;quot;id&amp;quot;&lt;/span&gt;&lt;span class="hljs-punctuation"&gt;:&lt;/span&gt; &lt;span class="hljs-string"&gt;&amp;quot;chunk-3-fp&amp;quot;&lt;/span&gt;&lt;span class="hljs-punctuation"&gt;,&lt;/span&gt; &lt;span class="hljs-attr"&gt;&amp;quot;status&amp;quot;&lt;/span&gt;&lt;span class="hljs-punctuation"&gt;:&lt;/span&gt; &lt;span class="hljs-string"&gt;&amp;quot;not-started&amp;quot;&lt;/span&gt; &lt;span class="hljs-punctuation"&gt;}&lt;/span&gt;
  &lt;span class="hljs-punctuation"&gt;]&lt;/span&gt;
&lt;span class="hljs-punctuation"&gt;}&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;when the client resumes, it computes the file fingerprint, asks the server &amp;quot;have we seen this?&amp;quot;, gets back the &lt;code&gt;chunks&lt;/code&gt; array, and uploads only the missing ones.&lt;/p&gt;
&lt;p&gt;&lt;img src="https://raw.githubusercontent.com/tautik/image-assets/main/chunked-upload.png" alt="chunked upload with fingerprinting — client splits file, computes per-chunk fingerprints, uploads each chunk to S3 via presigned URL, server tracks status and verifies via ListParts"&gt;&lt;/p&gt;
&lt;details class="recall"&gt;&lt;summary&gt;&lt;span class="recall-mark"&gt;&amp;#10045; RECALL&lt;/span&gt; your 50GB upload dies 45 minutes in. what makes resume possible, and why can&amp;#39;t file names do the job?&lt;/summary&gt;&lt;p&gt;the client chunks the file into 5–10MB pieces and ids each one by a &lt;strong&gt;fingerprint&lt;/strong&gt; — a hash of the chunk&amp;#39;s bytes. on resume it recomputes the file fingerprint, asks the server &amp;quot;have we seen this?&amp;quot;, gets back the chunk statuses, and uploads only the missing ones. names can&amp;#39;t work — two users can upload identically-named files. content-derived ids are unique and survive across sessions, which is also what makes chunking solve request-size limits, resumability, and parallelism in one shot.&lt;/p&gt;&lt;/details&gt;

&lt;h3 id="trust-but-verify"&gt;trust but verify&lt;/h3&gt;
&lt;p&gt;how does the server know a chunk was actually uploaded? naive answer: client sends a PATCH after each chunk completes. problem: a malicious client can lie. they could mark all chunks &lt;code&gt;uploaded&lt;/code&gt; without uploading any, leaving you with metadata that says &amp;quot;complete&amp;quot; pointing at empty S3 objects.&lt;/p&gt;
&lt;p&gt;the fix is &lt;strong&gt;trust but verify&lt;/strong&gt;. when the client claims chunk N is uploaded with ETag X, your backend calls S3&amp;#39;s &lt;code&gt;ListParts&lt;/code&gt; API to confirm. only after S3 vouches do you flip the chunk to &lt;code&gt;uploaded&lt;/code&gt;. once all chunks check out, call &lt;code&gt;CompleteMultipartUpload&lt;/code&gt; to assemble them into a single S3 object.&lt;/p&gt;
&lt;p&gt;S3&amp;#39;s multipart upload API basically packages this whole flow. you can use it directly, but knowing what&amp;#39;s underneath makes the trade-offs visible.&lt;/p&gt;
&lt;details class="recall"&gt;&lt;summary&gt;&lt;span class="recall-mark"&gt;&amp;#10045; RECALL&lt;/span&gt; the client PATCHes &amp;quot;chunk 3 uploaded, here&amp;#39;s the ETag&amp;quot;. why don&amp;#39;t you just believe it, and what do you do instead?&lt;/summary&gt;&lt;p&gt;a malicious client can mark every chunk &lt;code&gt;uploaded&lt;/code&gt; without sending a byte, leaving metadata that says &amp;quot;complete&amp;quot; pointing at empty S3 objects. trust but verify: on each claim the backend calls S3&amp;#39;s &lt;code&gt;ListParts&lt;/code&gt; to confirm the chunk actually landed, and only then flips its status. once every chunk checks out, &lt;code&gt;CompleteMultipartUpload&lt;/code&gt; assembles them into one object. it&amp;#39;s the clean general pattern for client-reported state on a hostile network.&lt;/p&gt;&lt;/details&gt;

&lt;h2 id="sharing-separate-table-not-a-list-field"&gt;sharing — separate table, not a list field&lt;/h2&gt;
&lt;p&gt;the obvious approach is to add a &lt;code&gt;sharelist: [user1, user2]&lt;/code&gt; field to file metadata. it works for &amp;quot;is this user allowed?&amp;quot; but it falls apart on the inverse query: &amp;quot;show me everything shared with me.&amp;quot; you&amp;#39;d have to scan every file&amp;#39;s sharelist looking for the user. terrible.&lt;/p&gt;
&lt;p&gt;put it in its own table:&lt;/p&gt;
&lt;div class="codeblock"&gt;&lt;pre&gt;&lt;code class="hljs"&gt;SharedFiles
| userId (PK) | fileId (SK) |
| user1       | fileId1     |
| user1       | fileId2     |
| user2       | fileId3     |&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;now both directions are O(1). &amp;quot;show me everything shared with me&amp;quot; is a single index lookup on &lt;code&gt;userId&lt;/code&gt;. &amp;quot;is user X allowed?&amp;quot; is a single point read on &lt;code&gt;(user, fileId)&lt;/code&gt;. cheap.&lt;/p&gt;
&lt;details class="recall"&gt;&lt;summary&gt;&lt;span class="recall-mark"&gt;&amp;#10045; RECALL&lt;/span&gt; why does a &lt;code&gt;sharelist: [user1, user2]&lt;/code&gt; field on file metadata fall apart, and what replaces it?&lt;/summary&gt;&lt;p&gt;it answers &amp;quot;is this user allowed?&amp;quot; just fine, but the inverse query — &amp;quot;show me everything shared with me&amp;quot; — forces a scan of every file&amp;#39;s sharelist. put the relationship in its own &lt;code&gt;SharedFiles&lt;/code&gt; table keyed &lt;code&gt;(userId, fileId)&lt;/code&gt;: now both directions are a single index lookup. sharing is a graph problem, not a list problem — model the edge, not an attribute.&lt;/p&gt;&lt;/details&gt;

&lt;h2 id="sync-push-poll-hybrid"&gt;sync — push + poll hybrid&lt;/h2&gt;
&lt;p&gt;every connected device needs to know when files change. two options:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;polling&lt;/strong&gt; — client asks &amp;quot;anything new?&amp;quot; every N seconds. simple, can lag, wastes calls when nothing changed.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;WebSocket / SSE&lt;/strong&gt; — server pushes change events to the client in real-time. fast, but connections drop and you can miss messages.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;dropbox uses both. &lt;strong&gt;WebSocket as the primary path, polling as the safety net.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;the client opens a single WebSocket per device (not per file). the server pushes change notifications for any file the user has access to. if the WebSocket drops or messages get lost, the client is also calling &lt;code&gt;GET /files/changes?since={timestamp}&lt;/code&gt; every few minutes. anything missed by the push gets caught by the poll.&lt;/p&gt;
&lt;p&gt;&lt;img src="https://raw.githubusercontent.com/tautik/image-assets/main/sync-hybrid.png" alt="sync hybrid — primary WebSocket push for real-time, periodic poll as safety net, last-write-wins on conflicts"&gt;&lt;/p&gt;
&lt;p&gt;on the local side, each OS gives you a file watcher (&lt;code&gt;FSEvents&lt;/code&gt; on macOS, &lt;code&gt;FileSystemWatcher&lt;/code&gt; on windows). when something changes locally, the client agent uploads it to remote. last-write-wins for conflicts.&lt;/p&gt;
&lt;details class="recall"&gt;&lt;summary&gt;&lt;span class="recall-mark"&gt;&amp;#10045; RECALL&lt;/span&gt; why does dropbox run polling alongside websockets instead of trusting the push path alone?&lt;/summary&gt;&lt;p&gt;sockets drop and messages get lost. the client keeps one websocket per device (not per file) for near-real-time pushes of any file it can access, and also calls &lt;code&gt;GET /files/changes?since=...&lt;/code&gt; every few minutes as a safety net — anything the push missed, the poll catches. push for latency, poll for correctness; conflicts resolve last-write-wins.&lt;/p&gt;&lt;/details&gt;

&lt;h2 id="delta-sync-only-ship-the-chunks-that-changed"&gt;delta sync — only ship the chunks that changed&lt;/h2&gt;
&lt;p&gt;once chunking is in place, sync gets a free win: when a file changes, we only need to upload (or download) the chunks that actually changed, not the whole file.&lt;/p&gt;
&lt;p&gt;but there&amp;#39;s a subtlety. if you chunk by fixed sizes (every 5MB), inserting a single byte at the start of the file shifts all chunk boundaries. now every chunk&amp;#39;s fingerprint is different. delta sync becomes useless.&lt;/p&gt;
&lt;p&gt;the fix is &lt;strong&gt;content-defined chunking&lt;/strong&gt; (CDC) — chunk boundaries are determined by the file&amp;#39;s content using a rolling hash (Rabin fingerprinting). a byte inserted near the start only affects the chunks immediately around it; the rest stay identical. this is how real systems achieve actual delta sync efficiency.&lt;/p&gt;
&lt;details class="recall"&gt;&lt;summary&gt;&lt;span class="recall-mark"&gt;&amp;#10045; RECALL&lt;/span&gt; you insert one byte at the start of a synced file. why does fixed-size chunking force a full re-upload, and how does content-defined chunking avoid it?&lt;/summary&gt;&lt;p&gt;with fixed-size chunks every boundary shifts by one byte, so every chunk&amp;#39;s fingerprint changes and delta sync degenerates into shipping the whole file. CDC picks boundaries from the content itself via a rolling hash (rabin fingerprinting), so an edit only changes the chunks immediately around it — everything else keeps its fingerprint and never moves. that&amp;#39;s what makes dropbox feel fast on edits.&lt;/p&gt;&lt;/details&gt;

&lt;h2 id="the-final-wiring"&gt;the final wiring&lt;/h2&gt;
&lt;p&gt;&lt;img src="https://raw.githubusercontent.com/tautik/image-assets/main/dropbox-final-architecture.png" alt="final dropbox architecture — uploader/downloader clients, API gateway, file service, file metadata DB, S3, CDN, SharedFiles table"&gt;&lt;/p&gt;
&lt;p&gt;what each component does:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;uploader&lt;/strong&gt; — client agent. watches local folder via OS file events, chunks files, computes fingerprints, calls the upload API.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;downloader&lt;/strong&gt; — client. polls or receives push notifications, fetches presigned URLs, downloads from CDN.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;API gateway&lt;/strong&gt; — auth, rate limit, route.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;file service&lt;/strong&gt; — generates presigned URLs (purely local, signs with AWS credentials), reads/writes metadata. never touches file bytes.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;file metadata DB&lt;/strong&gt; — DynamoDB or Postgres, doesn&amp;#39;t matter much. holds &lt;code&gt;FileMetadata&lt;/code&gt; and the &lt;code&gt;SharedFiles&lt;/code&gt; table.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;S3&lt;/strong&gt; — holds the actual file bytes.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;CDN&lt;/strong&gt; — caches files at edge locations for fast downloads, serves via signed URLs.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="security-in-two-minutes"&gt;security in two minutes&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;TLS everywhere&lt;/strong&gt; — HTTPS for all traffic, end of story.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;encryption at rest&lt;/strong&gt; — S3 encrypts files transparently with managed keys.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;signed URLs with short TTLs&lt;/strong&gt; — even if a download URL leaks, it expires in 5 minutes. for higher security, bind URLs to specific IPs or require auth cookies.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;compress before encrypting&lt;/strong&gt; — encryption introduces randomness, killing compression ratios. always compress first.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="what-i-39-d-remember"&gt;what i&amp;#39;d remember&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;presigned URLs are the entire point of file upload design at scale.&lt;/strong&gt; never proxy bytes through your server.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;chunking solves three problems at once&lt;/strong&gt; — request size limits, resumability, parallelism.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;fingerprints are content-derived ids.&lt;/strong&gt; use them everywhere — for files, for chunks, for dedup.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;trust but verify&lt;/strong&gt; is the clean way to handle client-reported state on a hostile network.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;sync is a hybrid problem.&lt;/strong&gt; push primary, poll fallback.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;CDC &amp;gt; fixed chunking&lt;/strong&gt; for delta sync. the rolling hash trick is what makes dropbox feel fast on edits.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;nothing here is novel. S3 multipart upload, presigned URLs, signed CDN URLs, file watchers — it&amp;#39;s all off-the-shelf. the design is in the wiring. &lt;strong&gt;you&amp;#39;re paid to solve a problem, not to ship the fanciest architecture.&lt;/strong&gt;&lt;/p&gt;
</content>
  </entry>
  <entry>
    <title>id generators - from `Date.now()` to snowflake</title>
    <link href="https://tautik.me/id-generator-snowflake/"/>
    <id>https://tautik.me/id-generator-snowflake/</id>
    <updated>2026-05-03T00:00:00Z</updated>
    <content type="html">&lt;p&gt;aight so today we&amp;#39;re talking about id generators. seems boring on paper, but stay with me — this is one of those topics where every line of complexity comes from a real production problem someone hit at scale. and the cool part is the entire problem statement collapses to: &lt;strong&gt;write a function that spits out something unique every time it is invoked&lt;/strong&gt;. that&amp;#39;s it. no service, no microservice, no fancy box.&lt;/p&gt;
&lt;p&gt;actually that&amp;#39;s the first thing you should internalize. &lt;em&gt;you don&amp;#39;t need a microservice for this&lt;/em&gt;. everyone wants to draw a box and call it &lt;code&gt;id-svc&lt;/code&gt; but please don&amp;#39;t do that until something forces you to. think of yourself as the only engineer who&amp;#39;s gonna build, deploy, and on-call this thing. suddenly your appetite for new boxes drops to zero. good.&lt;/p&gt;
&lt;h2 id="what-you-39-ll-take-away"&gt;what you&amp;#39;ll take away&lt;/h2&gt;
&lt;p&gt;quick pointers so you know what to look for as you read:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;function first, service later.&lt;/strong&gt; don&amp;#39;t reach for a microservice until something forces you to.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;whatever causes a collision becomes a differentiator.&lt;/strong&gt; two machines collide → machine id. two threads collide → counter. mechanical pattern.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;timestamp on the left = rough sortability for free.&lt;/strong&gt; snowflake, mongo objectid, instagram — all do this.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;distribution + strict monotonicity + high throughput → pick two.&lt;/strong&gt; spanner buys all three, but only with atomic-clock hardware.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;uuids are an index-bloat tax — except when they aren&amp;#39;t.&lt;/strong&gt; bad for mysql reads, perfect for cockroach writes. depends on the db.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;logical shards on physical hosts = cheap hot-shard rebalancing.&lt;/strong&gt; instagram&amp;#39;s trick. same idea elasticsearch uses.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;relax the right constraint for your usecase.&lt;/strong&gt; twitter dropped strict monotonicity, cockroach dropped sortability, flickr dropped &amp;quot;no central service&amp;quot;. each got something back.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="but-why-do-we-even-need-our-own-ids"&gt;but why do we even need our own ids?&lt;/h2&gt;
&lt;p&gt;your first question should hit you - if databases give us auto-increment ids, why are we even having this conversation?&lt;/p&gt;
&lt;p&gt;so imagine a sharded database. you&amp;#39;ve got 4 mysql shards each holding a slice of your &lt;code&gt;posts&lt;/code&gt; table. if every shard runs its own auto-increment, you&amp;#39;re gonna get id 1, 2, 3 in shard A &lt;em&gt;and&lt;/em&gt; id 1, 2, 3 in shard B. when you read a post by id 2, which one do you mean? lol. collision.&lt;/p&gt;
&lt;p&gt;so the moment you shard, you cannot rely on the database to give you unique ids. you have to provide the id yourself at insert time. that&amp;#39;s the whole motivation. there are other places too (event sourcing, distributed logs, whatever) but sharded dbs is the canonical one.&lt;/p&gt;
&lt;p&gt;&lt;img src="https://raw.githubusercontent.com/tautik/image-assets/main/sharded-db.png" alt="sharded db needs externally-provided ids"&gt;&lt;/p&gt;
&lt;p&gt;ok so what do we want? a function &lt;code&gt;get_id()&lt;/code&gt; that returns something unique every single time. let&amp;#39;s build it.&lt;/p&gt;
&lt;h2 id="time-as-id-dead-simple"&gt;time as id - dead simple&lt;/h2&gt;
&lt;p&gt;what&amp;#39;s unique through time? well, &lt;strong&gt;time itself&lt;/strong&gt;. it always moves forward. so the world&amp;#39;s simplest id generator:&lt;/p&gt;
&lt;div class="codeblock"&gt;&lt;pre&gt;&lt;code class="hljs"&gt;func get_id() {
    return get_epoch_ms()
}&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;return current epoch milliseconds. done. ship it.&lt;/p&gt;
&lt;p&gt;this works for &lt;strong&gt;so many use cases&lt;/strong&gt; that it&amp;#39;s actually criminal how often people skip past it.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;your solution should be functional with respect to your constraints&lt;/strong&gt;. nobody gets to call your design stupid because they don&amp;#39;t know your constraints.&lt;/p&gt;
&lt;p&gt;aight back to &lt;code&gt;get_epoch_ms()&lt;/code&gt;. what&amp;#39;s the catch?&lt;/p&gt;
&lt;h2 id="multi-machine-collision"&gt;multi-machine collision&lt;/h2&gt;
&lt;p&gt;what if two machines invoke this function in the same millisecond? same id. collision. damn.&lt;/p&gt;
&lt;p&gt;fix: prepend the machine id.&lt;/p&gt;
&lt;div class="codeblock"&gt;&lt;pre&gt;&lt;code class="hljs"&gt;func get_id() {
    return concat(machine_id, get_epoch_ms())
}&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;so machine &lt;code&gt;m1&lt;/code&gt; at time &lt;code&gt;1729&lt;/code&gt; returns &lt;code&gt;m1-1729&lt;/code&gt;. machine &lt;code&gt;m2&lt;/code&gt; at the same instant returns &lt;code&gt;m2-1729&lt;/code&gt;. no collision. each machine knows its own id at boot.&lt;/p&gt;
&lt;h2 id="threads-happen"&gt;threads happen&lt;/h2&gt;
&lt;p&gt;ok now what if my program has threads, and two threads on the &lt;em&gt;same&lt;/em&gt; machine invoke &lt;code&gt;get_id()&lt;/code&gt; in the same millisecond? collision again. you&amp;#39;ve got two ways out:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;add &lt;code&gt;thread_id&lt;/code&gt; as another differentiator&lt;/li&gt;
&lt;li&gt;add a static counter that gets atomically incremented every call&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;the counter approach is cleaner imo. you&amp;#39;ve got one int, every call does an atomic &lt;code&gt;counter++&lt;/code&gt;, and you append it to the id:&lt;/p&gt;
&lt;div class="codeblock"&gt;&lt;pre&gt;&lt;code class="hljs"&gt;int counter = 0
func get_id() {
    return concat(machine_id, get_epoch_ms(), counter++)
}&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;now even within the same ms on the same machine, two calls get different ids because the counter moved.&lt;/p&gt;
&lt;p&gt;but wait. think tautik think. if i already have a counter that always moves forward, &lt;strong&gt;what is the timestamp even doing here&lt;/strong&gt;?&lt;/p&gt;
&lt;p&gt;nothing. it&amp;#39;s redundant. so just rip it out:&lt;/p&gt;
&lt;div class="codeblock"&gt;&lt;pre&gt;&lt;code class="hljs"&gt;int counter = 0
func get_id() {
    return concat(machine_id, counter++)
}&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;this generates &lt;code&gt;m1-0&lt;/code&gt;, &lt;code&gt;m1-1&lt;/code&gt;, &lt;code&gt;m1-2&lt;/code&gt;, ... each id is leaner (no timestamp bytes), still unique, still atomically safe. and on a 64-bit counter you&amp;#39;d have to invoke this at insane rates to ever wrap around.&lt;/p&gt;
&lt;p&gt;&lt;img src="https://raw.githubusercontent.com/tautik/image-assets/main/id-evolution.png" alt="id construction — collision → fix → simplify"&gt;&lt;/p&gt;
&lt;details class="recall"&gt;&lt;summary&gt;&lt;span class="recall-mark"&gt;&amp;#10045; RECALL&lt;/span&gt; two machines collide in the same millisecond, then two threads on the same machine collide. what&amp;#39;s the fix in each case, and why does the timestamp become dead weight once you add a counter?&lt;/summary&gt;&lt;p&gt;whatever causes the collision becomes a differentiator — machines collide → prepend machine id, threads collide → append an atomically-incremented counter. and once you have a counter that only ever moves forward, the timestamp contributes nothing to uniqueness anymore, so &lt;code&gt;concat(machine_id, counter)&lt;/code&gt; is leaner and just as safe. a 64-bit counter won&amp;#39;t wrap at any sane rate.&lt;/p&gt;&lt;/details&gt;

&lt;h2 id="but-counters-are-volatile"&gt;but counters are volatile&lt;/h2&gt;
&lt;p&gt;so the problemmmm. the counter is in memory. if the process crashes or restarts, it goes back to 0. and now you start regenerating ids you&amp;#39;ve already issued. catastrophic.&lt;/p&gt;
&lt;p&gt;fix: persist it. easy.&lt;/p&gt;
&lt;p&gt;now most people&amp;#39;s brain immediately goes &amp;quot;let me put it in a database.&amp;quot; &lt;strong&gt;why?? bro you just need durability&lt;/strong&gt;. that&amp;#39;s the whole property you need. a local file gives you durability. why are you inducing a network call, a sql parser, a query planner, an execution plan, a commit protocol... just &lt;code&gt;fwrite()&lt;/code&gt; to a file. open file, write counter, flush, close. or keep it open and keep flushing. that&amp;#39;s all you need.&lt;/p&gt;
&lt;div class="codeblock"&gt;&lt;pre&gt;&lt;code class="hljs"&gt;int counter = load_from_disk()
func get_id() {
    counter++
    save_to_disk(counter)        // disk i/o on every call
    return concat(machine_id, counter)
}&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;this is the biggest disease in tech tbh. people see &amp;quot;durability&amp;quot; and reach for postgres. &lt;strong&gt;you are paid to solve a problem and not necessarily write code to solve it&lt;/strong&gt;. if a 4-byte file does the job, write a 4-byte file.&lt;/p&gt;
&lt;h2 id="but-disk-i-o-on-every-call"&gt;but disk i/o on every call?&lt;/h2&gt;
&lt;p&gt;yeah that&amp;#39;s slow. millions of &lt;code&gt;get_id()&lt;/code&gt; calls = millions of &lt;code&gt;fsync&lt;/code&gt;s. not gonna fly at any reasonable throughput. so we batch.&lt;/p&gt;
&lt;p&gt;instead of flushing every increment, flush every 1000. and here&amp;#39;s where it gets a little subtle:&lt;/p&gt;
&lt;div class="codeblock"&gt;&lt;pre&gt;&lt;code class="hljs"&gt;int counter = load_from_disk() + 1000     // flush_frequency
func get_id() {
    counter++
    if (counter % 1000 == 0) {
        save_to_disk(counter)
    }
    return concat(machine_id, counter)
}&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;look at line 1. on startup, you don&amp;#39;t load and start from &lt;code&gt;whatever_was_on_disk&lt;/code&gt;. you load and add &lt;code&gt;flush_frequency&lt;/code&gt;. why?&lt;/p&gt;
&lt;p&gt;because between the last successful flush and the crash, the counter moved forward by &lt;em&gt;some&lt;/em&gt; amount you don&amp;#39;t know. could be 1, could be 999. so to be safe you assume it moved by the full flush_frequency. add that, and your starting value is &lt;strong&gt;guaranteed&lt;/strong&gt; to be a value never issued before.&lt;/p&gt;
&lt;p&gt;&lt;img src="https://raw.githubusercontent.com/tautik/image-assets/main/flush-recovery.png" alt="flush-and-recover sequence with safe restart value"&gt;&lt;/p&gt;
&lt;p&gt;people sometimes try to do this time-based (&amp;quot;flush every 5 seconds&amp;quot;) and then on recovery do &amp;quot;historical analysis&amp;quot; of how many ids were typically generated in 5 seconds. &lt;strong&gt;don&amp;#39;t&lt;/strong&gt;. that estimate will be wrong eventually and you&amp;#39;ll regenerate an id. go frequency-based. opposite of time-based. with frequency, the math is exact.&lt;/p&gt;
&lt;p&gt;so now we&amp;#39;ve got: lean ids, durable counter, batched i/o, no service. for a single machine this is genuinely solid. but the moment we want monotonically increasing ids, the rules of the game change.&lt;/p&gt;
&lt;details class="recall"&gt;&lt;summary&gt;&lt;span class="recall-mark"&gt;&amp;#10045; RECALL&lt;/span&gt; your counter flushes to disk every 1000 increments and the process crashes between flushes. why do you restart at &lt;code&gt;last_flushed + flush_frequency&lt;/code&gt; instead of &lt;code&gt;last_flushed&lt;/code&gt;, and why is time-based flushing a trap?&lt;/summary&gt;&lt;p&gt;between the last flush and the crash the counter moved forward by some unknown amount up to 999, so you assume the worst — add the full flush frequency and your restart value is guaranteed never issued before. time-based flushing (&amp;quot;every 5 seconds&amp;quot;) forces you to &lt;em&gt;estimate&lt;/em&gt; how many ids fit in the window on recovery, and that estimate will eventually be wrong and you&amp;#39;ll reissue an id. frequency-based math is exact. and note: a local file gave you all of this — durability never required a database.&lt;/p&gt;&lt;/details&gt;

&lt;h2 id="monotonic-ids-why-bother"&gt;monotonic ids - why bother?&lt;/h2&gt;
&lt;p&gt;monotonically increasing means &lt;code&gt;id_{i+1} &amp;gt; id_i&lt;/code&gt; for every i. always. no dips. no out-of-order.&lt;/p&gt;
&lt;p&gt;why do we want this? &lt;strong&gt;conflict resolution&lt;/strong&gt;. who came first.&lt;/p&gt;
&lt;p&gt;imagine elasticsearch with multi-version document updates. transaction T1 sets &lt;code&gt;a=20&lt;/code&gt;, transaction T2 sets &lt;code&gt;a=30&lt;/code&gt;. they arrive at your search index out of order because you&amp;#39;re consuming in parallel. how do you know T1 came before T2? well... if their ids are monotonically increasing, you just compare. &lt;code&gt;id_T1 &amp;lt; id_T2&lt;/code&gt;, so T1 was earlier, and when applying out-of-order updates you keep the latest version and discard the older one. last-write-wins.&lt;/p&gt;
&lt;p&gt;without monotonicity? you have no way to order them. chicken and egg.&lt;/p&gt;
&lt;h2 id="strict-vs-rough-monotonicity"&gt;strict vs rough monotonicity&lt;/h2&gt;
&lt;p&gt;strict monotonicity in a distributed system is brutally hard (we&amp;#39;ll see why in a sec). but &lt;strong&gt;rough&lt;/strong&gt; monotonicity is easy. just put the timestamp on the &lt;strong&gt;left-hand side&lt;/strong&gt; of your id.&lt;/p&gt;
&lt;div class="codeblock"&gt;&lt;pre&gt;&lt;code class="hljs"&gt;id = concat(get_epoch_ms(), machine_id, counter)&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;LHS = most significant bits. since the timestamp moves forward every millisecond, the most significant bits dominate the ordering. ids generated later are almost always numerically larger than ids generated earlier. within a single ms there might be reshuffling between machines (machine_id breaks ties on the right) but at the macro level, monotonic.&lt;/p&gt;
&lt;p&gt;this is &lt;strong&gt;the pattern&lt;/strong&gt;. once you internalize it, you&amp;#39;ll see it everywhere. snowflake? timestamp on LHS. mongodb objectid? timestamp on LHS. instagram? timestamp on LHS. universal.&lt;/p&gt;
&lt;details class="recall"&gt;&lt;summary&gt;&lt;span class="recall-mark"&gt;&amp;#10045; RECALL&lt;/span&gt; why does putting the timestamp in the most significant bits of an id buy you rough — but only rough — monotonicity, and what does that sortability unlock?&lt;/summary&gt;&lt;p&gt;the high bits dominate numeric ordering and the timestamp only moves forward, so ids generated later are almost always numerically larger — snowflake, mongo objectid, and instagram all lean on exactly this. it&amp;#39;s only &lt;em&gt;rough&lt;/em&gt; because within a single ms the machine-id bits reshuffle the tail, and clock skew across machines causes dips. the payoff is cursor pagination: &lt;code&gt;WHERE id &amp;gt; :last_seen LIMIT n&lt;/code&gt; costs the same on page 2 and page 200,000, while &lt;code&gt;OFFSET&lt;/code&gt; walks and discards every skipped row.&lt;/p&gt;&lt;/details&gt;

&lt;h2 id="the-clock-skew-bossfight"&gt;the clock skew bossfight&lt;/h2&gt;
&lt;p&gt;so why doesn&amp;#39;t rough monotonicity become strict at scale? clock skew.&lt;/p&gt;
&lt;p&gt;picture 4 machines. machine ids 2, 4, 7, 9. they&amp;#39;re all running NTP but their clocks have drifted slightly:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;m2 thinks it&amp;#39;s &lt;code&gt;time=23&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;m4 thinks it&amp;#39;s &lt;code&gt;time=24&lt;/code&gt; (running fast)&lt;/li&gt;
&lt;li&gt;m7 thinks it&amp;#39;s &lt;code&gt;time=23&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;m9 thinks it&amp;#39;s &lt;code&gt;time=23&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;at &amp;quot;the same instant&amp;quot; we invoke &lt;code&gt;get_id()&lt;/code&gt; on all four. results:&lt;/p&gt;
&lt;div class="codeblock"&gt;&lt;pre&gt;&lt;code class="hljs"&gt;m2 → 232
m4 → 244
m7 → 237
m9 → 239&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;not monotonically increasing! 244 came out before 237 in wall-clock order. dip.&lt;/p&gt;
&lt;p&gt;&lt;img src="https://raw.githubusercontent.com/tautik/image-assets/main/clock-skew.png" alt="four machines with skewed clocks producing non-monotonic ids"&gt;&lt;/p&gt;
&lt;p&gt;and there&amp;#39;s nothing you can really do about clock skew with software alone. NTP doesn&amp;#39;t make clocks identical, just close-ish. the only company i know of that genuinely fixed this is google with &lt;strong&gt;truetime&lt;/strong&gt; in spanner — and they did it by literally putting &lt;strong&gt;atomic clocks and gps receivers in their datacenters&lt;/strong&gt;. specialized hardware. who has that luxury? nobody else really.&lt;/p&gt;
&lt;h2 id="the-central-service-trap"&gt;the central service trap&lt;/h2&gt;
&lt;p&gt;so you go: &amp;quot;ok let me have ONE machine generate ids. no clock skew, no problem.&amp;quot;&lt;/p&gt;
&lt;p&gt;cool. now you have a single point of failure. machine dies → no ids → entire service down. so you add a second id server behind a load balancer for redundancy.&lt;/p&gt;
&lt;p&gt;now your two id servers need to &lt;strong&gt;gossip&lt;/strong&gt; to agree on ranges. server A picks 102, gotta tell server B &amp;quot;don&amp;#39;t pick 102&amp;quot;. server B picks 105, gotta tell A. on every single id. and reservations have to be consistent. this is essentially a distributed consensus problem and your throughput just collapsed.&lt;/p&gt;
&lt;p&gt;&lt;img src="https://raw.githubusercontent.com/tautik/image-assets/main/central-svc-evolution.png" alt="evolution from single id server to gossiping pair"&gt;&lt;/p&gt;
&lt;p&gt;so we end up with this fundamental result, which is honestly kinda beautiful in how clean it is:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;there is no way to distribute id generation, guarantee strict monotonicity, AND have high throughput — without specialized hardware.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;pick any two. you cannot have all three. real-world systems almost universally &lt;strong&gt;drop monotonicity&lt;/strong&gt; (settle for rough/sortable) so they can keep distribution and throughput. that&amp;#39;s what twitter, instagram, discord, sony etc all do.&lt;/p&gt;
&lt;details class="recall"&gt;&lt;summary&gt;&lt;span class="recall-mark"&gt;&amp;#10045; RECALL&lt;/span&gt; you want distributed generation, strict monotonicity, and high throughput all at once. why can&amp;#39;t software alone get you all three, and which one do real systems drop?&lt;/summary&gt;&lt;p&gt;clock skew kills distributed strict ordering — NTP keeps clocks close-ish, never identical, so a machine running fast stamps &amp;quot;later&amp;quot; ids that come out earlier in wall-clock order. centralizing on one machine fixes skew but creates a SPOF, and adding a second id server means gossiping a consistent reservation for every single id — a consensus round that collapses throughput. only specialized hardware escapes the triangle (spanner&amp;#39;s truetime, with atomic clocks and gps in the datacenter). everyone else — twitter, instagram, discord — drops strict monotonicity and settles for rough.&lt;/p&gt;&lt;/details&gt;

&lt;h2 id="case-study-1-amazon-39-s-batched-ticket-service"&gt;case study 1: amazon&amp;#39;s batched ticket service&lt;/h2&gt;
&lt;p&gt;amazon does the central-service thing but they make it cheap. one mysql table:&lt;/p&gt;
&lt;div class="codeblock"&gt;&lt;pre&gt;&lt;code class="hljs"&gt;service     counter
-------     -------
orders        500
payments        0&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;when an order-service pod boots up, it calls &lt;code&gt;get_id_batch(500)&lt;/code&gt;. the id service does an atomic update on the row, bumps &lt;code&gt;counter&lt;/code&gt; from 500 to 1000, and returns the range &lt;code&gt;[501, 1000]&lt;/code&gt; to the caller.&lt;/p&gt;
&lt;p&gt;now that pod owns 500 ids locally. it doesn&amp;#39;t talk to the id service again until it&amp;#39;s used 80% of them. then it asks for the next batch.&lt;/p&gt;
&lt;p&gt;&lt;img src="https://raw.githubusercontent.com/tautik/image-assets/main/amazon-batching.png" alt="amazon batched id service architecture"&gt;&lt;/p&gt;
&lt;p&gt;what if the pod crashes after using 10 of its 500 ids? you lose the remaining 490. who cares. counter is a 64-bit unsigned int. let&amp;#39;s do napkin math: 100 servers each burning 500 ids/sec = 50,000 ids/sec ≈ 4.32 billion ids/day. &lt;code&gt;2^64 / 4.32 billion = ~4.3 billion days&lt;/code&gt;. and even if 50% of every batch is wasted from restarts, you&amp;#39;ve still got &lt;em&gt;billions&lt;/em&gt; of days. the calculator overflows before your id space does.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;don&amp;#39;t be miserly about wasting ids&lt;/strong&gt;. trying to deterministically reuse &amp;quot;leaked&amp;quot; ranges adds enormous complexity for zero practical benefit. let them die. ship faster.&lt;/p&gt;
&lt;details class="recall"&gt;&lt;summary&gt;&lt;span class="recall-mark"&gt;&amp;#10045; RECALL&lt;/span&gt; in amazon&amp;#39;s batched ticket scheme, a pod grabs a batch of ids and crashes after using a handful. why is &amp;quot;let them die&amp;quot; the right call instead of reclaiming the leaked range?&lt;/summary&gt;&lt;p&gt;the counter is a 64-bit int — even with half of every batch wasted to restarts, napkin math says the id space outlives you by billions of days. batching already bought the real win: a pod touches the central service once per batch instead of once per id, so generation stays local and fast. deterministic reuse of leaked ranges adds genuine complexity for zero practical benefit.&lt;/p&gt;&lt;/details&gt;

&lt;h2 id="case-study-2-why-not-just-uuids"&gt;case study 2: why not just UUIDs?&lt;/h2&gt;
&lt;p&gt;uuids are 128-bit integers. they&amp;#39;re random. they&amp;#39;re easy. why aren&amp;#39;t we using them?&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;index bloat&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;think about how an index is stored in mysql. for an index on column &lt;code&gt;name&lt;/code&gt;, the index leaf is essentially &lt;code&gt;(name, primary_key) → row&lt;/code&gt;. so if your primary key is a 4-byte int, an index entry is &lt;code&gt;4 + 4 = 8&lt;/code&gt; bytes. if your primary key is a 16-byte uuid, an index entry is &lt;code&gt;4 + 16 = 20&lt;/code&gt; bytes. &lt;strong&gt;2.5x larger&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;multiply that across every index on every table. now your indexes are 2.5x bigger. and here&amp;#39;s the killer: a database is fast only when its indexes fit in RAM. if your indexes spill to disk, every lookup becomes a disk seek before you even get to fetching the row. you&amp;#39;ve turned a memory operation into a disk operation. your query latency just exploded.&lt;/p&gt;
&lt;p&gt;so at scale, uuids are a tax you pay on every read. not worth it. (also they&amp;#39;re not sortable, which kills cursor-based pagination — more on that in a sec.)&lt;/p&gt;
&lt;h3 id="the-cockroachdb-counter-example"&gt;the cockroachdb counter-example&lt;/h3&gt;
&lt;p&gt;hold on though — cockroachdb&amp;#39;s docs literally &lt;strong&gt;recommend&lt;/strong&gt; using uuids as primary keys. why?&lt;/p&gt;
&lt;p&gt;because cockroachdb is range-partitioned. data is sorted by primary key and split into ranges across nodes. if your pk is monotonically increasing, &lt;strong&gt;every insert hits the node that owns the latest range&lt;/strong&gt;. that one node becomes a hotshard, the other N-1 nodes do nothing. you&amp;#39;ve defeated the entire point of a distributed database.&lt;/p&gt;
&lt;p&gt;with random uuids, inserts are spread evenly across all nodes. throughput scales horizontally.&lt;/p&gt;
&lt;p&gt;so the same property (randomness) that made uuids bad for sharded mysql makes them &lt;em&gt;good&lt;/em&gt; for cockroachdb. &lt;strong&gt;nothing is best. everything depends on your usecase.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src="https://raw.githubusercontent.com/tautik/image-assets/main/uuid-vs-monotonic.png" alt="uuid vs monotonic id — same property, opposite verdict"&gt;&lt;/p&gt;
&lt;details class="recall"&gt;&lt;summary&gt;&lt;span class="recall-mark"&gt;&amp;#10045; RECALL&lt;/span&gt; why are random uuids a tax on every read in sharded mysql, yet the &lt;em&gt;recommended&lt;/em&gt; primary key in cockroachdb?&lt;/summary&gt;&lt;p&gt;in mysql every secondary index entry carries the primary key, so a fat uuid pk bloats every index on every table — and the moment indexes spill out of RAM, each lookup eats a disk seek before you even fetch the row. cockroach is range-partitioned by pk: a monotonically increasing key funnels every insert to the node owning the latest range — one hotshard, N-1 idle nodes — while random uuids spread writes across the cluster. same property, randomness, opposite verdict. nothing is best; everything depends on the usecase.&lt;/p&gt;&lt;/details&gt;

&lt;h2 id="case-study-3-flickr-39-s-database-ticket-server"&gt;case study 3: flickr&amp;#39;s database ticket server&lt;/h2&gt;
&lt;p&gt;flickr did something a lot of people roll their eyes at, but it&amp;#39;s actually elegant. they spun up a dedicated mysql server whose only job was to run auto-increment.&lt;/p&gt;
&lt;div class="codeblock"&gt;&lt;span class="code-lang"&gt;sql&lt;/span&gt;&lt;pre&gt;&lt;code class="hljs language-sql"&gt;&lt;span class="hljs-keyword"&gt;CREATE TABLE&lt;/span&gt; tickets (
    id &lt;span class="hljs-type"&gt;BIGINT&lt;/span&gt; UNSIGNED &lt;span class="hljs-keyword"&gt;NOT NULL&lt;/span&gt; AUTO_INCREMENT
)&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;every time someone wants an id, they go to that mysql, do an insert, get back the auto-incremented id, and use it as the pk for their actual sharded data store. it&amp;#39;s effectively the central-id-service we discussed, just implemented with mysql&amp;#39;s existing primitives instead of writing one from scratch.&lt;/p&gt;
&lt;p&gt;was this the most elegant thing ever? no. did it work for flickr at their scale? yes. that&amp;#39;s the bar.&lt;/p&gt;
&lt;h2 id="case-study-4-twitter-snowflake"&gt;case study 4: twitter snowflake&lt;/h2&gt;
&lt;p&gt;ok now my favorite. twitter went the other direction — no service at all.&lt;/p&gt;
&lt;p&gt;snowflake is a &lt;strong&gt;64-bit integer&lt;/strong&gt; split into 3 chunks:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;41 bits: epoch milliseconds (since some custom epoch like 2010)&lt;/li&gt;
&lt;li&gt;10 bits: machine id (so up to 1024 machines)&lt;/li&gt;
&lt;li&gt;12 bits: per-machine counter&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;img src="https://raw.githubusercontent.com/tautik/image-assets/main/snowflake-bits.png" alt="snowflake 64-bit layout"&gt;&lt;/p&gt;
&lt;p&gt;generation is literally 3 lines of bit manipulation. there is &lt;strong&gt;no service&lt;/strong&gt;. every api server that wants to write a tweet just runs &lt;code&gt;get_id()&lt;/code&gt; locally as a function call. machine_id is assigned at boot via a tiny coordination service (think: a table with 1024 rows, claim one, release on shutdown). that&amp;#39;s the only network hop, and it happens once per pod lifetime.&lt;/p&gt;
&lt;p&gt;and because the timestamp is on the LHS, snowflake ids are &lt;strong&gt;roughly sortable&lt;/strong&gt;. which gives you something kinda magical for pagination.&lt;/p&gt;
&lt;h2 id="the-pagination-payoff"&gt;the pagination payoff&lt;/h2&gt;
&lt;p&gt;most apps paginate with &lt;code&gt;LIMIT n OFFSET k&lt;/code&gt;. &lt;code&gt;OFFSET k&lt;/code&gt; is brutal — the database walks the first &lt;code&gt;k&lt;/code&gt; rows just to skip them. as you go deeper into the result set, the query gets linearly slower. classic graph: deeper → slower.&lt;/p&gt;
&lt;p&gt;but if your ids are roughly sortable, you can paginate by id instead:&lt;/p&gt;
&lt;div class="codeblock"&gt;&lt;span class="code-lang"&gt;sql&lt;/span&gt;&lt;pre&gt;&lt;code class="hljs language-sql"&gt;&lt;span class="hljs-keyword"&gt;SELECT&lt;/span&gt; &lt;span class="hljs-operator"&gt;*&lt;/span&gt; &lt;span class="hljs-keyword"&gt;FROM&lt;/span&gt; tweets &lt;span class="hljs-keyword"&gt;WHERE&lt;/span&gt; id &lt;span class="hljs-operator"&gt;&amp;gt;&lt;/span&gt; :last_seen_id LIMIT &lt;span class="hljs-number"&gt;5&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;every page is &lt;code&gt;O(log n + k)&lt;/code&gt;. &lt;strong&gt;constant&lt;/strong&gt; with depth. doesn&amp;#39;t matter if you&amp;#39;re on page 2 or page 200,000, the query takes the same time.&lt;/p&gt;
&lt;p&gt;this is why twitter&amp;#39;s api uses &lt;code&gt;since_id&lt;/code&gt; instead of offset. it&amp;#39;s why dynamo uses &lt;code&gt;LastEvaluatedKey&lt;/code&gt;. it&amp;#39;s why mongodb pagination patterns push you toward &lt;code&gt;_id &amp;gt; :cursor&lt;/code&gt;. cursor-based pagination is the natural friend of sortable ids and they unlock each other.&lt;/p&gt;
&lt;h2 id="case-study-5-instagram-39-s-snowflake-variant"&gt;case study 5: instagram&amp;#39;s snowflake variant&lt;/h2&gt;
&lt;p&gt;instagram took twitter&amp;#39;s idea and made one really clever change. their layout:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;41 bits: epoch milliseconds (since jan 1 2011)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;13 bits: db shard id&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;10 bits: per-shard sequence number&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;notice — they replaced &amp;quot;machine id&amp;quot; with &amp;quot;&lt;strong&gt;db shard id&lt;/strong&gt;&amp;quot;. the id encodes where the row lives.&lt;/p&gt;
&lt;p&gt;and the genius is what they do with &lt;strong&gt;logical shards on physical servers&lt;/strong&gt;. instagram has thousands of logical shards (basically &lt;code&gt;CREATE DATABASE insta_0&lt;/code&gt;, &lt;code&gt;CREATE DATABASE insta_1&lt;/code&gt;, ... in postgres) but only ~10-15 physical postgres servers. each physical server holds many logical shards.&lt;/p&gt;
&lt;p&gt;&lt;img src="https://raw.githubusercontent.com/tautik/image-assets/main/instagram-logical-shards.png" alt="logical shards stacked inside fewer physical postgres servers"&gt;&lt;/p&gt;
&lt;p&gt;why? because handling a hot shard becomes trivial. when one physical server starts getting hot, you spin up a new physical box and just &lt;code&gt;pg_dump&lt;/code&gt; one of the logical shards from the hot box and &lt;code&gt;pg_restore&lt;/code&gt; it on the new box. dump-and-load operates on a whole directory at the file level — way faster than iterating row-by-row to figure out who-goes-where like you&amp;#39;d have to do if your physical and logical shards were 1:1.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;this is the same trick elasticsearch uses with shards&lt;/strong&gt;. the underlying pattern: have many small logical units inside fewer large physical containers, so you can move logical units around when load shifts. extremely clean.&lt;/p&gt;
&lt;p&gt;and the id generation lives &lt;strong&gt;inside the database&lt;/strong&gt; — it&amp;#39;s a stored function set as the &lt;code&gt;DEFAULT&lt;/code&gt; for the id column. when you insert a post and don&amp;#39;t specify an id, postgres calls the function, which packs &lt;code&gt;(timestamp | shard_id | counter)&lt;/code&gt; and that becomes the row&amp;#39;s pk. zero service, zero network calls, all happening inside the insert transaction.&lt;/p&gt;
&lt;details class="recall"&gt;&lt;summary&gt;&lt;span class="recall-mark"&gt;&amp;#10045; RECALL&lt;/span&gt; instagram runs thousands of logical postgres shards on only ~10-15 physical servers. what does that indirection buy them when one box runs hot?&lt;/summary&gt;&lt;p&gt;rebalancing becomes trivial — &lt;code&gt;pg_dump&lt;/code&gt; one logical shard off the hot box and &lt;code&gt;pg_restore&lt;/code&gt; it on a fresh one. dump-and-load moves a whole directory at the file level, no row-by-row resharding to figure out who-goes-where. if logical and physical shards were 1:1 you&amp;#39;d have none of that freedom. it&amp;#39;s the same trick elasticsearch uses: many small logical units inside fewer large physical containers, so you can move the units around when load shifts.&lt;/p&gt;&lt;/details&gt;

&lt;h2 id="closing-thoughts"&gt;closing thoughts&lt;/h2&gt;
&lt;p&gt;the thing i keep coming back to is — id generation seems trivial until you actually try to ship it at scale. then every constraint you add (durability, monotonicity, distribution, throughput) tightens the screws and forces tradeoffs. the entire history of this space is people &lt;strong&gt;relaxing the right constraint&lt;/strong&gt; for their specific use case:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;twitter relaxed strict monotonicity → got rough sortability + zero-service generation&lt;/li&gt;
&lt;li&gt;instagram relaxed strict monotonicity AND embedded shard info → got hot-shard rebalancing for free&lt;/li&gt;
&lt;li&gt;cockroachdb relaxed sortability entirely (uuids) → got distributed write throughput&lt;/li&gt;
&lt;li&gt;flickr relaxed &amp;quot;no central service&amp;quot; → got auto-increment for free without writing code&lt;/li&gt;
&lt;li&gt;amazon relaxed &amp;quot;no wasted ids&amp;quot; → got cheap, simple, robust batching&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;the courses portal i mentioned earlier? we relaxed everything except uniqueness. 6 random characters. ships every release. nobody complains.&lt;/p&gt;
&lt;p&gt;so the framework is: figure out your &lt;strong&gt;non-optional&lt;/strong&gt; constraints, your &lt;strong&gt;optional&lt;/strong&gt; constraints, the order you&amp;#39;d relax them, and then &lt;em&gt;design&lt;/em&gt; the id generator. don&amp;#39;t pattern-match to &amp;quot;snowflake!&amp;quot; because somebody used it at twitter. you might not need any of what twitter needed.&lt;/p&gt;
&lt;p&gt;and please. for the love of distributed systems. &lt;strong&gt;don&amp;#39;t make a microservice unless you have to.&lt;/strong&gt; a function in your app code, a stored procedure in your db, a 4-byte file on local disk — these are all valid id generators. the box-on-an-architecture-diagram is not the goal. the function returning something unique every time it&amp;#39;s invoked is the goal.&lt;/p&gt;
&lt;p&gt;nothing is best. everything depends on the usecase.&lt;/p&gt;
&lt;details class="recall"&gt;&lt;summary&gt;&lt;span class="recall-mark"&gt;&amp;#10045; RECALL&lt;/span&gt; before you pattern-match to &amp;quot;snowflake!&amp;quot;, what&amp;#39;s the actual framework for designing an id generator?&lt;/summary&gt;&lt;p&gt;list your &lt;strong&gt;non-optional&lt;/strong&gt; constraints, your &lt;strong&gt;optional&lt;/strong&gt; ones, and the order you&amp;#39;d relax them — then design. twitter relaxed strict monotonicity and got zero-service sortable ids; instagram also embedded shard info and got hot-shard rebalancing; cockroach relaxed sortability and got distributed write throughput; flickr relaxed &amp;quot;no central service&amp;quot; and got auto-increment for free; amazon relaxed &amp;quot;no wasted ids&amp;quot; and got cheap robust batching. and don&amp;#39;t make a microservice unless something forces you to — a function in app code, a stored procedure, or a 4-byte file on disk are all valid id generators.&lt;/p&gt;&lt;/details&gt;
</content>
  </entry>
  <entry>
    <title>designing instagram's hashtag page</title>
    <link href="https://tautik.me/ig-hastag/"/>
    <id>https://tautik.me/ig-hastag/</id>
    <updated>2026-04-20T00:00:00Z</updated>
    <content type="html">&lt;p&gt;a system that looks deceptively simple on the surface but hides a bunch of interesting engineering decisions underneath. you know the page — when you tap &lt;code&gt;#sunset&lt;/code&gt; on instagram and see the name, total posts, and a grid of top photos. let&amp;#39;s walk through how to actually build it.&lt;/p&gt;
&lt;h2 id="what-you-39-ll-take-away"&gt;what you&amp;#39;ll take away&lt;/h2&gt;
&lt;p&gt;quick pointers so you know what to look for as you read:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;a guiding principle saves you from the cap theorem.&lt;/strong&gt; &amp;quot;best user experience&amp;quot; wins ties so you stop fighting consistency vs availability vs latency.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;store just enough, not everything.&lt;/strong&gt; the gray area between &amp;quot;ID only&amp;quot; and &amp;quot;full metadata&amp;quot; is where the system gets cheap and simple.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;pagination isn&amp;#39;t always an optimization.&lt;/strong&gt; sometimes it&amp;#39;s just disk I/O dressed in a useful-looking flag.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;kafka isn&amp;#39;t infra you bolt on, it&amp;#39;s the natural answer when many services care about one event.&lt;/strong&gt; post service shouldn&amp;#39;t know who&amp;#39;s listening.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;two-buffer swap beats stop-the-world.&lt;/strong&gt; the critical section becomes a pointer flip — three CPU instructions.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2 id="the-requirement"&gt;the requirement&lt;/h2&gt;
&lt;p&gt;imagine you&amp;#39;re an early engineer at instagram and someone walks up to you and says — &amp;quot;hey, we need to build this page.&amp;quot; for every hashtag, all you have to show is:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;the name of the hashtag&lt;/li&gt;
&lt;li&gt;total number of posts (approximate is fine)&lt;/li&gt;
&lt;li&gt;top 100 photos tagged with it&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;the top 100 photos? that&amp;#39;s computed by a data science team. you don&amp;#39;t care about their logic — exponential decay, reaction counts in the last hour, whatever. they hand you a list of 100 post IDs. your job is to render the page. sounds simple right? well, the moment you dig in, you realize there&amp;#39;s so much more.&lt;/p&gt;
&lt;h2 id="design-principle-best-user-experience"&gt;design principle: best user experience&lt;/h2&gt;
&lt;p&gt;before we start, here&amp;#39;s a discipline that most engineers skip — &lt;strong&gt;define your guiding principle&lt;/strong&gt;. for this system, it&amp;#39;s &lt;strong&gt;best user experience&lt;/strong&gt;. every single design decision should optimize for UX. whenever you&amp;#39;re stuck between option A and option B, you pick the one that makes the user&amp;#39;s experience better.&lt;/p&gt;
&lt;p&gt;this is honestly one of the best habits you can build. without a guiding principle, you end up in the classic trap where someone says &amp;quot;i want strong consistency, high availability, AND fault tolerance&amp;quot; and you&amp;#39;re basically violating the CAP theorem. you can&amp;#39;t have it all. but what you&amp;#39;re okay giving up on should depend on what you&amp;#39;re optimizing for.&lt;/p&gt;
&lt;p&gt;&lt;img src="https://raw.githubusercontent.com/tautik/image-assets/main/kafka-fanout.png" alt="post svc → POST_PUBLISH topic → fan-out to feed, notification, search, and hashtag worker"&gt;&lt;/p&gt;
&lt;details class="recall"&gt;&lt;summary&gt;&lt;span class="recall-mark"&gt;&amp;#10045; RECALL&lt;/span&gt; you&amp;#39;re stuck between two designs and someone wants strong consistency, high availability, AND low latency all at once. how does a guiding principle get you unstuck?&lt;/summary&gt;&lt;p&gt;you can&amp;#39;t have it all — that&amp;#39;s the cap theorem trap. declaring a principle up front (&amp;quot;best user experience&amp;quot; here) gives you a tie-breaker: every contested decision gets resolved by asking which option serves the principle, so you know in advance what you&amp;#39;re willing to give up instead of re-fighting the same tradeoff on every choice.&lt;/p&gt;&lt;/details&gt;

&lt;h2 id="the-read-path"&gt;the read path&lt;/h2&gt;
&lt;p&gt;so the user taps &lt;code&gt;#sunset&lt;/code&gt;. a &lt;code&gt;GET /tags/{tag_name}&lt;/code&gt; request hits your hashtag API servers. and because we&amp;#39;re optimizing for UX, this &lt;strong&gt;one API call&lt;/strong&gt; should return everything — the name, the count, and the top 100 photos. no fan-out from the frontend making three separate calls. one request, one response, done.&lt;/p&gt;
&lt;p&gt;the hashtag db is a partitioned key-value store — could be mongodb, dynamodb, whatever. but why partitioned? because our access pattern is dead simple — given a hashtag name, fetch its document. that&amp;#39;s a &lt;strong&gt;key-value lookup&lt;/strong&gt;. the hashtag name (like &lt;code&gt;sunset&lt;/code&gt;) is the key, the document with count and top photos is the value. no range queries, no joins, no fancy relational stuff. just &lt;code&gt;get(key) → value&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;and because there are millions of hashtags, one machine can&amp;#39;t hold all of them. you need to spread the data across multiple nodes. the hashtag name is a natural partition key — it distributes well (hashtags are diverse enough), and all the data you need for one hashtag lives in a single document on a single node. no cross-partition queries needed. so the three requirements from storage are: &lt;strong&gt;partitioned, key-value access, and durable&lt;/strong&gt;. mongodb, dynamodb, couchbase — any of these work. pick based on team expertise and operational comfort.&lt;/p&gt;
&lt;p&gt;the document looks something like this:&lt;/p&gt;
&lt;div class="codeblock"&gt;&lt;span class="code-lang"&gt;json&lt;/span&gt;&lt;pre&gt;&lt;code class="hljs language-json"&gt;&lt;span class="hljs-punctuation"&gt;{&lt;/span&gt;
  &lt;span class="hljs-attr"&gt;&amp;quot;tag&amp;quot;&lt;/span&gt;&lt;span class="hljs-punctuation"&gt;:&lt;/span&gt; &lt;span class="hljs-string"&gt;&amp;quot;sunset&amp;quot;&lt;/span&gt;&lt;span class="hljs-punctuation"&gt;,&lt;/span&gt;
  &lt;span class="hljs-attr"&gt;&amp;quot;total_posts&amp;quot;&lt;/span&gt;&lt;span class="hljs-punctuation"&gt;:&lt;/span&gt; &lt;span class="hljs-number"&gt;1200000&lt;/span&gt;&lt;span class="hljs-punctuation"&gt;,&lt;/span&gt;
  &lt;span class="hljs-attr"&gt;&amp;quot;top_100&amp;quot;&lt;/span&gt;&lt;span class="hljs-punctuation"&gt;:&lt;/span&gt; &lt;span class="hljs-punctuation"&gt;[&lt;/span&gt; ... &lt;span class="hljs-punctuation"&gt;]&lt;/span&gt;
&lt;span class="hljs-punctuation"&gt;}&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;now the interesting question — what goes inside &lt;code&gt;top_100&lt;/code&gt;?&lt;/p&gt;
&lt;p&gt;&lt;img src="https://raw.githubusercontent.com/tautik/image-assets/main/read-path.png" alt="read path: client → hashtag API → partitioned key-value store; frontend lazy-loads images on viewport entry"&gt;&lt;/p&gt;
&lt;h2 id="storing-top-100-the-storage-tradeoff"&gt;storing top 100: the storage tradeoff&lt;/h2&gt;
&lt;p&gt;you have three options here and this is where things get spicy. let&amp;#39;s do the math for each so we make an informed decision instead of going with gut feel.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;option 1: store only post IDs.&lt;/strong&gt; so &lt;code&gt;top_100&lt;/code&gt; is just &lt;code&gt;[p1, p2, p3, ..., p100]&lt;/code&gt;. let&amp;#39;s break down the document size:&lt;/p&gt;
&lt;div class="codeblock"&gt;&lt;pre&gt;&lt;code class="hljs"&gt;tag name:       ~12 bytes (avg hashtag length)
total_posts:    ~32 bytes (number serialized as string + key overhead)
top_100 array:  100 × 8 bytes (snowflake IDs are 64-bit = 8 bytes each)
                = 800 bytes
─────────────────────────────────────
total:          ~850 bytes ≈ 1 KB&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;cheap on storage. but now when a request comes in, you read this document, get the 100 IDs, and then you have to do a &lt;strong&gt;batch read&lt;/strong&gt; from the posts database to fetch the actual post details — image URLs, captions, whatever you need to render the grid. that&amp;#39;s a second lookup. more latency. bad for UX.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;option 2: store entire post metadata.&lt;/strong&gt; so you denormalize everything — caption, image URL, user details, tags, the works. per post:&lt;/p&gt;
&lt;div class="codeblock"&gt;&lt;pre&gt;&lt;code class="hljs"&gt;post ID:        8 bytes   (snowflake)
caption:        ~160 bytes (avg caption with hashtags baked in,
                           think twitter-length text)
image URL:      ~160 bytes (CDN URLs can be long)
user metadata:  ~160 bytes (username, profile pic URL, etc.)
─────────────────────────────────────
per post:       ~488 bytes, but captions contain hashtags
                which eat space, so realistically ~560-660 bytes
                let's call it ~1 KB conservatively

top_100 array:  100 × ~1 KB = ~100 KB per document&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;that&amp;#39;s &lt;strong&gt;~100 KB&lt;/strong&gt; read from disk on every lookup. and here&amp;#39;s the nastier problem — if you store likes count here, every time a like happens, you have to update it in the posts db AND here. you just introduced a consistency nightmare and extra plumbing to keep things in sync.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;option 3: the middle ground.&lt;/strong&gt; this is where you put on the product manager hat. you ask yourself — what&amp;#39;s the &lt;strong&gt;bare minimum&lt;/strong&gt; we need to render the grid? just the photo. that&amp;#39;s it. the user sees a grid of images. they tap one, they go to the full post page. no caption, no likes, no username on the grid view. so all you store is the post ID and the image URL:&lt;/p&gt;
&lt;div class="codeblock"&gt;&lt;pre&gt;&lt;code class="hljs"&gt;post ID:        8 bytes   (snowflake)
image URL:      ~160 bytes
─────────────────────────────────────
per post:       ~168 bytes

top_100 array:  100 × 168 = ~16,800 bytes ≈ 16 KB per document&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;from 100 KB down to &lt;strong&gt;~16 KB&lt;/strong&gt;. peanuts. no extra consistency headaches. no syncing likes. no additional lookups. dead simple.&lt;/p&gt;
&lt;p&gt;and here&amp;#39;s the key insight — &lt;strong&gt;you as an engineering leader can propose product changes that simplify the system&lt;/strong&gt;. most people think this is not possible. it absolutely is. if your idea has merit and optimizes the system, your PMs will get it. it&amp;#39;s not product versus engineering — it&amp;#39;s product AND engineering towards the same goal. the answer usually lies in the gray area.&lt;/p&gt;
&lt;details class="recall"&gt;&lt;summary&gt;&lt;span class="recall-mark"&gt;&amp;#10045; RECALL&lt;/span&gt; for the top-100 grid, why is storing only post IDs bad, why is storing full post metadata also bad, and what&amp;#39;s the middle ground?&lt;/summary&gt;&lt;p&gt;IDs alone force a second batch read against the posts db on every page view — extra latency, worse ux. full metadata balloons the document to ~100 KB per read and creates a consistency nightmare (likes now live in two places that must stay in sync). the middle: store exactly what the grid renders — post id + image url, ~16 KB, one lookup, zero sync plumbing. and noticing that the grid only needs the photo is a product change an engineer can and should propose.&lt;/p&gt;&lt;/details&gt;

&lt;h2 id="why-pagination-is-overkill-here"&gt;why pagination is overkill here&lt;/h2&gt;
&lt;p&gt;now someone will inevitably say &amp;quot;shouldn&amp;#39;t we paginate the top 100?&amp;quot; and at first glance it sounds reasonable. but think about how the data is stored. you have &lt;strong&gt;one JSON document&lt;/strong&gt; with an array of 100 items. if you paginate with &lt;code&gt;page=1&amp;amp;size=10&lt;/code&gt;, what happens? the database still reads the entire document off disk, then discards 90 entries and returns 10. that&amp;#39;s a waste of disk I/O. you&amp;#39;re not saving anything.&lt;/p&gt;
&lt;p&gt;pagination makes sense when you have separate rows — like &lt;code&gt;SELECT * FROM posts LIMIT 10 OFFSET 20&lt;/code&gt;. here, you have one document containing an array. pagination is just unnecessary overhead.&lt;/p&gt;
&lt;p&gt;so we send all 100 post metadata (ID + image URL) to the frontend in one shot. but wait — what if the user never scrolls past the first 18 images (roughly 3 folds on a phone)? then we just wasted bandwidth loading 82 images for nothing.&lt;/p&gt;
&lt;h2 id="lazy-loading-to-the-rescue"&gt;lazy loading to the rescue&lt;/h2&gt;
&lt;p&gt;the trick is to &lt;strong&gt;offload this responsibility to the frontend&lt;/strong&gt;. send all 100 metadata entries, but let the frontend do &lt;strong&gt;lazy loading of images&lt;/strong&gt;. only fetch the actual image file when it enters the viewport. if the user never scrolls, those 82 images are never downloaded.&lt;/p&gt;
&lt;p&gt;this is a good example of how it&amp;#39;s not backend vs frontend — it&amp;#39;s backend AND frontend together solving the problem. rather than over-engineering pagination on the backend, you push the intelligence to where it belongs. efficient bandwidth usage, no database overhead, great UX.&lt;/p&gt;
&lt;details class="recall"&gt;&lt;summary&gt;&lt;span class="recall-mark"&gt;&amp;#10045; RECALL&lt;/span&gt; why is paginating the top-100 array a fake optimization, and how do you avoid wasting bandwidth without it?&lt;/summary&gt;&lt;p&gt;the top 100 live in one json document — the database reads the whole thing off disk regardless, then throws away 90 entries; pagination only saves work when items are separate rows. so send all 100 (id + image url) in one response and let the frontend lazy-load: an image downloads only when it enters the viewport, so a user who never scrolls past the first fold never fetches the rest.&lt;/p&gt;&lt;/details&gt;

&lt;h2 id="the-write-path-counting-hashtag-posts"&gt;the write path: counting hashtag posts&lt;/h2&gt;
&lt;p&gt;now we&amp;#39;ve sorted out reads. let&amp;#39;s talk writes. every time a post is published, we need to increment the &lt;code&gt;total_posts&lt;/code&gt; count for each hashtag in that post&amp;#39;s caption. but before we jump to the architecture, let&amp;#39;s build up to it.&lt;/p&gt;
&lt;p&gt;here&amp;#39;s what already exists in your infra. you have a &lt;strong&gt;post service&lt;/strong&gt; (svc is just shorthand for service — you&amp;#39;ll see it everywhere in system design diagrams) that handles post creation. users upload a photo, write a caption, hit publish, and the post gets stored in the &lt;strong&gt;posts db&lt;/strong&gt;. that&amp;#39;s your existing system. now you&amp;#39;re bolting the hashtag service on top of it.&lt;/p&gt;
&lt;p&gt;so the question is — when a post gets published, how does the hashtag service know about it? the naive thought is &amp;quot;just call the hashtag service directly from the post service.&amp;quot; but think about who else cares when a post is published:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;the &lt;strong&gt;feed service&lt;/strong&gt; needs to add it to followers&amp;#39; feeds&lt;/li&gt;
&lt;li&gt;the &lt;strong&gt;notification service&lt;/strong&gt; needs to notify followers&lt;/li&gt;
&lt;li&gt;the &lt;strong&gt;search service&lt;/strong&gt; needs to index it in elasticsearch&lt;/li&gt;
&lt;li&gt;the &lt;strong&gt;hashtag service&lt;/strong&gt; needs to update counts&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;if the post service directly calls each of these, you&amp;#39;ve tightly coupled everything. post service now needs to know about every downstream consumer. add a new consumer? modify the post service. one downstream is slow? post service slows down. one downstream is down? post service either fails or needs retry logic for each. that&amp;#39;s a mess.&lt;/p&gt;
&lt;p&gt;this is the classic case for &lt;strong&gt;event-driven architecture&lt;/strong&gt; with kafka as the glue. the post service doesn&amp;#39;t care who&amp;#39;s listening. it just publishes a &lt;code&gt;POST_PUBLISH&lt;/code&gt; event to a kafka topic and moves on. whoever is interested — feed, notifications, search, hashtag — independently consumes from that same topic. decoupled. extensible. if tomorrow you want to add a &amp;quot;trending&amp;quot; service, just add another consumer. zero changes to the post service.&lt;/p&gt;
&lt;p&gt;so kafka is not something we added for fun — it naturally falls out of the requirement that multiple services need to react to a post being published. the hashtag worker (wrkr = worker, another shorthand) is just one of many consumers on that topic.&lt;/p&gt;
&lt;p&gt;now the hashtag worker receives the event, gets the post ID, fetches the caption from the post service (or the caption is included in the kafka event itself), and extracts hashtags from it using a simple regex. the naive implementation looks like this:&lt;/p&gt;
&lt;div class="codeblock"&gt;&lt;span class="code-lang"&gt;python&lt;/span&gt;&lt;pre&gt;&lt;code class="hljs language-python"&gt;&lt;span class="hljs-keyword"&gt;def&lt;/span&gt; &lt;span class="hljs-title function_"&gt;process&lt;/span&gt;(&lt;span class="hljs-params"&gt;m&lt;/span&gt;):
    tags = extract_hashtags(m.caption)
    &lt;span class="hljs-keyword"&gt;for&lt;/span&gt; tag &lt;span class="hljs-keyword"&gt;in&lt;/span&gt; tags:
        db.incr(tag, &lt;span class="hljs-number"&gt;1&lt;/span&gt;)&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;simple. one db call per tag. but let&amp;#39;s estimate the scale. if instagram sees 100K posts per minute, and each post has ~8 hashtags on average, that&amp;#39;s &lt;strong&gt;800K database updates per minute&lt;/strong&gt; just for this one use case. one db call per tag is not gonna cut it.&lt;/p&gt;
&lt;details class="recall"&gt;&lt;summary&gt;&lt;span class="recall-mark"&gt;&amp;#10045; RECALL&lt;/span&gt; when a post is published, why shouldn&amp;#39;t the post service just call the hashtag service directly?&lt;/summary&gt;&lt;p&gt;because hashtag isn&amp;#39;t the only listener — feed, notifications, and search all care about the same event. direct calls couple the post service to every downstream: adding a consumer means modifying it, a slow consumer slows publishing, a dead one forces per-consumer retry logic. instead it publishes one &lt;code&gt;POST_PUBLISH&lt;/code&gt; event to a kafka topic and moves on; consumers subscribe independently, and adding a &amp;quot;trending&amp;quot; service tomorrow touches nothing upstream. kafka falls out of the requirement — it isn&amp;#39;t bolted on for fun.&lt;/p&gt;&lt;/details&gt;

&lt;h2 id="approach-2-in-memory-batching"&gt;approach 2: in-memory batching&lt;/h2&gt;
&lt;p&gt;so we buffer. instead of writing every increment to the db immediately, we accumulate counts in an in-memory map and flush periodically.&lt;/p&gt;
&lt;div class="codeblock"&gt;&lt;span class="code-lang"&gt;python&lt;/span&gt;&lt;pre&gt;&lt;code class="hljs language-python"&gt;&lt;span class="hljs-keyword"&gt;def&lt;/span&gt; &lt;span class="hljs-title function_"&gt;process&lt;/span&gt;(&lt;span class="hljs-params"&gt;m&lt;/span&gt;):
    tags = extract_hashtags(m.caption)
    &lt;span class="hljs-keyword"&gt;for&lt;/span&gt; tag &lt;span class="hljs-keyword"&gt;in&lt;/span&gt; tags:
        m[tag] += &lt;span class="hljs-number"&gt;1&lt;/span&gt;

    &lt;span class="hljs-keyword"&gt;if&lt;/span&gt; has_been_long():   &lt;span class="hljs-comment"&gt;# &amp;gt; 5 minutes or count &amp;gt; 1000&lt;/span&gt;
        &lt;span class="hljs-keyword"&gt;for&lt;/span&gt; tag, count &lt;span class="hljs-keyword"&gt;in&lt;/span&gt; m:
            db.incr(tag, count)
        m.clear()&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;now instead of &lt;code&gt;db.incr(tag, 1)&lt;/code&gt; eight hundred thousand times, you do &lt;code&gt;db.incr(tag, count)&lt;/code&gt; where count could be hundreds or thousands. you&amp;#39;ve slashed the db calls by a massive factor.&lt;/p&gt;
&lt;p&gt;but should you trigger the flush on time or frequency? time-based (every 5 minutes) has a risk — what if a surge of posts comes in and your map fills up so much that you run out of memory and the process crashes? frequency-based (every 1000 messages) is safer in that regard. pick based on your appetite for risk.&lt;/p&gt;
&lt;p&gt;one small but crucial thing — &lt;strong&gt;write the buffer to disk instead of purely in-memory&lt;/strong&gt;. if the worker crashes, in-memory data is gone. an embedded db like rocksdb or leveldb that supports &lt;code&gt;incr&lt;/code&gt; operations gives you durability without much complexity.&lt;/p&gt;
&lt;details class="recall"&gt;&lt;summary&gt;&lt;span class="recall-mark"&gt;&amp;#10045; RECALL&lt;/span&gt; your hashtag counter is doing 800k db increments a minute. how do you slash that, and what are the two risks in the fix?&lt;/summary&gt;&lt;p&gt;buffer counts in a map and flush periodically — one &lt;code&gt;db.incr(tag, count)&lt;/code&gt; instead of count separate calls. risk one: a purely time-based flush can let the map grow unbounded during a surge and oom the process, so a count-based trigger is safer. risk two: in-memory data dies with the worker, so back the buffer with an embedded store like rocksdb that supports incr — durability without much complexity.&lt;/p&gt;&lt;/details&gt;

&lt;h2 id="the-stop-the-world-problem"&gt;the stop-the-world problem&lt;/h2&gt;
&lt;p&gt;here&amp;#39;s where it gets really interesting. when you flush the buffer, you&amp;#39;re iterating through the map and making sequential db calls. during this time, &lt;strong&gt;you&amp;#39;ve stopped consuming from kafka&lt;/strong&gt;. if the flush takes 5 seconds (say 1000 tags × 5ms per call), that&amp;#39;s 5 seconds of zero consumption. bad.&lt;/p&gt;
&lt;p&gt;so you think — let me do this in a separate thread. but now the map is shared between two threads: the consumer thread (writing to the map) and the flusher thread (reading from it). if you try to iterate and modify the map simultaneously, you get concurrent modification exceptions. if you take a lock, you&amp;#39;re back to stopping the world.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;deep copy approach:&lt;/strong&gt; take a lock, deep copy the map, clear the original, release the lock, then flush the copy in a thread. the critical section is now just the deep copy + clear, not the entire flush. much better. but deep copy takes time and doubles your memory.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;the two-buffer swap — minimal stop the world:&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;this is the elegant solution. think of it like a mcdonald&amp;#39;s coke fountain competition — you have two glasses. while you&amp;#39;re drinking from one, you fill the other. apply this to buffers.&lt;/p&gt;
&lt;p&gt;you always write to the active buffer. when it&amp;#39;s time to flush, you &lt;strong&gt;swap references&lt;/strong&gt; — &lt;code&gt;ma, mp = mp, ma&lt;/code&gt;. that&amp;#39;s it. three CPU instructions. your critical section is literally a variable swap. no deep copy, no doubled memory, no long lock times.&lt;/p&gt;
&lt;div class="codeblock"&gt;&lt;pre&gt;&lt;code class="hljs"&gt;if count == 1000:
    mu.lock()
        ma, mp = mp, ma
    mu.unlock()
    go writeToDB(mp)&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;the flusher thread works on the passive buffer at its own pace while the consumer keeps writing to the now-active buffer. when the flush is done, swap again. this is the same pattern used in production systems handling petabytes of data — google&amp;#39;s dataproc remote shuffle service used exactly this to avoid stopping consumption while writing to remote storage.&lt;/p&gt;
&lt;p&gt;&lt;img src="https://raw.githubusercontent.com/tautik/image-assets/main/buffer-swap.png" alt="two-buffer swap: active buffer accepts writes while passive buffer flushes; the three pseudocode variants stacked side by side"&gt;&lt;/p&gt;
&lt;details class="recall"&gt;&lt;summary&gt;&lt;span class="recall-mark"&gt;&amp;#10045; RECALL&lt;/span&gt; flushing the buffer inline stops kafka consumption, a flusher thread causes concurrent modification, and locking the map stops the world again. how does the two-buffer swap escape all three?&lt;/summary&gt;&lt;p&gt;keep an active and a passive buffer. the consumer always writes to active; at flush time you take the lock only to swap the two references — three cpu instructions — then a background thread drains the passive buffer at its own pace while consumption continues uninterrupted. no deep copy, no doubled memory, a near-zero critical section. same pattern google&amp;#39;s dataproc shuffle service used at petabyte scale.&lt;/p&gt;&lt;/details&gt;

&lt;h2 id="approach-3-repartitioning-by-hashtag"&gt;approach 3: repartitioning by hashtag&lt;/h2&gt;
&lt;p&gt;one more thing. the kafka topic &lt;code&gt;POST_PUBLISH&lt;/code&gt; is partitioned by post ID. so two posts containing &lt;code&gt;#sunset&lt;/code&gt; could end up on two different worker machines. each does &lt;code&gt;incr(sunset, 1)&lt;/code&gt; independently instead of one doing &lt;code&gt;incr(sunset, 2)&lt;/code&gt;. not the absolute minimum db calls.&lt;/p&gt;
&lt;p&gt;if you want to minimize further, you write an &lt;strong&gt;adapter&lt;/strong&gt; that reads from &lt;code&gt;POST_PUBLISH&lt;/code&gt;, extracts hashtags, and writes to a new kafka topic &lt;code&gt;POST_HASHTAG&lt;/code&gt; &lt;strong&gt;partitioned by hashtag&lt;/strong&gt;. now all &lt;code&gt;#sunset&lt;/code&gt; events land on the same consumer. your batching is maximally efficient.&lt;/p&gt;
&lt;p&gt;&lt;img src="https://raw.githubusercontent.com/tautik/image-assets/main/repartition-adapter.png" alt="repartition adapter: POST_PUBLISH (partitioned by post_id) → hashtag extractor → POST_HASHTAG (partitioned by hashtag)"&gt;&lt;/p&gt;
&lt;p&gt;but — this adds another kafka topic, more infra to manage, more cost. the benefit should outweigh the operational complexity. for most cases, the two-buffer batching approach is good enough. don&amp;#39;t over-engineer.&lt;/p&gt;
&lt;h2 id="read-path-optimization-cdn"&gt;read path optimization: CDN&lt;/h2&gt;
&lt;p&gt;one last thing on the read side. for a given hashtag, the count doesn&amp;#39;t change every second. the top 100 photos don&amp;#39;t rotate every minute. this data is relatively stable. perfect candidate for a &lt;strong&gt;CDN&lt;/strong&gt;. stick a CDN in front of your hashtag API, set a reasonable TTL, and most requests never even hit your backend.&lt;/p&gt;
&lt;p&gt;since the hashtag page has no personalization — &lt;code&gt;#sunset&lt;/code&gt; looks the same for everyone — there&amp;#39;s no reason not to cache it on CDN. anything on CDN, assume it&amp;#39;s public.&lt;/p&gt;
&lt;h2 id="the-full-picture"&gt;the full picture&lt;/h2&gt;
&lt;p&gt;zooming out — you can see how every piece slots together. post service publishes events, an adapter repartitions them by hashtag, counting workers batch and update the partitioned db, the hashtag API reads behind a CDN. read path and write path optimized independently.&lt;/p&gt;
&lt;p&gt;&lt;img src="https://raw.githubusercontent.com/tautik/image-assets/main/full-architecture.png" alt="full hashtag system end-to-end: post svc, kafka, repartition adapter, counting workers, partitioned db, CDN, hashtag API"&gt;&lt;/p&gt;
&lt;h2 id="key-takeaways"&gt;key takeaways&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;kafka as a glue&lt;/strong&gt; binding services together. post service publishes, hashtag workers consume, feed/notification/search services also consume.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;adapter pattern&lt;/strong&gt; for repartitioning — if you&amp;#39;re unhappy with the partition key, write a relay agent that reads and repartitions.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;read path vs write path&lt;/strong&gt; — separate them, optimize them independently. reads get replicas, caches, CDNs. writes get batching, buffering, partitioning.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;wear three hats&lt;/strong&gt; — architect, product manager, engineer. a senior engineer proposes product changes that simplify the system.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;and the most important takeaway? &lt;strong&gt;don&amp;#39;t skip steps&lt;/strong&gt;. don&amp;#39;t jump to the complex solution because it sounds impressive. start simple. measure. find the actual bottleneck. then optimize. most people love being at the peak of the complexity curve. but just one extra push of thinking can simplify your solution dramatically.&lt;/p&gt;
</content>
  </entry>
  <entry>
    <title>designing the unread message count</title>
    <link href="https://tautik.me/ig-unread-message/"/>
    <id>https://tautik.me/ig-unread-message/</id>
    <updated>2026-04-20T00:00:00Z</updated>
    <content type="html">&lt;h1&gt;the tiny number on your messaging icon: designing the unread sender count&lt;/h1&gt;
&lt;p&gt;a system that looks deceptively simple on the surface but hides a bunch of interesting engineering decisions underneath. you know that little number on your messaging icon — the one that tells you how many new people messaged you. let&amp;#39;s walk through how to actually build it.&lt;/p&gt;
&lt;h2 id="what-you-39-ll-take-away"&gt;what you&amp;#39;ll take away&lt;/h2&gt;
&lt;p&gt;quick pointers so you know what to look for as you read:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;composite indexes win or lose by column order.&lt;/strong&gt; &lt;code&gt;(to, ts, from)&lt;/code&gt; is one query plan, &lt;code&gt;(ts, to, from)&lt;/code&gt; is a full scan in disguise.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;it&amp;#39;s not an anti-pattern to optimize for your most critical query.&lt;/strong&gt; hyper-specific indexes are fine if they serve the product.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;the punching bag pattern protects critical components from redundant load.&lt;/strong&gt; drop ops you know won&amp;#39;t change state before they hit the core.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;start simple. measure. then optimize.&lt;/strong&gt; don&amp;#39;t jump to redis on day zero if a single SQL query handles it.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2 id="the-requirement"&gt;the requirement&lt;/h2&gt;
&lt;p&gt;you know that little number on the messaging icon in linkedin, twitter, instagram? that&amp;#39;s not the total number of unread messages. it&amp;#39;s the number of &lt;strong&gt;unique people&lt;/strong&gt; from whom you received new messages since you last tapped on the icon.&lt;/p&gt;
&lt;p&gt;so if your friend sends you 100 messages, the indicator shows &lt;code&gt;1&lt;/code&gt;, not &lt;code&gt;100&lt;/code&gt;. if three different people each send you messages, it shows &lt;code&gt;3&lt;/code&gt;. you tap the icon, the counter resets. next time someone messages you, it starts counting again.&lt;/p&gt;
&lt;p&gt;sounds simple. let&amp;#39;s see.&lt;/p&gt;
&lt;h2 id="approach-1-count-on-the-fly"&gt;approach 1: count on the fly&lt;/h2&gt;
&lt;p&gt;your schema in mysql (or any relational db) has three tables — &lt;code&gt;users&lt;/code&gt;, &lt;code&gt;messages&lt;/code&gt;, and &lt;code&gt;user_activity&lt;/code&gt;. &lt;code&gt;user_activity.last_read_at&lt;/code&gt; resets to current time whenever the user taps the message icon. the query to get the unread count is literally:&lt;/p&gt;
&lt;div class="codeblock"&gt;&lt;span class="code-lang"&gt;sql&lt;/span&gt;&lt;pre&gt;&lt;code class="hljs language-sql"&gt;&lt;span class="hljs-keyword"&gt;SELECT&lt;/span&gt; &lt;span class="hljs-built_in"&gt;COUNT&lt;/span&gt;(&lt;span class="hljs-keyword"&gt;UNIQUE&lt;/span&gt;(&lt;span class="hljs-keyword"&gt;from&lt;/span&gt;))
&lt;span class="hljs-keyword"&gt;FROM&lt;/span&gt; messages
&lt;span class="hljs-keyword"&gt;WHERE&lt;/span&gt; &lt;span class="hljs-keyword"&gt;to&lt;/span&gt; &lt;span class="hljs-operator"&gt;=&lt;/span&gt; ?
&lt;span class="hljs-keyword"&gt;AND&lt;/span&gt; &lt;span class="hljs-type"&gt;timestamp&lt;/span&gt; &lt;span class="hljs-operator"&gt;&amp;gt;&lt;/span&gt; last_read_at&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;that&amp;#39;s it. what looks like a super complicated system is literally this one query. at small to medium scale, this works just fine.&lt;/p&gt;
&lt;p&gt;&lt;img src="https://raw.githubusercontent.com/tautik/image-assets/main/unread-schema-and-query.png" alt="messages schema (id, from, to, msg, ts) + user_activity.last_read_at watermark + the COUNT(UNIQUE(from)) query"&gt;&lt;/p&gt;
&lt;p&gt;but will this scale? that depends entirely on your indexing strategy. and this is where most people mess up.&lt;/p&gt;
&lt;h2 id="the-indexing-deep-dive"&gt;the indexing deep dive&lt;/h2&gt;
&lt;p&gt;let&amp;#39;s say you blindly create individual indexes on &lt;code&gt;to&lt;/code&gt; and &lt;code&gt;timestamp&lt;/code&gt;. seems reasonable right? here&amp;#39;s how the query evaluates.&lt;/p&gt;
&lt;p&gt;take user C who was last online at timestamp &lt;code&gt;11:11&lt;/code&gt;. the query fires &lt;code&gt;WHERE to = C AND timestamp &amp;gt; 11:11&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;the &lt;code&gt;to&lt;/code&gt; index (B+ tree, ordered by &lt;code&gt;to&lt;/code&gt; then &lt;code&gt;id&lt;/code&gt;) gives you all messages ever sent to C. could be millions of rows across 10 years. the &lt;code&gt;timestamp&lt;/code&gt; index gives you all messages after &lt;code&gt;11:11&lt;/code&gt; — potentially the entire table if the timestamp is old enough. then the database does a &lt;strong&gt;set intersection&lt;/strong&gt; of these two result sets to find the matching rows.&lt;/p&gt;
&lt;p&gt;imagine millions of entries from the &lt;code&gt;to&lt;/code&gt; index intersected with millions from the &lt;code&gt;timestamp&lt;/code&gt; index. catastrophically slow.&lt;/p&gt;
&lt;details class="recall"&gt;&lt;summary&gt;&lt;span class="recall-mark"&gt;&amp;#10045; RECALL&lt;/span&gt; both &lt;code&gt;to&lt;/code&gt; and &lt;code&gt;timestamp&lt;/code&gt; had their own indexes. why was the query still catastrophically slow?&lt;/summary&gt;&lt;p&gt;each index alone produces a huge candidate set — &lt;em&gt;every&lt;/em&gt; message ever sent to C, and &lt;em&gt;every&lt;/em&gt; message after the watermark. neither narrows both predicates together, so the database must set-intersect millions of entries from each side before it can count anything.&lt;/p&gt;&lt;/details&gt;

&lt;h2 id="composite-index-to-the-rescue"&gt;composite index to the rescue&lt;/h2&gt;
&lt;p&gt;the fix is a &lt;strong&gt;composite index on &lt;code&gt;(to, timestamp, from)&lt;/code&gt;&lt;/strong&gt;. now when you fire &lt;code&gt;WHERE to = C AND timestamp &amp;gt; 11:11&lt;/code&gt;:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;O(log n)&lt;/strong&gt; — binary search to find where &lt;code&gt;to = C&lt;/code&gt; starts&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;skip&lt;/strong&gt; — within C&amp;#39;s entries, jump to where &lt;code&gt;timestamp &amp;gt; 11:11&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;k sequential reads&lt;/strong&gt; — scan only the matching entries&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;total: &lt;strong&gt;O(log n + k)&lt;/strong&gt; where k is the number of matching rows. no set intersection. no pointed lookups on the main table for the &lt;code&gt;from&lt;/code&gt; column (because it&amp;#39;s baked into the index). done.&lt;/p&gt;
&lt;p&gt;now compare:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;without &lt;code&gt;from&lt;/code&gt; in index:&lt;/strong&gt; &lt;code&gt;O(log n + k) + k × O(log n)&lt;/code&gt; — for each of the k matched rows, you do a pointed lookup on the main table to get the &lt;code&gt;from&lt;/code&gt; column for the &lt;code&gt;COUNT(UNIQUE(from))&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;with &lt;code&gt;from&lt;/code&gt; in index:&lt;/strong&gt; &lt;code&gt;O(log n + k)&lt;/code&gt; — everything you need is right there in the index.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;img src="https://raw.githubusercontent.com/tautik/image-assets/main/composite-index-skip.png" alt="(to, ts, from) composite index layout: binary search to find to=C, skip to where ts &amp;gt; 11:11, then sequential scan"&gt;&lt;/p&gt;
&lt;p&gt;the overhead? just 8 extra bytes per index entry for storing &lt;code&gt;from&lt;/code&gt;. even with 10 million messages, that&amp;#39;s 80 MB. peanuts compared to the compute savings.&lt;/p&gt;
&lt;p&gt;and yes, this index is hyper-optimized for this one query. that&amp;#39;s perfectly fine. &lt;strong&gt;it&amp;#39;s not an anti-pattern to optimize for your most critical query.&lt;/strong&gt; as long as it works for your product and business, do it.&lt;/p&gt;
&lt;p&gt;also — the &lt;strong&gt;order of columns in the composite index matters&lt;/strong&gt;. if you did &lt;code&gt;(timestamp, to, from)&lt;/code&gt; instead, the first column would match a huge range of timestamps, then you&amp;#39;d have to linearly scan through all of them to filter by &lt;code&gt;to&lt;/code&gt;. completely defeats the purpose. the index should be ordered to match your query&amp;#39;s selectivity — &lt;code&gt;to&lt;/code&gt; first (narrows to one user), then &lt;code&gt;timestamp&lt;/code&gt; (narrows to recent messages), then &lt;code&gt;from&lt;/code&gt; (avoids main table lookup).&lt;/p&gt;
&lt;details class="recall"&gt;&lt;summary&gt;&lt;span class="recall-mark"&gt;&amp;#10045; RECALL&lt;/span&gt; why does &lt;code&gt;(to, timestamp, from)&lt;/code&gt; make the query O(log n + k) while &lt;code&gt;(timestamp, to, from)&lt;/code&gt; degenerates into a scan — and what does carrying &lt;code&gt;from&lt;/code&gt; in the index buy you?&lt;/summary&gt;&lt;p&gt;the equality column must lead: &lt;code&gt;to = C&lt;/code&gt; binary-searches to one user&amp;#39;s slice, then the range on &lt;code&gt;timestamp&lt;/code&gt; skips within it. timestamp-first matches a huge range that still has to be linearly filtered by &lt;code&gt;to&lt;/code&gt;. carrying &lt;code&gt;from&lt;/code&gt; means the count never touches the main table — without it you&amp;#39;d pay k extra pointed lookups, &lt;code&gt;k × O(log n)&lt;/code&gt;, for 8 bytes per entry saved.&lt;/p&gt;&lt;/details&gt;

&lt;details class="recall"&gt;&lt;summary&gt;&lt;span class="recall-mark"&gt;&amp;#10045; RECALL&lt;/span&gt; the &lt;code&gt;(to, timestamp, from)&lt;/code&gt; index exists to serve exactly one query. is that an anti-pattern?&lt;/summary&gt;&lt;p&gt;no — hyper-specific indexes are fine when they serve your most critical query. judge the index by what it does for the product, not by how general it is. the column order just follows the query&amp;#39;s selectivity (equality first, range second, payload last), and as long as that works for your product and business, optimizing for one query is a feature, not a smell.&lt;/p&gt;&lt;/details&gt;

&lt;h2 id="approach-2-pre-computed-with-redis"&gt;approach 2: pre-computed with redis&lt;/h2&gt;
&lt;p&gt;the on-the-fly approach works at decent scale. but if you want to pre-compute, here&amp;#39;s how it shapes up.&lt;/p&gt;
&lt;p&gt;you need a way to know whether a message was actually delivered or not. websockets help — if the user is connected, the message is delivered live and doesn&amp;#39;t count. if the user is offline, the message is undelivered and that&amp;#39;s when the counter should bump. so you need an online/offline service that knows the websocket state of every user.&lt;/p&gt;
&lt;p&gt;whenever a message can&amp;#39;t be delivered in real-time (user is offline or inactive), an &lt;code&gt;ON_MSG_UNSENT&lt;/code&gt; event hits kafka partitioned by receiver ID. workers consume it and fire &lt;code&gt;SADD&lt;/code&gt; on redis — adding the sender to the receiver&amp;#39;s set.&lt;/p&gt;
&lt;p&gt;so if D sends A 10 messages, the worker fires &lt;code&gt;SADD A D&lt;/code&gt; ten times. but since it&amp;#39;s a set, D only appears once. the count? just &lt;code&gt;SLEN A&lt;/code&gt;. returns &lt;code&gt;3&lt;/code&gt; if A has messages from B, C, and D.&lt;/p&gt;
&lt;p&gt;when A taps the message icon: &lt;code&gt;DEL A&lt;/code&gt;. counter reset. done.&lt;/p&gt;
&lt;p&gt;the &lt;strong&gt;read path&lt;/strong&gt; is a single &lt;code&gt;SLEN&lt;/code&gt; call. the &lt;strong&gt;clear path&lt;/strong&gt; is a single &lt;code&gt;DEL&lt;/code&gt;. both O(1). the &lt;strong&gt;write path&lt;/strong&gt; is &lt;code&gt;SADD&lt;/code&gt; calls from workers.&lt;/p&gt;
&lt;details class="recall"&gt;&lt;summary&gt;&lt;span class="recall-mark"&gt;&amp;#10045; RECALL&lt;/span&gt; the single SQL query already works. what extra machinery does the redis pre-compute drag in, and when is moving to it actually justified?&lt;/summary&gt;&lt;p&gt;pre-computing forces you to know whether a message was &lt;em&gt;delivered&lt;/em&gt; — so you now need websocket online/offline state, kafka events for undelivered messages, and workers firing &lt;code&gt;SADD&lt;/code&gt;. that&amp;#39;s a lot of moving parts versus one well-indexed query. it&amp;#39;s justified only when the on-the-fly query measurably hits its ceiling: start simple, measure, then optimize.&lt;/p&gt;&lt;/details&gt;

&lt;h2 id="the-punching-bag-pattern"&gt;the punching bag pattern&lt;/h2&gt;
&lt;p&gt;now here&amp;#39;s the micro-optimization. if D sends A 100 messages, the worker fires &lt;code&gt;SADD A D&lt;/code&gt; a hundred times. but after the first one, D is already in the set. the remaining 99 are &lt;strong&gt;redundant operations&lt;/strong&gt; — they don&amp;#39;t change the data but they still consume redis resources.&lt;/p&gt;
&lt;p&gt;at peak load, imagine 90% of your commands are redundant. your redis cluster is both read-heavy (polling for status) and write-heavy (ingesting events). every unnecessary command adds up.&lt;/p&gt;
&lt;p&gt;the &lt;strong&gt;punching bag pattern&lt;/strong&gt; is about protecting your critical component. you add an auxiliary redis replica in front. before firing &lt;code&gt;SADD&lt;/code&gt; on the primary, you check the replica — is this member already in the set? if yes, skip. if no, write to primary.&lt;/p&gt;
&lt;p&gt;&lt;img src="https://raw.githubusercontent.com/tautik/image-assets/main/redis-punching-bag.png" alt="punching bag pattern: ON_MSG_UNSENT → status update workers → check auxiliary redis (replica) before SADD on primary cluster"&gt;&lt;/p&gt;
&lt;p&gt;this isn&amp;#39;t your day-zero solution. it&amp;#39;s a day-n optimization when you observe that a huge percentage of writes are redundant. the pattern shows up everywhere — rate limiters are essentially punching bags that absorb load before it hits your core system.&lt;/p&gt;
&lt;p&gt;two flavors of the punching bag:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;streaming buffer&lt;/strong&gt; — batch and buffer writes before they hit the db&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;check-and-set&lt;/strong&gt; — discard redundant operations before they reach the critical component&lt;/li&gt;
&lt;/ul&gt;
&lt;details class="recall"&gt;&lt;summary&gt;&lt;span class="recall-mark"&gt;&amp;#10045; RECALL&lt;/span&gt; D sends A 100 messages while A is offline. how many of the 100 &lt;code&gt;SADD&lt;/code&gt;s actually change state — and how does the punching bag absorb the rest?&lt;/summary&gt;&lt;p&gt;exactly one — set membership is idempotent, so the other 99 are redundant load. the punching bag puts an auxiliary replica in front of the primary: check membership there first, and only write through when the member is new (the check-and-set flavor). a day-n optimization you add when you &lt;em&gt;measure&lt;/em&gt; that most writes are redundant.&lt;/p&gt;&lt;/details&gt;

&lt;h2 id="key-takeaways"&gt;key takeaways&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;start with a single SQL query.&lt;/strong&gt; at small to medium scale, &lt;code&gt;SELECT COUNT(UNIQUE(from)) FROM messages WHERE to = ? AND timestamp &amp;gt; last_read_at&lt;/code&gt; literally is the system. don&amp;#39;t overcomplicate.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;composite indexes are surgical tools.&lt;/strong&gt; column order is everything. &lt;code&gt;(to, ts, from)&lt;/code&gt; makes the query O(log n + k). reorder the columns and you&amp;#39;ve built a beautifully indexed full table scan.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;including columns in the index avoids main table lookups.&lt;/strong&gt; 8 extra bytes per row to skip k pointed lookups is one of the cheapest trades in databases.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;redis pre-computation is a day-n move.&lt;/strong&gt; only when the on-the-fly query hits its ceiling.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;the punching bag pattern saves your critical component&lt;/strong&gt; from doing work it doesn&amp;#39;t need to do. drop redundant ops before they hit the core.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;and the bigger picture — &lt;strong&gt;identify the read path and write path, optimize them independently&lt;/strong&gt;. for reads: indexes, replicas, caches, pre-computed counts. for writes: batching, buffering, punching bags to absorb redundancy.&lt;/p&gt;
&lt;p&gt;nothing is best. everything depends on the usecase. and the answer almost always lies in the gray area — not purely in engineering, not purely in product, but in that sweet spot where both work together.&lt;/p&gt;
</content>
  </entry>
  <entry>
    <title>bit.ly system design — building a url shortener</title>
    <link href="https://tautik.me/url-shortener/"/>
    <id>https://tautik.me/url-shortener/</id>
    <updated>2026-04-05T00:00:00Z</updated>
    <content type="html">&lt;p&gt;every system design conversation eventually circles back to bit.ly. it&amp;#39;s the canonical &amp;quot;looks dead simple but isn&amp;#39;t&amp;quot; service — take a long url, hand back a short one, redirect on click. the surface is straightforward. then you start asking how it scales to a billion urls and 100M daily active users, and the design gets way more interesting.&lt;/p&gt;
&lt;p&gt;this is a full walkthrough — requirements, api, the dumb-first-design, then the deep dives where things actually get fun. i&amp;#39;m gonna walk through it as if i&amp;#39;m building it myself, talking through each decision as it comes up.&lt;/p&gt;
&lt;h2 id="what-you-39-ll-take-away"&gt;what you&amp;#39;ll take away&lt;/h2&gt;
&lt;p&gt;quick pointers so you know what to look for as you read:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;the read/write asymmetry is wild.&lt;/strong&gt; 1000:1 reads-to-writes. design around reads first.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;counter + base62 is the cleanest short-code generator.&lt;/strong&gt; no collision checks, no extra reads.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;6 base62 chars get you 56 billion urls.&lt;/strong&gt; plenty of room for the next decade.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;redis as a counter is the secret weapon.&lt;/strong&gt; single-threaded, atomic incr — perfect for this.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;counter batching kills the cross-network latency.&lt;/strong&gt; grab 1000 ids at a time, use them locally.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;302 redirect, not 301.&lt;/strong&gt; keeps control on our side, allows analytics, doesn&amp;#39;t get cached forever.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;boring is fine.&lt;/strong&gt; postgres, redis, a load balancer. nothing fancy is needed for this whole system.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="what-we-39-re-building"&gt;what we&amp;#39;re building&lt;/h2&gt;
&lt;p&gt;a url shortener. user sends in &lt;code&gt;https://www.example.com/some/very/long/url&lt;/code&gt;, we hand back something like &lt;code&gt;short.ly/abc123&lt;/code&gt;. anyone who hits that short url gets bounced to the original. plus a couple of optional flavors:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;custom alias&lt;/strong&gt; — user picks the short code (&lt;code&gt;short.ly/evan&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;expiration time&lt;/strong&gt; — short url stops working after a date&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;scope-wise we&amp;#39;re skipping user accounts, click analytics, and spam detection. they add complexity without changing the core architecture.&lt;/p&gt;
&lt;h2 id="what-the-system-has-to-do"&gt;what the system has to do&lt;/h2&gt;
&lt;p&gt;requirements come in two flavors. functional — the features. non-functional — the qualities.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;functional:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;create a short url from a long url, optionally with custom alias and expiration&lt;/li&gt;
&lt;li&gt;redirect from short url to the original&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;non-functional:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;short codes are unique. one short code maps to one long url, no collisions ever.&lt;/li&gt;
&lt;li&gt;redirects feel instant — under ~200ms end to end.&lt;/li&gt;
&lt;li&gt;99.99% available. we lean availability over consistency.&lt;/li&gt;
&lt;li&gt;handles 1B short urls total and 100M daily active users.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;now the most important fact about this whole system, the one that drives every architectural decision later: &lt;strong&gt;read/write traffic is wildly asymmetric&lt;/strong&gt;. one user creates a short url, then potentially millions click it. typical ratio is &lt;code&gt;1000 reads per 1 write&lt;/code&gt;. caching strategy, service topology, database choice — all of it falls out of that single number.&lt;/p&gt;
&lt;details class="recall"&gt;&lt;summary&gt;&lt;span class="recall-mark"&gt;&amp;#10045; RECALL&lt;/span&gt; what single fact about a url shortener&amp;#39;s traffic drives every other design decision, and what falls out of it?&lt;/summary&gt;&lt;p&gt;the read/write asymmetry — roughly 1000 reads for every write, because one person creates a link and millions click it. once you internalize that, the architecture falls out: design the read path first (index, cache, maybe a cdn at the edge), split read and write services so the hot side autoscales alone, and let the roughly-one-write-per-second side stay tiny and boring.&lt;/p&gt;&lt;/details&gt;

&lt;h2 id="core-entities"&gt;core entities&lt;/h2&gt;
&lt;p&gt;before drawing boxes, name the things our system moves around.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;original url&lt;/strong&gt; — the long thing the user gave us&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;short url&lt;/strong&gt; (or short code) — what we generated&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;user&lt;/strong&gt; — who created it&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;really two entities, since short and long urls live in the same row of the same table. user is auxiliary.&lt;/p&gt;
&lt;h2 id="the-api"&gt;the api&lt;/h2&gt;
&lt;p&gt;two endpoints. one to shorten, one to redirect.&lt;/p&gt;
&lt;div class="codeblock"&gt;&lt;pre&gt;&lt;code class="hljs"&gt;POST /urls
{
  "long_url": "https://www.example.com/some/very/long/url",
  "custom_alias": "optional",
  "expiration_date": "optional"
}
→ { "short_url": "http://short.ly/abc123" }&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;div class="codeblock"&gt;&lt;pre&gt;&lt;code class="hljs"&gt;GET /{short_code}
→ HTTP 302 redirect to the original long url&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;dead simple. no fancy verbs, no nested resources. one post, one get.&lt;/p&gt;
&lt;h2 id="the-dumb-first-design"&gt;the dumb-first design&lt;/h2&gt;
&lt;p&gt;start as small as possible — client, server, database. that&amp;#39;s it.&lt;/p&gt;
&lt;p&gt;&lt;img src="https://raw.githubusercontent.com/tautik/image-assets/main/basic-flow.png" alt="basic bit.ly architecture — client posts to a primary server, server reads/writes a single database for both shortening and redirecting"&gt;&lt;/p&gt;
&lt;p&gt;on a write:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;client &lt;code&gt;POST&lt;/code&gt;s to the primary server with the long url&lt;/li&gt;
&lt;li&gt;server validates the url (something like an &lt;code&gt;is-url&lt;/code&gt; check)&lt;/li&gt;
&lt;li&gt;server generates a short code (we&amp;#39;ll get to &lt;em&gt;how&lt;/em&gt; in a sec — this is the fun part)&lt;/li&gt;
&lt;li&gt;if the user gave us a custom alias, we use that — but we first check the db to make sure it&amp;#39;s not already taken. nightmare scenario is a custom alias colliding with a generated code in the future. easy fix: prefix all generated codes with a character that aliases can&amp;#39;t use, or keep them in different namespaces.&lt;/li&gt;
&lt;li&gt;server writes &lt;code&gt;(short_code, long_url, created_at, expires_at, created_by)&lt;/code&gt; to the db&lt;/li&gt;
&lt;li&gt;server returns the short url to the client&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;on a read:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;user&amp;#39;s browser hits &lt;code&gt;short.ly/abc123&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;server looks up &lt;code&gt;abc123&lt;/code&gt; in the db&lt;/li&gt;
&lt;li&gt;if it&amp;#39;s there and not expired, server returns a &lt;code&gt;302 Found&lt;/code&gt; with the long url in the &lt;code&gt;Location&lt;/code&gt; header&lt;/li&gt;
&lt;li&gt;browser follows the redirect, user lands on the original site&lt;/li&gt;
&lt;li&gt;if expired, return &lt;code&gt;410 Gone&lt;/code&gt;. if missing entirely, &lt;code&gt;404&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;quick aside on &lt;code&gt;301&lt;/code&gt; vs &lt;code&gt;302&lt;/code&gt;. &lt;code&gt;301&lt;/code&gt; is &amp;quot;permanent&amp;quot; — browsers and intermediate caches will cache the redirect, future requests might never hit our server. &lt;code&gt;302&lt;/code&gt; is temporary — every request comes through us.&lt;/p&gt;
&lt;p&gt;we want &lt;code&gt;302&lt;/code&gt;. why?&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;it lets us update or expire short urls without fighting browser caches&lt;/li&gt;
&lt;li&gt;if we ever want analytics (clicks, geo, referrer), every request needs to come through us&lt;/li&gt;
&lt;li&gt;the cost of a server hit per redirect is way smaller than losing observability&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;right, working system. the requirements aren&amp;#39;t fully met yet though — we hand-waved short code generation, redirects aren&amp;#39;t fast, we can&amp;#39;t scale. let&amp;#39;s actually get to the interesting parts.&lt;/p&gt;
&lt;details class="recall"&gt;&lt;summary&gt;&lt;span class="recall-mark"&gt;&amp;#10045; RECALL&lt;/span&gt; why return a &lt;code&gt;302&lt;/code&gt; redirect instead of the &amp;quot;more correct&amp;quot; permanent &lt;code&gt;301&lt;/code&gt;?&lt;/summary&gt;&lt;p&gt;a 301 gets cached by browsers and intermediaries, so future clicks may never reach your server — you lose the ability to update or expire short urls and you lose every click for analytics. a 302 keeps each request flowing through you: control, expiration, observability. the cost of one server hit per redirect is tiny next to going blind.&lt;/p&gt;&lt;/details&gt;

&lt;h2 id="deep-dive-1-generating-unique-short-codes"&gt;deep dive 1 — generating unique short codes&lt;/h2&gt;
&lt;p&gt;three properties we want:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;unique&lt;/strong&gt; — never collide&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;short&lt;/strong&gt; — 5–7 characters&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;fast to generate&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;let&amp;#39;s walk a few options.&lt;/p&gt;
&lt;h3 id="option-1-random-check"&gt;option 1: random + check&lt;/h3&gt;
&lt;p&gt;generate a random number, base62-encode it, slice the first 6 characters.&lt;/p&gt;
&lt;p&gt;base62 encoding is a numbering system that uses &lt;code&gt;0-9, a-z, A-Z&lt;/code&gt; for 62 total symbols. 6 base62 characters gives &lt;code&gt;62^6 ≈ 56 billion&lt;/code&gt; combinations. lots of room.&lt;/p&gt;
&lt;p&gt;problem: random isn&amp;#39;t unique. how often will two random short codes collide? more often than you&amp;#39;d think. this is the &lt;strong&gt;birthday paradox&lt;/strong&gt; in action — in a room of just 23 people there&amp;#39;s already a 50% chance two share a birthday despite 365 possible birthdays. apply the same math to 1B short codes randomly chosen from 56B options and you get roughly &lt;strong&gt;880k collisions&lt;/strong&gt;. not catastrophic but not zero.&lt;/p&gt;
&lt;p&gt;so we&amp;#39;d need a db check before saving — generate a candidate, look it up, if it exists try again, otherwise save. that adds a read on every write. not great.&lt;/p&gt;
&lt;h3 id="option-2-hash-the-long-url"&gt;option 2: hash the long url&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;hash(canonicalize(long_url))&lt;/code&gt;, take the first 6 base62 chars. md5, murmur, sha-256, whatever.&lt;/p&gt;
&lt;p&gt;a good hash function has the avalanche property — change one bit on the input and the output looks completely different. so the collision behavior is the same as random: 56B possible outputs, same ~880k collisions at 1B urls. same db check needed.&lt;/p&gt;
&lt;p&gt;what&amp;#39;s nice about hashing — same long url always maps to the same short code, so we get deduplication for free. what&amp;#39;s &lt;em&gt;not&lt;/em&gt; nice — most url shorteners actually want multiple short codes per long url (different users want different aliases, different expirations, separate analytics). dedup is often a feature you don&amp;#39;t want.&lt;/p&gt;
&lt;h3 id="option-3-counter-base62-the-winner"&gt;option 3: counter + base62 — the winner&lt;/h3&gt;
&lt;p&gt;just keep a counter. first url gets short code 1, second gets 2, third gets 3. base62-encode the counter to keep things compact.&lt;/p&gt;
&lt;p&gt;the counter guarantees uniqueness by construction. no collision checks, no extra reads. the encoding keeps it short — at 1B urls, our short code is a 6-character string. quick math: &lt;code&gt;1,000,000,000&lt;/code&gt; in base62 is &lt;code&gt;15ftgG&lt;/code&gt;. and &lt;code&gt;62^6 ≈ 56 billion&lt;/code&gt;, so we don&amp;#39;t need to bump up to 7 characters until we cross that threshold (which would take a lifetime at any reasonable url shortener&amp;#39;s growth rate).&lt;/p&gt;
&lt;p&gt;&lt;img src="https://raw.githubusercontent.com/tautik/image-assets/main/shortcode-options.png" alt="three short-code strategies — random + check, hash long url, counter + base62 — counter wins because it skips the db read on every write"&gt;&lt;/p&gt;
&lt;p&gt;there&amp;#39;s one fair concern with the counter: it&amp;#39;s predictable. a competitor or scraper can iterate &lt;code&gt;1, 2, 3, ...&lt;/code&gt; and discover every short url we&amp;#39;ve generated. two ways to deal with this:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;accept it.&lt;/strong&gt; short urls are usually meant to be shared publicly anyway. rate-limit and move on.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;scramble the counter&lt;/strong&gt; before encoding. xor with a secret key, or use a &amp;quot;bijective&amp;quot; function (the squids library is one) that maps &lt;code&gt;1 → &amp;quot;Xa3kL9&amp;quot;&lt;/code&gt; reversibly but unpredictably. you keep the uniqueness, you lose the predictability.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;we&amp;#39;re going with the counter. cleanest, fastest, no read amplification on writes.&lt;/p&gt;
&lt;details class="recall"&gt;&lt;summary&gt;&lt;span class="recall-mark"&gt;&amp;#10045; RECALL&lt;/span&gt; why does counter + base62 beat random codes and hashing the long url for short-code generation?&lt;/summary&gt;&lt;p&gt;random and hash both collide — birthday paradox, so at a billion urls you&amp;#39;re looking at hundreds of thousands of collisions — which forces a db existence check before every save: read amplification on the write path. a counter is unique by construction, no checks at all, and base62-encoding keeps it short, with six characters covering tens of billions of codes. the one cost is predictability; either accept it (short urls are shared publicly anyway, rate-limit and move on) or scramble the counter with a bijective function before encoding.&lt;/p&gt;&lt;/details&gt;

&lt;h2 id="deep-dive-2-making-redirects-fast"&gt;deep dive 2 — making redirects fast&lt;/h2&gt;
&lt;p&gt;the read path is &lt;code&gt;short_code → long_url&lt;/code&gt;. without optimization the server walks every row of the urls table on each lookup. at 1B rows that&amp;#39;s a non-starter — full table scan for every redirect. dead.&lt;/p&gt;
&lt;h3 id="step-1-index-the-short-code"&gt;step 1: index the short code&lt;/h3&gt;
&lt;p&gt;stick a primary key (or unique index) on &lt;code&gt;short_code&lt;/code&gt;. now the database keeps a b-tree of short codes pointing to row locations on disk. lookups become &lt;code&gt;O(log n)&lt;/code&gt; — typically a handful of memory reads followed by one disk seek to fetch the row.&lt;/p&gt;
&lt;p&gt;postgres does this automatically for primary keys. for a &lt;code&gt;WHERE short_code = ?&lt;/code&gt; lookup this is plenty fast — well under 10ms even at 1B rows.&lt;/p&gt;
&lt;h3 id="step-2-cache-the-hot-path"&gt;step 2: cache the hot path&lt;/h3&gt;
&lt;p&gt;even with an index, every lookup eventually touches disk. and a &lt;em&gt;huge&lt;/em&gt; fraction of our traffic is going to be a small number of viral short codes. that&amp;#39;s a caching layup.&lt;/p&gt;
&lt;p&gt;stick a redis (or memcached) instance in front of the db. on every read:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;check redis for &lt;code&gt;short_code → long_url&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;hit? return immediately — sub-millisecond&lt;/li&gt;
&lt;li&gt;miss? read from db, populate redis, return&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;eviction policy is &lt;strong&gt;least recently used&lt;/strong&gt; — if the cache fills, kick whatever&amp;#39;s been quiet longest. natural fit for url shorteners because old links go cold fast.&lt;/p&gt;
&lt;div class="codeblock"&gt;&lt;pre&gt;&lt;code class="hljs"&gt;key:   "abc123"
value: "https://www.example.com/long/url"&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;dead simple key-value lookup. redis hits sub-ms, db hits maybe 10ms. for hot urls we basically never touch the database. tie the cache TTL to the url&amp;#39;s expiration time so expired entries fall out automatically.&lt;/p&gt;
&lt;h3 id="step-3-optional-cache-at-the-edge"&gt;step 3 (optional): cache at the edge&lt;/h3&gt;
&lt;p&gt;for global users, even a redis hit means a round-trip to whatever region our service runs in. a user in tokyo hitting a virginia data center is eating 200ms just on the network.&lt;/p&gt;
&lt;p&gt;a &lt;strong&gt;CDN&lt;/strong&gt; (cloudflare, fastly, akamai) caches responses at edge servers worldwide. for popular short codes the redirect can be served from the tokyo edge in 10–20ms without ever reaching our origin.&lt;/p&gt;
&lt;p&gt;trade-offs:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;cache invalidation across many edge nodes is harder&lt;/li&gt;
&lt;li&gt;you lose visibility — the request never hits your server, so analytics get weird&lt;/li&gt;
&lt;li&gt;it costs money&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;worth it for the most-clicked links and globally distributed audiences. for everything else, a single redis layer in your primary region is plenty.&lt;/p&gt;
&lt;h2 id="deep-dive-3-scaling-to-1b-urls-and-100m-dau"&gt;deep dive 3 — scaling to 1B urls and 100M DAU&lt;/h2&gt;
&lt;p&gt;let&amp;#39;s do the back-of-envelope math.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;writes:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;1B urls total over the lifetime of the service&lt;/li&gt;
&lt;li&gt;assume linear-ish growth → roughly 100k new urls per day&lt;/li&gt;
&lt;li&gt;that&amp;#39;s &lt;code&gt;~1 url per second&lt;/code&gt; average. peaks maybe 10x. easy.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;reads:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;100M DAU × 1 redirect/user/day ≈ 100M redirects/day&lt;/li&gt;
&lt;li&gt;100M ÷ 86,400s ≈ &lt;code&gt;1,200 reads/sec&lt;/code&gt; average&lt;/li&gt;
&lt;li&gt;peaks 10x or 100x → &lt;code&gt;12k – 120k reads/sec&lt;/code&gt; at the upper bound&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;storage:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;per row: short_code (&lt;del&gt;8 bytes) + long_url (&lt;/del&gt;100 bytes) + timestamps (&lt;del&gt;16 bytes) + custom_alias (&lt;/del&gt;100 bytes) + metadata (~80 bytes) ≈ 300 bytes. round up to 500.&lt;/li&gt;
&lt;li&gt;500 bytes × 1B rows = &lt;code&gt;500 GB&lt;/code&gt;. fits on a single modern instance with room to spare.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;so the dataset isn&amp;#39;t the problem. the read throughput is. and we already solved most of that with caching. let&amp;#39;s wire up the rest.&lt;/p&gt;
&lt;h3 id="split-read-and-write-services"&gt;split read and write services&lt;/h3&gt;
&lt;p&gt;reads and writes have completely different traffic profiles:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;writes: &lt;code&gt;~1/sec&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;reads: &lt;code&gt;10k+/sec&lt;/code&gt;, possibly bursting to 100k/sec&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;scaling them together is wasteful. we&amp;#39;ll split them into two services behind an api gateway. the gateway routes &lt;code&gt;POST /urls&lt;/code&gt; to the &lt;strong&gt;write service&lt;/strong&gt; and &lt;code&gt;GET /{short_code}&lt;/code&gt; to the &lt;strong&gt;read service&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;each service horizontally scales independently. read service runs hot — many instances, all behind a load balancer, hitting redis cache and falling back to db. write service stays small — one or two instances handle the entire write load.&lt;/p&gt;
&lt;p&gt;is splitting really worth it? honestly, for a service this small, sometimes no. running two services means two deployments, two dashboards, two on-call rotations. but the read/write asymmetry here is so extreme that the split is genuinely useful — you can autoscale the read service on cpu/memory thresholds without ever touching the write service.&lt;/p&gt;
&lt;h3 id="the-global-counter-problem"&gt;the global counter problem&lt;/h3&gt;
&lt;p&gt;now that we&amp;#39;ve split into multiple write service instances, the counter has to live somewhere shared. you can&amp;#39;t have each instance keeping its own count — they&amp;#39;d all hand out short_code &lt;code&gt;1&lt;/code&gt; simultaneously.&lt;/p&gt;
&lt;p&gt;the answer is a &lt;strong&gt;central redis instance&lt;/strong&gt; holding the counter. redis is single-threaded, so its &lt;code&gt;INCR&lt;/code&gt; command is atomic — two simultaneous calls always get different values. one gets 1000, the next gets 1001, never the same one twice.&lt;/p&gt;
&lt;p&gt;every time the write service needs a new short code:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;code&gt;INCR counter&lt;/code&gt; on redis → returns next value&lt;/li&gt;
&lt;li&gt;base62-encode it&lt;/li&gt;
&lt;li&gt;write &lt;code&gt;(short_code, long_url, ...)&lt;/code&gt; to the db&lt;/li&gt;
&lt;li&gt;return short url to client&lt;/li&gt;
&lt;/ol&gt;
&lt;details class="recall"&gt;&lt;summary&gt;&lt;span class="recall-mark"&gt;&amp;#10045; RECALL&lt;/span&gt; multiple write instances each need globally unique ids. why is a single redis counter the answer, and what makes it safe?&lt;/summary&gt;&lt;p&gt;redis is single-threaded, so &lt;code&gt;INCR&lt;/code&gt; is atomic — two simultaneous calls can never receive the same value, which is exactly the uniqueness guarantee short codes need with zero coordination protocol. and a single redis box handles 100k+ ops/sec, while this system writes about one url per second — the counter is never the bottleneck. if redis loses a few values during failover, fine: the database&amp;#39;s unique constraint is the safety net.&lt;/p&gt;&lt;/details&gt;

&lt;h3 id="counter-batching-kill-the-per-write-network-hop"&gt;counter batching — kill the per-write network hop&lt;/h3&gt;
&lt;p&gt;doing a redis call on every single write is fine but wasteful. we can do better.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;batch counter ranges&lt;/strong&gt; to each write service instance. when a write service starts, it asks redis for a chunk of 1000 counter values:&lt;/p&gt;
&lt;div class="codeblock"&gt;&lt;pre&gt;&lt;code class="hljs"&gt;INCRBY counter 1000  → returns N (start of the batch)&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;the instance now owns counters &lt;code&gt;N&lt;/code&gt; through &lt;code&gt;N+999&lt;/code&gt; locally. it serves urls from this range with zero cross-network calls. when it runs out, it asks for the next 1000.&lt;/p&gt;
&lt;p&gt;if a write service crashes mid-batch, those unused counters are lost forever. who cares — 56 billion total slots, losing a few hundred is invisible. we just need uniqueness, not continuity.&lt;/p&gt;
&lt;details class="recall"&gt;&lt;summary&gt;&lt;span class="recall-mark"&gt;&amp;#10045; RECALL&lt;/span&gt; what does counter batching fix, and why is losing a batch when an instance crashes a non-issue?&lt;/summary&gt;&lt;p&gt;it kills the per-write network hop — instead of hitting redis for every url, an instance grabs a range of ids with one &lt;code&gt;INCRBY&lt;/code&gt; and burns through them locally, refilling when empty. a crash loses the unused remainder of the batch, and nobody cares: you need uniqueness, not continuity, and the base62 keyspace has tens of billions of slots to spare.&lt;/p&gt;&lt;/details&gt;

&lt;h3 id="multi-region-split-the-counter-space"&gt;multi-region — split the counter space&lt;/h3&gt;
&lt;p&gt;if the service runs in multiple regions (US, EU, APAC), having every region hit a single global counter is a latency disaster. instead, &lt;strong&gt;partition the counter space&lt;/strong&gt; so each region owns a slice:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;US gets &lt;code&gt;[0, 1B)&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;EU gets &lt;code&gt;[1B, 2B)&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;APAC gets &lt;code&gt;[2B, 3B)&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;each region runs its own redis with its own slice of the namespace. no cross-region coordination on the hot path. a &lt;code&gt;UNIQUE&lt;/code&gt; constraint on &lt;code&gt;short_code&lt;/code&gt; in the database is the ultimate safety net if anything ever drifts.&lt;/p&gt;
&lt;h3 id="the-database"&gt;the database&lt;/h3&gt;
&lt;p&gt;we said 500 GB on a single instance. that&amp;#39;s fine for postgres or mysql today — vertically scale to instances with multi-TB SSDs and hundreds of GB of ram easily. so we don&amp;#39;t actually need to shard.&lt;/p&gt;
&lt;p&gt;if we ever do need to shard (say, data grows past 5 TB), we&amp;#39;d shard by &lt;code&gt;short_code&lt;/code&gt; — &lt;code&gt;hash(short_code) % N&lt;/code&gt; to pick a shard. but for the next decade of growth, one well-tuned postgres handles this whole system.&lt;/p&gt;
&lt;p&gt;high availability matters though. one box dying takes down the entire product. so:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;read replicas&lt;/strong&gt; for redundancy and to absorb db reads if redis ever goes cold&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;regular snapshots&lt;/strong&gt; to s3 (or equivalent) for point-in-time recovery&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;automatic failover&lt;/strong&gt; — if the primary dies, promote a replica&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;for redis (counter and cache), redis sentinel or cluster mode handles failover. if redis loses the latest counter values during a failover, we lose a few short codes — fine, the database&amp;#39;s unique constraint catches anything that would actually collide.&lt;/p&gt;
&lt;h2 id="the-final-design"&gt;the final design&lt;/h2&gt;
&lt;p&gt;zooming out, here&amp;#39;s everything wired together.&lt;/p&gt;
&lt;p&gt;&lt;img src="https://raw.githubusercontent.com/tautik/image-assets/main/final-architecture.png" alt="full bit.ly architecture — api gateway routing to read and write services, redis cache and global counter, postgres database with replicas"&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;client&lt;/strong&gt; hits the &lt;strong&gt;api gateway&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;gateway routes writes to the &lt;strong&gt;write service&lt;/strong&gt; (small fleet)&lt;ul&gt;
&lt;li&gt;write service grabs a counter value (from its local batch, refilling from the global counter redis when empty)&lt;/li&gt;
&lt;li&gt;base62-encodes it, writes the row to the &lt;strong&gt;postgres database&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;gateway routes reads to the &lt;strong&gt;read service&lt;/strong&gt; (large fleet)&lt;ul&gt;
&lt;li&gt;read service checks the &lt;strong&gt;redis cache&lt;/strong&gt; first&lt;/li&gt;
&lt;li&gt;on miss, reads the database, populates cache, returns&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;read service responds with &lt;code&gt;302&lt;/code&gt; and the long url&lt;/li&gt;
&lt;li&gt;browser follows the redirect&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;every component scales independently. read service autoscales on traffic spikes without touching writes. cache absorbs the hot path. database serves only cache misses. counter is durable, atomic, and never a bottleneck thanks to batching.&lt;/p&gt;
&lt;details class="recall"&gt;&lt;summary&gt;&lt;span class="recall-mark"&gt;&amp;#10045; RECALL&lt;/span&gt; what&amp;#39;s actually &amp;quot;fancy&amp;quot; in the final bit.ly design — and what should that teach you?&lt;/summary&gt;&lt;p&gt;nothing — postgres, redis, an api gateway, a load balancer. the one structural flourish, the read/write service split, is earned by the extreme traffic asymmetry; everything else stays deliberately boring. no sharding for a dataset that fits one box, no microservices for a one-write-per-second workload. boring components, well arranged, carry a billion urls — you&amp;#39;re paid to solve a problem, not to ship the fanciest architecture.&lt;/p&gt;&lt;/details&gt;

&lt;h2 id="what-i-39-d-take-away-from-all-this"&gt;what i&amp;#39;d take away from all this&lt;/h2&gt;
&lt;p&gt;a few thoughts after walking through it.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;start with the asymmetry.&lt;/strong&gt; reads vs writes is the lens through which every other decision is made. once you internalize 1000:1, the cache, the service split, the counter — they all just fall out.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;counter + base62 is the answer&lt;/strong&gt; for short-code generation in 99% of these systems. it skips the collision-check tax that random and hash approaches need.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;single redis is enough.&lt;/strong&gt; for the counter, for the cache, for everything in this system. redis at 100k+ ops/sec on a single box is more than this whole service needs.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;microservices for a tiny system are theater.&lt;/strong&gt; the read/write split here is justified by the traffic asymmetry, but a lot of designs split for the sake of splitting. don&amp;#39;t.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;boring is fine.&lt;/strong&gt; postgres, redis, an api gateway, a load balancer. nothing here is novel — and that&amp;#39;s the whole point.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;nothing is best. everything depends on the actual traffic, the actual constraints, the actual money. but for a url shortener at this scale, this design holds up. &lt;strong&gt;you&amp;#39;re paid to solve a problem, not to ship the fanciest architecture.&lt;/strong&gt;&lt;/p&gt;
</content>
  </entry>
  <entry>
    <title>remote locks and distributed locks</title>
    <link href="https://tautik.me/remote-locks-and-distributed-locks/"/>
    <id>https://tautik.me/remote-locks-and-distributed-locks/</id>
    <updated>2026-02-11T00:00:00Z</updated>
    <content type="html">&lt;h1&gt;where do remote locks even fit in?&lt;/h1&gt;
&lt;p&gt;before we jump into remote locks and distributed locks, lets take a step back and understand where they fit in. there&amp;#39;s a beautiful logical evolution here that most people miss.&lt;/p&gt;
&lt;p&gt;when you have multiple threads that need to synchronize, you use a mutex or a semaphore. why? because threads share the same memory space, so an in-memory primitive is the closest possible synchronization mechanism. dead simple.&lt;/p&gt;
&lt;p&gt;now bump it up a level. when you have multiple processes on the same machine that need to synchronize, you use the disk. why? because disk is the shared common storage between processes. a beautiful real-world example of this is &lt;code&gt;apt-get upgrade&lt;/code&gt;. try opening two terminals and running &lt;code&gt;apt-get upgrade&lt;/code&gt; on both simultaneously. the second one will throw an error saying &lt;code&gt;dpkg.lock&lt;/code&gt; file exists. the process creates a lock file on disk when it starts, and if another instance sees that file, it kills itself. two processes synchronizing through disk.&lt;/p&gt;
&lt;p&gt;now bump it up one more level. when you have multiple machines that need to synchronize, what&amp;#39;s the closest shared resource? the network. and that&amp;#39;s exactly where &lt;strong&gt;remote locks&lt;/strong&gt; come in.&lt;/p&gt;
&lt;p&gt;&lt;img src="https://raw.githubusercontent.com/tautik/image-assets/main/sync-evolution.png" alt="synchronization evolution across threads, processes, and machines"&gt;&lt;/p&gt;
&lt;p&gt;you try to synchronize with the closest possible shared storage available. that&amp;#39;s the pattern.&lt;/p&gt;
&lt;details class="recall"&gt;&lt;summary&gt;&lt;span class="recall-mark"&gt;&amp;#10045; RECALL&lt;/span&gt; threads sync through memory, processes through disk, machines through the network. what&amp;#39;s the underlying rule, and why does &lt;code&gt;apt-get&lt;/code&gt; use a lock file instead of a mutex?&lt;/summary&gt;&lt;p&gt;you synchronize through the closest shared resource available. threads share memory → mutex/semaphore. processes don&amp;#39;t share memory but share the disk → lock file (&lt;code&gt;dpkg.lock&lt;/code&gt;: create it on start, kill yourself if it already exists). machines share neither memory nor disk, only the network → remote locks through a central lock manager.&lt;/p&gt;&lt;/details&gt;

&lt;h2 id="what-you-39-ll-take-away"&gt;what you&amp;#39;ll take away&lt;/h2&gt;
&lt;p&gt;quick pointers so you know what to look for as you read:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;closest shared resource wins.&lt;/strong&gt; threads → memory, processes → disk, machines → network. pick your sync primitive accordingly.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;two non-negotiables for any lock manager.&lt;/strong&gt; atomic ops + automatic expiration. without TTL, one dead consumer halts the whole system.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;SET NX&lt;/code&gt; is the whole game on the acquire side.&lt;/strong&gt; atomic check-and-set means two consumers can&amp;#39;t sneak past simultaneously.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;release is sneakier than acquire.&lt;/strong&gt; blind delete corrupts the system after a TTL expires. always verify ownership before releasing — atomically.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;redlock buys availability with throughput.&lt;/strong&gt; 5 nodes, majority quorum, no replication. survives 2 failures but every acquire is now a consensus round.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;quorum count is static, not dynamic.&lt;/strong&gt; even when nodes go down, the magic number stays at majority of original — shrinking it breaks correctness.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;fanciest architecture rarely wins.&lt;/strong&gt; single-node redis is what most teams should reach for. redlock exists for when correctness during failure is non-negotiable.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="what-are-remote-locks"&gt;what are remote locks?&lt;/h2&gt;
&lt;p&gt;remote locks are essentially locks managed by a central machine — we call it the &lt;strong&gt;lock manager&lt;/strong&gt;. it&amp;#39;s a central component that multiple machines coordinate through.&lt;/p&gt;
&lt;p&gt;&lt;img src="https://raw.githubusercontent.com/tautik/image-assets/main/lock-manager-arch.png" alt="3 machines coordinating through a central lock manager (redis)"&gt;&lt;/p&gt;
&lt;p&gt;the 3 machines coordinate through a central lock manager. simple enough right?&lt;/p&gt;
&lt;h2 id="lets-build-some-intuition-with-a-stupid-queue"&gt;lets build some intuition with a stupid queue&lt;/h2&gt;
&lt;p&gt;to understand remote locks better, let&amp;#39;s set up a problem. imagine you have a message broker — a remote queue. but this queue is stupid. it gives you no guarantees whatsoever. it&amp;#39;s not SQS, it&amp;#39;s not kafka, it&amp;#39;s nothing fancy. it&amp;#39;s a mythical, stupid, unprotected queue.&lt;/p&gt;
&lt;p&gt;&lt;img src="https://raw.githubusercontent.com/tautik/image-assets/main/stupid-queue.png" alt="a stupid unprotected queue with 3 consumers coordinating through redis"&gt;&lt;/p&gt;
&lt;p&gt;what we want is that when one consumer reads from the queue, the other two should wait. once the first one is done, the next one gets a turn. that&amp;#39;s it. we want multiple machines to coordinate so that only one accesses the queue at a time.&lt;/p&gt;
&lt;h2 id="the-consumer-39-s-pseudocode"&gt;the consumer&amp;#39;s pseudocode&lt;/h2&gt;
&lt;p&gt;at a high level, every consumer runs this loop:&lt;/p&gt;
&lt;div class="codeblock"&gt;&lt;pre&gt;&lt;code class="hljs"&gt;ACQ_LOCK()        ←  acquire the lock
  READ_MSG()      ←  read, process, and delete the message
REL_LOCK()        ←  release the lock&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;all consumers wait on &lt;code&gt;ACQ_LOCK()&lt;/code&gt; while one of them does &lt;code&gt;READ_MSG()&lt;/code&gt;. once the active consumer releases the lock, the next one gets in. then the third one. and so on.&lt;/p&gt;
&lt;h2 id="what-do-we-need-from-the-lock-manager"&gt;what do we need from the lock manager?&lt;/h2&gt;
&lt;p&gt;two core properties:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;atomic operations&lt;/strong&gt; — so that two machines don&amp;#39;t acquire the lock simultaneously. when one consumer is setting the lock, no other consumer should be able to sneak in. no race conditions.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;automatic expiration (TTL)&lt;/strong&gt; — imagine consumer 1 acquires the lock, starts processing, and then dies mid-way. if there&amp;#39;s no expiration, that lock is held forever. nobody can make progress. so we need a timeout that auto-releases the lock after some time in case there&amp;#39;s no graceful deletion.&lt;/p&gt;
&lt;p&gt;so which database gives us both atomicity and TTL? redis. it&amp;#39;s the popular choice because it&amp;#39;s in-memory, which means it&amp;#39;s fast. dynamodb works too, but redis is what most people reach for.&lt;/p&gt;
&lt;details class="recall"&gt;&lt;summary&gt;&lt;span class="recall-mark"&gt;&amp;#10045; RECALL&lt;/span&gt; your lock manager has atomic operations but no TTL. what single event halts the whole system, and why?&lt;/summary&gt;&lt;p&gt;a consumer acquires the lock and dies mid-processing. with no expiration that lock is held forever, every other consumer waits on acquire indefinitely, and nobody makes progress — one dead machine stops the world. TTL auto-releases the lock when there&amp;#39;s no graceful deletion, so the system heals itself. atomicity alone only protects acquisition; it does nothing for liveness.&lt;/p&gt;&lt;/details&gt;

&lt;h2 id="implementation-with-redis"&gt;implementation with redis&lt;/h2&gt;
&lt;p&gt;the idea is straightforward. you set a key in redis that says which consumer holds the lock. the key is the queue id, and the value is the consumer id.&lt;/p&gt;
&lt;div class="codeblock"&gt;&lt;pre&gt;&lt;code class="hljs"&gt;eg:    q7 : consumer2    [ex: 300]
        ↑                    ↑
   lock held by         expiration: 5 min
   consumer 2&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;the magic here is &lt;code&gt;SET NX&lt;/code&gt; — set if not exists. if the key already exists, it means some other consumer holds the lock, and the command returns &lt;code&gt;0&lt;/code&gt;. if it doesn&amp;#39;t exist, the key is set and it returns &lt;code&gt;1&lt;/code&gt;. each command in redis is atomic, so no two consumers can race past this.&lt;/p&gt;
&lt;h3 id="acquire-lock"&gt;acquire lock&lt;/h3&gt;
&lt;div class="codeblock"&gt;&lt;span class="code-lang"&gt;python&lt;/span&gt;&lt;pre&gt;&lt;code class="hljs language-python"&gt;&lt;span class="hljs-keyword"&gt;def&lt;/span&gt; &lt;span class="hljs-title function_"&gt;acquire_lock&lt;/span&gt;(&lt;span class="hljs-params"&gt;q&lt;/span&gt;):
    consumer_id = get_my_id()

    &lt;span class="hljs-keyword"&gt;while&lt;/span&gt; &lt;span class="hljs-literal"&gt;True&lt;/span&gt;:
        v = redis.setnx(q, consumer_id)
        &lt;span class="hljs-keyword"&gt;if&lt;/span&gt; v == &lt;span class="hljs-number"&gt;1&lt;/span&gt;:
            redis.expire(q, &lt;span class="hljs-number"&gt;300&lt;/span&gt;)   &lt;span class="hljs-comment"&gt;# TTL of 5 minutes&lt;/span&gt;
            &lt;span class="hljs-keyword"&gt;return&lt;/span&gt;
        &lt;span class="hljs-keyword"&gt;else&lt;/span&gt;:
            &lt;span class="hljs-keyword"&gt;continue&lt;/span&gt;               &lt;span class="hljs-comment"&gt;# busy wait&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;yep, it&amp;#39;s busy waiting. not the most elegant, but it works. each consumer keeps trying &lt;code&gt;setnx&lt;/code&gt; in a loop. the moment it succeeds (returns &lt;code&gt;1&lt;/code&gt;), it sets the TTL and returns from the function. otherwise, it keeps spinning.&lt;/p&gt;
&lt;h3 id="release-lock"&gt;release lock&lt;/h3&gt;
&lt;p&gt;your first instinct might be to just delete the key:&lt;/p&gt;
&lt;div class="codeblock"&gt;&lt;span class="code-lang"&gt;python&lt;/span&gt;&lt;pre&gt;&lt;code class="hljs language-python"&gt;&lt;span class="hljs-keyword"&gt;def&lt;/span&gt; &lt;span class="hljs-title function_"&gt;release_lock&lt;/span&gt;(&lt;span class="hljs-params"&gt;q&lt;/span&gt;):
    redis.delete(q)&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;but think about this. consumer 1 acquires the lock, starts processing, and takes longer than expected. the TTL expires. the lock auto-releases. consumer 2 now acquires the lock and starts processing. consumer 1 finishes its work and calls &lt;code&gt;release_lock&lt;/code&gt; — and blindly deletes the key. but that key now belongs to consumer 2. consumer 1 just deleted someone else&amp;#39;s lock. now consumer 3 can waltz in and you&amp;#39;ve got two consumers processing simultaneously. chaos.&lt;/p&gt;
&lt;p&gt;that&amp;#39;s why we verify ownership before deleting:&lt;/p&gt;
&lt;div class="codeblock"&gt;&lt;span class="code-lang"&gt;python&lt;/span&gt;&lt;pre&gt;&lt;code class="hljs language-python"&gt;&lt;span class="hljs-keyword"&gt;def&lt;/span&gt; &lt;span class="hljs-title function_"&gt;release_lock&lt;/span&gt;(&lt;span class="hljs-params"&gt;q&lt;/span&gt;):
    consumer_id = get_my_id()
    v = redis.get(q)
    &lt;span class="hljs-keyword"&gt;if&lt;/span&gt; v == consumer_id:
        redis.delete(q)&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;if the value in redis doesn&amp;#39;t match my consumer id, i don&amp;#39;t touch it. it&amp;#39;s not my lock to release.&lt;/p&gt;
&lt;p&gt;but there&amp;#39;s one more subtlety here. the &lt;code&gt;get&lt;/code&gt; and &lt;code&gt;delete&lt;/code&gt; are two separate commands. what if between the &lt;code&gt;get&lt;/code&gt; (which returns my consumer id) and the &lt;code&gt;delete&lt;/code&gt;, the TTL expires, another consumer acquires the lock, and then my &lt;code&gt;delete&lt;/code&gt; fires? same problem again. these two operations need to be &lt;strong&gt;atomic&lt;/strong&gt;. in redis, you achieve this using &lt;code&gt;EVAL&lt;/code&gt; — executing a lua script atomically on the server.&lt;/p&gt;
&lt;div class="codeblock"&gt;&lt;pre&gt;&lt;code class="hljs"&gt;redis.eval("if get(q) == c : del(q)")&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;the lua script runs atomically on the redis server. no interleaving between the check and the delete. no race condition. clean.&lt;/p&gt;
&lt;details class="recall"&gt;&lt;summary&gt;&lt;span class="recall-mark"&gt;&amp;#10045; RECALL&lt;/span&gt; why does acquire need &lt;code&gt;SET NX&lt;/code&gt; specifically — what breaks if a consumer does a &lt;code&gt;GET&lt;/code&gt; to check the key and then a &lt;code&gt;SET&lt;/code&gt; to claim it?&lt;/summary&gt;&lt;p&gt;between the check and the set, a second consumer can run the same check, also see no key, and both end up &amp;quot;holding&amp;quot; the lock. &lt;code&gt;SET NX&lt;/code&gt; collapses check-and-claim into one command, and since every redis command is atomic, exactly one consumer gets back &lt;code&gt;1&lt;/code&gt; and everyone else gets &lt;code&gt;0&lt;/code&gt;. the entire acquire side rests on that single atomic check-and-set.&lt;/p&gt;&lt;/details&gt;

&lt;details class="recall"&gt;&lt;summary&gt;&lt;span class="recall-mark"&gt;&amp;#10045; RECALL&lt;/span&gt; consumer 1&amp;#39;s TTL expired mid-processing and consumer 2 now holds the lock. what goes wrong if consumer 1&amp;#39;s release just deletes the key — and why isn&amp;#39;t plain get-then-delete enough either?&lt;/summary&gt;&lt;p&gt;a blind delete removes consumer 2&amp;#39;s lock, letting a third consumer in — two consumers now process simultaneously. get-then-delete still races: the TTL can expire &lt;em&gt;between&lt;/em&gt; the get and the delete. the check and the delete must run as one atomic operation — a lua script via &lt;code&gt;EVAL&lt;/code&gt; on the redis server.&lt;/p&gt;&lt;/details&gt;

&lt;h2 id="where-else-do-we-see-remote-locks"&gt;where else do we see remote locks?&lt;/h2&gt;
&lt;p&gt;this pattern is everywhere in distributed systems. mongodb transactions, for example, use remote locks on the involved rows. when you run a multi-document transaction in mongodb, it acquires locks on the documents involved so that no other transaction can modify them concurrently. the mongos router coordinates these locks across shards — essentially the same remote locking pattern we just built. one central coordinator making sure multiple machines don&amp;#39;t step on each other&amp;#39;s toes. if you want to dig deeper into how mongodb handles this internally, their &lt;a href="https://www.mongodb.com/docs/manual/core/transactions/"&gt;official docs on transactions&lt;/a&gt; and the &lt;a href="https://source.wiredtiger.com/develop/arch-transaction.html"&gt;wiredtiger storage engine&amp;#39;s locking model&lt;/a&gt; are solid reads.&lt;/p&gt;
&lt;p&gt;but there&amp;#39;s a problem. what happens if your single redis node goes down? nobody can acquire the lock. you&amp;#39;ve got a &lt;strong&gt;single point of failure&lt;/strong&gt;. and that&amp;#39;s exactly why distributed locks exist.&lt;/p&gt;
&lt;h2 id="distributed-locks-redlock"&gt;distributed locks — redlock&lt;/h2&gt;
&lt;p&gt;the idea behind distributed locks is simple: what we did with a remote lock on one node, just distribute it across multiple nodes.&lt;/p&gt;
&lt;p&gt;&lt;img src="https://raw.githubusercontent.com/tautik/image-assets/main/redlock-nodes.png" alt="5 independent redis master nodes for redlock, no replication"&gt;&lt;/p&gt;
&lt;p&gt;you have 5 independent redis master nodes. no replication between them. all standalone. the concept is that instead of acquiring a lock on one node, you acquire a lock on the &lt;strong&gt;majority&lt;/strong&gt; of these nodes.&lt;/p&gt;
&lt;h3 id="acquire-lock-distributed"&gt;acquire lock (distributed)&lt;/h3&gt;
&lt;div class="codeblock"&gt;&lt;span class="code-lang"&gt;python&lt;/span&gt;&lt;pre&gt;&lt;code class="hljs language-python"&gt;REDIS_SERVERS = [., ., ., ., .]
QUORUM_COUNT = ceil(&lt;span class="hljs-built_in"&gt;len&lt;/span&gt;(REDIS_SERVERS) / &lt;span class="hljs-number"&gt;2&lt;/span&gt;)    &lt;span class="hljs-comment"&gt;# 3 out of 5&lt;/span&gt;

&lt;span class="hljs-keyword"&gt;def&lt;/span&gt; &lt;span class="hljs-title function_"&gt;acq_lock&lt;/span&gt;():
    count_acq = &lt;span class="hljs-number"&gt;0&lt;/span&gt;

    &lt;span class="hljs-keyword"&gt;for&lt;/span&gt; i &lt;span class="hljs-keyword"&gt;in&lt;/span&gt; random.shuffle(&lt;span class="hljs-built_in"&gt;range&lt;/span&gt;(&lt;span class="hljs-number"&gt;5&lt;/span&gt;)):          &lt;span class="hljs-comment"&gt;# random order&lt;/span&gt;
        count_acq += redis[i].setnx(q, c, ex=&lt;span class="hljs-number"&gt;300&lt;/span&gt;)

    &lt;span class="hljs-keyword"&gt;if&lt;/span&gt; count_acq &amp;gt;= QUORUM_COUNT:
        &lt;span class="hljs-keyword"&gt;return&lt;/span&gt;                                   &lt;span class="hljs-comment"&gt;# got the lock!&lt;/span&gt;
    &lt;span class="hljs-keyword"&gt;else&lt;/span&gt;:
        &lt;span class="hljs-comment"&gt;# release locks on nodes where we did acquire&lt;/span&gt;
        &lt;span class="hljs-keyword"&gt;for&lt;/span&gt; i &lt;span class="hljs-keyword"&gt;in&lt;/span&gt; &lt;span class="hljs-built_in"&gt;range&lt;/span&gt;(&lt;span class="hljs-number"&gt;5&lt;/span&gt;):
            redis[i].&lt;span class="hljs-built_in"&gt;eval&lt;/span&gt;(&lt;span class="hljs-string"&gt;&amp;quot;if get(q) == c : del(q)&amp;quot;&lt;/span&gt;)&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;the client goes through all 5 nodes, trying to &lt;code&gt;SET NX&lt;/code&gt; on each. if it acquires the lock on the majority (3 out of 5), it&amp;#39;s done — lock acquired. if not, it &lt;strong&gt;releases whatever locks it did acquire&lt;/strong&gt; and tries again.&lt;/p&gt;
&lt;p&gt;that last part is crucial. if you don&amp;#39;t release the partial locks, you get deadlocks. imagine consumer 1 gets a lock on nodes A and B, consumer 2 gets C and D, consumer 3 gets E. nobody has the majority. so everybody releases and retries. on the next round, maybe consumer 2 gets A, B, and C — majority acquired.&lt;/p&gt;
&lt;h3 id="release-lock-distributed"&gt;release lock (distributed)&lt;/h3&gt;
&lt;p&gt;same lua script as before, but fired at all 5 nodes:&lt;/p&gt;
&lt;div class="codeblock"&gt;&lt;pre&gt;&lt;code class="hljs"&gt;for i in range(5):
    redis[i].eval("if get(q) == c : del(q)")&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;h2 id="fault-tolerance-the-whole-point"&gt;fault tolerance — the whole point&lt;/h2&gt;
&lt;p&gt;so why did we go through all this trouble? because with distributed locking, we no longer have a single point of failure.&lt;/p&gt;
&lt;p&gt;lets say consumer 2 holds the lock on nodes A, B, and C. if node D goes down, no problem — 2 still holds the majority. if node E also goes down, still no problem — 2 has 3 out of 3 remaining, but more importantly, 2 already holds 3 out of the original 5.&lt;/p&gt;
&lt;p&gt;with 5 nodes, you can &lt;strong&gt;survive 2 node failures&lt;/strong&gt;.&lt;/p&gt;
&lt;h3 id="the-quorum-count-trap"&gt;the quorum count trap&lt;/h3&gt;
&lt;p&gt;here&amp;#39;s where people get confused. lets say nodes C and D go down. you think &amp;quot;well, 3 nodes remain, so the quorum should be 2 out of 3 now, right?&amp;quot;&lt;/p&gt;
&lt;p&gt;hell naw.&lt;/p&gt;
&lt;p&gt;look at the code again:&lt;/p&gt;
&lt;div class="codeblock"&gt;&lt;pre&gt;&lt;code class="hljs"&gt;REDIS_SERVERS = [., ., ., ., .]
QUORUM_COUNT = ceil(len(REDIS_SERVERS) / 2)    # 3&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;&lt;code&gt;REDIS_SERVERS&lt;/code&gt; is a &lt;strong&gt;static configuration&lt;/strong&gt;. it&amp;#39;s a hardcoded list that every consumer has when it starts up. it doesn&amp;#39;t dynamically shrink when a node becomes unreachable. a node going down doesn&amp;#39;t mean it gracefully removes itself from everyone&amp;#39;s config — it just stops responding. the list still has 5 entries. &lt;code&gt;len(REDIS_SERVERS)&lt;/code&gt; is still 5. &lt;code&gt;QUORUM_COUNT&lt;/code&gt; is still 3.&lt;/p&gt;
&lt;p&gt;and honestly, you wouldn&amp;#39;t want it to change. think about why. the whole point of quorum is that any two majorities must overlap on at least one node. if you have 5 nodes and need 3, any two groups of 3 will share at least 1 node — which prevents two consumers from both thinking they own the lock. the moment you start shrinking the quorum dynamically (&amp;quot;oh only 3 nodes are alive, so 2 is enough&amp;quot;), you break that overlap guarantee. consumer 1 could get nodes A and B, consumer 2 could get node E, and both think they have majority of the &amp;quot;alive&amp;quot; set. that defeats the entire purpose.&lt;/p&gt;
&lt;p&gt;so the quorum is fixed at 3 out of 5. if 2 nodes are down, you need &lt;strong&gt;all 3 remaining nodes&lt;/strong&gt; to agree. that&amp;#39;s harder, sure. but that&amp;#39;s the price of correctness. and if 3 nodes go down? well, majority is mathematically impossible with only 2 nodes, so the system halts. no lock can be acquired. that&amp;#39;s a feature, not a bug — better to stop than to hand out locks incorrectly.&lt;/p&gt;
&lt;details class="recall"&gt;&lt;summary&gt;&lt;span class="recall-mark"&gt;&amp;#10045; RECALL&lt;/span&gt; nodes C and D are down. how many of the 3 remaining nodes do you now need to acquire the lock — and why doesn&amp;#39;t the quorum shrink to 2-of-3?&lt;/summary&gt;&lt;p&gt;all 3. the quorum is &lt;code&gt;ceil(5/2) = 3&lt;/code&gt;, computed from the &lt;strong&gt;static&lt;/strong&gt; server list — a dead node doesn&amp;#39;t remove itself from anyone&amp;#39;s config. and shrinking it would break the overlap guarantee: any two majorities of the original 5 must share at least one node, which is exactly what prevents two consumers from both believing they hold the lock.&lt;/p&gt;&lt;/details&gt;

&lt;h2 id="the-tradeoff-spectrum"&gt;the tradeoff spectrum&lt;/h2&gt;
&lt;p&gt;here&amp;#39;s where it gets interesting. there are three architectures, three different trade-off profiles. think of it like convincing friends to go to a restaurant.&lt;/p&gt;
&lt;p&gt;&lt;img src="https://raw.githubusercontent.com/tautik/image-assets/main/tradeoff-spectrum.png" alt="tradeoff spectrum: single node vs master-replica vs redlock"&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;remote lock (single node)&lt;/strong&gt; — very high throughput because you only need to convince one friend to go to the restaurant. very high correctness because there&amp;#39;s one source of truth. but if that node dies, game over. availability is gone.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;remote lock with replica&lt;/strong&gt; — you get throughput (just write to master) and you get availability (if master dies, replica takes over). but correctness takes a hit. imagine you acquire a lock on the master, and before it propagates to the replica via async replication, the master dies. the replica never got the lock entry. now two consumers think they own the lock. bad.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;distributed lock (redlock)&lt;/strong&gt; — you get high correctness because majority consensus means even if a node goes down, the lock state is safe. you get high availability for the same reason. but throughput suffers because now you&amp;#39;re convincing 3 out of 5 friends to agree on a restaurant. consensus always slows things down.&lt;/p&gt;
&lt;p&gt;&lt;img src="https://raw.githubusercontent.com/tautik/image-assets/main/26pm.webp" alt="graph"&gt;&lt;/p&gt;
&lt;p&gt;as the number of redis nodes increases, your lock acquisition time goes up. and as the number of clients (consumers) contending for the lock increases, it gets even slower. exponentially slower.&lt;/p&gt;
&lt;h2 id="when-do-you-use-what"&gt;when do you use what?&lt;/h2&gt;
&lt;p&gt;there&amp;#39;s no one right answer. it depends on context.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;remote lock (single node)&lt;/strong&gt; — most people use this. high throughput, simple setup, and they&amp;#39;re okay with the availability risk. classic use case: consumer synchronization where lock contention is frequent and you need fast acquisition.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;distributed lock (redlock)&lt;/strong&gt; — this exists for situations where availability is absolutely critical. think database leader election. it doesn&amp;#39;t happen frequently, but when it does, you cannot tolerate failures. if you give up on correctness during leader election, you get data inconsistency, data corruption — huge problems. so you&amp;#39;re willing to pay the latency cost for the guarantee.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;remote lock with replica&lt;/strong&gt; — right in the middle. a little VC money, a little correctness, a little availability. sometimes good enough.&lt;/p&gt;
&lt;p&gt;nothing is best. everything depends upon the usecase and the constraints you&amp;#39;re operating under. you pick one over another based on what trade-offs you&amp;#39;re willing to make. &lt;strong&gt;you are paid to solve a problem and not necessarily use the fanciest architecture to solve it&lt;/strong&gt;.&lt;/p&gt;
&lt;details class="recall"&gt;&lt;summary&gt;&lt;span class="recall-mark"&gt;&amp;#10045; RECALL&lt;/span&gt; one team needs locks for database leader election, another for high-contention queue consumers. which architecture do you hand each team, and what does each give up?&lt;/summary&gt;&lt;p&gt;leader election → &lt;strong&gt;redlock&lt;/strong&gt;: it&amp;#39;s rare but correctness + availability during failure are non-negotiable, so you pay the consensus latency. queue consumers → &lt;strong&gt;single-node redis&lt;/strong&gt;: contention is frequent, acquisition must be fast, and the SPOF risk is acceptable. the replica setup sits in the middle — and gives up correctness during async-replication failover.&lt;/p&gt;&lt;/details&gt;
</content>
  </entry>
  <entry>
    <title>Message queue</title>
    <link href="https://tautik.me/message-queue/"/>
    <id>https://tautik.me/message-queue/</id>
    <updated>2026-01-03T00:00:00Z</updated>
    <content type="html">&lt;p&gt;queues are one of those things that sound dead simple from the outside. you push stuff in one end, you read it from the other end, fifo, done. but the moment you try to build anything real on top of a queue, you realize there are like five layers of decisions hiding under the hood. and one of the biggest myths people walk around with is that &lt;strong&gt;queues are fifo&lt;/strong&gt;. they are not. not in any system you&amp;#39;d actually run in prod.&lt;/p&gt;
&lt;p&gt;so let&amp;#39;s actually master queues. async patterns, push vs pull mechanisms, why fifo breaks the moment you have more than one worker, and where kafka stops being a &amp;quot;nice to have&amp;quot; and becomes the only sane answer.&lt;/p&gt;
&lt;h2 id="what-you-39-ll-take-away"&gt;what you&amp;#39;ll take away&lt;/h2&gt;
&lt;p&gt;quick pointers so you know what to look for as you read:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;synchronous = whatever&amp;#39;s slowest is your floor.&lt;/strong&gt; one rate-limited dep can take down the whole flow. async is the escape hatch.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;enqueue is cheap, processing is async.&lt;/strong&gt; the api responds in 200ms, the email lands 6 hours later, and for non-critical work that&amp;#39;s totally fine.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;push vs pull is the real fork.&lt;/strong&gt; push (rabbitmq) hides complexity in the broker. pull (sqs) gives you control and makes you write the rest.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;dedup, retries, backoff — only &amp;quot;free&amp;quot; with push.&lt;/strong&gt; in pull-land you write all three yourself. that&amp;#39;s the price of control.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;fifo is a lie when you scale out.&lt;/strong&gt; multiple workers + variable processing time = out-of-order delivery, no matter what queue you&amp;#39;re using.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;kafka exists because ordering needs partitioning.&lt;/strong&gt; key-based partitions guarantee order &lt;em&gt;within a key&lt;/em&gt;, parallelism across keys.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;mix queues by use case.&lt;/strong&gt; notifications can be push-based, video pipelines pull-based, event log on kafka — nobody&amp;#39;s stopping you.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="the-synchronous-trap"&gt;the synchronous trap&lt;/h2&gt;
&lt;p&gt;let&amp;#39;s start with something concrete. you&amp;#39;re building a signup flow. user hits &lt;code&gt;POST /signup&lt;/code&gt;, you&amp;#39;ve got a server, you&amp;#39;ve got postgres, and you want to send a welcome email. easy:&lt;/p&gt;
&lt;div class="codeblock"&gt;&lt;span class="code-lang"&gt;python&lt;/span&gt;&lt;pre&gt;&lt;code class="hljs language-python"&gt;&lt;span class="hljs-keyword"&gt;def&lt;/span&gt; &lt;span class="hljs-title function_"&gt;signup&lt;/span&gt;(&lt;span class="hljs-params"&gt;req&lt;/span&gt;):
    insert_user_in_db()
    send_welcome_email()
    token = create_token()
    &lt;span class="hljs-keyword"&gt;return&lt;/span&gt; token&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;four steps, all in sequence. all synchronous. and at small scale this works perfectly fine — one user signs up, four steps fire, response goes back. ship it.&lt;/p&gt;
&lt;p&gt;&lt;img src="https://raw.githubusercontent.com/tautik/image-assets/main/sync-arch.png" alt="synchronous signup flow with rate-limited email as a bottleneck"&gt;&lt;/p&gt;
&lt;p&gt;now bump the load to 10,000 signups in a minute. suddenly that &lt;code&gt;send_welcome_email&lt;/code&gt; line is the problem. two things happen.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;email service is an external dep.&lt;/strong&gt; it&amp;#39;s somebody else&amp;#39;s box — aws ses, sendgrid, whatever. if it&amp;#39;s slow, your signup is slow. if it&amp;#39;s down, your signup is down. your &lt;em&gt;signup&lt;/em&gt; now depends on an &lt;em&gt;email server&lt;/em&gt;. that&amp;#39;s a wild coupling for something as fundamental as user creation.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;rate limits hit.&lt;/strong&gt; email providers cap you. let&amp;#39;s say ses gives you 25 emails per minute, which is honestly a healthy quota. but if 10,000 users hit signup in the same minute, you&amp;#39;re firing 10,000 email calls into a 25-call budget. the rate limiter says no. your &lt;code&gt;send_welcome_email&lt;/code&gt; starts erroring. and because the whole flow is synchronous, that error kills the entire signup. one external rate limit takes down user creation. unacceptable.&lt;/p&gt;
&lt;p&gt;so what&amp;#39;s actually critical here? &lt;code&gt;insert_user_in_db&lt;/code&gt; — yes, without that there&amp;#39;s no user. &lt;code&gt;create_token&lt;/code&gt; — yes, the user needs a session. &lt;code&gt;return response&lt;/code&gt; — obviously. but &lt;code&gt;send_welcome_email&lt;/code&gt;? the user does not need that email in the same 200ms as their signup. if the email shows up 30 seconds later, who cares. if it shows up 5 minutes later, still fine. it&amp;#39;s a non-critical task that&amp;#39;s been hard-coupled into a critical path.&lt;/p&gt;
&lt;p&gt;this is exactly where async processing exists.&lt;/p&gt;
&lt;h2 id="the-async-escape-hatch"&gt;the async escape hatch&lt;/h2&gt;
&lt;p&gt;instead of &lt;em&gt;sending&lt;/em&gt; the email inline, you &lt;em&gt;enqueue&lt;/em&gt; the work and let something else process it later.&lt;/p&gt;
&lt;div class="codeblock"&gt;&lt;span class="code-lang"&gt;python&lt;/span&gt;&lt;pre&gt;&lt;code class="hljs language-python"&gt;&lt;span class="hljs-keyword"&gt;def&lt;/span&gt; &lt;span class="hljs-title function_"&gt;signup&lt;/span&gt;(&lt;span class="hljs-params"&gt;req&lt;/span&gt;):
    insert_user_in_db()
    messageQueue.enqueue(sendWelcomeEmail)
    token = create_token()
    &lt;span class="hljs-keyword"&gt;return&lt;/span&gt; token&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;three sync steps plus one cheap enqueue. signup is fast again. the queue holds the email job. some other process — a worker — is going to pick it up later and actually call the email service. that worker can respect the 25/min rate limit, sleep when it hits the cap, retry when slots open up. the queue absorbs all the spikiness.&lt;/p&gt;
&lt;p&gt;&lt;img src="https://raw.githubusercontent.com/tautik/image-assets/main/async-arch.png" alt="async signup: server enqueues to a message queue, consumer workers drain it at their own pace"&gt;&lt;/p&gt;
&lt;p&gt;so if you got 10,000 signups in a minute, your queue now has 10,000 pending email jobs. the worker picks 25/min and the queue drains slowly. user #1&amp;#39;s email arrives in seconds. user #9,999&amp;#39;s email might arrive 6 hours later. and that&amp;#39;s &lt;em&gt;fine&lt;/em&gt;. &lt;strong&gt;welcome emails are not synchronous-critical.&lt;/strong&gt; the same way youtube doesn&amp;#39;t show your uploaded video instantly — it takes 10-15 minutes because some worker is processing it asynchronously. literally everywhere you look, the non-critical stuff is on a queue.&lt;/p&gt;
&lt;p&gt;this is async processing. and queues are how you implement it.&lt;/p&gt;
&lt;details class="recall"&gt;&lt;summary&gt;&lt;span class="recall-mark"&gt;&amp;#10045; RECALL&lt;/span&gt; your signup endpoint dies whenever the email provider rate-limits. what&amp;#39;s the actual design mistake, and what&amp;#39;s the fix?&lt;/summary&gt;&lt;p&gt;a non-critical task (the welcome email) got hard-coupled into the critical path — synchronous means the slowest, flakiest dependency sets your floor, so one external rate limit takes down user creation itself. fix: enqueue the email job (enqueue is cheap) and return; a worker drains the queue at the provider&amp;#39;s pace, the queue absorbs the spike, and the email lands minutes or hours later — fine, because it was never synchronous-critical.&lt;/p&gt;&lt;/details&gt;

&lt;h2 id="the-two-ends-of-every-queue"&gt;the two ends of every queue&lt;/h2&gt;
&lt;p&gt;every queue has a producer side and a consumer side. producer is the easy part. doesn&amp;#39;t matter what queue you&amp;#39;re using — rabbitmq, kafka, sqs, bullmq — putting things &lt;em&gt;in&lt;/em&gt; is always a one-liner. you call some sdk method, you give it a payload, it goes in. no surprise.&lt;/p&gt;
&lt;p&gt;the &lt;em&gt;consumer&lt;/em&gt; side is where it gets spicy. how does that worker actually get the next message? this is where queues split into two completely different worlds.&lt;/p&gt;
&lt;h2 id="push-vs-pull-the-fundamental-fork"&gt;push vs pull — the fundamental fork&lt;/h2&gt;
&lt;p&gt;every queue is either &lt;strong&gt;push-based&lt;/strong&gt; or &lt;strong&gt;pull-based&lt;/strong&gt;. this single decision changes how you write the consumer, how you handle failures, how dedup works, and where the bottlenecks land.&lt;/p&gt;
&lt;p&gt;&lt;img src="https://raw.githubusercontent.com/tautik/image-assets/main/push-vs-pull.png" alt="push vs pull: rabbitmq broker dispatching to registered workers vs sqs consumers polling on their own schedule"&gt;&lt;/p&gt;
&lt;h3 id="push-based-the-broker-drives"&gt;push-based — the broker drives&lt;/h3&gt;
&lt;p&gt;push-based means the queue itself decides who gets which message and when. &lt;strong&gt;rabbitmq&lt;/strong&gt; is the canonical example. here&amp;#39;s the dance.&lt;/p&gt;
&lt;p&gt;a worker spins up and the first thing it does is &lt;code&gt;register&lt;/code&gt; itself with rabbitmq. it&amp;#39;s basically saying &amp;quot;hey, i&amp;#39;m alive, i can process messages — when you have one, send it my way.&amp;quot; rabbitmq writes that down in some internal map: &lt;code&gt;worker-7 is available&lt;/code&gt;. the worker then sits there and waits.&lt;/p&gt;
&lt;p&gt;every 30 seconds (or whatever the heartbeat interval is), the worker pings rabbitmq with a heartbeat. &amp;quot;still alive. still alive. still alive.&amp;quot; if rabbitmq stops hearing those for more than its tolerance, it marks the worker dead and stops sending it messages. without heartbeats, a crashed worker would still be receiving work that nobody&amp;#39;s processing — that&amp;#39;s how you&amp;#39;d lose messages.&lt;/p&gt;
&lt;p&gt;now when a producer pushes a message, rabbitmq looks at its registered live workers, picks one, and &lt;em&gt;pushes&lt;/em&gt; the message at it. the worker didn&amp;#39;t ask. it didn&amp;#39;t poll. the message just shows up on its socket.&lt;/p&gt;
&lt;p&gt;what&amp;#39;s good about this:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;consumer code is dead simple.&lt;/strong&gt; you literally write a callback &lt;code&gt;onMessage(msg)&lt;/code&gt; and rabbitmq invokes it. no polling loop. no &amp;quot;is there anything yet?&amp;quot; logic.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;deduplication is built-in.&lt;/strong&gt; since one broker is choosing who gets what, it&amp;#39;d never hand the same message to two workers. it can&amp;#39;t, by definition. one source of truth.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;retries are built-in.&lt;/strong&gt; if a worker takes the message but never acks it within some window, rabbitmq notices and re-queues it for someone else. you didn&amp;#39;t have to write that.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;what&amp;#39;s not good:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;the broker is doing a &lt;em&gt;lot&lt;/em&gt; of work. one box is tracking every worker, every heartbeat, every assignment. that&amp;#39;s a real bottleneck and it slows down at scale.&lt;/li&gt;
&lt;li&gt;you give up control. you can&amp;#39;t say &amp;quot;i want to process at exactly 50 msg/sec&amp;quot; — the broker decides cadence.&lt;/li&gt;
&lt;/ul&gt;
&lt;details class="recall"&gt;&lt;summary&gt;&lt;span class="recall-mark"&gt;&amp;#10045; RECALL&lt;/span&gt; in a push-based queue like rabbitmq, what is the broker actually doing for you — and what&amp;#39;s the price?&lt;/summary&gt;&lt;p&gt;it tracks registered workers via heartbeats, decides who gets each message (so dedup is free by construction — one source of truth can&amp;#39;t hand the same message to two workers), and re-queues anything that isn&amp;#39;t acked in time (retries free). the price: one box doing all that bookkeeping becomes a real bottleneck at scale, and you surrender control — the broker decides cadence, not you.&lt;/p&gt;&lt;/details&gt;

&lt;h3 id="pull-based-you-drive"&gt;pull-based — you drive&lt;/h3&gt;
&lt;p&gt;pull-based flips it. the queue does &lt;em&gt;nothing&lt;/em&gt; on its own. it just sits there. workers have to actively go fetch.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;sqs&lt;/strong&gt; (amazon&amp;#39;s simple queue service) is the textbook pull-based system. it gives you two apis: &lt;code&gt;/push&lt;/code&gt; to put a message in, &lt;code&gt;/pull&lt;/code&gt; to ask for one. that&amp;#39;s the whole interface. no registration. no heartbeats. no broker choosing assignments. workers just call &lt;code&gt;/pull&lt;/code&gt; whenever they want.&lt;/p&gt;
&lt;p&gt;so your worker code looks like:&lt;/p&gt;
&lt;div class="codeblock"&gt;&lt;pre&gt;&lt;code class="hljs"&gt;while True:
    msg = sqs.pull()
    if msg:
        process(msg)
        sqs.delete(msg)
    else:
        sleep(backoff)&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;a &lt;code&gt;setInterval&lt;/code&gt; in js land. a &lt;code&gt;while True&lt;/code&gt; in python. polling, basically.&lt;/p&gt;
&lt;p&gt;now everything that was free in rabbitmq becomes your problem.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;dedup is your problem.&lt;/strong&gt; if three workers all call &lt;code&gt;/pull&lt;/code&gt; at almost the same instant, sqs might hand the same message to multiple of them. you have to handle that yourself. typical fix is a redis lock — before processing message id 7, acquire a lock on &lt;code&gt;lock:msg:7&lt;/code&gt;. if you can&amp;#39;t get it, skip, somebody else has it. it works, but you wrote it.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;retries are your problem.&lt;/strong&gt; if you pulled a message and crashed mid-process, you have to put it back. or build a dead-letter queue (DLQ) where failed messages go for inspection. sqs gives you primitives, not policies — you wire it.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;backoff is your problem.&lt;/strong&gt; if your queue is empty and you&amp;#39;re polling every minute, you&amp;#39;re burning api calls (and sqs literally charges you per call). after 20 empty polls, slow down — go to every 5 minutes. after another 20 empties, every 15. the moment you find a message again, snap back to fast polling. &lt;strong&gt;you have to write that backoff strategy.&lt;/strong&gt; rabbitmq workers don&amp;#39;t have this problem because the broker pushes when there&amp;#39;s something to push.&lt;/p&gt;
&lt;p&gt;so why would anyone use pull?&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;control.&lt;/strong&gt; all the control is yours. you decide cadence, concurrency, retry policy, backoff, dedup strategy. for some workloads — high-throughput batch processing, video pipelines, anything where you want to tune carefully — that control is gold. and there&amp;#39;s no single broker bottleneck because there&amp;#39;s no broker doing dispatch.&lt;/p&gt;
&lt;p&gt;tbh i lean pull-based for most production stuff. bullmq (which sits on redis) and sqs are pull-based and they&amp;#39;re what i reach for. bullmq gives you nice abstractions on top so you barely notice the polling. sqs is full raw — you implement everything — but it&amp;#39;s a fun engineering problem to solve and the operational overhead is basically zero. on a recent project of mine all the queues are sqs with a &lt;code&gt;while True&lt;/code&gt; loop, backoff strategies, retry handling, the works. it&amp;#39;s a vibe.&lt;/p&gt;
&lt;details class="recall"&gt;&lt;summary&gt;&lt;span class="recall-mark"&gt;&amp;#10045; RECALL&lt;/span&gt; you move from rabbitmq to sqs. which three things just became your problem, and why?&lt;/summary&gt;&lt;p&gt;dedup — concurrent &lt;code&gt;/pull&lt;/code&gt; calls can hand the same message to multiple workers, so you write a redis lock per message id. retries — crash mid-process and you re-queue it yourself or wire a dead-letter queue. backoff — empty polls burn api calls (sqs charges per call), so you write the slow-down-then-snap-back polling strategy. sqs gives you primitives, not policies; that&amp;#39;s the price of control, and the reward is you tune cadence, concurrency, and retry exactly how you want, with no broker bottleneck.&lt;/p&gt;&lt;/details&gt;

&lt;h2 id="now-the-spicy-part-queues-are-not-fifo"&gt;now the spicy part — queues are not fifo&lt;/h2&gt;
&lt;p&gt;ask anyone what a queue is and they&amp;#39;ll say &amp;quot;first in, first out.&amp;quot; and yeah, that&amp;#39;s the textbook data structure definition. but in a real distributed system, &lt;strong&gt;queues do not give you fifo&lt;/strong&gt;. let me show why.&lt;/p&gt;
&lt;p&gt;say you&amp;#39;re building an sms onboarding flow that sends a sequence: welcome → product tour → 3-day-later check-in. order matters. the user can&amp;#39;t get the check-in before the welcome. so you push them in order: msg 1, 2, 3, 4, 5, 6 into the queue.&lt;/p&gt;
&lt;p&gt;if you only had one worker, fine. but you don&amp;#39;t — you have multiple. that&amp;#39;s literally the whole point of horizontal scaling. so worker A grabs msg 1, worker B grabs 2, worker C grabs 3, A grabs 4, B grabs 5, C grabs 6.&lt;/p&gt;
&lt;p&gt;&lt;img src="https://raw.githubusercontent.com/tautik/image-assets/main/fifo-myth.png" alt="multiple workers consuming the same queue process messages out of order — fifo guarantee evaporates"&gt;&lt;/p&gt;
&lt;p&gt;now what actually finishes first?&lt;/p&gt;
&lt;p&gt;worker A&amp;#39;s process for msg 1 might be slow (db hiccup, network jitter, gc pause, whatever). worker B finishes msg 2 first. msg 2 arrived first. then worker C finishes msg 3. then worker A &lt;em&gt;finally&lt;/em&gt; finishes msg 1. and so on.&lt;/p&gt;
&lt;p&gt;end result: the user got messages in the order &lt;strong&gt;2, 3, 1, 5, 4, 6&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;the queue is fifo on the way in. it&amp;#39;s not fifo on the way out.&lt;/strong&gt; the moment you have more than one worker — and you always do, because that&amp;#39;s literally what queues are for — sequential delivery is gone.&lt;/p&gt;
&lt;p&gt;for the welcome → tour → checkin flow, this is genuinely broken. user gets the product tour before the welcome. then the check-in before the tour. then the welcome. they&amp;#39;re confused. you shipped a bug.&lt;/p&gt;
&lt;p&gt;so how do you actually preserve order when you need it? hell naw to &amp;quot;just use one worker&amp;quot; — you need parallelism &lt;em&gt;and&lt;/em&gt; ordering. that combo is the hard problem.&lt;/p&gt;
&lt;details class="recall"&gt;&lt;summary&gt;&lt;span class="recall-mark"&gt;&amp;#10045; RECALL&lt;/span&gt; you pushed messages 1–6 into a queue in order. why does the user still receive them out of order?&lt;/summary&gt;&lt;p&gt;multiple workers plus variable processing time. the queue is fifo on the way in, but worker A can stall on msg 1 (db hiccup, gc pause, network jitter) while worker B finishes msg 2 — delivery order is gone. and you always have multiple workers, because parallelism is the entire point of the queue, so single-queue fifo is a myth at any real scale regardless of which queue tech you use.&lt;/p&gt;&lt;/details&gt;

&lt;h2 id="kafka-ordering-inside-a-partition"&gt;kafka — ordering inside a partition&lt;/h2&gt;
&lt;p&gt;this is exactly where kafka shines, and where the other queue systems quietly fall short. kafka introduces &lt;strong&gt;partitions&lt;/strong&gt; and &lt;strong&gt;keys&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;instead of one queue with everything mixed together, kafka splits the topic into N partitions. when you push a message, you attach a &lt;strong&gt;key&lt;/strong&gt; — usually something like the user id. kafka hashes that key and sends the message to one specific partition. &lt;em&gt;all messages with the same key always land in the same partition.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;then on the consumer side, kafka has consumer groups. each partition is consumed by exactly one consumer in the group. so partition 0 → consumer A, partition 1 → consumer B, partition 2 → consumer C.&lt;/p&gt;
&lt;p&gt;&lt;img src="https://raw.githubusercontent.com/tautik/image-assets/main/kafka-partitions.png" alt="kafka with partitions: messages keyed by user id all land in the same partition, so the same consumer processes them in order; different keys go to different partitions for parallelism"&gt;&lt;/p&gt;
&lt;p&gt;what you get out of this:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;parallelism&lt;/strong&gt; — different users&amp;#39; messages go to different partitions, processed concurrently by different consumers&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;order within a key&lt;/strong&gt; — all of user 1&amp;#39;s messages live in the same partition, processed by the same consumer, in the order they were pushed&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;so user 1 gets welcome → tour → checkin in the right order. user 2&amp;#39;s messages can interleave with user 1&amp;#39;s because they&amp;#39;re on a different partition, but user 2&amp;#39;s &lt;em&gt;own&lt;/em&gt; sequence is preserved too. ordering where it matters, parallelism where it doesn&amp;#39;t.&lt;/p&gt;
&lt;p&gt;this is why people reach for kafka in event-sourcing setups, in pipelines that care about per-entity ordering, in anything where &amp;quot;process this user&amp;#39;s stuff in order&amp;quot; is a real requirement. rabbitmq and sqs don&amp;#39;t have this concept first-class. kafka does, and it&amp;#39;s beautifully designed.&lt;/p&gt;
&lt;p&gt;if you want to actually get good at this, kafka&amp;#39;s consumer groups concept is worth a separate deep-dive — partition rebalancing, offsets, what happens when a consumer dies, the works. there&amp;#39;s a lot under the hood.&lt;/p&gt;
&lt;details class="recall"&gt;&lt;summary&gt;&lt;span class="recall-mark"&gt;&amp;#10045; RECALL&lt;/span&gt; you need parallelism &lt;em&gt;and&lt;/em&gt; per-user ordering. why can&amp;#39;t a plain queue give you both, and what&amp;#39;s the shape of the system that can?&lt;/summary&gt;&lt;p&gt;with one shared queue any worker grabs any message, so ordering and parallelism fight each other — fixing order means one worker, fixing throughput means many. the fix is partitioning by key: all of one user&amp;#39;s messages land in the same partition, consumed by one consumer in pushed order, while other users&amp;#39; partitions process in parallel. order within a key, parallelism across keys — the model kafka makes first-class and rabbitmq/sqs don&amp;#39;t.&lt;/p&gt;&lt;/details&gt;

&lt;h2 id="the-actual-lesson-here"&gt;the actual lesson here&lt;/h2&gt;
&lt;p&gt;queues are not one thing. they&amp;#39;re a whole spectrum of design decisions:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;sync vs async architecture is the first call. async wins for any non-critical work.&lt;/li&gt;
&lt;li&gt;push vs pull is the second call. push for simple consumers and built-in dedup. pull for control and tunability.&lt;/li&gt;
&lt;li&gt;single-queue fifo is a myth at any real scale.&lt;/li&gt;
&lt;li&gt;when ordering matters, you&amp;#39;re either reaching for kafka or rolling your own partitioning logic on top.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;and here&amp;#39;s the philosophy. &lt;strong&gt;there is no best queue.&lt;/strong&gt; rabbitmq is great when you want simple. sqs is great when you want zero ops and full control. kafka is great when ordering and replay matter. bullmq is great when you&amp;#39;re already on redis. you can mix and match — your notification system can be push-based, your video pipeline can be pull-based, your event log can be on kafka. nobody&amp;#39;s stopping you. &lt;strong&gt;you are paid to solve a problem and not necessarily use the fanciest queue to solve it.&lt;/strong&gt;&lt;/p&gt;
&lt;details class="recall"&gt;&lt;summary&gt;&lt;span class="recall-mark"&gt;&amp;#10045; RECALL&lt;/span&gt; someone asks which queue is &amp;quot;best&amp;quot; for the new system. what&amp;#39;s the right answer?&lt;/summary&gt;&lt;p&gt;there isn&amp;#39;t one — match the mechanism to the workload. push when you want dead-simple consumers with free dedup and retries, pull when you want control and near-zero ops, kafka when per-key ordering or replay actually matters. and mix them freely in one architecture: push-based notifications, pull-based video pipeline, event log on kafka. you&amp;#39;re paid to solve a problem, not to use the fanciest queue.&lt;/p&gt;&lt;/details&gt;
</content>
  </entry>
  <entry>
    <title>scaling reads</title>
    <link href="https://tautik.me/scaling-reads/"/>
    <id>https://tautik.me/scaling-reads/</id>
    <updated>2025-12-05T00:00:00Z</updated>
    <content type="html">&lt;p&gt;scaling reads sounds boring on the tin and then you realize it&amp;#39;s basically every system design problem in disguise. the moment your app gets even a little popular, reads outpace writes by 10×, then 100×, then 1000× — and your single database starts crying. this post walks through the actual ladder you climb to handle it: from &amp;quot;just add an index&amp;quot; all the way up to global CDNs, with the trade-offs that bite you at each rung.&lt;/p&gt;
&lt;p&gt;think about your instagram feed. you open the app, and bam — dozens of photos, each one pulling its own metadata, user info, like counts, comment previews. one feed load might fire &lt;strong&gt;100+ db queries&lt;/strong&gt;. meanwhile what&amp;#39;d you actually do? you posted one photo. that&amp;#39;s a single write. one tweet → thousands of reads. one product upload → hundreds of browses. youtube serves billions of video views per day on top of millions of uploads. textbook ratio starts at 10:1 reads-to-writes but content-heavy apps are prolly looking at 100:1 or 1000:1.&lt;/p&gt;
&lt;p&gt;and here&amp;#39;s the problemmmm — when reads pile up, your db isn&amp;#39;t slowing down because of bad code. it&amp;#39;s physics. CPU cores execute a finite number of instructions per second, RAM holds a finite amount, disk I/O is bounded by what the SSD can physically push. when you hit those walls, no amount of clever code is gonna help. you&amp;#39;ve gotta change the architecture.&lt;/p&gt;
&lt;h2 id="what-you-39-ll-take-away"&gt;what you&amp;#39;ll take away&lt;/h2&gt;
&lt;p&gt;quick pointers so you know what to look for as you read:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;reads always outgrow writes.&lt;/strong&gt; plan the system around reads first.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;start cheap, climb only as far as you need.&lt;/strong&gt; indexes → denorm → replicas → cache → CDN.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;composite + covering indexes beat denormalization most of the time.&lt;/strong&gt; cheaper, simpler, no consistency drama.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;replicas buy throughput, caches buy latency.&lt;/strong&gt; different problems, different tools.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;cache invalidation is where the bodies are buried.&lt;/strong&gt; TTL is a safety net, not a strategy.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;physics wins eventually.&lt;/strong&gt; at extreme scale you need versioned keys, request coalescing, probabilistic refresh.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;the right answer is sometimes &amp;quot;we don&amp;#39;t need to scale yet.&amp;quot;&lt;/strong&gt; modern hardware is wild.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="the-ladder"&gt;the ladder&lt;/h2&gt;
&lt;p&gt;read scaling is a ladder. each rung adds operational pain in exchange for more throughput:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;optimize within your db&lt;/strong&gt; — indexes, composite/covering indexes, denormalization&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;scale horizontally&lt;/strong&gt; — read replicas, then sharding when you must&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;add caches&lt;/strong&gt; — Redis/Memcached, then CDNs at the edge&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;let&amp;#39;s walk it.&lt;/p&gt;
&lt;h2 id="optimize-within-your-database"&gt;optimize within your database&lt;/h2&gt;
&lt;p&gt;before you reach for new infrastructure, your existing db almost always has more headroom than you think. modern postgres or mysql is not a toy — with the right schema and indexes you can squeeze tens of thousands of QPS out of a single box.&lt;/p&gt;
&lt;h3 id="indexes-always-step-one"&gt;indexes — always step one&lt;/h3&gt;
&lt;p&gt;an index is just a sorted lookup table that points back into your real data. think of the index in the back of a textbook — instead of flipping every page hunting for &amp;quot;B-tree&amp;quot;, you check the index, see &amp;quot;page 184&amp;quot;, jump straight there.&lt;/p&gt;
&lt;p&gt;without one, your db does a &lt;strong&gt;full table scan&lt;/strong&gt; — reads every row to find what you&amp;#39;re after. with one, it jumps directly. that&amp;#39;s &lt;code&gt;O(n)&lt;/code&gt; becoming &lt;code&gt;O(log n)&lt;/code&gt; — the difference between scanning a million rows and checking maybe 20 entries in a tree. most general-purpose indexes are B-trees. there are specialized ones — hash for exact matches, GIN/GiST for full-text or geo — but B-tree is your default.&lt;/p&gt;
&lt;p&gt;so the first move for any read scaling problem? add indexes on the columns you query, sort by, or join on. social app filters posts by hashtag → index &lt;code&gt;hashtag&lt;/code&gt;. sort products by price → index &lt;code&gt;price&lt;/code&gt;. dead simple.&lt;/p&gt;
&lt;p&gt;old textbooks freak out about &amp;quot;too many indexes slowing down writes.&amp;quot; this fear is way overblown for modern hardware. yes there&amp;#39;s some write overhead per index, but &lt;strong&gt;under-indexing kills more apps than over-indexing ever will&lt;/strong&gt;. add the indexes you need. don&amp;#39;t be cute about it.&lt;/p&gt;
&lt;h3 id="composite-covering-indexes-the-cheaper-alternative-to-denormalization"&gt;composite + covering indexes — the cheaper alternative to denormalization&lt;/h3&gt;
&lt;p&gt;before you reach for denormalization (which is messy, more in a sec), see whether a &lt;strong&gt;composite index&lt;/strong&gt; can solve it first. composite means an index across multiple columns. great for queries that filter or sort on more than one thing.&lt;/p&gt;
&lt;div class="codeblock"&gt;&lt;pre&gt;&lt;code class="hljs"&gt; 
SELECT post_id, post_title FROM posts
WHERE user_id = ? AND created_at &amp;gt; ?
ORDER BY created_at DESC;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;a composite index on &lt;code&gt;(user_id, created_at)&lt;/code&gt; lets the db satisfy the entire &lt;code&gt;WHERE&lt;/code&gt; and the sort using just the index. push it further with a &lt;strong&gt;covering index&lt;/strong&gt; — &lt;code&gt;(user_id, created_at, post_title)&lt;/code&gt; — and the index contains every column the query needs. the db doesn&amp;#39;t even touch the table. people call this an &amp;quot;index-only scan&amp;quot; and it&amp;#39;s stupidly fast.&lt;/p&gt;
&lt;p&gt;a few rules. column order matters — put the most selective filter first. &lt;code&gt;(user_id, created_at)&lt;/code&gt; helps queries on &lt;code&gt;user_id&lt;/code&gt; alone, but not on &lt;code&gt;created_at&lt;/code&gt; alone. sort order is free if you align it — if your &lt;code&gt;ORDER BY&lt;/code&gt; matches the column order in the index, the db skips the sort step entirely. and don&amp;#39;t over-cover — every column inflates write cost and storage.&lt;/p&gt;
&lt;p&gt;why does this matter? because composite/covering indexes often kill the &lt;em&gt;need&lt;/em&gt; for denormalization. denormalization brings storage bloat AND consistency headaches. a composite index brings just write cost. that&amp;#39;s a way better trade. &lt;strong&gt;try this first.&lt;/strong&gt; denormalize only when no index can satisfy the query.&lt;/p&gt;
&lt;h3 id="hardware-boring-but-it-works"&gt;hardware — boring but it works&lt;/h3&gt;
&lt;p&gt;sometimes the answer is just bigger hardware. swap spinning disks for SSDs, get 10–100× faster random I/O. add RAM, more of your dataset stays in memory. add cores, handle more concurrent queries.&lt;/p&gt;
&lt;p&gt;&lt;img src="https://raw.githubusercontent.com/tautik/image-assets/main/vertical-scaling.png" alt="vertical scaling — small db \(8GB / 100GB\) vs big db \(128GB / 16TB\)"&gt;&lt;/p&gt;
&lt;p&gt;won&amp;#39;t solve every problem but it buys breathing room, fast. real limits though — you&amp;#39;ll hit the ceiling on the biggest box your cloud provider offers, and a single failure takes the whole thing down. it&amp;#39;s a stopgap that buys time to do the real architectural work.&lt;/p&gt;
&lt;h3 id="denormalization-when-no-index-can-save-you"&gt;denormalization — when no index can save you&lt;/h3&gt;
&lt;p&gt;normalization splits data across tables to avoid duplication. nice for storage, ugly for reads — joins everywhere. for read-heavy systems where joins start eating CPU, &lt;strong&gt;denormalization&lt;/strong&gt; flips the script: you intentionally duplicate data to make reads single-table.&lt;/p&gt;
&lt;p&gt;classic e-commerce. normalized version joins &lt;code&gt;users&lt;/code&gt;, &lt;code&gt;orders&lt;/code&gt;, &lt;code&gt;order_items&lt;/code&gt;, &lt;code&gt;products&lt;/code&gt;:&lt;/p&gt;
&lt;div class="codeblock"&gt;&lt;pre&gt;&lt;code class="hljs"&gt; 
SELECT u.name, o.order_date, p.product_name, p.price
FROM users u
JOIN orders o ON u.id = o.user_id
JOIN order_items oi ON o.id = oi.order_id
JOIN products p ON oi.product_id = p.id
WHERE o.id = 12345;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;four tables, three joins. fine at small scale, painful at thousands of order pages per second. denormalized: just have an &lt;code&gt;order_summary&lt;/code&gt; table with everything inline.&lt;/p&gt;
&lt;div class="codeblock"&gt;&lt;pre&gt;&lt;code class="hljs"&gt; 
SELECT user_name, order_date, product_name, price
FROM order_summary
WHERE order_id = 12345;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;one table, one row, done.&lt;/p&gt;
&lt;p&gt;&lt;img src="https://raw.githubusercontent.com/tautik/image-assets/main/denormalization.png" alt="denormalization — three normalized tables collapse into one fat orders table with embedded info"&gt;&lt;/p&gt;
&lt;p&gt;yes you&amp;#39;re storing the user name redundantly. yes that&amp;#39;s storage cost. for a read-heavy system that&amp;#39;s often worth it. the catch — when a user changes their name, you&amp;#39;ve gotta update it everywhere. that&amp;#39;s the consistency tax, and every place a denormalized field lives is a place that can drift out of sync if your write path has a bug.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;rule of thumb: only denormalize when reads vastly outnumber writes. if writes are frequent the consistency complexity prolly isn&amp;#39;t worth it.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;&lt;strong&gt;materialized views&lt;/strong&gt; are denormalization for aggregations — instead of recomputing the average rating on every product page load, you precompute once and store the result. typically refreshed by a background job.&lt;/p&gt;
&lt;div class="codeblock"&gt;&lt;pre&gt;&lt;code class="hljs"&gt; 
CREATE MATERIALIZED VIEW product_ratings AS
SELECT p.id, AVG(r.rating)
FROM products p
JOIN reviews r ON p.id = r.product_id
GROUP BY p.id;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;details class="recall"&gt;&lt;summary&gt;&lt;span class="recall-mark"&gt;&amp;#10045; RECALL&lt;/span&gt; your joins are eating cpu on a read-heavy table. why try a covering index before reaching for denormalization?&lt;/summary&gt;&lt;p&gt;a covering index puts every column the query needs into the index itself — the db does an index-only scan and never touches the table. that buys most of denormalization&amp;#39;s read win at the cost of just some write overhead, while denormalization adds storage bloat plus a consistency tax: every duplicated field is a place that can drift out of sync. denormalize only when no index can satisfy the query.&lt;/p&gt;&lt;/details&gt;

&lt;h2 id="scale-your-database-horizontally"&gt;scale your database horizontally&lt;/h2&gt;
&lt;p&gt;at some point one machine isn&amp;#39;t enough. rough rule of thumb: above about &lt;strong&gt;50–100k indexed reads per second&lt;/strong&gt; , you&amp;#39;ve gotta either add a cache or distribute across more boxes. exact numbers vary based on hardware and query patterns, but that&amp;#39;s the ballpark.&lt;/p&gt;
&lt;h3 id="read-replicas-leader-followers"&gt;read replicas — leader/followers&lt;/h3&gt;
&lt;p&gt;simplest horizontal play. you keep your primary db (the leader) handling all writes, spin up extra copies (followers) that get every write replicated to them. reads can go to any follower. read throughput multiplies.&lt;/p&gt;
&lt;p&gt;&lt;img src="https://raw.githubusercontent.com/tautik/image-assets/main/leader-follower.png" alt="leader-follower replication — leader pushes writes to followers, followers serve reads"&gt;&lt;/p&gt;
&lt;p&gt;bonus: redundancy. if the leader dies, you promote a follower. minimal downtime.&lt;/p&gt;
&lt;p&gt;the trade-off is &lt;strong&gt;replication lag&lt;/strong&gt;. when you write to the leader, it takes some time before followers see that write. read-your-own-write becomes weird — a user updates their profile, the request hits a follower that&amp;#39;s a beat behind, and they see their old name. classic gotcha.&lt;/p&gt;
&lt;p&gt;so you&amp;#39;ve got a choice. &lt;strong&gt;synchronous replication&lt;/strong&gt; — leader waits for followers to confirm before acking. fully consistent, but writes are bottlenecked by the slowest follower. &lt;strong&gt;asynchronous&lt;/strong&gt; — leader acks the write, replicates in the background. fast writes, real lag window. most production systems are async by default and just accept the staleness. the ones that need fresh reads after writes route critical reads back to the leader.&lt;/p&gt;
&lt;h3 id="sharding-when-one-box-can-39-t-even-hold-the-data"&gt;sharding — when one box can&amp;#39;t even hold the data&lt;/h3&gt;
&lt;p&gt;read replicas help when &lt;em&gt;throughput&lt;/em&gt; is the issue. they don&amp;#39;t help when the &lt;em&gt;dataset&lt;/em&gt; is the issue. if you have 50TB of data and a single instance can&amp;#39;t even store it, you need sharding — splitting the data itself across multiple databases. two common ways:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;functional sharding&lt;/strong&gt; — split by domain. user data in one db, product data in another, likes in a third.&lt;/p&gt;
&lt;p&gt;&lt;img src="https://raw.githubusercontent.com/tautik/image-assets/main/functional-sharding.png" alt="functional sharding — server fans out to DB1 \(Posts\), DB2 \(Users\), DB3 \(Likes\)"&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;geographic sharding&lt;/strong&gt; — split by region. US users in US dbs, EU users in EU dbs. lower latency, less load on any single instance.&lt;/p&gt;
&lt;p&gt;&lt;img src="https://raw.githubusercontent.com/tautik/image-assets/main/geographic-sharding.png" alt="geographic sharding — DBs per region, each region serves its own"&gt;&lt;/p&gt;
&lt;p&gt;real talk though: sharding is operationally a beast — cross-shard queries, rebalancing pain, distributed transactions, hot shards. it&amp;#39;s primarily a &lt;em&gt;write scaling&lt;/em&gt; technique. for read scaling specifically, &lt;strong&gt;caching is almost always the better play&lt;/strong&gt;.&lt;/p&gt;
&lt;details class="recall"&gt;&lt;summary&gt;&lt;span class="recall-mark"&gt;&amp;#10045; RECALL&lt;/span&gt; a user updates their profile and immediately sees their old name. what happened, and what are your options?&lt;/summary&gt;&lt;p&gt;replication lag — the read hit an async follower that hadn&amp;#39;t applied the write yet. you can go synchronous (fully consistent, but every write waits on the slowest follower), or stay async and route read-your-own-write critical reads back to the leader. most production systems pick async plus selective leader reads, accepting staleness everywhere else in exchange for fast writes.&lt;/p&gt;&lt;/details&gt;

&lt;h2 id="add-caches"&gt;add caches&lt;/h2&gt;
&lt;p&gt;you&amp;#39;ve optimized the db, you&amp;#39;ve added replicas. you still need more. now you reach for cache.&lt;/p&gt;
&lt;p&gt;most apps have heavily skewed access patterns. millions read the same viral tweet. thousands hit the same popular product. the same trending video gets pulled millions of times. you&amp;#39;re literally executing the same query over and over to return the same result. that&amp;#39;s a caching layup.&lt;/p&gt;
&lt;p&gt;caches store hot data in memory. databases read from disk and run query planners. caches just hand you the bytes back. the gap is &lt;strong&gt;sub-millisecond cache reads vs tens of milliseconds for even a well-tuned query&lt;/strong&gt;. orders of magnitude.&lt;/p&gt;
&lt;h3 id="application-level-caching-redis-or-memcached"&gt;application-level caching — Redis or Memcached&lt;/h3&gt;
&lt;p&gt;stick a Redis or Memcached instance between your app and your db. on every read, check the cache first. hit? return immediately. miss? query the db, populate the cache, return.&lt;/p&gt;
&lt;p&gt;&lt;img src="https://raw.githubusercontent.com/tautik/image-assets/main/app-level-caching.png" alt="application-level caching — server checks cache first, falls back to db on miss"&gt;&lt;/p&gt;
&lt;p&gt;popular data naturally stays hot. celebrity profiles get hit constantly so they live in cache forever. inactive profiles get cached only when accessed and expire after their TTL. the system self-tunes.&lt;/p&gt;
&lt;p&gt;now the hard part. &lt;strong&gt;cache invalidation&lt;/strong&gt; is genuinely one of the trickiest things in software. when underlying data changes, you&amp;#39;ve gotta make sure the cache doesn&amp;#39;t keep serving the old version. main strategies:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;TTL&lt;/strong&gt; — fixed lifetime per entry. simple, but you accept staleness up to the window. great for data with predictable update cadence.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;write-through&lt;/strong&gt; — delete or update the cache the moment you write to db. consistent, but adds latency to every write and you&amp;#39;ve gotta handle the case where db write succeeds but cache invalidation fails.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;write-behind&lt;/strong&gt; — queue invalidation events, process async. faster writes, brief stale window.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;tagged&lt;/strong&gt; — group entries by tag (&lt;code&gt;user:123:posts&lt;/code&gt;), invalidate by tag when related data changes. powerful but you&amp;#39;ve gotta track relationships.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;versioned keys&lt;/strong&gt; — encode a version into the key. bump on writes, old entries become unreachable. clean, no race conditions — more below.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;most production systems combine approaches. short TTLs (5–15 min) as a safety net plus active invalidation for critical data like profiles or inventory. low-stakes data (recommendation scores, view counts) can lean entirely on TTL.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;drive your TTL from a product requirement. if the spec says &amp;quot;search results can be at most 30 seconds stale&amp;quot;, your TTL is 30 seconds. let the requirement set the consistency budget.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3 id="cdn-edge-caching"&gt;CDN + edge caching&lt;/h3&gt;
&lt;p&gt;CDNs extend caching to globally-distributed edge servers. originally just for static assets — images, CSS, JS — but modern CDNs cache dynamic content too: API responses, query results.&lt;/p&gt;
&lt;p&gt;&lt;img src="https://raw.githubusercontent.com/tautik/image-assets/main/cdn-edge-caching.png" alt="CDN edge caching — origin server in one region, CDN nodes worldwide; client reads from closest CDN"&gt;&lt;/p&gt;
&lt;p&gt;the latency win is dramatic. a user in tokyo hitting your origin in virginia is doing a 200ms round-trip. hitting a tokyo CDN edge? 10ms. that&amp;#39;s a different category of fast.&lt;/p&gt;
&lt;p&gt;for read-heavy apps, &lt;strong&gt;CDN caching can wipe out 90%+ of origin load&lt;/strong&gt;. product pages, public profiles, search results — anything multiple people request — is a candidate. trade-off is invalidation across many edge locations, gnarly when you need it but worth the engineering for the win.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;CDNs only make sense for content shared across users. don&amp;#39;t cache user-specific stuff like personal settings or private messages — there&amp;#39;s no hit-rate benefit when only one user ever requests it.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;details class="recall"&gt;&lt;summary&gt;&lt;span class="recall-mark"&gt;&amp;#10045; RECALL&lt;/span&gt; you already have read replicas. what does a cache buy you that another replica doesn&amp;#39;t?&lt;/summary&gt;&lt;p&gt;latency, not just throughput — a cache hands back bytes from memory in sub-millisecond time, while even a well-tuned replica query pays disk reads and a query planner for tens of milliseconds. and access patterns are heavily skewed: millions of reads hammer the same viral content, so serving the identical result from memory instead of re-executing the same query is the whole win. replicas multiply query capacity; caches change the category of fast.&lt;/p&gt;&lt;/details&gt;

&lt;details class="recall"&gt;&lt;summary&gt;&lt;span class="recall-mark"&gt;&amp;#10045; RECALL&lt;/span&gt; why is &amp;quot;just set a TTL&amp;quot; not an invalidation strategy, and what do production systems actually do?&lt;/summary&gt;&lt;p&gt;TTL alone means accepting staleness up to the full window for everything, including data where stale is unacceptable. production systems combine: short TTLs as a safety net plus active invalidation (write-through, tagged, or versioned keys) for critical data like profiles and inventory, while low-stakes data leans on TTL alone. and the TTL itself should come from a product requirement — &amp;quot;at most 30 seconds stale&amp;quot; means a 30-second TTL.&lt;/p&gt;&lt;/details&gt;

&lt;h2 id="applying-this-in-real-systems"&gt;applying this in real systems&lt;/h2&gt;
&lt;p&gt;most production systems eventually need read scaling somewhere. the discipline is figuring out &lt;em&gt;where&lt;/em&gt;. walk through your API endpoint by endpoint and identify the high-volume ones — that&amp;#39;s where the work goes. start with query optimization, then caching, then replicas.&lt;/p&gt;
&lt;p&gt;what makes a system robust is identifying read bottlenecks &lt;strong&gt;proactively&lt;/strong&gt; , before the pager goes off. when sketching a new feature&amp;#39;s API, pause at endpoints that&amp;#39;ll get hammered. how often will this be called? does it need to be fresh? is the data shared across users? the answers tell you exactly which tools to reach for.&lt;/p&gt;
&lt;p&gt;a few patterns that show up over and over:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;URL shorteners&lt;/strong&gt; — extreme read/write skew. one URL shortened once, accessed millions of times. caching dream. cache the short→long mapping in Redis with no expiration, CDN globally.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;event ticketing&lt;/strong&gt; — event pages get crushed when tickets drop. cache event details, venue info, seating charts aggressively. but &lt;strong&gt;never cache actual seat availability&lt;/strong&gt; — you&amp;#39;ll oversell.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;social feeds&lt;/strong&gt; — pre-compute feeds for active users, cache recent posts from followed users. users mostly only read the first page so aggressive caching pays off.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;video platforms&lt;/strong&gt; — cache metadata aggressively (titles, descriptions, thumbnails change rarely). view counts can be eventually consistent. CDN every thumbnail.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;and where this whole playbook doesn&amp;#39;t apply: write-heavy systems like Uber&amp;#39;s location tracking or IoT sensors (focus on writes first), tiny scale where a single indexed db handles everything (don&amp;#39;t over-engineer), strongly consistent systems like financial transactions (you can still cache, but with aggressive invalidation), and real-time collab like Google Docs (caching actively hurts).&lt;/p&gt;
&lt;details class="recall"&gt;&lt;summary&gt;&lt;span class="recall-mark"&gt;&amp;#10045; RECALL&lt;/span&gt; before adding any infrastructure, how do you decide where read-scaling work actually goes — and when is the answer &amp;quot;nowhere yet&amp;quot;?&lt;/summary&gt;&lt;p&gt;walk the api endpoint by endpoint and find the high-volume ones, asking three questions: how often is it called, does it need to be fresh, is the data shared across users. then climb the ladder in order — query optimization, then caching, then replicas — without skipping rungs (check whether a composite index solves it for free before reaching for redis). and skip the playbook entirely for write-heavy systems, tiny scale, or real-time collab where caching actively hurts.&lt;/p&gt;&lt;/details&gt;

&lt;h2 id="the-gnarly-edge-cases"&gt;the gnarly edge cases&lt;/h2&gt;
&lt;p&gt;a few specific failure modes show up over and over once a system gets real traffic. worth knowing how to spot them.&lt;/p&gt;
&lt;h3 id="quot-queries-got-slower-as-the-data-grew-quot"&gt;&amp;quot;queries got slower as the data grew&amp;quot;&lt;/h3&gt;
&lt;p&gt;your app launched with 10k users and queries were instant. now you&amp;#39;ve got 10 million users and a simple lookup takes 30 seconds. CPU pinned at 100%. simple queries, nothing fancy.&lt;/p&gt;
&lt;p&gt;the answer is almost always &lt;strong&gt;a missing index&lt;/strong&gt;. without one, every query does a full table scan. 10 million rows scanned to find one user by email. multiply by hundreds of concurrent logins and the db spends all its time reading disk.&lt;/p&gt;
&lt;div class="codeblock"&gt;&lt;pre&gt;&lt;code class="hljs"&gt; 
-- before: full table scan
EXPLAIN SELECT * FROM users WHERE email = 'user@example.com';
-- Seq Scan on users (cost=0.00..412,000.00 rows=1)

CREATE INDEX idx_users_email ON users(email);

-- after: index scan
-- Index Scan using idx_users_email (cost=0.43..8.45 rows=1)&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;for compound queries, column order in the index matters. for &lt;code&gt;WHERE status = ? AND created_at &amp;gt; ?&lt;/code&gt;, an index on &lt;code&gt;(status, created_at)&lt;/code&gt; helps queries on status alone and queries on both — but won&amp;#39;t help queries filtering only by created_at.&lt;/p&gt;
&lt;h3 id="quot-millions-of-concurrent-reads-to-the-same-key-quot"&gt;&amp;quot;millions of concurrent reads to the same key&amp;quot;&lt;/h3&gt;
&lt;p&gt;celebrity drops a post. millions try to read the same cached entry simultaneously. your cache server, which normally handles 50k qps, is suddenly looking at 500k qps for ONE key. it starts timing out. site goes down — purely from read traffic.&lt;/p&gt;
&lt;p&gt;the problem is that traditional caching assumes load distributes across many keys. when everyone wants ONE key, the assumption breaks. even though the data is in memory, serializing it and sending it over the network 500k times per second melts the cache server.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;fix 1: request coalescing.&lt;/strong&gt; when multiple requests hit the same key on the same server, combine them into a single backend request. one fetch, broadcast the result.&lt;/p&gt;
&lt;div class="codeblock"&gt;&lt;pre&gt;&lt;code class="hljs"&gt; 
class CoalescingCache:
    def __init__(self):
        self.inflight = {}

    async def get(self, key):
        if key in self.inflight:
            return await self.inflight[key]
        future = asyncio.Future()
        self.inflight[key] = future
        try:
            value = await fetch_from_backend(key)
            future.set_result(value)
            return value
        finally:
            del self.inflight[key]&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;caps backend load at N — the number of app servers. even if a billion users want the same key, the backend only sees one request per server.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;fix 2: cache key fanout.&lt;/strong&gt; spread one hot key across multiple entries. instead of &lt;code&gt;feed:taylor-swift&lt;/code&gt;, store the same data under &lt;code&gt;feed:taylor-swift:1&lt;/code&gt; through &lt;code&gt;:N&lt;/code&gt;. clients pick a random suffix. now those 500k qps distribute across N keys. trade-offs: more memory, more invalidation work. for hot-key scenarios that threaten availability, that redundancy is dirt cheap insurance.&lt;/p&gt;
&lt;details class="recall"&gt;&lt;summary&gt;&lt;span class="recall-mark"&gt;&amp;#10045; RECALL&lt;/span&gt; a celebrity post turns one cache key into 10× your cache server&amp;#39;s capacity and the site dies from pure read traffic. why does the cache melt even though the data is in memory, and what are the two fixes?&lt;/summary&gt;&lt;p&gt;caching assumes load spreads across many keys — when everyone wants ONE key, serializing and shipping the same value hundreds of thousands of times per second saturates the server regardless of where the data lives. fix one: request coalescing — duplicate in-flight requests on a server collapse into a single backend fetch, capping load at one request per app server. fix two: key fanout — store the value under N suffixed copies and let clients pick randomly, spreading the heat.&lt;/p&gt;&lt;/details&gt;

&lt;h3 id="quot-cache-stampede-when-hot-entries-expire-quot"&gt;&amp;quot;cache stampede when hot entries expire&amp;quot;&lt;/h3&gt;
&lt;p&gt;your homepage data has a 1-hour TTL. serving 100k qps from cache like a champ. the hour ticks over. all 100k requests in that instant see a miss simultaneously. every single one tries to rebuild from the db. your db, sized for maybe 1k qps of misses on a normal day, is now staring at 100k identical queries. self-DDoS. &lt;strong&gt;cache stampede.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;three approaches, in increasing sophistication:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;distributed lock&lt;/strong&gt; — only the first miss rebuilds; everyone else waits. protects the backend, but if the rebuild fails or stalls, thousands of waiters time out. fragile.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;probabilistic early refresh&lt;/strong&gt; — as the entry ages, each request has a small but growing chance of triggering a background refresh. 1% at minute 50, 5% at 55, 20% at 59. instead of stampeding at minute 60, refreshes spread across the last 10 minutes. clean, no locks.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;scheduled background refresh&lt;/strong&gt; — a worker continuously refreshes the hottest entries before expiry. they never go stale. costs ops complexity and you waste work on entries that might not be requested, but for hot content it&amp;#39;s worth it.&lt;/li&gt;
&lt;/ul&gt;
&lt;details class="recall"&gt;&lt;summary&gt;&lt;span class="recall-mark"&gt;&amp;#10045; RECALL&lt;/span&gt; a hot entry&amp;#39;s TTL expires and your db gets self-DDoSed by identical rebuild queries. why does probabilistic early refresh beat a distributed lock here?&lt;/summary&gt;&lt;p&gt;the stampede happens because every request sees the miss at the same instant. a lock lets only the first miss rebuild, but if that rebuild stalls or fails, thousands of waiters time out — fragile. probabilistic refresh gives each request a small, growing chance of triggering a background rebuild as the entry ages, so refreshes spread across the final minutes instead of detonating at expiry. no locks, no waiters, clean.&lt;/p&gt;&lt;/details&gt;

&lt;h3 id="quot-users-need-updates-immediately-eventual-consistency-isn-39-t-enough-quot"&gt;&amp;quot;users need updates immediately — eventual consistency isn&amp;#39;t enough&amp;quot;&lt;/h3&gt;
&lt;p&gt;eventual consistency is fine for most stuff. user changes their bio? if it shows up in 30 seconds across all caches, who cares. but for some data, stale is unacceptable. event venue updates 30 minutes before kickoff? attendees can&amp;#39;t be looking at the old address.&lt;/p&gt;
&lt;p&gt;naive approach is to delete the cache on write. real problems: which caches do you delete from (Redis, CDN, browser)? what if delete fails? &lt;strong&gt;race condition&lt;/strong&gt; — another request reads from a follower replica that hasn&amp;#39;t replicated yet, gets the old value, writes it back to the cache &lt;em&gt;after&lt;/em&gt; you deleted it. cache has stale data again.&lt;/p&gt;
&lt;p&gt;better approach: &lt;strong&gt;cache versioning&lt;/strong&gt;. instead of deleting old entries, you make them unreachable by changing the cache key whenever data changes. each record has a &lt;code&gt;version&lt;/code&gt; column. every update increments it in the same transaction. on read, fetch the current version, build the cache key as &lt;code&gt;entity:id:vN&lt;/code&gt;, fetch using that key. on write, bump the version (&lt;code&gt;v42 → v43&lt;/code&gt;), write new data under the new key.&lt;/p&gt;
&lt;div class="codeblock"&gt;&lt;pre&gt;&lt;code class="hljs"&gt; 
event:123:v42     # before update
event:123:v43     # after update — readers automatically move here&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;old entries never get explicitly deleted — they just become unreachable. let TTLs clean them up.&lt;/p&gt;
&lt;p&gt;why this kills the race condition: a &amp;quot;late writer&amp;quot; can&amp;#39;t overwrite new data because the db forced a new version number. their write lands at &lt;code&gt;v42&lt;/code&gt;, nobody&amp;#39;s reading &lt;code&gt;v42&lt;/code&gt; anymore. no partial-invalidation worries — you&amp;#39;re not deleting anything, you&amp;#39;re routing past it. atomic by definition because version increments are atomic in the db.&lt;/p&gt;
&lt;p&gt;trade-offs are real: two cache lookups per request, small extra latency. old versioned keys accumulate so you&amp;#39;ve gotta TTL them. and this works best for single-entity caches like profiles or product details — doesn&amp;#39;t help much with computed data like search results or feeds where invalidation is inherently complex.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;for computed data, a &lt;strong&gt;deleted items cache&lt;/strong&gt; is your friend. small working set of recently deleted/hidden/changed IDs. when serving feeds, filter cached results against this set. lets you serve mostly-correct cached data immediately while doing proper invalidation in the background.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;details class="recall"&gt;&lt;summary&gt;&lt;span class="recall-mark"&gt;&amp;#10045; RECALL&lt;/span&gt; why does cache versioning kill the stale-write race that delete-on-write can&amp;#39;t?&lt;/summary&gt;&lt;p&gt;with delete-on-write, a reader can grab the old value from a lagging replica and write it back into the cache &lt;em&gt;after&lt;/em&gt; your delete — stale data resurrected. with versioned keys, every update atomically bumps a version in the same db transaction, readers build keys from the current version, and the late writer lands on &lt;code&gt;vN&lt;/code&gt; while everyone reads &lt;code&gt;vN+1&lt;/code&gt;. nothing is deleted — you route past old entries and let TTLs sweep them up.&lt;/p&gt;&lt;/details&gt;

&lt;h2 id="wrapping-up"&gt;wrapping up&lt;/h2&gt;
&lt;p&gt;read scaling is the most common scaling challenge in production because read traffic grows exponentially faster than write traffic, and at scale &lt;strong&gt;physics wins&lt;/strong&gt;. no amount of clever code can outrun hardware limits when you&amp;#39;re serving millions of concurrent users.&lt;/p&gt;
&lt;p&gt;the path is the same every time: optimize within the db first (indexes, composite/covering, denormalize if you must), scale horizontally if you&amp;#39;ve gotta (read replicas first, sharding only when the dataset is the bottleneck), cache the rest (Redis/Memcached at the app layer, CDN at the edge).&lt;/p&gt;
&lt;p&gt;the most common mistake is skipping rungs. teams jump straight to &amp;quot;let&amp;#39;s add Redis&amp;quot; without ever checking if a composite index would&amp;#39;ve solved it for free. don&amp;#39;t be that team. start cheap, climb only as far as you need.&lt;/p&gt;
&lt;p&gt;nothing is best. everything depends on your usecase, your read patterns, your consistency budget. &lt;strong&gt;you&amp;#39;re paid to solve a problem, not to ship the fanciest architecture.&lt;/strong&gt;&lt;/p&gt;
</content>
  </entry>
  <entry>
    <title>why databases use b+ trees to hold data</title>
    <link href="https://tautik.me/b-trees/"/>
    <id>https://tautik.me/b-trees/</id>
    <updated>2025-10-01T00:00:00Z</updated>
    <content type="html">&lt;p&gt;so we all know most databases store data in b+ trees, but &lt;strong&gt;why&lt;/strong&gt;? not just sql databases either - even non-relational databases like mongodb leverage b+ trees to store their data. mongodb&amp;#39;s storage engine wiredtiger serializes collection data as b+ trees on disk. but let me tell you why there was even a need to introduce a data structure like b+ trees in the first place, and how this actually works in practice.&lt;/p&gt;
&lt;h2 id="what-you-39-ll-take-away"&gt;what you&amp;#39;ll take away&lt;/h2&gt;
&lt;p&gt;quick pointers so you know what to look for as you read:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;disk io constraints drive everything.&lt;/strong&gt; you can&amp;#39;t insert a line in the middle of a file — a write at an offset overwrites what&amp;#39;s there.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;sequential storage makes every operation &lt;code&gt;o(n)&lt;/code&gt;.&lt;/strong&gt; insert, update, delete — each one means rewriting the whole file.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;b+ tree nodes are sized to the disk block.&lt;/strong&gt; one disk read = one node ≈ a hundred rows. align the data structure with the hardware.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;data lives only in the leaves, and leaves link sideways.&lt;/strong&gt; that&amp;#39;s what makes range queries a linear walk instead of repeated tree climbs.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;every &lt;code&gt;o(n)&lt;/code&gt; operation becomes &lt;code&gt;o(log n)&lt;/code&gt; or better&lt;/strong&gt; — which is why sql and nosql engines alike sit on b+ trees.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="starting-simple-the-naive-approach-that-doesn-39-t-work"&gt;starting simple: the naive approach that doesn&amp;#39;t work&lt;/h2&gt;
&lt;p&gt;let&amp;#39;s start simple and see why the obvious approach fails spectacularly.&lt;/p&gt;
&lt;p&gt;say our table records are stored in one file sequentially - literally one row after another in a single file. dead simple, right? when i say &amp;quot;row&amp;quot; here, i&amp;#39;m not just talking about sql tables. this applies to documents in mongodb, any entity you&amp;#39;re storing - sql databases call them rows, nosql databases call them documents, but the idea holds true across the database spectrum.&lt;/p&gt;
&lt;h3 id="the-brutal-reality-of-sequential-storage"&gt;the brutal reality of sequential storage&lt;/h3&gt;
&lt;h5&gt;insert operations: &lt;code&gt;o(n)&lt;/code&gt; complexity&lt;/h5&gt;
&lt;p&gt;here&amp;#39;s the problem with inserting into a sequential file: you can easily append at the end, but what about inserting in the middle? databases typically store data ordered by primary key. so when you insert rows 1, 2, 5, then try to insert row 4...&lt;/p&gt;
&lt;p&gt;you&amp;#39;re screwed. why? &lt;strong&gt;you cannot just insert a line in the middle of a file&lt;/strong&gt;. this isn&amp;#39;t some in-memory buffer in your code editor where you hit enter and everything shifts down. on disk, when you write at a particular offset, you override what&amp;#39;s there. period.&lt;/p&gt;
&lt;p&gt;so what do you do? you have to:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;find the insertion position&lt;/li&gt;
&lt;li&gt;copy all rows before that position to a new file&lt;/li&gt;
&lt;li&gt;add the new row&lt;/li&gt;
&lt;li&gt;copy all remaining rows&lt;/li&gt;
&lt;li&gt;replace the old file&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;img src="https://raw.githubusercontent.com/tautik/image-assets/main/42am.webp" alt="/static/img/42am.webp"&gt;&lt;/p&gt;
&lt;p&gt;every. single. insert. creates a new file. that&amp;#39;s &lt;code&gt;o(n)&lt;/code&gt; complexity right there.&lt;/p&gt;
&lt;h5&gt;update operations: the width problem&lt;/h5&gt;
&lt;p&gt;updates are equally problematic. say you want to update row 3:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;linear scan to find it (worst case &lt;code&gt;o(n)&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;start writing at that location&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;but wait - you can only write &lt;strong&gt;exactly&lt;/strong&gt; the same number of bytes as the existing row. if the original row was 100 bytes and your update needs 120 bytes, those extra 20 bytes will override the beginning of row 4. you can&amp;#39;t just push things forward - this is disk io, not ram.&lt;/p&gt;
&lt;p&gt;&lt;img src="https://raw.githubusercontent.com/tautik/image-assets/main/45am.webp" alt="/static/img/45am.webp"&gt;&lt;/p&gt;
&lt;p&gt;so if you need more space? create a new file. copy everything. again.&lt;/p&gt;
&lt;h5&gt;find operations: pure linear scan&lt;/h5&gt;
&lt;p&gt;finding a single row? linear scan through the entire file. &lt;code&gt;o(n)&lt;/code&gt;. there&amp;#39;s literally no other way with this structure.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;range queries: slightly less terrible (but still bad)&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;range queries seem efficient once you find the first row - you can read sequentially from there. but finding that first row? still &lt;code&gt;o(n)&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;delete operations: another new file&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;deleting row 3? you guessed it:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;find the row (&lt;code&gt;o(n)&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;create new file&lt;/li&gt;
&lt;li&gt;copy everything except that row&lt;/li&gt;
&lt;li&gt;replace old file&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;strong&gt;&lt;em&gt;the fundamental insight:&lt;code&gt;o(n)&lt;/code&gt; complexity for every operation is far too much. we need something better.&lt;/em&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;details class="recall"&gt;&lt;summary&gt;&lt;span class="recall-mark"&gt;&amp;#10045; RECALL&lt;/span&gt; why can&amp;#39;t a sequential file just &amp;quot;insert a row in the middle&amp;quot; the way your text editor inserts a line?&lt;/summary&gt;&lt;p&gt;on disk, writing at an offset overwrites what&amp;#39;s there — nothing shifts down. so a mid-file insert means copying everything to a new file, and an update can&amp;#39;t even grow a row without clobbering its neighbor. every operation — insert, update, delete, even find — degenerates to &lt;code&gt;o(n)&lt;/code&gt; full-file work, which is fatal for a transactional database.&lt;/p&gt;&lt;/details&gt;

&lt;h2 id="enter-b-trees-the-game-changer"&gt;enter b+ trees: the game changer&lt;/h2&gt;
&lt;p&gt;given that &lt;code&gt;o(n)&lt;/code&gt; operations won&amp;#39;t work for transactional databases, we need a better solution. this is where b+ trees come into the picture.&lt;/p&gt;
&lt;h3 id="how-b-trees-actually-store-your-data"&gt;how b+ trees actually store your data&lt;/h3&gt;
&lt;p&gt;think about this: rows or documents in a table are clubbed together into b+ tree nodes.&lt;/p&gt;
&lt;p&gt;let&amp;#39;s get concrete with numbers:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;b+ tree node size&lt;/strong&gt; : 4kb (matches disk block size)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;average row size&lt;/strong&gt; : 40 bytes&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;rows per node&lt;/strong&gt; : 4kb ÷ 40 bytes ≈ &lt;strong&gt;100 rows&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;why 4kb? &lt;strong&gt;this is crucial&lt;/strong&gt; : disk io happens in blocks, typically 4kb. even if you want to read 1 byte, you read the entire 4kb block. the os does this for you - reads the block, extracts your byte, discards the rest. so we align our b+ tree node size with the disk block size. &lt;strong&gt;&lt;em&gt;in one disk read, we read 1 node ≈ 100 rows.&lt;/em&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;h3 id="the-beautiful-tree-structure"&gt;the beautiful tree structure&lt;/h3&gt;
&lt;p&gt;your table becomes a collection of b+ tree nodes on disk. these nodes can be anywhere on the disk - they&amp;#39;re not necessarily sequential. the database maintains the structure through pointers (disk offsets).&lt;/p&gt;
&lt;p&gt;consider a table with 400 rows: &lt;img src="https://raw.githubusercontent.com/tautik/image-assets/main/49am.webp" alt="nodes image"&gt;&lt;/p&gt;
&lt;p&gt;the table is always arranged by its primary key, and the b+ tree nodes (leaf nodes) are connected accordingly. but here&amp;#39;s where it gets interesting...&lt;/p&gt;
&lt;h3 id="the-multi-level-magic"&gt;the multi-level magic&lt;/h3&gt;
&lt;p&gt;a b+ tree isn&amp;#39;t just leaf nodes. it has multiple levels:&lt;/p&gt;
&lt;p&gt;&lt;img src="https://raw.githubusercontent.com/tautik/image-assets/main/56am.webp" alt="/static/img/56am.webp"&gt;&lt;/p&gt;
&lt;p&gt;ps: sorry for my bad drawing :(&lt;/p&gt;
&lt;h6&gt;note: (leaf nodes linked linearly)&lt;/h6&gt;
&lt;p&gt;&lt;strong&gt;every b+ tree node is serialized and stored on disk.&lt;/strong&gt; they&amp;#39;re not in memory (though they can be cached there for performance).&lt;/p&gt;
&lt;p&gt;non-leaf nodes hold routing information - they tell you which child node holds which range of data. the root might store [1, 201, 401], meaning:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;values &amp;lt; 201 are in the left subtree&lt;/li&gt;
&lt;li&gt;values 201-400 are in the middle&lt;/li&gt;
&lt;li&gt;values &amp;gt; 400 are in the right&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;leaf nodes hold the actual row data&lt;/strong&gt; and are linked linearly to enable efficient range traversal.&lt;/p&gt;
&lt;details class="recall"&gt;&lt;summary&gt;&lt;span class="recall-mark"&gt;&amp;#10045; RECALL&lt;/span&gt; why is the b+ tree node size matched to the disk block size, and what does that buy per read?&lt;/summary&gt;&lt;p&gt;disk io happens in whole blocks — ask for one byte and the os reads the entire block anyway. sizing the node to the block means a single disk read delivers exactly one full node packed with rows, zero waste. the data structure is shaped by the hardware, not the other way around.&lt;/p&gt;&lt;/details&gt;

&lt;h2 id="operations-in-b-trees-where-the-magic-happens"&gt;operations in b+ trees: where the magic happens&lt;/h2&gt;
&lt;h3 id="find-one-by-id-from-o-n-to-o-log-n"&gt;find one by id: from &lt;code&gt;o(n)&lt;/code&gt; to &lt;code&gt;o(log n)&lt;/code&gt;&lt;/h3&gt;
&lt;p&gt;let&amp;#39;s find row with id 3:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;read root node from disk&lt;/strong&gt; - check routing info&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;follow pointer to appropriate child&lt;/strong&gt; - 3 is between 1 and 201&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;read that node&lt;/strong&gt; - 3 is between 1 and 101&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;read leaf node&lt;/strong&gt; - contains rows 1-100&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;extract row 3&lt;/strong&gt; from the 100 rows in memory&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;strong&gt;total disk reads: 3&lt;/strong&gt; (for a 3-level tree). no matter which row you want, it&amp;#39;s exactly 3 disk reads. not 1 extra.&lt;/p&gt;
&lt;h3 id="insert-no-more-file-rewriting"&gt;insert: no more file rewriting&lt;/h3&gt;
&lt;p&gt;want to insert row 4?&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;traverse to find the right leaf&lt;/strong&gt; (3 disk reads)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;load that node into memory&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;insert row 4 in the right position&lt;/strong&gt; (array manipulation in ram)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;flush the node back to disk&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;that&amp;#39;s it. &lt;strong&gt;we only touched the blocks we needed&lt;/strong&gt;. no rewriting the entire file. the tree might need rebalancing (standard b+ tree stuff), but we&amp;#39;re not touching unrelated nodes.&lt;/p&gt;
&lt;h3 id="update-surgical-precision"&gt;update: surgical precision&lt;/h3&gt;
&lt;p&gt;update row 202:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;navigate to the leaf&lt;/strong&gt; (3 reads)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;load node into memory&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;update the row&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;flush back to disk&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;if the update changes the row size, we handle it in memory before flushing. no overwriting neighboring rows.&lt;/p&gt;
&lt;h3 id="delete-clean-and-simple"&gt;delete: clean and simple&lt;/h3&gt;
&lt;p&gt;delete row 401:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;find the leaf&lt;/strong&gt; (3 reads)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;load into memory&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;remove from array&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;flush back&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;the tree might rebalance, but again, we&amp;#39;re only touching what we need to. you can also do some kinda &lt;a href="https://tautik.me/soft-delete/"&gt;soft delete&lt;/a&gt; and do a batch delete later on by running a cron.&lt;/p&gt;
&lt;details class="recall"&gt;&lt;summary&gt;&lt;span class="recall-mark"&gt;&amp;#10045; RECALL&lt;/span&gt; what makes a b+ tree insert &amp;quot;surgical&amp;quot; where the sequential file had to be rewritten end to end?&lt;/summary&gt;&lt;p&gt;you traverse routing nodes to the one leaf that owns the key — a handful of disk reads — mutate it in memory, and flush just that node back. only the blocks you need are touched: row growth and shrinkage are handled in memory before the flush, and any rebalancing stays local to the affected nodes instead of touching the whole file.&lt;/p&gt;&lt;/details&gt;

&lt;h3 id="range-queries-the-secret-weapon"&gt;range queries: the secret weapon&lt;/h3&gt;
&lt;p&gt;this is where b+ trees truly shine. find all rows with id between 100 and 600:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;find the leaf containing 100&lt;/strong&gt; (3 reads)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;use the leaf-to-leaf links to traverse linearly&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;read subsequent leaves until you reach 600&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;strong&gt;&lt;em&gt;why b+ trees force you to store data at the leaf level:&lt;/em&gt;&lt;/strong&gt; this linear traversal between leaves. b-trees allow data in non-leaf nodes, but b+ trees don&amp;#39;t - precisely because it makes range queries super efficient. once you reach the first leaf, you just traverse sideways instead of going up and down the tree repeatedly.&lt;/p&gt;
&lt;details class="recall"&gt;&lt;summary&gt;&lt;span class="recall-mark"&gt;&amp;#10045; RECALL&lt;/span&gt; why do b+ trees (unlike plain b-trees) refuse to store row data in non-leaf nodes?&lt;/summary&gt;&lt;p&gt;keeping data only in the leaves lets the leaves link linearly — a range query finds its first leaf in &lt;code&gt;o(log n)&lt;/code&gt; and then just walks sideways until the upper bound, never climbing the tree again. non-leaf nodes stay pure routing information, which also keeps them small enough that the tree stays shallow.&lt;/p&gt;&lt;/details&gt;

&lt;h2 id="the-bottom-line"&gt;the bottom line&lt;/h2&gt;
&lt;p&gt;this is why databases use b+ trees. the evolution from naive sequential storage to b+ trees solves fundamental problems:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;predictable performance&lt;/strong&gt; : &lt;code&gt;o(log n)&lt;/code&gt; for single-row operations&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;efficient range queries&lt;/strong&gt; : linear traversal at leaf level&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;optimal disk io&lt;/strong&gt; : node size matches disk block size&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;localized updates&lt;/strong&gt; : only touch the blocks you need&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;and this isn&amp;#39;t just sql databases - mongodb&amp;#39;s wiredtiger storage engine does exactly this. the beauty of b+ trees transcends the sql/nosql divide because the underlying problem - efficient disk-based storage with fast retrieval - is universal.&lt;/p&gt;
&lt;p&gt;think about it: every operation that was &lt;code&gt;o(n)&lt;/code&gt; in our naive approach is now &lt;code&gt;o(log n)&lt;/code&gt; or better. that&amp;#39;s the power of choosing the right data structure for your storage layer. and that&amp;#39;s precisely why, when you dig into any production database, you&amp;#39;ll find b+ trees at its heart.&lt;/p&gt;
&lt;details class="recall"&gt;&lt;summary&gt;&lt;span class="recall-mark"&gt;&amp;#10045; RECALL&lt;/span&gt; mongodb isn&amp;#39;t sql — so why does wiredtiger end up on the same b+ trees as mysql?&lt;/summary&gt;&lt;p&gt;because the underlying problem is identical: storing records on block-based disks with fast point lookups and range scans. rows or documents, the physics of disk io doesn&amp;#39;t change — which is why the structure transcends the sql/nosql divide and shows up at the heart of basically every production database.&lt;/p&gt;&lt;/details&gt;

&lt;h2 id="key-takeaways"&gt;key takeaways&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;disk io constraints drive everything&lt;/strong&gt; - you can&amp;#39;t just insert in the middle of a file&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;align data structures with hardware&lt;/strong&gt; - b+ tree nodes match disk block size&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;optimize for the common case&lt;/strong&gt; - most database operations are finds and range queries&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;trade complexity for performance&lt;/strong&gt; - b+ tree maintenance is worth the &lt;code&gt;o(log n)&lt;/code&gt; guarantees&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;the next time someone asks why databases use b+ trees, you know it&amp;#39;s not just tradition - it&amp;#39;s physics, hardware constraints, and decades of engineering wisdom rolled into one elegant solution.&lt;/p&gt;
</content>
  </entry>
  <entry>
    <title>kv store on relational db: a storage-compute separation story</title>
    <link href="https://tautik.me/kv-store/"/>
    <id>https://tautik.me/kv-store/</id>
    <updated>2025-09-23T00:00:00Z</updated>
    <content type="html">&lt;h2 id="why-another-database"&gt;why another database?&lt;/h2&gt;
&lt;p&gt;your first question should hit you: &amp;quot;why on earth should we do this?&amp;quot; like dynamodb exists, redis exists, valkey exists, this exists, that exists. why do i have to do this?&lt;/p&gt;
&lt;p&gt;but instead of looking at it from a lens of &amp;quot;hey why the world needs it,&amp;quot; let&amp;#39;s look at it from a mental model perspective. the core essence of this is &lt;strong&gt;storage-compute separation&lt;/strong&gt;. that&amp;#39;s our biggest takeaway from this. second takeaway would be elegant queries. so storage and compute separation - this is how you can create a new db every time you want.&lt;/p&gt;
&lt;p&gt;consider &lt;strong&gt;sparksql&lt;/strong&gt; - it accepts sql queries but it fetches data from files, apis, databases etc and gives you the result internally. so its not a sql based database but instead we can prolly say it has a sql interface with flexible storage. one more example - &lt;strong&gt;dynamodb&lt;/strong&gt; has a mongodb-like api which is exposed to the user but its built on amazon aurora (a relational db that uses sql). so now you see the compute and storage separation? just tweaking any one of em and you&amp;#39;ve created a new db.&lt;/p&gt;
&lt;h2 id="what-you-39-ll-take-away"&gt;what you&amp;#39;ll take away&lt;/h2&gt;
&lt;p&gt;quick pointers so you know what to look for as you read:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;storage-compute separation is how new databases get made.&lt;/strong&gt; swap the interface or swap the storage and you&amp;#39;ve &amp;quot;created&amp;quot; a new db — dynamodb over aurora, sparksql over files.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;store absolute expiry, never relative ttl.&lt;/strong&gt; &lt;code&gt;now() + ttl&lt;/code&gt; at write time — a bare &lt;code&gt;300&lt;/code&gt; is meaningless when you read it later.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;upsert beats select-then-decide,&lt;/strong&gt; and &lt;code&gt;INSERT ON DUPLICATE KEY UPDATE&lt;/code&gt; beats &lt;code&gt;REPLACE INTO&lt;/code&gt; because an in-place update beats delete+insert.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;batch delete in primary-key order and index your where-clause columns.&lt;/strong&gt; closed key ranges mean minimal rebalancing; the index turns a full scan into &lt;code&gt;o(log n + k)&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;scale bottom-up, and let the user pick consistency.&lt;/strong&gt; &lt;code&gt;?consistent=true&lt;/code&gt; goes to master; everything else eats replica staleness.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;partitioning is a metadata-vs-flexibility dial.&lt;/strong&gt; static explodes metadata, hashing repartitions everything on change, range sits in between.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="what-we-39-re-building"&gt;what we&amp;#39;re building&lt;/h2&gt;
&lt;p&gt;to create a key value (we will call it kv in here for easy reference ) store which speaks in &lt;code&gt;http/rest&lt;/code&gt; and stores in &lt;code&gt;mysql&lt;/code&gt;. basically we&amp;#39;re exposing db as a service with two parts - the computation layer where we expose the http apis to the user, and mysql db as our storage layer. everything will be stored in that mysql. damn this will be exciting and simple to start with.&lt;/p&gt;
&lt;p&gt;&lt;img src="https://raw.githubusercontent.com/tautik/image-assets/main/34pm.webp" alt="/static/img/34pm.webp"&gt;&lt;/p&gt;
&lt;p&gt;so what are the requirements? we need http based apis - &lt;code&gt;get&lt;/code&gt;, &lt;code&gt;post&lt;/code&gt;, &lt;code&gt;put&lt;/code&gt;, &lt;code&gt;delete&lt;/code&gt;. all operations are in sync (to keep stuff simple). some basic schema to start with. and post that we can discuss on scaling, ttl, optimising queries etc.&lt;/p&gt;
&lt;p&gt;we keep stuff simple, a simple key value db so we expose 4 rest apis and we want to hold this key value store in the mysql db. here&amp;#39;s our schema:&lt;/p&gt;
&lt;div class="codeblock"&gt;&lt;pre&gt;&lt;code class="hljs"&gt; 
CREATE TABLE store (
    key VARCHAR(255) PRIMARY KEY,
    value TEXT,
    expired_at BIGINT  -- absolute timestamp
);&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;details class="recall"&gt;&lt;summary&gt;&lt;span class="recall-mark"&gt;&amp;#10045; RECALL&lt;/span&gt; dynamodb runs on aurora and sparksql queries flat files. what&amp;#39;s the mental model, and how does this kv store fit it?&lt;/summary&gt;&lt;p&gt;storage-compute separation — the query interface and the storage engine are independent choices, and swapping either one &amp;quot;creates&amp;quot; a new database. here the compute layer is a set of http/rest apis and the storage layer is plain mysql: a kv store that&amp;#39;s really just a db exposed as a service on top of a relational engine.&lt;/p&gt;&lt;/details&gt;

&lt;h2 id="implementing-the-computation-layer"&gt;implementing the computation layer&lt;/h2&gt;
&lt;h3 id="the-put-operation"&gt;the put operation&lt;/h3&gt;
&lt;p&gt;aight this is gonna be fun. how will our put operation look like first? &lt;code&gt;put(k1, v1, 300)&lt;/code&gt;? - we shouldnt be storing 300sec directly as ttl. since thats wrong (i have seen lots of people doing that mistake). we store &lt;strong&gt;absolute time&lt;/strong&gt; , that is &lt;code&gt;created_at + ttl&lt;/code&gt; -&amp;gt; or you can say &lt;code&gt;created_at + 300&lt;/code&gt;, so the put query should look like &lt;code&gt;put(k1, v1, now()+300)&lt;/code&gt;. and mind it we are doing insert command in here.&lt;/p&gt;
&lt;p&gt;but can you now think what problem might be in here? well well well its time to triage that.&lt;/p&gt;
&lt;p&gt;its simple - if a user logs in for the first time, the insert command works perfectly: &lt;code&gt;INSERT INTO store VALUES (&amp;#39;user_123_session&amp;#39;, &amp;#39;{&amp;quot;logged_in&amp;quot;: true, &amp;quot;cart&amp;quot;: []}&amp;#39;, now() + 300);&lt;/code&gt;. but if the user refreshes or logs in again with the same session key, we try: &lt;code&gt;INSERT INTO store VALUES (&amp;#39;user_123_session&amp;#39;, &amp;#39;{&amp;quot;logged_in&amp;quot;: true, &amp;quot;cart&amp;quot;: [&amp;quot;item1&amp;quot;]}&amp;#39;, now() + 300);&lt;/code&gt;. now we get an error since it already exists - the insert command will fail.&lt;/p&gt;
&lt;p&gt;so now we know we need to create if the key doesnt exist and update if it already exists. we could wrap this in a transaction to ensure atomicity:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;first &lt;code&gt;select&lt;/code&gt; to check if it exists&lt;/li&gt;
&lt;li&gt;then either &lt;code&gt;insert&lt;/code&gt; or &lt;code&gt;update&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;all wrapped in transaction boundaries&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;but my friends, we are doing 3 operations internally just to do it. one transaction, one select, one operation of insert or update. lets try to optimise it.&lt;/p&gt;
&lt;p&gt;mysql supports &lt;strong&gt;upserts&lt;/strong&gt; which is exactly what we need. we have two options here. option 1 is using &lt;code&gt;REPLACE INTO&lt;/code&gt;:&lt;/p&gt;
&lt;div class="codeblock"&gt;&lt;pre&gt;&lt;code class="hljs"&gt; 
REPLACE INTO store VALUES (k1, v2, now() + 300);&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;this has reduced our previous query to 1 operation. but the problemmmm - when conflict occurs (that means a key already exists) it internally deletes the row and inserts another row which is slower.&lt;/p&gt;
&lt;p&gt;option 2 is using &lt;code&gt;INSERT ON DUPLICATE KEY UPDATE&lt;/code&gt;:&lt;/p&gt;
&lt;div class="codeblock"&gt;&lt;pre&gt;&lt;code class="hljs"&gt; 
INSERT INTO store VALUES (k1, v2, now() + 300)
ON DUPLICATE KEY UPDATE 
    value = v2, 
    expired_at = now() + 300;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;this is &lt;strong&gt;32x faster&lt;/strong&gt; (yep i read that on the internet). since instead of delete+insert, it does a proper update when conflict occurs. since in my work i am using prisma currently, theres a simple query by my dear prisma orm which is &lt;code&gt;prisma.&amp;lt;table_name&amp;gt;.upsert&lt;/code&gt;.&lt;/p&gt;
&lt;details class="recall"&gt;&lt;summary&gt;&lt;span class="recall-mark"&gt;&amp;#10045; RECALL&lt;/span&gt; why store &lt;code&gt;now() + 300&lt;/code&gt; instead of &lt;code&gt;300&lt;/code&gt; in the expiry column, and why does a plain &lt;code&gt;INSERT&lt;/code&gt; break the moment a user logs in twice?&lt;/summary&gt;&lt;p&gt;a relative ttl is meaningless at read time — you can&amp;#39;t tell when it started counting. absolute expiry makes the get a simple &lt;code&gt;expired_at &amp;gt; now()&lt;/code&gt; comparison. and a repeat put on an existing key fails on the primary key, so you need an upsert: &lt;code&gt;INSERT ON DUPLICATE KEY UPDATE&lt;/code&gt; does a real in-place update on conflict, collapsing the select-then-insert-or-update transaction into one operation and beating &lt;code&gt;REPLACE INTO&lt;/code&gt;, which deletes and reinserts.&lt;/p&gt;&lt;/details&gt;

&lt;h3 id="the-get-operation"&gt;the get operation&lt;/h3&gt;
&lt;p&gt;lets try to get the users who havent been expired. for that the query would look like:&lt;/p&gt;
&lt;div class="codeblock"&gt;&lt;pre&gt;&lt;code class="hljs"&gt; 
SELECT * FROM store 
WHERE key = k1 AND expired_at &amp;gt; NOW();&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;dead simple. we check the key and make sure its not expired. nothing fancy here.&lt;/p&gt;
&lt;h3 id="the-delete-operation"&gt;the delete operation&lt;/h3&gt;
&lt;p&gt;again we have 2 options - hard delete or soft delete. i have already written about why soft delete is ideal (&lt;a href="https://tautik.me/soft-delete/"&gt;Link&lt;/a&gt;). in soft delete you just update a column to indicate it has been soft deleted and later on using batch delete you clear the data. here is the example:&lt;/p&gt;
&lt;div class="codeblock"&gt;&lt;pre&gt;&lt;code class="hljs"&gt; 
UPDATE store SET expired_at = -1 
WHERE key = k1 AND expired_at &amp;gt; NOW();&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;so why &lt;code&gt;expired_at = -1&lt;/code&gt;? this will help us later determine and provide stats to user about which key has been expired naturally vs deleted by the user. you might ask, tautik - where can i find this in practice? umm lots of cases:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;when a user session has been timed out&lt;/li&gt;
&lt;li&gt;application tries to delete a cache entry that already expired&lt;/li&gt;
&lt;li&gt;cleanup processing that we run on already-cleaned data&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;its micro optimisation because it only helps in edge cases, but when you have millions of operations, these edge cases add up.&lt;/p&gt;
&lt;h3 id="batch-deletes-and-optimisation"&gt;batch deletes and optimisation&lt;/h3&gt;
&lt;p&gt;so how should we ideally batch delete? well make sure to always use &lt;code&gt;ORDER BY key&lt;/code&gt;, not &lt;code&gt;ORDER BY expired_at&lt;/code&gt;. since when we are deleting the row it would require minimal rebalancing because the keys that are getting deleted are from a closed set of ranges. thus multiple keys on the same page might be deleted with this iteration:&lt;/p&gt;
&lt;div class="codeblock"&gt;&lt;pre&gt;&lt;code class="hljs"&gt; 
DELETE FROM store 
WHERE expired_at &amp;lt; NOW() 
ORDER BY key 
LIMIT 1000;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;now what further optimisation can we do? think about it. if we have 1mil rows, and we apply above query - we need to look for each row which satisfies this condition. since it will do a &lt;strong&gt;full table scan&lt;/strong&gt; without the indexes. after that it will get those rows, sort it, and delete the first 1k rows from above query.&lt;/p&gt;
&lt;p&gt;what happens with an index on &lt;code&gt;expired_at&lt;/code&gt;? first create the index: &lt;code&gt;CREATE INDEX idx_expired_at ON store(expired_at);&lt;/code&gt;. now whenever we run the delete query, our db will do lookup on index to quickly find the rows where &lt;code&gt;expired_at &amp;lt; now()&lt;/code&gt;, and only reads 50k expired rows (not all 1 mil, and yep assuming 50k rows are expired, all are assumptions). and then we delete the first 1k rows. dead simple. i will write more about indexes later on lol. its so fun. everything is fun. aight back to the topic.&lt;/p&gt;
&lt;p&gt;ps: if you dont know, we have reduced the time complexity from &lt;code&gt;o(n)&lt;/code&gt; to &lt;code&gt;o(log n + k)&lt;/code&gt; after doing quick index lookup + reading only matching rows. so what we can learn - &lt;strong&gt;you can always create indexes on columns you use in where clauses&lt;/strong&gt; , especially for cleanup operations that run frequently.&lt;/p&gt;
&lt;details class="recall"&gt;&lt;summary&gt;&lt;span class="recall-mark"&gt;&amp;#10045; RECALL&lt;/span&gt; in the cleanup job, why &lt;code&gt;ORDER BY key&lt;/code&gt; instead of &lt;code&gt;ORDER BY expired_at&lt;/code&gt; — and what does the index on &lt;code&gt;expired_at&lt;/code&gt; change?&lt;/summary&gt;&lt;p&gt;deleting in primary-key order removes rows from a closed range, so they cluster on the same pages and the tree needs minimal rebalancing; ordering by expiry scatters the deletes everywhere. the index is what finds the victims: without it every run is a full table scan, with it the lookup reads only the matching rows — &lt;code&gt;o(n)&lt;/code&gt; down to &lt;code&gt;o(log n + k)&lt;/code&gt;. index the columns in your where clauses, especially for cleanup that runs constantly.&lt;/p&gt;&lt;/details&gt;

&lt;h2 id="scaling-this-thing"&gt;scaling this thing&lt;/h2&gt;
&lt;p&gt;yeppie, this is the fun part. so one way i can think of doing this is by just scaling the kv computation layer (for our easy use, lets call it kv api server). but again wrong - what if we are unnecessarily scaling stuff but our db is not able to handle that load? thats why we always do &lt;strong&gt;bottom up&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;lets start bottom up by trying to scale our storage layer. so what if we have 90:10 read requests? we simply add &lt;strong&gt;READ REPLICAS&lt;/strong&gt; to handle those reads and lets add database proxy in between as well to route the requests from computation layer to storage layer.&lt;/p&gt;
&lt;p&gt;but then here comes the problem - if we have read replicas, we know we are signing up for staleness. but then how will we ensure that every client around the world receives accurate data? like which client should we route to the replicas, which client to master? i know right this computation internally can become such an overhead ahh. so lets do this like how dynamodb and other services do. the right solution is: &lt;strong&gt;let the user decide!&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src="https://raw.githubusercontent.com/tautik/image-assets/main/29pm.webp" alt="read replica reference"&gt;&lt;/p&gt;
&lt;div class="codeblock"&gt;&lt;pre&gt;&lt;code class="hljs"&gt; 
GET /key?consistent=true  → goes to master
GET /key                 → goes to replica (default)&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;i know this is funny, but why complicate stuff. see the philosophy is: &lt;strong&gt;&amp;quot;you are paid to solve a problem and not necessarily write code to solve it&amp;quot;&lt;/strong&gt;. i should prolly tweet about this. so yep dont over optimize stuff.&lt;/p&gt;
&lt;details class="recall"&gt;&lt;summary&gt;&lt;span class="recall-mark"&gt;&amp;#10045; RECALL&lt;/span&gt; read replicas mean staleness. how does the kv api dodge the &amp;quot;which client needs fresh data&amp;quot; problem entirely?&lt;/summary&gt;&lt;p&gt;punt it to the caller — &lt;code&gt;GET /key?consistent=true&lt;/code&gt; routes to master, the default goes to a replica. the server can&amp;#39;t know which reads are critical; the user does, so building routing heuristics is wasted work. and the scaling itself goes bottom-up: no point scaling the api layer into a storage layer that can&amp;#39;t take the load.&lt;/p&gt;&lt;/details&gt;

&lt;h3 id="handling-write-scaling"&gt;handling write scaling&lt;/h3&gt;
&lt;p&gt;so now that we are done with scaling and handling reads, lets think about writes. for writes we know the request goes to the master. but when the master cant handle the write load, we basically shard the database.&lt;/p&gt;
&lt;p&gt;&lt;img src="https://raw.githubusercontent.com/tautik/image-assets/main/22pm.webp" alt="/static/img/22pm.webp"&gt;&lt;/p&gt;
&lt;p&gt;for this we have partition strategies. there are 3 basically - hashing, static, and range based. so lets have a config db at the start - our computation-layer (or the proxy layer if you have) now refers to the config db which contains the rules for where to route the user.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;static mapping&lt;/strong&gt; : one metadata entry for each key in config db. but imagine if this is key value - we will be storing for each of the entry in the db. if you have million keys, will we store million entries in our config db? hell naw, its too much metadata explosion&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;hash based partitioning&lt;/strong&gt; : another end of the extreme, zero metadata. but the problem is if hash function changes, we need to re-partition everything - huge data movement: &lt;code&gt;shard = hash(key) % number_of_shards&lt;/code&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;range based partitioning&lt;/strong&gt; : lies in between of static and hash based. now we have minimal metadata, and its just simple:&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;div class="codeblock"&gt;&lt;pre&gt;&lt;code class="hljs"&gt; A-K → Shard 1
    L-P → Shard 2  
    Q-Z → Shard 3
    &lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;so depending upon metadata size, key control and flexibility we choose one. nothing is best. everything depends upon our usecase.&lt;/p&gt;
&lt;details class="recall"&gt;&lt;summary&gt;&lt;span class="recall-mark"&gt;&amp;#10045; RECALL&lt;/span&gt; static mapping, hash, and range partitioning — what&amp;#39;s the actual dial you&amp;#39;re turning when you pick one?&lt;/summary&gt;&lt;p&gt;metadata versus flexibility. static keeps one config entry per key — total control, but a million keys means metadata explosion. hash needs zero metadata, but change the function (or shard count) and you&amp;#39;re re-partitioning everything. range sits in between: minimal metadata, simple rules like &lt;code&gt;A-K → shard 1&lt;/code&gt;. choose by metadata budget and how much key control you need — nothing is best.&lt;/p&gt;&lt;/details&gt;
</content>
  </entry>
  <entry>
    <title>Caching : Thundering Herd and Request Hedging</title>
    <link href="https://tautik.me/caching/"/>
    <id>https://tautik.me/caching/</id>
    <updated>2025-09-07T00:00:00Z</updated>
    <content type="html">&lt;h2 id="caching-it-39-s-not-just-about-memory"&gt;Caching: It&amp;#39;s Not Just About Memory&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Myth-busting time&lt;/strong&gt; : Caching doesn&amp;#39;t mean in-memory. I see this confusion everywhere.&lt;/p&gt;
&lt;p&gt;We accept data staleness in exchange for avoiding expensive operations. Every time you cache something, you&amp;#39;re saying &amp;#39;I&amp;#39;d rather serve data that might be 5 minutes old than wait 2 seconds for a database query&lt;/p&gt;
&lt;h3 id="what-caching-really-means"&gt;What Caching Really Means&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Cache = saving expensive operations&lt;/strong&gt;. That&amp;#39;s it.&lt;/p&gt;
&lt;p&gt;Expensive operations include:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Database queries with 14 table joins&lt;/li&gt;
&lt;li&gt;Network calls to external services&lt;/li&gt;
&lt;li&gt;Complex computations&lt;/li&gt;
&lt;li&gt;File system reads&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;You can cache:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;In memory (Redis)&lt;/li&gt;
&lt;li&gt;On disk (API server&amp;#39;s unused SSD)&lt;/li&gt;
&lt;li&gt;In browser (localStorage)&lt;/li&gt;
&lt;li&gt;At CDN edge&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id="the-under-utilized-cache-location"&gt;The Under-utilized Cache Location&lt;/h3&gt;
&lt;p&gt;Here&amp;#39;s something nobody talks about - &lt;strong&gt;your API server&amp;#39;s disk is sitting there doing nothing&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;You spin up an EC2 instance:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;4GB RAM (fully utilized)&lt;/li&gt;
&lt;li&gt;20GB SSD (5% utilized)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Why not cache on that SSD? It&amp;#39;s faster than network calls to Redis, costs nothing extra, and the space is already paid for.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;But wait&lt;/strong&gt; - multiple API servers means cache inconsistency! Which is why we usually centralize with Redis. But for read-heavy, rarely-changing data? Disk cache works beautifully.&lt;/p&gt;
&lt;details class="recall"&gt;&lt;summary&gt;&lt;span class="recall-mark"&gt;&amp;#10045; RECALL&lt;/span&gt; why is your API server&amp;#39;s idle SSD a legitimate cache tier, and what&amp;#39;s the catch once you run multiple servers?&lt;/summary&gt;&lt;p&gt;caching means saving expensive operations, not &amp;quot;put it in RAM&amp;quot; — and a local disk read beats a network round-trip to redis while using space you&amp;#39;ve already paid for. the catch: each server&amp;#39;s disk cache is its own little world, so multiple API servers drift inconsistent. that&amp;#39;s why mutable data centralizes in redis, while read-heavy, rarely-changing data is the sweet spot for disk cache.&lt;/p&gt;&lt;/details&gt;

&lt;h2 id="what-you-39-ll-take-away"&gt;what you&amp;#39;ll take away&lt;/h2&gt;
&lt;p&gt;quick pointers so you know what to look for as you read:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;caching ≠ in-memory.&lt;/strong&gt; a cache is anywhere you save the result of an expensive operation — RAM, disk, browser, CDN edge.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;your API server&amp;#39;s idle SSD is a free cache tier.&lt;/strong&gt; great for read-heavy, rarely-changing data; the catch is per-server inconsistency.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;cache expiry + concurrent traffic = stampede.&lt;/strong&gt; N identical misses become N identical expensive queries, and the database melts.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;request hedging collapses N misses into 1 query.&lt;/strong&gt; the first request does the work, everyone else waits on a semaphore.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;waiters read from a temporary result map.&lt;/strong&gt; re-hitting the cache after the signal would just be a second stampede.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="cache-stampede-amp-request-hedging"&gt;Cache Stampede &amp;amp; Request Hedging&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;The fundamental question we should always ask is: &amp;quot;What happens when your cache expires and 1000 requests hit simultaneously?&amp;quot;&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Answer: Your database dies, your site goes down, and you get paged at 3 AM. Let me show you how to prevent this nightmare scenario.&lt;/p&gt;
&lt;h2 id="the-cache-stampede-problem"&gt;The Cache Stampede Problem&lt;/h2&gt;
&lt;p&gt;Picture this: You have a popular blog post cached in Redis. The cache expires. Suddenly, 1000 concurrent requests hit your API at the exact same moment.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;What happens?&lt;/strong&gt;&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;All 1000 requests check Redis → cache miss&lt;/li&gt;
&lt;li&gt;All 1000 requests query the database&lt;/li&gt;
&lt;li&gt;Database connection pool gets overwhelmed&lt;/li&gt;
&lt;li&gt;Database melts under load&lt;/li&gt;
&lt;li&gt;Site goes down&lt;/li&gt;
&lt;li&gt;You&amp;#39;re now debugging at 3 AM while your users are angry&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;This is called a &lt;strong&gt;cache stampede&lt;/strong&gt; or &lt;strong&gt;thundering herd problem&lt;/strong&gt; , and it&amp;#39;s one of the most common ways high-traffic applications fail.&lt;/p&gt;
&lt;p&gt;&lt;img src="https://raw.githubusercontent.com/tautik/image-assets/main/45pm.webp" alt="image"&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Why is this so dangerous?&lt;/strong&gt; Even if you have database connection pooling (which you should), making N identical expensive queries to your database for the same data doesn&amp;#39;t make any sense. It&amp;#39;s pure waste that can bring down your entire system.&lt;/p&gt;
&lt;details class="recall"&gt;&lt;summary&gt;&lt;span class="recall-mark"&gt;&amp;#10045; RECALL&lt;/span&gt; a hot key expires and 1000 concurrent requests arrive at once. walk the failure chain — and why doesn&amp;#39;t connection pooling save you?&lt;/summary&gt;&lt;p&gt;all 1000 miss the cache, all 1000 fire the same query at the database, the connection pool saturates, the db melts, the site goes down. pooling only caps concurrency — it doesn&amp;#39;t change the fact that N identical expensive queries for the same data is pure waste. the fix isn&amp;#39;t more database capacity, it&amp;#39;s collapsing the N requests into 1.&lt;/p&gt;&lt;/details&gt;

&lt;h2 id="the-real-world-impact"&gt;The Real-World Impact&lt;/h2&gt;
&lt;p&gt;This isn&amp;#39;t some theoretical problem I&amp;#39;m throwing at you. &lt;strong&gt;This is literally what CDNs solve every single day.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Think about it: CloudFlare, AWS CloudFront, and every other CDN faces this exact problem. When a cached resource expires and thousands of requests come in simultaneously, they can&amp;#39;t all hit the origin server. The origin would die instantly.&lt;/p&gt;
&lt;p&gt;CDNs use sophisticated request hedging to ensure that only ONE request goes to the origin while everyone else waits for that response. This is production-tested at massive scale.&lt;/p&gt;
&lt;h2 id="the-solution-request-hedging-smart-debouncing"&gt;The Solution: Request Hedging (Smart Debouncing)&lt;/h2&gt;
&lt;p&gt;Here&amp;#39;s the elegant solution - and this is literally the pseudo-code you&amp;#39;d write:&lt;/p&gt;
&lt;div class="codeblock"&gt;&lt;pre&gt;&lt;code class="hljs"&gt; 
# Pseudo-code that would work if you saved this as .py
sem_map = {}  # Use thread-safe implementation
res_map = {}  # Temporary result storage

def get_blog(k):
    # First, try cache
    v = cache.get(k)
    if v is not None:
        return v
    
    # Check if someone else is already fetching this
    s = sem_map.get(k)
    if s:
        s.wait()  # Wait for someone else to do the work
        v = res_map.get(k)  # Get the result they fetched
        return v
    else:
        # I'm the first one - I'll do the work
        sem_map[k] = new_semaphore()
        sem_map[k].block()  # Block others
        
        # Do the expensive work
        v = db.get(k)
        cache.put(k, v)
        res_map[k] = v  # Store temporarily for waiting requests
        
        # Signal that I'm done
        sem_map[k].signal()
        sem_map.remove(k)
        
        return v&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;details class="recall"&gt;&lt;summary&gt;&lt;span class="recall-mark"&gt;&amp;#10045; RECALL&lt;/span&gt; in request hedging, what does the first cache-missing request do differently from all the ones behind it?&lt;/summary&gt;&lt;p&gt;the first request finds no semaphore for the key, so it creates one, blocks everyone else, does the expensive db fetch, writes the result to the cache &lt;em&gt;and&lt;/em&gt; a temporary result map, then signals and removes the semaphore. every later request finds the semaphore, waits, and grabs the value from the result map. one db query, no matter how many concurrent misses pile up.&lt;/p&gt;&lt;/details&gt;

&lt;h2 id="implementation-details-that-matter"&gt;Implementation Details That Matter&lt;/h2&gt;
&lt;h3 id="why-the-temporary-result-map"&gt;Why the Temporary Result Map?&lt;/h3&gt;
&lt;p&gt;You might wonder: &amp;quot;Why not just make waiting requests hit the cache again after the signal?&amp;quot;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Because that creates unnecessary load!&lt;/strong&gt; If everyone waits and then immediately hits the cache again, you&amp;#39;ve just created another stampede on your cache layer.&lt;/p&gt;
&lt;p&gt;The &lt;code&gt;res_map&lt;/code&gt; is a &lt;strong&gt;temporary local storage&lt;/strong&gt; (5-minute TTL) that holds the result just long enough for waiting requests to grab it directly. This eliminates the extra cache round-trip.&lt;/p&gt;
&lt;details class="recall"&gt;&lt;summary&gt;&lt;span class="recall-mark"&gt;&amp;#10045; RECALL&lt;/span&gt; after the leader signals, why do waiters read from &lt;code&gt;res_map&lt;/code&gt; instead of just hitting the cache again?&lt;/summary&gt;&lt;p&gt;because hundreds of requests simultaneously re-hitting the cache is just a second stampede, aimed at the cache layer this time. the temporary local result map holds the value just long enough for the waiters to grab it directly — zero extra round-trips, no new herd.&lt;/p&gt;&lt;/details&gt;

&lt;h2 id="when-you-actually-need-this"&gt;When You Actually Need This&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;&amp;quot;I&amp;#39;ve been using Redis for years and never needed this!&amp;quot;&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Fair point. This isn&amp;#39;t some academic exercise. You need request hedging when you have:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;High traffic&lt;/strong&gt; with &lt;strong&gt;shared expensive resources&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Cache expiration&lt;/strong&gt; happening under &lt;strong&gt;concurrent load&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Database queries that take &amp;gt;100ms&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Flash sale scenarios&lt;/strong&gt; or &lt;strong&gt;viral content&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id="cdn-use-case-real-world-example"&gt;CDN Use Case (Real-World Example)&lt;/h3&gt;
&lt;p&gt;CDNs face this constantly:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Origin: Your S3 bucket or API server&lt;/li&gt;
&lt;li&gt;Cache: CDN edge servers worldwide&lt;/li&gt;
&lt;li&gt;Problem: Popular resource expires, 10,000 requests hit one edge server&lt;/li&gt;
&lt;li&gt;Solution: Only ONE request goes to origin, others wait&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;&lt;em&gt;This pattern has prevented countless outages for companies you use every day.&lt;/em&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;details class="recall"&gt;&lt;summary&gt;&lt;span class="recall-mark"&gt;&amp;#10045; RECALL&lt;/span&gt; you&amp;#39;ve run redis for years without request hedging and nothing broke. what combination of conditions changes that?&lt;/summary&gt;&lt;p&gt;high traffic on a shared expensive resource, with cache expiry landing under concurrent load — slow db queries (&amp;gt;100ms), flash sales, viral content. CDNs live this every day: a popular resource expires at an edge server, thousands of requests pile up, and exactly one is let through to the origin while the rest wait. if your traffic never concentrates on one expiring key like that, you genuinely don&amp;#39;t need it.&lt;/p&gt;&lt;/details&gt;
</content>
  </entry>
  <entry>
    <title>How DNS Really works and How it scales infinitely</title>
    <link href="https://tautik.me/dns/"/>
    <id>https://tautik.me/dns/</id>
    <updated>2025-09-07T00:00:00Z</updated>
    <content type="html">&lt;p&gt;&lt;strong&gt;Why should we care about DNS?&lt;/strong&gt; Because it&amp;#39;s one of the most beautiful pieces of software ever written - it made the internet what it is possible today by giving a human-readable name to every single thing out there, not requiring us to remember weird IP addresses of machines. But here&amp;#39;s the thing: most people think DNS is just &amp;quot;domain name to IP address lookup.&amp;quot; That&amp;#39;s like saying the internet is just &amp;quot;computers talking to each other.&amp;quot; The real story is way more interesting.&lt;/p&gt;
&lt;h2 id="what-you-39-ll-take-away"&gt;what you&amp;#39;ll take away&lt;/h2&gt;
&lt;p&gt;quick pointers so you know what to look for as you read:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;dns is not a lookup table, it&amp;#39;s a hierarchy.&lt;/strong&gt; no single machine knows everything — each step takes you closer to the one that does.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;a centralized dns database is impossible.&lt;/strong&gt; volume of data, volume of queries, and every update funneling through one system that can never go down.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;authoritative name servers are the source of truth.&lt;/strong&gt; the entire resolution dance exists just to find the one that owns your zone.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;records are typed mappings.&lt;/strong&gt; A records map names to ips, CNAMEs map names to names — and the resolver chases the chain.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;caching at every layer is the secret sauce.&lt;/strong&gt; most lookups never leave your local network, and root servers barely get touched.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="the-fundamental-problem-dns-solves"&gt;The Fundamental Problem DNS Solves&lt;/h2&gt;
&lt;p&gt;To connect to any machine on the internet, you need its IP address. When you type &lt;code&gt;www.google.com&lt;/code&gt; in your browser, what your browser actually needs is the IP address to establish that TCP connection. But how do we discover that &lt;code&gt;google.com&lt;/code&gt; → &lt;code&gt;17.53.21.253&lt;/code&gt;?&lt;/p&gt;
&lt;p&gt;So there would be a place where this mapping is stored - somewhere this particular mapping needs to be configured: &lt;code&gt;www.google.com&lt;/code&gt; means &lt;code&gt;17.53.21.253&lt;/code&gt;. This is typically the A record or the CNAME record in the DNS configuration.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Why do we need DNS records at all?&lt;/strong&gt; Because we need to store different types of mappings, not just domain-to-IP.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;A Record&lt;/strong&gt; - Maps a domain name directly to an IPv4 address:&lt;/p&gt;
&lt;div class="codeblock"&gt;&lt;pre&gt;&lt;code class="hljs"&gt; 
www.google.com → 17.53.21.253&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;&lt;strong&gt;CNAME Record&lt;/strong&gt; - Maps a domain name to another domain name (alias):&lt;/p&gt;
&lt;div class="codeblock"&gt;&lt;pre&gt;&lt;code class="hljs"&gt; 
blog.google.com → www.google.com&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Think of CNAME as a redirect. When someone looks up &lt;code&gt;blog.google.com&lt;/code&gt;, DNS says &amp;quot;actually, go look up &lt;code&gt;www.google.com&lt;/code&gt; instead.&amp;quot;&lt;/p&gt;
&lt;details class="recall"&gt;&lt;summary&gt;&lt;span class="recall-mark"&gt;&amp;#10045; RECALL&lt;/span&gt; when would you configure a &lt;code&gt;CNAME&lt;/code&gt; instead of an &lt;code&gt;A&lt;/code&gt; record, and what does the resolver do when it hits one?&lt;/summary&gt;&lt;p&gt;an &lt;code&gt;A&lt;/code&gt; record pins a name to an ipv4 address; a &lt;code&gt;CNAME&lt;/code&gt; aliases a name to another name, so when the target&amp;#39;s ip changes you update one record instead of many. the resolver treats a &lt;code&gt;CNAME&lt;/code&gt; as a redirect — &amp;quot;go look up this other name instead&amp;quot; — and keeps resolving until it lands on an actual address.&lt;/p&gt;&lt;/details&gt;

&lt;h2 id="dns-zones-the-logical-foundation"&gt;DNS Zones: The Logical Foundation&lt;/h2&gt;
&lt;p&gt;A &lt;strong&gt;DNS Zone&lt;/strong&gt; is like a database table that contains all the DNS records for a particular domain and its subdomains.&lt;/p&gt;
&lt;p&gt;Example zone file for &lt;code&gt;google.com&lt;/code&gt;:&lt;/p&gt;
&lt;div class="codeblock"&gt;&lt;pre&gt;&lt;code class="hljs"&gt; 
google.com.        A      17.53.21.253
www.google.com.    A      17.53.21.253  
mail.google.com.   A      74.125.224.83
blog.google.com.   CNAME  www.google.com&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;&lt;strong&gt;Why zones?&lt;/strong&gt; Organization and delegation. Google manages everything under &lt;code&gt;google.com&lt;/code&gt; in their zone, while &lt;code&gt;university.edu&lt;/code&gt; manages their own zone separately.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Cloud Providers:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;AWS Route 53&lt;/strong&gt; - Amazon&amp;#39;s DNS service&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Google Cloud DNS&lt;/strong&gt; - Google&amp;#39;s DNS service&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Azure DNS&lt;/strong&gt; - Microsoft&amp;#39;s DNS service&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Cloudflare DNS&lt;/strong&gt; - Also provides CDN services&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Traditional DNS Providers:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;GoDaddy DNS&lt;/strong&gt; - Comes free with domain registration&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Namecheap DNS&lt;/strong&gt; - Free with domains&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;DNSimple&lt;/strong&gt; - Paid DNS hosting specialist&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;NS1&lt;/strong&gt; - Enterprise DNS provider&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Free Options:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Cloudflare&lt;/strong&gt; - Free tier available&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Hurricane Electric&lt;/strong&gt; - Free DNS hosting&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;If you&amp;#39;re using AWS, you&amp;#39;d configure this in Route 53 as a hosted zone. This zone contains everything about google.com - all the mappings, all the subdomains, all the different record types.&lt;/p&gt;
&lt;h2 id="authoritative-name-servers-the-source-of-truth"&gt;Authoritative Name Servers: The Source of Truth&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Authoritative Name Server&lt;/strong&gt; = The server that actually stores and serves your DNS zone data.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Zones are served via Authoritative Name Servers.&lt;/strong&gt; An authoritative name server hosts multiple zones, and &lt;strong&gt;it answers DNS questions for the zones it owns.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src="https://raw.githubusercontent.com/tautik/image-assets/main/58pm-1.webp" alt="/static/img/58pm-1.webp"&gt;&lt;/p&gt;
&lt;p&gt;These servers typically look like &lt;code&gt;ns1.gns.com&lt;/code&gt;, &lt;code&gt;ns2.gns.com&lt;/code&gt;, &lt;code&gt;ns3.gns.com&lt;/code&gt; - usually offered by cloud providers like GoDaddy, Namecheap, or AWS. When you buy a domain, you configure which name servers should handle it.&lt;/p&gt;
&lt;p&gt;Here&amp;#39;s the key insight: &lt;strong&gt;if you somehow know the authoritative name server&amp;#39;s address, you can get the record you&amp;#39;re looking for.&lt;/strong&gt; But how does your browser know which name server to ask?&lt;/p&gt;
&lt;details class="recall"&gt;&lt;summary&gt;&lt;span class="recall-mark"&gt;&amp;#10045; RECALL&lt;/span&gt; what makes a name server &amp;quot;authoritative&amp;quot;, and why is the whole resolution process really just a hunt for one?&lt;/summary&gt;&lt;p&gt;an authoritative name server actually hosts the zone — it stores the records and answers for the zones it owns, no guessing, no cache. if you already knew its address you could ask it directly and be done; the entire hierarchy of root and tld servers exists only because you don&amp;#39;t, and each step just points you closer to it.&lt;/p&gt;&lt;/details&gt;

&lt;h2 id="the-two-approaches-centralized-vs-decentralized"&gt;The Two Approaches: Centralized vs Decentralized&lt;/h2&gt;
&lt;p&gt;Let&amp;#39;s think about this problem. How would we reach these authoritative name servers?&lt;/p&gt;
&lt;h3 id="option-1-the-centralized-database"&gt;Option 1: The Centralized Database&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Let there be a single massive &amp;quot;database&amp;quot; system that everybody reaches out to when they want DNS information.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src="https://raw.githubusercontent.com/tautik/image-assets/main/57pm-1.webp" alt="/static/img/57pm-1.webp"&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;This centralized way is not scalable, fault tolerant, or even manageable.&lt;/strong&gt; Think about the concerns: volume of data, number of requests and updates, and any change in any info needs to be communicated to this single system.&lt;/p&gt;
&lt;p&gt;If this goes down, the entire internet is down. The sheer volume of requests it would need to handle and the amount of data it would need to store makes this approach impossible.&lt;/p&gt;
&lt;h3 id="option-2-decentralized-approach-no-one-machine-knows-it-all"&gt;Option 2: Decentralized Approach - No One Machine Knows It All&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;This is where the beauty of DNS architecture comes in.&lt;/strong&gt; The world went with decentralization where no single machine knows everything.&lt;/p&gt;
&lt;details class="recall"&gt;&lt;summary&gt;&lt;span class="recall-mark"&gt;&amp;#10045; RECALL&lt;/span&gt; why can&amp;#39;t dns be one giant centralized database that everyone queries?&lt;/summary&gt;&lt;p&gt;it fails on every axis — the volume of data, the volume of reads and updates, every change in the world funneling through one system, and a single point of failure that would take the whole internet down with it. so dns went decentralized: no one machine knows it all, but each machine knows enough to send you one step closer.&lt;/p&gt;&lt;/details&gt;

&lt;h2 id="dns-resolvers-your-gateway-to-the-system"&gt;DNS Resolvers: Your Gateway to the System&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;DNS Resolver is a server that carries out the resolution of Domain Name to IP address.&lt;/strong&gt; Where does this DNS resolver run?&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Typically runs at ISP, but you can run your own locally.&lt;/strong&gt; Most home routers are real DNS resolvers. You can check yours by running:&lt;/p&gt;
&lt;div class="codeblock"&gt;&lt;pre&gt;&lt;code class="hljs"&gt; 
ipconfig /all    # Windows
# Look for DNS Servers entry&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;On my machine, I get &lt;code&gt;192.168.0.1&lt;/code&gt; - that&amp;#39;s my router doing DNS resolution for me. You can change this to popular DNS resolvers like:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Google&lt;/strong&gt; : &lt;code&gt;8.8.8.8&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Cloudflare&lt;/strong&gt; : &lt;code&gt;1.1.1.1&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="root-name-servers-the-foundation-of-the-internet"&gt;Root Name Servers: The Foundation of the Internet&lt;/h2&gt;
&lt;p&gt;Think about this chicken-and-egg problem: To find any website, you need to ask a DNS server. But &lt;strong&gt;how do you find the DNS server&amp;#39;s address?&lt;/strong&gt; You&amp;#39;d need DNS to find DNS!&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Root Name Servers solve this bootstrap problem.&lt;/strong&gt; They&amp;#39;re the &amp;quot;starting point&amp;quot; that everyone agrees on.&lt;/p&gt;
&lt;p&gt;Here&amp;#39;s where it gets really interesting. Say we are looking for &lt;code&gt;www.google.com&lt;/code&gt; - we need to reach its Authoritative Name Server, but we do not know where it is!&lt;/p&gt;
&lt;p&gt;&lt;a href="https://tautik.me/root-name-servers/"&gt;13 Root Name Servers&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Calls to Root NS are infrequent, because even IP of TLD (Top-Level-Domain) servers does not change often, so it is heavily cached.&lt;/strong&gt;&lt;/p&gt;
&lt;details class="recall"&gt;&lt;summary&gt;&lt;span class="recall-mark"&gt;&amp;#10045; RECALL&lt;/span&gt; why do resolvers almost never actually hit the root name servers?&lt;/summary&gt;&lt;p&gt;the root only tells you where the tld servers are, and tld server addresses barely ever change — so that answer gets cached heavily and reused for days. the root is the bootstrap starting point everyone agrees on, not a server in the hot path.&lt;/p&gt;&lt;/details&gt;

&lt;h2 id="the-complete-dns-resolution-process"&gt;The Complete DNS Resolution Process&lt;/h2&gt;
&lt;p&gt;Now, how does the DNS resolution process actually work? Let&amp;#39;s walk through it step by step, assuming nothing is cached:&lt;/p&gt;
&lt;p&gt;&lt;img src="https://raw.githubusercontent.com/tautik/image-assets/main/30pm-1.webp" alt="/static/img/30pm-1.webp"&gt;&lt;/p&gt;
&lt;h2 id="putting-it-all-together"&gt;Putting It All Together&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;You buy&lt;/strong&gt; &lt;code&gt;mysite.com&lt;/code&gt; from GoDaddy (registrar)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;GoDaddy registers&lt;/strong&gt; your domain with &lt;code&gt;.com&lt;/code&gt; TLD servers&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;By default&lt;/strong&gt; , GoDaddy&amp;#39;s name servers become authoritative for your zone&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;You can change&lt;/strong&gt; to use AWS Route 53 instead&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Your DNS zone&lt;/strong&gt; contains A records, CNAME records, etc.&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 id="step-by-step-resolution"&gt;Step by Step Resolution&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;1. Query to Root Name Server&lt;/strong&gt; When queried for Domain Name, it responds with IP address of server handling the TLD (e.g., .com, .in, .edu, etc.)&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;2. Query to TLD Server&lt;/strong&gt; The .com TLD server responds with the authoritative name server that owns the zone &lt;code&gt;google.com&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;3. Query to Authoritative Name Server&lt;/strong&gt; Because it owns the corresponding zone, it can respond with what&amp;#39;s configured against &lt;code&gt;www.google.com&lt;/code&gt;, which is the IP address &lt;code&gt;17.53.21.253&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;4. Browser Connection&lt;/strong&gt; Your browser gets the IP address, establishes TCP connection to the load balancer, sends HTTP request, gets response - and that&amp;#39;s how you see Google&amp;#39;s homepage.&lt;/p&gt;
&lt;details class="recall"&gt;&lt;summary&gt;&lt;span class="recall-mark"&gt;&amp;#10045; RECALL&lt;/span&gt; walk through resolving &lt;code&gt;www.google.com&lt;/code&gt; from a totally cold cache — who do you ask, and what does each server actually tell you?&lt;/summary&gt;&lt;p&gt;the resolver asks a root server, which points to the &lt;code&gt;.com&lt;/code&gt; tld servers; the tld server points to the authoritative name server that owns the &lt;code&gt;google.com&lt;/code&gt; zone; the authoritative server returns the record configured against &lt;code&gt;www&lt;/code&gt; — the actual ip. nobody answers the question directly except the last hop; everyone else just delegates you one level down.&lt;/p&gt;&lt;/details&gt;

&lt;h2 id="the-caching-strategy-how-dns-scales-infinitely"&gt;The Caching Strategy: How DNS Scales Infinitely&lt;/h2&gt;
&lt;p&gt;Here&amp;#39;s the secret sauce: &lt;strong&gt;each machine takes us closer to machine that knows it.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Denotes caching&lt;/strong&gt; - This information is cached for a few hours across multiple layers:&lt;/p&gt;
&lt;p&gt;&lt;img src="https://raw.githubusercontent.com/tautik/image-assets/main/18pm-1.webp" alt="/static/img/18pm-1.webp"&gt;&lt;/p&gt;
&lt;p&gt;The recursion continues from Google Name Server to Authoritative Name Server of domain zone &lt;code&gt;google.com&lt;/code&gt;. Then it checks the record against &lt;code&gt;www&lt;/code&gt;, which may point to a load balancer, and it then resolves to the IP of the load balancer.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Key insight:&lt;/strong&gt; &lt;em&gt;The beauty of DNS is that it&amp;#39;s a step-by-step resolution process.&lt;/em&gt; You go to &lt;code&gt;.com&lt;/code&gt; TLD, they give you &lt;code&gt;google.com&lt;/code&gt; authority, you go to that and say &amp;quot;Give me &lt;code&gt;www.google.com&lt;/code&gt;&amp;quot; - it&amp;#39;s hierarchical resolution that scales infinitely.&lt;/p&gt;
&lt;details class="recall"&gt;&lt;summary&gt;&lt;span class="recall-mark"&gt;&amp;#10045; RECALL&lt;/span&gt; what&amp;#39;s the actual mechanism that lets dns scale &amp;quot;infinitely&amp;quot;?&lt;/summary&gt;&lt;p&gt;hierarchy plus caching. the hierarchy splits the namespace so each server only owns its slice, and every answer along the chain gets cached — browser, os, resolver — for hours. so most lookups never leave the local network, and the higher you go up the hierarchy the less traffic ever arrives.&lt;/p&gt;&lt;/details&gt;

&lt;h2 id="why-this-architecture-is-brilliant"&gt;Why This Architecture Is Brilliant&lt;/h2&gt;
&lt;p&gt;The DNS architecture solves several critical problems:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Infinite Scalability&lt;/strong&gt; - No single point of failure, distributed globally with heavy caching at every layer.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Fault Tolerance&lt;/strong&gt; - Multiple servers at each level, anycast distribution, and redundant authoritative name servers.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Human Usability&lt;/strong&gt; - We remember &lt;code&gt;google.com&lt;/code&gt; instead of &lt;code&gt;17.53.21.253&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Decentralized Management&lt;/strong&gt; - Organizations control their own zones without depending on a central authority.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Performance&lt;/strong&gt; - Heavy caching means most DNS lookups never leave your local network.&lt;/p&gt;
&lt;p&gt;This is one of those systems where the more you understand it, the more you appreciate how elegantly it solves an impossibly complex global coordination problem. The fact that typing any domain name anywhere in the world just works - that&amp;#39;s the magic of DNS.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Note:&lt;/strong&gt; In upcoming content, I&amp;#39;ll be building my own DNS server to show you exactly how this protocol works under the hood.&lt;/p&gt;
</content>
  </entry>
</feed>