<?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: Albert Kamalov</title>
    <description>The latest articles on DEV Community by Albert Kamalov (@kamalov2000).</description>
    <link>https://dev.to/kamalov2000</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%2F4207411%2Fb4be270b-e33e-44c2-bedc-7ee6e8ce5008.png</url>
      <title>DEV Community: Albert Kamalov</title>
      <link>https://dev.to/kamalov2000</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/kamalov2000"/>
    <language>en</language>
    <item>
      <title>I estimated 500 RPS from the System Design book and measured 24. Here's what broke.</title>
      <dc:creator>Albert Kamalov</dc:creator>
      <pubDate>Sun, 25 Oct 2026 23:12:58 +0000</pubDate>
      <link>https://dev.to/kamalov2000/i-estimated-500-rps-from-the-system-design-book-and-measured-24-heres-what-broke-50gp</link>
      <guid>https://dev.to/kamalov2000/i-estimated-500-rps-from-the-system-design-book-and-measured-24-heres-what-broke-50gp</guid>
      <description>&lt;p&gt;There's a chapter everyone who prepared for a system design interview has read: "Back-of-the-envelope estimation" in Alex Xu's &lt;em&gt;System Design Interview&lt;/em&gt;. Divide DAU by 86,400, double it for peak, multiply out the storage for a few years — and you get "1,160 writes per second, 11,600 reads, 36.5 TB". I could do that arithmetic too. Then I asked myself a simple question: "and will Postgres actually take it?" — and realised I didn't know. I knew how to compute the load. I had no idea what happens to the database when that load arrives.&lt;/p&gt;

&lt;p&gt;So instead of guessing, I measured. I took the system from chapter 8 of the same book — the URL shortener — built it in Docker on my laptop, worked out from the book what it should sustain, and pointed a load generator at it. Below: the estimate, the stand, the numbers, and what I learned from them. Spoiler: the estimate missed the measurement not by a few percent but by three orders of magnitude, and that was the most useful thing that happened in all the time I spent with the book.&lt;/p&gt;

&lt;h2&gt;
  
  
  The estimate, by the book
&lt;/h2&gt;

&lt;p&gt;The URL shortener from chapter 8. Xu's assumptions: 100 million new links per day, a 10:1 read-to-write ratio, 10 years of retention, about 100 bytes per record.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Writes: 100,000,000 / 86,400 ≈ 1,160 per second.&lt;/li&gt;
&lt;li&gt;Reads: ×10 ≈ 11,600 per second.&lt;/li&gt;
&lt;li&gt;Records over 10 years: 100 M × 365 × 10 = 365 billion.&lt;/li&gt;
&lt;li&gt;Storage: 365 billion × 100 bytes = 36.5 TB.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;But 11,600 reads per second is company scale, not laptop scale. I needed a target I could check at home. I picked 500 read requests per second — roughly 4 million DAU at one click per user per day, with a peak factor of 2. A perfectly ordinary mid-sized service. And I wrote down my expectation: at that RPS, p95 latency should be around 20 ms — a key lookup in a table of a million rows, what could possibly take long.&lt;/p&gt;

&lt;p&gt;This matters and I'll insist on it: &lt;strong&gt;write the expectation down before you run anything.&lt;/strong&gt; Otherwise, after the measurement, your brain will helpfully tell you "yeah, that's about what I thought".&lt;/p&gt;

&lt;h2&gt;
  
  
  The stand
&lt;/h2&gt;

&lt;p&gt;Docker Compose — three long-running containers plus k6, which only comes up for the duration of a run:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;api&lt;/code&gt; — a Go service: &lt;code&gt;POST /shorten&lt;/code&gt; takes a long URL and returns a short code (base62 of the id), &lt;code&gt;GET /{code}&lt;/code&gt; looks it up and returns a 302. Database pool size: 10.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;postgres&lt;/code&gt; — one table, &lt;code&gt;urls(id, code, long_url, created_at)&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;redis&lt;/code&gt; — present but off (&lt;code&gt;CACHE=off&lt;/code&gt;). It'll matter later.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;k6&lt;/code&gt; — the load generator, &lt;code&gt;constant-arrival-rate&lt;/code&gt; scenario: exactly N requests per second regardless of whether the server keeps up.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I loaded a million rows with &lt;code&gt;COPY&lt;/code&gt; — 11.7 seconds, 102 MB on disk. Picked 20 random codes out of them for k6 to hit with GETs.&lt;/p&gt;

&lt;p&gt;One deliberate detail I'll state up front so it doesn't feel like a trick: &lt;strong&gt;there is no index on the &lt;code&gt;code&lt;/code&gt; column.&lt;/strong&gt; Only the primary key on &lt;code&gt;id&lt;/code&gt;. Why will become clear from the result.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;NAME                   SERVICE    STATUS
napkinlab-api-1        api        Up 24 minutes (healthy)
napkinlab-postgres-1   postgres   Up 56 minutes (healthy)
napkinlab-redis-1      redis      Up 56 minutes (healthy)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The measurement
&lt;/h2&gt;

&lt;p&gt;Run: 500 requests per second, 60 seconds.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;target_rps | actual RPS | p95, ms  | p99, ms  | error rate
500        | 24.17      | 22130.61 | 22808.33 | 0.00%
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read the second column again. I asked for 500 requests per second — the stand served 24. The rest didn't fail; they queued: p95 is 22 seconds. Not milliseconds. Seconds. I expected 20 ms and got 1,100× that.&lt;/p&gt;

&lt;p&gt;A second run a couple of hours later, with fewer neighbours on the laptop, gave 72 RPS and a p95 of 7.5 seconds — this is what &lt;code&gt;make report&lt;/code&gt; prints:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Stage 1 — estimate vs. measurement   (results/read_500rps_20261024T201101.json)

target_rps | actual RPS      | p95, ms    | p99, ms    | error rate
-----------+-----------------+------------+------------+------------
500        | 72.41           | 7477.01    | 7633.63    | 0.00%

Expected:  p95 ≤ 20 ms at 500 RPS — "will hold: simple query, only a million rows"
Dropped k6 iterations (ran out of VUs): 25162
Estimate vs. fact: p95 7477.0 ms against expected 20 ms (374× higher); actual RPS 72.4 below target 500
The estimate did not hold. Why — stages 2 and 3.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A 3× spread between runs is itself a lesson about measuring on a laptop. But the order of magnitude is the same: asked for 500, got dozens, waited seconds.&lt;/p&gt;

&lt;p&gt;If this weren't a lab but production, it would look like this: users click a link, the page spins for twenty seconds, half of them close the tab. Meanwhile the logs show zero errors. Error-rate monitoring is silent. That's the second lesson: "0% errors" says nothing about whether the system is alive.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it broke
&lt;/h2&gt;

&lt;p&gt;The arithmetic is simple, and it could have been done before the run if you knew where to look.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;SELECT long_url FROM urls WHERE code = $1&lt;/code&gt; without an index on &lt;code&gt;code&lt;/code&gt; is a sequential pass over the whole table: Postgres reads a million rows to return one.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;                                                     QUERY PLAN
--------------------------------------------------------------------------------------------------------------------
 Gather  (cost=1000.00..16552.43 rows=1 width=31) (actual time=10.664..43.648 rows=1 loops=1)
   Workers Planned: 2
   Workers Launched: 2
   Buffers: shared hit=10344
   -&amp;gt;  Parallel Seq Scan on urls  (cost=0.00..15552.33 rows=1 width=31) (actual time=26.482..35.769 rows=0 loops=3)
         Filter: (code = '3Gd3'::text)
         Rows Removed by Filter: 333333
         Buffers: shared hit=10344
 Planning:
   Buffers: shared hit=63
 Planning Time: 0.450 ms
 Execution Time: 43.693 ms
(12 rows)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;Rows Removed by Filter: 333333&lt;/code&gt; in each of three processes — that's the whole million, scanned for one row. Alone, on a warm cache, such a query takes 44 ms. But under load there are ten of them at once (pool size 10), each spawning two more parallel workers, all sharing the same cores: one query stretches to hundreds of milliseconds. Ten connections at 150–400 ms each is a ceiling of a few dozen requests per second. Everything above that waits for a free connection. At 500 incoming per second the queue grows by hundreds of requests every second, and after a minute p95 is queueing time, not query time.&lt;/p&gt;

&lt;p&gt;I checked this with a separate run: load in steps of 10 → 25 → 50 → 100 requests per second, 20 seconds per step. At 10 and 25 the stand still answers in milliseconds; at 50, p95 crosses 200 ms; at 100 it's already 7 seconds. The limit is around 50 requests per second, and it doesn't depend on whether all requests hit 20 hot keys or are spread across a hundred thousand: without an index every request costs the same, and "hot keys" don't exist as far as Postgres is concerned.&lt;/p&gt;

&lt;p&gt;Diagnostics confirm it: &lt;code&gt;pg_stat_statements&lt;/code&gt; shows one query eating all the time; &lt;code&gt;EXPLAIN (ANALYZE, BUFFERS)&lt;/code&gt; — a Seq Scan over a million rows; Postgres saturates every core it's given while the Go service sits at 30% CPU — it's just waiting. The pool queue peaks at 219 requests.&lt;/p&gt;

&lt;p&gt;Now the main point. The book's estimate said: 500 requests per second is nothing, any database will handle it. And that's true — for a database with an index. The chapter 2 arithmetic gives you a &lt;em&gt;number of requests&lt;/em&gt;. It does not and cannot give you the &lt;em&gt;cost of one request&lt;/em&gt;, because that depends not on DAU but on the schema, the indexes, and whether the working set fits in memory. 500 RPS at 0.1 ms and 500 RPS at 300 ms are the same number in an estimate and two different universes in a measurement.&lt;/p&gt;

&lt;p&gt;I knew that in theory. The gap between "knew" and "saw a 22-second p95 on my own laptop" turned out to be larger than I expected.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to do about it
&lt;/h2&gt;

&lt;p&gt;The obvious answer is to add the index, and it's the right one. With an index on &lt;code&gt;code&lt;/code&gt;, the same stepped run found no limit up to 1,000 requests per second — I stopped there because the laptop couldn't honestly generate more. p95 at 100 RPS: 1 ms instead of 7 seconds. One &lt;code&gt;CREATE INDEX&lt;/code&gt; — three orders of magnitude.&lt;/p&gt;

&lt;p&gt;But I deliberately didn't stop there and tested the fix that comes to many people's minds first: a Redis cache. This is where it gets interesting. On the profile with 20 hot keys, the cache fixes things just as well as the index: limit above 1,000 RPS, p95 1.4 ms. On the profile with a hundred thousand uniformly spread keys it fixes almost nothing: limit around 100 RPS, p95 at that load 2.3 seconds. The cache rarely hits, and every miss is the same Seq Scan over a million rows. The index fixes both profiles; the cache fixes one — and which one you have in production, the chapter 2 estimate won't tell you.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Stage 5 — before / index / cache (steps 10 → 1000 RPS, 20 s each; limit = first step with p95 &amp;gt; 200 ms)

Profile hot
metric              | before       | index        | cache
--------------------+--------------+--------------+--------------
limit RPS           | ~50          | &amp;gt; 1000       | &amp;gt; 1000
p95 @100 RPS, ms    | 7252.25      | 0.96         | 1.38
p95 @500 RPS, ms *  | 9671.82      | 1.34         | 0.85
errors              | 0.00%        | 0.00%        | 0.00%

Profile uniform
metric              | before       | index        | cache
--------------------+--------------+--------------+--------------
limit RPS           | ~50          | &amp;gt; 1000       | ~100
p95 @100 RPS, ms    | 5155.77      | 1.5          | 2265.03
p95 @500 RPS, ms *  | 7828.10      | 1.28         | 7087.21
errors              | 0.00%        | 0.00%        | 0.00%

* there is no 500 step: the value is a linear interpolation between the 400 and 700 steps.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A caveat on the numbers: this is a laptop, other containers were running alongside, and measurements vary by 30–50% between runs. The orders of magnitude don't change, but if you get 40 RPS instead of 50, that's not a bug.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways, no moral
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;A book estimate is a number of requests. What the system does with them, the estimate doesn't say. That's a different skill, and it's taught not by books but by measuring.&lt;/li&gt;
&lt;li&gt;Write your expectation down before you run. "That's about what I thought" is not knowledge.&lt;/li&gt;
&lt;li&gt;Zero errors is not a sign of health. Look at p95 and at the queue.&lt;/li&gt;
&lt;li&gt;"Hot keys" are a property of your traffic, not of your database. A cache helps exactly as much as your traffic repeats itself.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The whole stand is in an open repository: &lt;code&gt;docker compose up&lt;/code&gt;, &lt;code&gt;make seed&lt;/code&gt;, &lt;code&gt;make stage1&lt;/code&gt; — and ten minutes later you'll see the same table on your own laptop, with your own numbers. There's a chapter 2 worksheet in there too, so you can write your expectation down before you run. If you want to go through the rest by hand — find the limit, diagnose it from &lt;code&gt;pg_stat_statements&lt;/code&gt;, write a prediction and test it with a measurement for the index, the cache, the pool and a 301 — those are stages 2–5, described in the README.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/kamalov2000/napkinlab" rel="noopener noreferrer"&gt;https://github.com/kamalov2000/napkinlab&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you have your own numbers from a similar stand — especially if they don't match mine — I'd like to hear them in the comments. Laptop measurements don't transfer to production, and the more different laptops, the more honest the picture.&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>systemdesign</category>
      <category>performance</category>
      <category>go</category>
    </item>
  </channel>
</rss>
