<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: M. Alwi Sukra</title>
    <description>The latest articles on DEV Community by M. Alwi Sukra (@arkoesalwi).</description>
    <link>https://dev.to/arkoesalwi</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3922142%2F6181c905-c563-4315-acd8-5eec06d607b5.jpg</url>
      <title>DEV Community: M. Alwi Sukra</title>
      <link>https://dev.to/arkoesalwi</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/arkoesalwi"/>
    <language>en</language>
    <item>
      <title>TIL - Picking a Database by Its Read/Write Pattern</title>
      <dc:creator>M. Alwi Sukra</dc:creator>
      <pubDate>Sun, 14 Jun 2026 04:36:15 +0000</pubDate>
      <link>https://dev.to/arkoesalwi/til-picking-a-database-by-its-readwrite-pattern-l27</link>
      <guid>https://dev.to/arkoesalwi/til-picking-a-database-by-its-readwrite-pattern-l27</guid>
      <description>&lt;p&gt;I've shipped systems on Postgres, BigTable, and a column store. If you asked me which one to reach for, I could answer from experience. But until I read DDIA Chapter 3, I couldn't have told you why they behave so differently, because I'd never actually thought about what the storage engine does with the bytes underneath.&lt;/p&gt;

&lt;p&gt;That turned out to be the whole lesson. The internal data structure of a database isn't an implementation detail we can ignore. It's designed and tuned around a read/write access pattern. So once we understand the structures, "which database" stops being a vibe and starts being a consequence of how our workload reads and writes.&lt;/p&gt;




&lt;h3&gt;
  
  
  Two worlds: OLTP and OLAP
&lt;/h3&gt;

&lt;p&gt;DDIA splits database workloads into two shapes. These aren't database types, they're descriptions of how a workload behaves:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;OLTP (Online Transaction Processing): point reads/writes, low latency, high concurrency, few rows touched per query, needs fresh data. (The app serving user requests)&lt;/li&gt;
&lt;li&gt;OLAP (Online Analytical Processing): large scans, few columns across many rows, throughput over latency, bulk/batch writes, tolerates staleness. (The analytics and reporting)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The thing that finally clicked for me: these labels are something we put on a workload after we've described it, not a bucket we pick first. And one system usually has both. The write path can be analytical-shaped while the read path is transactional-shaped.&lt;/p&gt;




&lt;h3&gt;
  
  
  Picking an engine is asking two questions about the workload
&lt;/h3&gt;

&lt;p&gt;If the storage engine is tuned to the access pattern, then choosing one is really about describing how our workload reads and writes, then matching it. Two questions get us most of the way there.&lt;/p&gt;

&lt;p&gt;One quick note before that. I'm only scratching the surface of each technology here. This is what clicked for me, not a deep dive into compaction internals or page layouts. If you want the full mechanics, the book itself (and plenty of good articles) go far deeper. Treat the names below as starting points to go read about, not as the last word.&lt;/p&gt;




&lt;h4&gt;
  
  
  Q1: write-heavy or read-heavy? (LSM vs B-tree)
&lt;/h4&gt;

&lt;p&gt;Inside the OLTP world, there are two ways to organize data on disk, and the difference is entirely about how each one handles a write.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Log-structured (LSM-tree)&lt;/strong&gt;. New and updated data is appended. Writes land in an in-memory sorted structure (the &lt;em&gt;memtable&lt;/em&gt;), which gets flushed to disk as an immutable, sorted file (an &lt;em&gt;SSTable&lt;/em&gt;). Background compaction merges those files and throws away superseded values. Because every write is a sequential append, write throughput is high. The cost is &lt;em&gt;read amplification&lt;/em&gt;, where a key might live in several SSTables, so reads check multiple files (Bloom filters exist to skip the ones that definitely don't have the key). Databases built this way: Cassandra, RocksDB, LevelDB, HBase, and BigTable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Update-in-place (B-tree)&lt;/strong&gt;. Data lives in fixed-size pages, and an update rewrites the page where the key already is. Reads are predictable (a bounded number of page lookups), and this structure fits transactions cleanly. The cost is write amplification: writes are random I/O, and a single update often rewrites a whole page (plus the write-ahead log entry, and sometimes a page split). Databases built this way: Postgres, MySQL (InnoDB), and most traditional relational databases.&lt;/p&gt;

&lt;p&gt;So the heuristic "LSM for write-heavy, B-tree for read-heavy or transactional" isn't a rule to memorize. It falls out of how each structure treats a write. That's the thesis in miniature: the engine is shaped by the access pattern. This is why Cassandra can absorb a firehose of writes while Postgres gives us clean transactions. They picked different sides of this tradeoff.&lt;/p&gt;




&lt;h4&gt;
  
  
  Q2: point access or analytical scan? (and why "row vs column" is the wrong axis)
&lt;/h4&gt;

&lt;p&gt;This is the part that reframed how I think about it.&lt;/p&gt;

&lt;p&gt;I used to assume the storage axis was "row-oriented vs column-oriented." After this chapter I think that's the wrong axis. The real distinction is whether our storage unit is &lt;em&gt;explicitly keyed&lt;/em&gt; or &lt;em&gt;positionally implied&lt;/em&gt;.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;In a &lt;strong&gt;row store&lt;/strong&gt; like Postgres or MySQL, a row is one keyed record with all its columns fused together. Update one column, and it rewrites the whole record.&lt;/li&gt;
&lt;li&gt;In a &lt;strong&gt;wide-column store&lt;/strong&gt; like BigTable, Cassandra, or HBase, the unit isn't &lt;code&gt;row -&amp;gt; all columns&lt;/code&gt;. The on-disk key is the full cell coordinate &lt;code&gt;(row key, column, timestamp) -&amp;gt; value&lt;/code&gt;. Each cell is independently keyed. A row's cells are sorted to sit next to each other (that's the only thing "row-oriented" really means here, just locality), but each cell is written and updated on its own.&lt;/li&gt;
&lt;li&gt;In a &lt;strong&gt;column store&lt;/strong&gt; like Parquet, ClickHouse, Vertica, or BigQuery, each column is a separate file of bare values, with no key stored next to each value. Row N is whatever sits at position N in every file. Position is the implicit key.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For example, let's say we have these rows:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;row A: impression=11, click=12, cost=13
row B: impression=14, click=15, cost=16
row C: impression=17, click=18, cost=19
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The storage visualization for each type is:&lt;/p&gt;

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

&lt;p&gt;That single distinction explains the write behavior the orientation framing can't:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A row store updates one column by rewriting the whole row, since the columns are fused into a single keyed record. Cheap when you're touching one row at a time, but two writers updating different columns of the same row still contend on the same record.&lt;/li&gt;
&lt;li&gt;A wide-column store can update one column of one row without touching the others, because the cell is its own keyed thing. Independent writers writing different columns to the same row never collide.&lt;/li&gt;
&lt;li&gt;A column store can't cheaply insert one row, because there's no key to address it by. Position ties every value to its row, so inserting means realigning every file (and the compressed, sorted columns reject cheap in-place edits). That same positional layout is exactly what makes scans and compression spectacular.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Strength and weakness come from the same design decision. Keyed buys us independent writes and locality. Positional buys us compression and scan speed. Neither is "better." Each is tuned to a different access pattern.&lt;/p&gt;

&lt;p&gt;The read side mirrors it: &lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A row store fetches one whole record in a single read, but scanning one column means dragging every full row off disk. &lt;/li&gt;
&lt;li&gt;A wide-column store reads one row as a contiguous scan over its adjacent cells, but it's still reading row by row, not scanning one column across everything. &lt;/li&gt;
&lt;li&gt;A column store reads one column across millions of rows by touching just that file, but reassembling one whole row means gathering a value from every file. &lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Point access wants keyed; analytical scan wants positional. Read and write pull the same direction, because they're the same access-pattern bet.&lt;/p&gt;




&lt;h3&gt;
  
  
  Where I'd been relying on this without naming it
&lt;/h3&gt;

&lt;p&gt;A system I worked on stored predictions for an ads insights platform: three prediction types (impression uplift, click uplift, cost saving), produced by several suggestion engines, kept in BigTable. We did reason our way there from the read/write pattern. What this chapter gave me was sharper vocabulary for the thing we were already doing.&lt;/p&gt;

&lt;p&gt;Run the workload through the two questions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Writes were high-volume and batch: several engines aggregating offline, then loading results in. That's append-heavy ingest, the LSM side of Q1.&lt;/li&gt;
&lt;li&gt;Reads were point and prefix lookups on a composite key like &lt;code&gt;shop#group&lt;/code&gt; (read one group, or a range of groups under a shop). Not analytical scans across one column, so keyed, not positional, on Q2.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Append-heavy writes, keyed point and prefix reads. That shape fits a wide-column store like BigTable almost exactly, so that's where we landed. None of it is "row vs column" or "SQL vs NoSQL." It's just the workload described honestly, and the engine fell out of the description.&lt;/p&gt;

&lt;p&gt;The fit wasn't perfect, either. Some read paths still needed online aggregation, which traded read latency for write-path simplicity, but that's a story for another post.&lt;/p&gt;




&lt;h3&gt;
  
  
  The part the access pattern doesn't decide
&lt;/h3&gt;

&lt;p&gt;Here's what I only noticed reading this chapter. We assessed the read/write pattern, it pointed at BigTable, and we stopped there. I never actually asked the next question: could Postgres have done this too?&lt;/p&gt;

&lt;p&gt;The honest answer is, probably yes.&lt;/p&gt;

&lt;p&gt;Whole-row update contention from multiple writers? Postgres has a workaround. Split each writer's columns into separate tables keyed on &lt;code&gt;(shop, group)&lt;/code&gt;. No shared row, no contention.&lt;/p&gt;

&lt;p&gt;Avoid joins on large tables? That join only exists because I split the tables. Keep one wide table and there's no join at all. And even the split join is cheap when it's indexed on a bounded set of rows.&lt;/p&gt;

&lt;p&gt;The data too big for one machine? It wasn't, at the time. It fit comfortably on a single node.&lt;/p&gt;

&lt;p&gt;So the thing that pointed me at BigTable, the access pattern, wasn't actually the thing that ruled Postgres out. Postgres could have served the same reads and writes. I just never ran the comparison, because I'd found something that fit and didn't look back.&lt;/p&gt;

&lt;p&gt;That's the lesson I took from it. The access pattern narrows you to an engine class, and that narrowing is real and useful. But it doesn't rule out the alternatives within reach of that class. What actually separates BigTable from Postgres here is how each one grows past a single machine. Postgres is single-node by design: scaling out means sharding it yourself, picking a shard key, routing queries in the application, and rebalancing by hand as it grows. BigTable partitions automatically, splitting data into tablets by row-key range and spreading them across machines on its own. So once the data outgrows one node, Postgres turns into an operational project and BigTable just keeps going. That's the real dividing line, and it's the question I never got around to asking out loud.&lt;/p&gt;




&lt;h3&gt;
  
  
  Takeaway
&lt;/h3&gt;

&lt;p&gt;So the honest shape of database selection, after this chapter:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The read/write access pattern selects the engine class: LSM vs B-tree, keyed vs positional. This part is real and reasoned, and it's what I actually did.&lt;/li&gt;
&lt;li&gt;Scale and transactional needs finish the selection, and these are independent of the access pattern. Two stores can match the access pattern perfectly and differ entirely on whether they shard or do multi-row ACID. This is the step it's easy to skip once you've found something that fits.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The data structure follows the access pattern. The product follows the data structure plus scale.&lt;/p&gt;

&lt;p&gt;This is the storage-engine layer of the decision. Replication, partitioning, and consistency (later chapters in the book) add their own factors, but those sit on top of this layer, they don't replace it.&lt;/p&gt;

</description>
      <category>software</category>
      <category>database</category>
    </item>
    <item>
      <title>TIL - Graph Thinking Without a Graph Database</title>
      <dc:creator>M. Alwi Sukra</dc:creator>
      <pubDate>Thu, 28 May 2026 17:19:16 +0000</pubDate>
      <link>https://dev.to/arkoesalwi/til-graph-thinking-without-a-graph-database-1d11</link>
      <guid>https://dev.to/arkoesalwi/til-graph-thinking-without-a-graph-database-1d11</guid>
      <description>&lt;p&gt;This week I read DDIA Chapter 2 related to data models. Most of it felt familiar. Relational vs document, many-to-many with junction tables, schema-on-read vs schema-on-write. These were things I had opinions about already.&lt;/p&gt;

&lt;p&gt;But the graph data model section was a blind spot. I assumed graph databases were for social networks, interesting but not relevant to anything I was doing.&lt;/p&gt;




&lt;h3&gt;
  
  
  What graph data models actually are
&lt;/h3&gt;

&lt;p&gt;One key aspect that the chapter emphasizes is how different data models handle many-to-many relationships. In a relational data model, we usually have several tables and a junction table that connects them. We also can add some additional columns to that junction table.&lt;/p&gt;

&lt;p&gt;For a graph data model, we can think of it having 2 different tables: &lt;strong&gt;nodes&lt;/strong&gt; and &lt;strong&gt;edges&lt;/strong&gt;. Nodes are entities. Edges are relationships between them. Both can have properties.&lt;/p&gt;

&lt;p&gt;There is not much difference between a relational and graph data model for a single relationship at a fixed depth. For example, a &lt;code&gt;friendships&lt;/code&gt; table with &lt;code&gt;user_a_id, user_b_id, since, is_close_friend&lt;/code&gt; is basically an edge with properties. Relational handles that fine.&lt;/p&gt;

&lt;p&gt;The difference shows up when we start traversing.&lt;/p&gt;

&lt;p&gt;Say we want "friends of friends". With a junction table, that's a self-join. "Friends of friends of friends" is another join. "Anyone reachable from me through any number of friendship hops" is a recursive CTE. It works, but the query complexity has nothing to do with how simple the question sounds.&lt;/p&gt;

&lt;p&gt;In a graph query language, traversal is the native operation. Here's friends of friends in Cypher (Neo4j's query language):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cypher"&gt;&lt;code&gt;&lt;span class="k"&gt;MATCH&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="py"&gt;me:&lt;/span&gt;&lt;span class="n"&gt;User&lt;/span&gt; &lt;span class="ss"&gt;{&lt;/span&gt;&lt;span class="py"&gt;id:&lt;/span&gt; &lt;span class="n"&gt;$userId&lt;/span&gt;&lt;span class="ss"&gt;})&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="ss"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;:FOLLOWS&lt;/span&gt;&lt;span class="ss"&gt;]&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="n"&gt;friend&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="ss"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;:FOLLOWS&lt;/span&gt;&lt;span class="ss"&gt;]&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fof&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;RETURN&lt;/span&gt; &lt;span class="n"&gt;fof&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We can read it almost like a sentence: &lt;em&gt;match the pattern where I follow a friend, who follows a friend-of-friend&lt;/em&gt;. The arrows are edges; &lt;code&gt;[:FOLLOWS]&lt;/code&gt; is the edge type to traverse.&lt;/p&gt;

&lt;p&gt;And arbitrary depth is just one more character:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cypher"&gt;&lt;code&gt;&lt;span class="k"&gt;MATCH&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="py"&gt;me:&lt;/span&gt;&lt;span class="n"&gt;User&lt;/span&gt; &lt;span class="ss"&gt;{&lt;/span&gt;&lt;span class="py"&gt;id:&lt;/span&gt; &lt;span class="n"&gt;$userId&lt;/span&gt;&lt;span class="ss"&gt;})&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="ss"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;:FOLLOWS&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="ss"&gt;]&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="n"&gt;reachable&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;RETURN&lt;/span&gt; &lt;span class="n"&gt;reachable&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;*&lt;/code&gt; means "follow any number of these edges." Same query shape whether it's one hop or ten. In SQL, that jump from fixed depth to arbitrary depth means rewriting our query as a recursive CTE.&lt;/p&gt;

&lt;p&gt;The second difference is that a graph treats different relationship types uniformly. In relational, &lt;code&gt;follows&lt;/code&gt;, &lt;code&gt;blocks&lt;/code&gt;, and &lt;code&gt;memberships&lt;/code&gt; are usually separate tables, and traversing across them means a different join per table. In a graph, they're all just edges, and we can traverse across types in a single pattern.&lt;/p&gt;

&lt;p&gt;So my take is that the real distinction is the traversal. Especially variable-depth traversal across multiple relationship types. It's a first-class operation in a graph model and an awkward bolt-on in SQL.&lt;/p&gt;




&lt;h3&gt;
  
  
  The shape that fits
&lt;/h3&gt;

&lt;p&gt;The chapter convinced me that graph models suit problems where:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Relationships are recursive or variable-depth (friends of friends, transitive dependencies, reachability)&lt;/li&gt;
&lt;li&gt;Multiple paths can exist between the same two entities&lt;/li&gt;
&lt;li&gt;The type of relationship matters as much as the entities themselves&lt;/li&gt;
&lt;li&gt;Queries are about traversal and reachability, not just lookup&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If our data is mostly "fetch a row by ID" or "join two tables on a foreign key", relational is fine. But the moment we start asking "what's reachable from here through any valid path?", &lt;strong&gt;that's a graph question, whether we store it in a graph database or not&lt;/strong&gt;.&lt;/p&gt;




&lt;h3&gt;
  
  
  Looking at my own work through this lens
&lt;/h3&gt;

&lt;p&gt;I worked on an ads management system. The schema looked like this:&lt;/p&gt;

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

&lt;p&gt;Some queries this service needed to answer:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Find all keywords in a shop.&lt;/li&gt;
&lt;li&gt;Find all keywords in a group.&lt;/li&gt;
&lt;li&gt;Find all keywords in an ad. (Keywords directly on the ad, plus keywords on the group that contains it.)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Reasonable schema, queries, and I'd worked with this code. But when I tried drawing the data as a graph, this is what I got:&lt;/p&gt;

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

&lt;p&gt;There are multiple paths from a shop to a keyword: through an ad group, through an ad, through both. When I query "all keywords in a shop", I'm doing a graph traversal: "find every Keyword reachable from this Shop through any path of contains edges". I just hadn't been calling it that.&lt;/p&gt;




&lt;h3&gt;
  
  
  What actually changed for me
&lt;/h3&gt;

&lt;h4&gt;
  
  
  1. I started thinking about reachability instead of joins
&lt;/h4&gt;

&lt;p&gt;Before: every question about the data felt like a question about which tables to join. To get keywords in a shop, I join &lt;code&gt;keyword&lt;/code&gt; tables with &lt;code&gt;ad&lt;/code&gt;/&lt;code&gt;ad_group&lt;/code&gt; tables and filter by &lt;code&gt;shop_id&lt;/code&gt;. The query was a sequence of join operations.&lt;/p&gt;

&lt;p&gt;After: every question about the data feels like a question about which nodes are reachable from which. Find all keyword nodes reachable from this shop. The traversal is the question, and the join is just one implementation of the traversal.&lt;/p&gt;

&lt;p&gt;The shift sounds subtle, but it's what made other approaches (denormalization, recursive CTEs, even just rephrasing the SQL) become visible. Once the question is "what's reachable from here?", the answer doesn't have to be "join these tables." It can be anything that gets us the same set of reachable nodes.&lt;/p&gt;

&lt;h4&gt;
  
  
  2. I noticed the multiple paths problem
&lt;/h4&gt;

&lt;p&gt;In a tree, every node has exactly one parent. The hierarchy I was working with isn't a tree. A keyword can be reached from a shop through an ad group, or through an ad, or through both. An ad can belong to a shop directly, or be inside a group.&lt;/p&gt;

&lt;p&gt;I'd been treating this as a quirk of the schema. The nullable columns, the join table, the two separate keyword tables, these were just "how things are." But the graph lens names it clearly: the data has multiple paths between the same kinds of nodes. That's a structural property, not a quirk.&lt;/p&gt;

&lt;p&gt;And it explains why my SQL queries kept needing unions. Each &lt;code&gt;UNION&lt;/code&gt; branch is one path. The graph is telling me up front that I'm going to need multiple branches; the schema was hiding that until the query made it visible.&lt;/p&gt;

&lt;h4&gt;
  
  
  3. I saw edges that weren't in my schema
&lt;/h4&gt;

&lt;p&gt;The graph diagram has a &lt;code&gt;contains&lt;/code&gt; edge from &lt;code&gt;ad_group&lt;/code&gt; to &lt;code&gt;ad&lt;/code&gt;. In my schema, that relationship lives in the &lt;code&gt;ad_group_ad&lt;/code&gt; junction table.&lt;/p&gt;

&lt;p&gt;But the graph also has implicit relationships that my schema doesn't model directly. The "keyword in shop X" relationship is real (and we query for it constantly), but no column or table represents it directly. It's a derived relationship, computed every time we run the traversal.&lt;/p&gt;

&lt;p&gt;That's where the option space opens up. Once I can see shop-to-keyword as a meaningful relationship, I can ask whether to materialize it (denormalize shop_id onto every keyword) or keep deriving it (current approach with traversal). Both are valid; the graph view is what made the choice visible.&lt;/p&gt;




&lt;h3&gt;
  
  
  What it would look like as a graph query
&lt;/h3&gt;

&lt;p&gt;In Cypher, "all keywords in a shop" is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cypher"&gt;&lt;code&gt;&lt;span class="k"&gt;MATCH&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="py"&gt;s:&lt;/span&gt;&lt;span class="n"&gt;Shop&lt;/span&gt; &lt;span class="ss"&gt;{&lt;/span&gt;&lt;span class="py"&gt;id:&lt;/span&gt; &lt;span class="n"&gt;$shopId&lt;/span&gt;&lt;span class="ss"&gt;})&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="ss"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;:CONTAINS&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="ss"&gt;]&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="py"&gt;k:&lt;/span&gt;&lt;span class="n"&gt;Keyword&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;RETURN&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In our actual schema, the same query takes two branches:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- Keywords on groups in this shop&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;ad_group_keyword&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;
&lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;ad_group&lt;/span&gt; &lt;span class="k"&gt;g&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ad_group_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;g&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="k"&gt;g&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;shop_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="n"&gt;shopId&lt;/span&gt;

&lt;span class="k"&gt;UNION&lt;/span&gt;

&lt;span class="c1"&gt;-- Keywords on ads in this shop&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;ad_keyword&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;
&lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;ad&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ad_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;shop_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="n"&gt;shopId&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'active'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It works. But this query got slow on us in a way that took us a while to understand.&lt;/p&gt;

&lt;p&gt;The issue wasn't the &lt;code&gt;JOIN&lt;/code&gt; itself, or the &lt;code&gt;UNION&lt;/code&gt;. It was the query planner. When a shop has many keywords, the planner sometimes picks an index path that ends up scanning across the ad tables (soft-deleted rows included), even when the request is for a small page of results.&lt;/p&gt;

&lt;p&gt;The behavior was hard to predict because it depended on the shop's data distribution. Small shops were fine. Large shops sometimes triggered the bad path. And because the keyword list is a batch API, a single slow query multiplied across the batch and put real pressure on the database.&lt;/p&gt;

&lt;p&gt;The team's fix was to stop letting the planner choose. We took the joins out of SQL and resolved them in application code: query each table separately with &lt;code&gt;WHERE shop_id = X AND status = 'active'&lt;/code&gt; (which uses clean indexes predictably), then stitch the results in Go.&lt;/p&gt;

&lt;p&gt;It works. But it's a workaround for a query that's conceptually one thing: find all keywords reachable from this shop. The graph traversal is happening, just spread across multiple queries and some application code, with the planner taken out of the loop entirely.&lt;/p&gt;




&lt;h3&gt;
  
  
  Would I actually use a graph database?
&lt;/h3&gt;

&lt;p&gt;Constraints:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The hierarchy is only 3-4 levels deep. Graph databases shine on deep or unbounded traversal. Mine is bounded.&lt;/li&gt;
&lt;li&gt;Nobody on the team has run Neo4j in production. PostgreSQL we know cold.&lt;/li&gt;
&lt;li&gt;Our source of truth is already in Postgres. Adding a graph database means syncing two stores or migrating the source of truth, both big commitments.&lt;/li&gt;
&lt;li&gt;Our queries are predictable. We're not doing pattern matching or shortest-path.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I'm not sure I'd reach for Neo4j here. The elegance of the Cypher query is real, but the operational cost feels high for the shape of problem I have.&lt;/p&gt;

&lt;p&gt;What's more interesting is that the graph lens opened up another option.&lt;/p&gt;




&lt;h3&gt;
  
  
  A different option
&lt;/h3&gt;

&lt;p&gt;What if every entity in the hierarchy carried its ancestor IDs directly?&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ad:               id, shop_id, ad_group_id (nullable), status
ad_group_keyword: id, ad_group_id, tag, shop_id
ad_keyword:       id, ad_id, shop_id, ad_group_id (nullable), status
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;ad_group_ad&lt;/code&gt; still records the actual ad-to-group membership, but &lt;code&gt;ad.ad_group_id&lt;/code&gt; is a maintained denormalization. Same idea on the keyword tables: each keyword carries &lt;code&gt;shop_id&lt;/code&gt;, and &lt;code&gt;ad_keyword&lt;/code&gt; also carries &lt;code&gt;ad_group_id&lt;/code&gt; and &lt;code&gt;status&lt;/code&gt;. The ID columns are nullable where the relationship doesn't apply.&lt;/p&gt;

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

&lt;p&gt;Now the "find" queries get simpler:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- All keywords in a shop&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;ad_group_keyword&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;shop_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="n"&gt;shopId&lt;/span&gt;
&lt;span class="k"&gt;UNION&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;ad_keyword&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;shop_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="n"&gt;shopId&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'active'&lt;/span&gt;

&lt;span class="c1"&gt;-- All keywords in a group&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;ad_group_keyword&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;ad_group_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="n"&gt;groupId&lt;/span&gt;
&lt;span class="k"&gt;UNION&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;ad_keyword&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;ad_group_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="n"&gt;groupId&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'active'&lt;/span&gt;

&lt;span class="c1"&gt;-- All keywords in an ad&lt;/span&gt;
&lt;span class="c1"&gt;-- Step 1: get the ad's group (single PK read)&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;ad_group_id&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;ad&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="n"&gt;adId&lt;/span&gt;
&lt;span class="c1"&gt;-- Step 2: pull keywords from both sources&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;ad_keyword&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;ad_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="n"&gt;adId&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'active'&lt;/span&gt;
&lt;span class="k"&gt;UNION&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;ad_group_keyword&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;ad_group_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="n"&gt;adGroupId&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These are direct lookups on indexed columns. There's no join for the planner to mis-optimize, no intermediate result set whose size depends on data distribution. The query behavior is the same whether a shop has 50 keywords or 50,000.&lt;/p&gt;

&lt;p&gt;The third case is two reads, but step 1 is just a primary-key lookup on the &lt;code&gt;ad&lt;/code&gt; row we'd usually be fetching anyway.&lt;/p&gt;

&lt;p&gt;The cost is that denormalization is now a system, not a single column. Moving an ad updates &lt;code&gt;ad_group_id&lt;/code&gt; across its keywords; soft-deleting an ad updates &lt;code&gt;status&lt;/code&gt; across its keywords. Two operations, both fan out to the keyword rows, both have to be transactional or the data drifts.&lt;/p&gt;

&lt;p&gt;Drift is the part that worries me. The query patterns get dramatically cleaner, but the write paths get more places they could go wrong. Six months later, someone adds a new way to move ads between groups and forgets to update the keywords. The reads quietly return wrong results. So I'm not sure denormalization is the answer either.&lt;/p&gt;

&lt;p&gt;What surprised me is that I'd been treating it as a normalization problem ("where should the foreign keys go?") instead of a modeling problem ("what shape does the data actually have?"). The graph perspective is what reframed it for me.&lt;/p&gt;




&lt;h3&gt;
  
  
  The takeaway
&lt;/h3&gt;

&lt;p&gt;The data was always graph-shaped. The queries were always graph queries. The schema and the application code were doing graph work without the vocabulary to describe it. And once I could see the shape, alternatives I hadn't been considering became visible, even if I haven't decided which one is right.&lt;/p&gt;

&lt;p&gt;I'm not sure I'll ever reach for a graph database. But learning about them is already changing how I think about modeling, even though I'm not using one.&lt;/p&gt;

</description>
      <category>software</category>
      <category>database</category>
    </item>
    <item>
      <title>TIL - What Response Time Metrics Really Mean</title>
      <dc:creator>M. Alwi Sukra</dc:creator>
      <pubDate>Sun, 10 May 2026 07:55:53 +0000</pubDate>
      <link>https://dev.to/arkoesalwi/til-what-response-time-metrics-really-mean-1df9</link>
      <guid>https://dev.to/arkoesalwi/til-what-response-time-metrics-really-mean-1df9</guid>
      <description>&lt;p&gt;I always thought high percentiles didn't really matter, they only impact a small number of users, right? I interpreted them as the worst case (something unlikely to affect most users).&lt;/p&gt;

&lt;p&gt;This week I read DDIA and came across the part describing how Amazon sets their response time requirements at p99.9. That means the requirement is based on 1 in 1000 users :). But the reason is something I never thought of: the users in the high percentiles are most likely the ones with the most data, which makes them important users for Amazon.&lt;/p&gt;

&lt;p&gt;I reflected on this with my experience working on an Ads Platform. Some processes were slow and it was almost always the same small group of users with many ads, which I assume also correlates with ads revenue contribution. I wonder if we had designed the system around those high-percentile users, maybe we could have made the platform better for all users, and best for our most important sellers.&lt;/p&gt;




&lt;h3&gt;
  
  
  Response time isn't the same as latency
&lt;/h3&gt;

&lt;p&gt;I don't know why, but somehow I just know that response time and latency are different:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Service time&lt;/strong&gt;: how long the server actually spends processing the request.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Latency&lt;/strong&gt;: time the request spends waiting (queued, in transit, blocked).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Response time&lt;/strong&gt;: what the caller sees: service time + network + queueing + everything else.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Response time is from the caller's perspective. Service time is from the callee's. They're almost never equal. I think this is important because most of us only track one side.&lt;/p&gt;




&lt;h3&gt;
  
  
  Average hides the shape
&lt;/h3&gt;

&lt;p&gt;Response times aren't a single number, they're a distribution. Most requests are fast, a few are very slow, and the average sits somewhere awkward between them.&lt;/p&gt;

&lt;p&gt;Average doesn't tell us how many users actually experienced the delay. An average of 200ms can mean everyone gets ~200ms, or that most get 50ms while a few get 2 seconds. The average doesn't tell us which one we have.&lt;/p&gt;

&lt;p&gt;That's why averages aren't enough. We need a metric that respects the shape.&lt;/p&gt;




&lt;h3&gt;
  
  
  Percentiles, properly
&lt;/h3&gt;

&lt;p&gt;A percentile shows "what response time were X% of requests faster than?"&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;p50: half were faster.&lt;/li&gt;
&lt;li&gt;p95: 95% were faster, 5% were slower.&lt;/li&gt;
&lt;li&gt;p99: 99% were faster, 1% were slower.&lt;/li&gt;
&lt;li&gt;p99.9: 99.9% were faster, 0.1% were slower.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If p99 = 500ms, it means 1 out of every 100 requests took longer than 500ms. That's the part I used to dismiss as noise.&lt;/p&gt;




&lt;h3&gt;
  
  
  Which percentile to chase
&lt;/h3&gt;

&lt;p&gt;Once we accept the tail matters, the next question is how far in?&lt;/p&gt;

&lt;p&gt;Honestly, I don't know how to answer the question. Maybe the choice isn't really technical and it's a business question: which users have we decided to serve well? p99 means we're serving 99% of requests well. p99.9 means we're including heavy users (the ones who, going back to the Amazon insight, probably matter most).&lt;/p&gt;




&lt;h3&gt;
  
  
  A few things I wish I'd known earlier
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Measure at both caller and callee&lt;/strong&gt;. Callee might report p99 = 50ms while caller sees p99 = 300ms for the same calls. The 250ms gap is in the network, the connection pool, queueing, or the caller's own thread pool. If we only look at one side, we miss it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Timeouts decouple the metrics&lt;/strong&gt;. If the caller times out at 200ms and the callee takes 500ms, the callee's dashboard shows a successful 500ms response to a request the caller already gave up on. Both metrics are technically correct but are misleading on their own.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't average percentiles across servers&lt;/strong&gt;. This is my second confession. For years, when our dashboard showed p99 from multiple servers, I'd take the average of those numbers and call it our "global p99." That's mathematically meaningless. The average of ten p99s is not the p99 of the combined population. The right way is to merge the underlying histograms first, then compute the percentile.&lt;/li&gt;
&lt;/ul&gt;




&lt;h3&gt;
  
  
  Takeaway
&lt;/h3&gt;

&lt;p&gt;A metric isn't just a number. It's a statement about which users we've decided to serve well.&lt;/p&gt;

&lt;p&gt;An average says "I care about the typical user." p99 says "I care about almost everyone." p99.9 says "I care about the heavy users too, the ones who probably matter most to the business."&lt;/p&gt;

&lt;p&gt;For years, I was implicitly choosing the first one without realizing I was choosing anything.&lt;/p&gt;

</description>
      <category>software</category>
      <category>monitoring</category>
      <category>performance</category>
    </item>
  </channel>
</rss>
