<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Sergei Solod]]></title><description><![CDATA[Sergei Solod]]></description><link>https://sergeisolod.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Tue, 15 Sep 2026 10:13:15 GMT</lastBuildDate><atom:link href="https://sergeisolod.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[My FDCServers VPS Stayed Online While Disk Reads Took 18.7 Seconds]]></title><description><![CDATA[I actually liked FDCServers.
For quite a while, my VPS worked exactly the way I wanted it to.
It handled real production traffic. Nginx behaved normally. The backend behaved normally. I was not watchi]]></description><link>https://sergeisolod.hashnode.dev/fdcservers-vps-18-7-second-disk-reads</link><guid isPermaLink="true">https://sergeisolod.hashnode.dev/fdcservers-vps-18-7-second-disk-reads</guid><category><![CDATA[Linux]]></category><category><![CDATA[Devops]]></category><category><![CDATA[vps]]></category><category><![CDATA[performance]]></category><category><![CDATA[debugging]]></category><dc:creator><![CDATA[Sergei Solod]]></dc:creator><pubDate>Sun, 13 Sep 2026 20:37:17 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6953c791eb36c2149a16c84b/9820b5ae-2633-4325-9737-e1fdce07f806.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>I actually liked FDCServers.</p>
<p>For quite a while, my VPS worked exactly the way I wanted it to.</p>
<p>It handled real production traffic. Nginx behaved normally. The backend behaved normally. I was not watching the server every five minutes or looking for reasons to blame the hosting provider.</p>
<p>Then requests started slowing down.</p>
<p>Some eventually timed out completely.</p>
<p>The strange part was that the VPS did not look dead.</p>
<p>It was online.</p>
<p>Nginx was running.</p>
<p>The application was running.</p>
<p>Ping worked.</p>
<p>Sometimes I could SSH into the machine, check a few basic metrics, and see nothing dramatic.</p>
<p>Then I managed to catch the server during one of the actual failures.</p>
<p>That changed everything.</p>
<pre><code class="language-text">CPU iowait:       97-100%
I/O PSI full:     ~95-98%
read latency:     up to 18.7 seconds
flush latency:    up to 53.6 seconds
I/O queue depth:  128+
</code></pre>
<p>Those are seconds, not milliseconds.</p>
<p>At the worst point I observed ordinary reads taking up to 18.7 seconds and flushes taking more than 53 seconds.</p>
<p>The VPS was technically online.</p>
<p>The workload was barely moving.</p>
<p>This article is about how I traced that failure from the application layer down through Nginx, EXT4, Linux pressure metrics, D-state processes and the virtual block device.</p>
<p>It is also about where the investigation had to stop, because inside a VPS there is a point beyond which the guest simply cannot see.</p>
<p>And yes, eventually I cancelled the server.</p>
<p>FDCServers refunded me.</p>
<p>But this is not an article claiming that every FDCServers VPS behaves like this.</p>
<p>It is the story of one production VPS and one extremely interesting intermittent storage problem.</p>
<hr />
<h2>The first problem: the server looked healthy when it wasn't</h2>
<p>Like most people, I started with the obvious checks:</p>
<pre><code class="language-bash">top
free -h
df -h
systemctl status nginx
</code></pre>
<p>And this is where intermittent failures become dangerous.</p>
<p>During healthy periods, the machine genuinely looked healthy.</p>
<p>For example:</p>
<pre><code class="language-text">D-state processes: 0
CPU iowait:        ~0%
disk latency:      ~2-4 ms

I/O PSI some:      0.04
I/O PSI full:      0.04
</code></pre>
<p>Nothing there screams disaster.</p>
<p>If support had inspected the VPS at exactly that moment, they could honestly have told me:</p>
<blockquote>
<p>Everything looks normal.</p>
</blockquote>
<p>And they would have been right.</p>
<p>At that moment.</p>
<p>That became one of the most important lessons from the whole incident:</p>
<blockquote>
<p>Never investigate an intermittent performance problem only after it has disappeared.</p>
</blockquote>
<p>The useful evidence exists while the failure is happening.</p>
<p>Not after a reboot.</p>
<p>Not ten minutes later.</p>
<p>Not after load disappears.</p>
<p>During the failure.</p>
<hr />
<h2>The <code>iostat</code> output that made this impossible to ignore</h2>
<p>Eventually I captured this:</p>
<pre><code class="language-text">r_await = 18744 ms
f_await = 53561 ms
aqu-sz  = 128.54
util    ≈ 100%
read    = 88 KB/s
</code></pre>
<p>That was the moment the investigation stopped being vague.</p>
<p>Roughly translated:</p>
<pre><code class="language-text">read latency    ≈ 18.7 seconds
flush latency   ≈ 53.6 seconds
queue depth     &gt; 128
utilization     ≈ 100%
read throughput ≈ 88 KB/s
</code></pre>
<p>A device being 100% utilized does not automatically mean there is a problem.</p>
<p>A healthy storage device can be busy.</p>
<p>But the rest of the picture mattered:</p>
<ul>
<li><p>enormous latency</p>
</li>
<li><p>queue depth above 128</p>
</li>
<li><p>almost no useful read throughput</p>
</li>
<li><p>blocked processes</p>
</li>
<li><p>failing HTTP requests</p>
</li>
</ul>
<p>The virtual block device was extremely busy while accomplishing very little.</p>
<p>That is a very different situation from simply having a busy disk.</p>
<hr />
<h2>Why I didn't stop at iowait</h2>
<p>During the worst periods I also saw:</p>
<pre><code class="language-text">blocked processes: 4-9
CPU iowait:        97-100%
CPU idle:          0%
</code></pre>
<p>It would be easy to look at that and say:</p>
<blockquote>
<p>The CPU is spending 100% of its time waiting for the disk.</p>
</blockquote>
<p>But I do not like using iowait as a complete diagnosis.</p>
<p>Linux CPU accounting is more nuanced than that.</p>
<p>iowait is useful evidence, but it is still evidence from one angle.</p>
<p>So instead of treating it as the answer, I started looking for independent signals.</p>
<p>The strongest one turned out to be PSI.</p>
<hr />
<h2>PSI showed that the workload itself was being stalled</h2>
<p>Linux exposes Pressure Stall Information through <code>/proc/pressure</code>.</p>
<p>For I/O:</p>
<pre><code class="language-bash">cat /proc/pressure/io
</code></pre>
<p>During one of the severe incidents:</p>
<pre><code class="language-text">some avg10=99.14
full avg10=95.55
</code></pre>
<p>Later, <code>full</code> approached 98%.</p>
<p>This was one of the clearest signals in the entire investigation.</p>
<p>In simplified terms:</p>
<ul>
<li><p><code>some</code> means at least some non-idle work is stalled on I/O</p>
</li>
<li><p><code>full</code> means all non-idle tasks are stalled at the same time</p>
</li>
</ul>
<p>When <code>full</code> approaches 100%, the system may still technically be alive, but almost every opportunity for useful workload progress is being lost to I/O stalls.</p>
<p>That described my VPS almost perfectly.</p>
<p>The machine existed.</p>
<p>The processes existed.</p>
<p>But useful work was barely being completed.</p>
<hr />
<h2>D-state showed that this was bigger than one process</h2>
<p>Next I looked for tasks stuck in uninterruptible sleep:</p>
<pre><code class="language-bash">ps -eo state,pid,ppid,etime,wchan:50,comm,args \
  | awk 'NR==1 || $1 ~ /^D/'
</code></pre>
<p>During the failures I saw several unrelated components entering D-state:</p>
<pre><code class="language-text">jbd2
systemd-journald
Nginx workers
Nginx cache processes
other filesystem activity
</code></pre>
<p>One process entering D-state briefly is not enough to prove much.</p>
<p>But multiple unrelated processes blocking at the same time is different.</p>
<p>At that point, blaming one application worker no longer made much sense.</p>
<p>The affected processes shared something lower in the stack.</p>
<p>The filesystem and storage path became the obvious place to look next.</p>
<hr />
<h2>The kernel stacks showed exactly where Nginx was waiting</h2>
<p>The blocked paths were revealing.</p>
<p>For <code>jbd2</code> I saw paths involving:</p>
<pre><code class="language-text">wait_on_buffer
jbd2_log_wait_commit
jbd2_journal_commit_transaction
</code></pre>
<p>For Nginx:</p>
<pre><code class="language-text">folio_wait_bit_common
filemap_read
generic_file_read_iter
ext4_file_read_iter
vfs_read
pread64
</code></pre>
<p>Then the kernel produced this:</p>
<pre><code class="language-text">INFO: task nginx blocked for more than 122 seconds.
</code></pre>
<p>At the same time:</p>
<pre><code class="language-bash">systemctl status nginx
</code></pre>
<p>still said:</p>
<pre><code class="language-text">active (running)
</code></pre>
<p>Both were true.</p>
<p>That distinction is worth remembering.</p>
<p><code>active (running)</code> tells you the service has not exited.</p>
<p>It does not tell you the service is capable of completing useful work.</p>
<p>A process can exist.</p>
<p>A service manager can consider it healthy enough to remain active.</p>
<p>And users can still be staring at timeouts.</p>
<hr />
<h2>The most useful test took about three milliseconds</h2>
<p>The cleanest experiment was not complicated.</p>
<p>I tested the normal public path through Nginx.</p>
<p>It failed:</p>
<pre><code class="language-text">HTTP=000
SSL connection timeout
</code></pre>
<p>Then I bypassed Nginx and called the backend directly on the same VPS.</p>
<p>The result:</p>
<pre><code class="language-text">connect = 0.000423 s
TTFB    = 0.003063 s
total   = 0.003139 s
</code></pre>
<p>About three milliseconds.</p>
<p>That was extremely useful.</p>
<p>The backend could:</p>
<ul>
<li><p>accept the connection</p>
</li>
<li><p>execute the request</p>
</li>
<li><p>generate a response</p>
</li>
<li><p>return it almost immediately</p>
</li>
</ul>
<p>At the same time, Nginx workers were blocked in filesystem reads.</p>
<p>So the failure boundary started looking like this:</p>
<pre><code class="language-text">application execution       -&gt; progressing normally
filesystem-backed web path  -&gt; not progressing normally
</code></pre>
<p>That did not reveal the physical root cause.</p>
<p>But it made the application itself a much less convincing suspect.</p>
<p>The application was doing its job.</p>
<p>Something lower in the filesystem and virtual storage path was not.</p>
<hr />
<h2>The VPS could go from healthy to unusable in about 30 seconds</h2>
<p>The intermittent nature of the problem made it particularly annoying.</p>
<p>At one point I captured the VPS in this state:</p>
<pre><code class="language-text">HTTP:          200
D-state:       0
CPU iowait:    3%
r_await:       ~1.18 ms
I/O PSI full:  ~2.95%
</code></pre>
<p>Nothing alarming there.</p>
<p>Then roughly 30 seconds later:</p>
<pre><code class="language-text">D-state:       4
CPU iowait:    91%
CPU idle:      0%
I/O PSI some:  86.11%
I/O PSI full:  78.02%
r_await:       236.50 ms
HTTP:          000
</code></pre>
<p>And later:</p>
<pre><code class="language-text">CPU iowait:    96-100%
I/O PSI full:  ~98%
HTTPS queue:   512
HTTP:          000
</code></pre>
<p>Then the workload disappeared.</p>
<p>The machine recovered:</p>
<pre><code class="language-text">D-state:      0
CPU iowait:   6%
r_await:      ~0.98 ms
queue depth:  ~0.07
HTTP:         200
</code></pre>
<p>This explains why intermittent infrastructure failures can become so frustrating between a customer and a hosting provider.</p>
<p>I could show a VPS that was almost completely stalled.</p>
<p>Someone else could inspect the same VPS ten minutes later and see sub-millisecond storage latency.</p>
<p>Both observations could be completely accurate.</p>
<p>They were simply observing two different states.</p>
<hr />
<h2>Sometimes <code>r_await = 0</code> tells you almost nothing</h2>
<p>One strange moment in the investigation was seeing:</p>
<pre><code class="language-text">r_await = 0
</code></pre>
<p>while the VPS was obviously unhealthy.</p>
<p>At the same time there was:</p>
<ul>
<li><p>extreme iowait</p>
</li>
<li><p>D-state processes</p>
</li>
<li><p>outstanding I/O</p>
</li>
<li><p>failing requests</p>
</li>
<li><p>almost no useful read throughput</p>
</li>
</ul>
<p>So had the storage suddenly become perfect?</p>
<p>No.</p>
<p>The problem was that almost nothing was completing.</p>
<p>Metrics based on completed operations become less informative when the system reaches a state where operations barely complete at all.</p>
<p>That is why I stopped trusting any individual storage metric.</p>
<p>I wanted all of them together:</p>
<pre><code class="language-text">latency
IOPS
throughput
queue depth
in-flight I/O
D-state tasks
PSI
request completion
</code></pre>
<p>One metric can mislead you.</p>
<p>A group of independent metrics telling the same story is much harder to ignore.</p>
<hr />
<h2>The first major incident happened during a backup</h2>
<p>This was where FDCServers entered the investigation more directly.</p>
<p>One of the first severe failures overlapped with a scheduled backup.</p>
<p>FDCServers confirmed that the backup was running.</p>
<p>That was a reasonable explanation candidate.</p>
<p>If the sequence had been:</p>
<pre><code class="language-text">backup begins
I/O stalls
backup ends
problem disappears forever
</code></pre>
<p>I probably would not be writing this article.</p>
<p>But the same class of storage stall later appeared outside that original backup window.</p>
<p>And it returned on multiple days.</p>
<p>So the backup could have explained one event.</p>
<p>It could not explain the full pattern I was observing.</p>
<hr />
<h2>One incident lasted more than four hours</h2>
<p>During one incident, my own service logs later showed the VPS unavailable for:</p>
<pre><code class="language-text">4 hours, 41 minutes and 15 seconds
</code></pre>
<p>I need to be careful with that number.</p>
<p>I cannot prove that the storage stall caused that exact VPS state.</p>
<p>To prove that, I would need access to host-side information that I simply did not have.</p>
<p>And that gets to the main limitation of diagnosing infrastructure from inside a virtual machine.</p>
<p>My visibility looked roughly like this:</p>
<pre><code class="language-text">application
    ↓
Linux VFS
    ↓
EXT4
    ↓
virtual block device
    ↓
?
</code></pre>
<p>Everything behind that question mark belonged to infrastructure outside the guest.</p>
<p>Possibilities could include:</p>
<ul>
<li><p>virtualization</p>
</li>
<li><p>host-level I/O queues</p>
</li>
<li><p>storage networking</p>
</li>
<li><p>distributed storage</p>
</li>
<li><p>physical media</p>
</li>
<li><p>host scheduling</p>
</li>
<li><p>other storage systems invisible to the VM</p>
</li>
</ul>
<p>I could see what Linux inside the VPS was waiting for.</p>
<p>I could see where useful work stopped.</p>
<p>But I could not see the provider's entire storage architecture.</p>
<p>So I am not going to invent a root cause.</p>
<p>I cannot honestly say:</p>
<blockquote>
<p>A physical SSD failed.</p>
</blockquote>
<p>I cannot honestly say:</p>
<blockquote>
<p>A specific storage node was broken.</p>
</blockquote>
<p>I did not have that evidence.</p>
<hr />
<h2>FDCServers migrated my VPS to another node</h2>
<p>Support continued investigating.</p>
<p>Eventually FDCServers migrated the VPS to another node.</p>
<p>That was a meaningful step.</p>
<p>If the problem had been isolated to something on the original host, moving the VM could have solved it.</p>
<p>At that point I hoped the investigation was finished.</p>
<p>Unfortunately, after the migration I captured another severe guest-side storage stall.</p>
<p>That does not prove that every FDCServers node had the same problem.</p>
<p>It does not prove that every event had an identical physical root cause.</p>
<p>And it certainly does not prove that the entire FDCServers infrastructure was unhealthy.</p>
<p>The conclusion I could support was much narrower:</p>
<blockquote>
<p>From my perspective, the production problem affecting this VPS had not been eliminated.</p>
</blockquote>
<p>That was enough for me.</p>
<hr />
<h2>At some point, debugging stops being fun and becomes an operational decision</h2>
<p>I genuinely enjoyed this investigation.</p>
<p>That may sound strange considering it involved production problems, but this is exactly the kind of thing I like about Linux.</p>
<p>Linux turned:</p>
<blockquote>
<p>My website randomly stops responding.</p>
</blockquote>
<p>into something much more precise:</p>
<blockquote>
<p>The application backend is healthy, while filesystem-dependent processes are simultaneously blocked below VFS/EXT4, the virtual block device is heavily queued, and almost the entire workload is stalled on I/O.</p>
</blockquote>
<p>That is a useful technical result.</p>
<p>But a useful technical result does not automatically make production safe again.</p>
<p>I did not control:</p>
<ul>
<li><p>the hypervisor</p>
</li>
<li><p>the physical host</p>
</li>
<li><p>the storage backend</p>
</li>
<li><p>the infrastructure behind the virtual disk</p>
</li>
<li><p>any of the provider-side systems below my VPS</p>
</li>
</ul>
<p>And after the same class of failure returned even after migration, continuing the investigation stopped making operational sense.</p>
<p>Production is not supposed to be an endless forensic experiment.</p>
<p>So I asked FDCServers to cancel the VPS.</p>
<p>They refunded me.</p>
<p>I think that part matters too.</p>
<hr />
<h2>Is this a negative review of FDCServers?</h2>
<p>For this particular VPS, yes.</p>
<p>The experience became bad enough that I no longer trusted the server with production traffic.</p>
<p>That is why I cancelled it.</p>
<p>But I do not think the evidence allows me to turn that into:</p>
<blockquote>
<p>FDCServers is a bad hosting provider.</p>
</blockquote>
<p>That is a much bigger claim.</p>
<p>My actual experience was mixed.</p>
<p>Things I appreciated:</p>
<ul>
<li><p>the VPS worked normally before these incidents</p>
</li>
<li><p>support investigated the issue</p>
</li>
<li><p>they attempted to solve it with a node migration</p>
</li>
<li><p>they refunded me when I eventually left</p>
</li>
</ul>
<p>Things I could not accept:</p>
<ul>
<li><p>severe intermittent storage stalls</p>
</li>
<li><p>production requests timing out</p>
</li>
<li><p>reads reaching 18.7 seconds</p>
</li>
<li><p>flush latency reaching 53.6 seconds</p>
</li>
<li><p>the issue returning on multiple days</p>
</li>
<li><p>another severe stall after migration</p>
</li>
</ul>
<p>So the strongest conclusion I am comfortable making is this:</p>
<blockquote>
<p>One FDCServers VPS became unreliable enough for my production workload that I cancelled it.</p>
</blockquote>
<p>That is what my evidence supports.</p>
<p>I have not retested their current infrastructure.</p>
<p>I cannot tell you how their VPS platform performs today.</p>
<p>Infrastructure changes constantly.</p>
<p>Hardware gets replaced.</p>
<p>Storage gets rebalanced.</p>
<p>Customers move.</p>
<p>Software gets updated.</p>
<p>Problems disappear.</p>
<p>Different problems appear.</p>
<p>A single past incident should not automatically become a permanent verdict about an entire provider.</p>
<hr />
<h2>What I actually proved</h2>
<p>From inside the VPS, I had evidence for all of this:</p>
<pre><code class="language-text">read latency:       up to 18.7 s
flush latency:      up to 53.6 s
I/O PSI full:       almost 100%
CPU iowait:         almost 100%
I/O queue:          128+
Nginx:              blocked in filesystem reads
EXT4/jbd2:          blocked waiting for I/O
direct backend:     ~3 ms
HTTP through Nginx: timing out
</code></pre>
<p>I could demonstrate that:</p>
<ol>
<li><p>the application backend could still respond quickly</p>
</li>
<li><p>filesystem-dependent processes were blocking</p>
</li>
<li><p>unrelated system components were being affected simultaneously</p>
</li>
<li><p>Linux showed extreme I/O pressure</p>
</li>
<li><p>the virtual block path showed severe queueing and latency</p>
</li>
<li><p>useful workload progress almost stopped</p>
</li>
</ol>
<p>That was enough to locate the failure boundary.</p>
<p>It was enough to stop blaming the application.</p>
<p>And it was enough to make an operational decision.</p>
<hr />
<h2>What I could not prove</h2>
<p>I could not prove:</p>
<ul>
<li><p>which physical disk handled the I/O</p>
</li>
<li><p>whether any physical disk had failed</p>
</li>
<li><p>whether the underlying problem was host contention</p>
</li>
<li><p>whether distributed storage was involved</p>
</li>
<li><p>whether storage networking was involved</p>
</li>
<li><p>which exact provider-side component caused the stall</p>
</li>
<li><p>whether every incident shared exactly the same host-side root cause</p>
</li>
</ul>
<p>I think this distinction matters.</p>
<p>When writing an incident report, I try to separate three things:</p>
<pre><code class="language-text">what I observed
</code></pre>
<pre><code class="language-text">what I suspect
</code></pre>
<pre><code class="language-text">what I cannot know from my layer
</code></pre>
<p>Those are not the same thing.</p>
<hr />
<h2>The Linux debugging workflow I use now</h2>
<p>This incident changed how I investigate slow or mysteriously unresponsive Linux servers.</p>
<p>I still begin with the basics:</p>
<pre><code class="language-bash">top
free -h
df -h
</code></pre>
<p>But if something feels wrong, I quickly collect more useful evidence:</p>
<pre><code class="language-bash">date -u
uptime

cat /proc/pressure/io
cat /proc/pressure/memory
cat /proc/pressure/cpu

vmstat 1 10
iostat -x 1 10

ps -eo state,pid,ppid,etime,wchan:50,comm,args \
  | awk 'NR==1 || $1 ~ /^D/'

ss -lntp

journalctl -k --since "30 min ago" --no-pager
</code></pre>
<p>Then I try to isolate the request path:</p>
<pre><code class="language-text">public request
      ↓
reverse proxy
      ↓
direct backend
      ↓
filesystem
      ↓
block device
</code></pre>
<p>Instead of asking:</p>
<blockquote>
<p>Why is the server slow?</p>
</blockquote>
<p>I now prefer:</p>
<blockquote>
<p>At which layer does useful work stop completing?</p>
</blockquote>
<p>That question gives you somewhere concrete to investigate.</p>
<hr />
<h2>Capture evidence before you reboot</h2>
<p>There is one final habit I took away from this incident.</p>
<p>Sometimes rebooting is absolutely the correct production response.</p>
<p>Availability matters.</p>
<p>But rebooting also destroys evidence.</p>
<p>Before rebooting you might have:</p>
<pre><code class="language-text">D-state:     high
I/O PSI:     ~97%
iowait:      ~100%
queues:      large
requests:    failing
</code></pre>
<p>After reboot:</p>
<pre><code class="language-text">D-state:     0
latency:     milliseconds
requests:    healthy
</code></pre>
<p>Great.</p>
<p>The service is back.</p>
<p>But the state you needed to investigate has disappeared.</p>
<p>If the business impact allows you a few minutes, capture:</p>
<pre><code class="language-text">UTC timestamp
PSI
vmstat
iostat
D-state + wchan
kernel messages
socket queues
request timings
</code></pre>
<p>Then recover the server.</p>
<p>A few minutes of evidence can be worth hours of investigation later.</p>
<hr />
<h2>The server was running. The workload wasn't.</h2>
<p>That is the sentence that best summarizes the incident.</p>
<p>The VPS remained online.</p>
<p>Nginx remained running.</p>
<p>The backend process remained alive.</p>
<p>And during the worst periods Linux still showed:</p>
<pre><code class="language-text">read latency:       18.7 seconds
flush latency:      53.6 seconds
I/O PSI full:       ~98%
CPU iowait:         ~100%
queue depth:        128+
</code></pre>
<p>The application itself could respond in about three milliseconds.</p>
<p>But the filesystem-backed request path was barely progressing.</p>
<p>Eventually the story became:</p>
<pre><code class="language-text">FDCServers investigated
        ↓
VPS migrated
        ↓
severe storage stall appeared again
        ↓
I cancelled the VPS
        ↓
FDCServers refunded me
</code></pre>
<p>I never learned which exact host-side component caused the failure.</p>
<p>And I do not need to pretend that I did.</p>
<p>The guest-side evidence was enough to identify where progress stopped.</p>
<p>Enough to stop blaming my application.</p>
<p>Enough to communicate the problem.</p>
<p>Enough to decide that production should run somewhere else.</p>
<p>That is what good observability is supposed to give you.</p>
<p>A VM can be online.</p>
<p>A process can be running.</p>
<p>A service can show <code>active (running)</code>.</p>
<p>Ping can work.</p>
<p>And the machine can still be accomplishing almost nothing.</p>
<p>Linux will usually give you the clues.</p>
<p>The difficult part is catching them while the failure is still happening.</p>
]]></content:encoded></item><item><title><![CDATA[AVA Hosting Said “No Sharing.” My VPS Reported 32.73% CPU Steal]]></title><description><![CDATA[A production debugging story about one KVM VPS, Linux steal time, and why a running server can still be starved of CPU.
This is not a negative review of AVA Hosting.
It is simply a record of what happ]]></description><link>https://sergeisolod.hashnode.dev/ava-hosting-said-no-sharing-my-vps-reported-32-73-cpu-steal</link><guid isPermaLink="true">https://sergeisolod.hashnode.dev/ava-hosting-said-no-sharing-my-vps-reported-32-73-cpu-steal</guid><category><![CDATA[Linux]]></category><category><![CDATA[Devops]]></category><category><![CDATA[vps]]></category><category><![CDATA[performance]]></category><category><![CDATA[KVM]]></category><dc:creator><![CDATA[Sergei Solod]]></dc:creator><pubDate>Tue, 01 Sep 2026 22:03:07 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6953c791eb36c2149a16c84b/2c44aed6-264a-4dd3-be64-8db37d3a8af2.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p><em>A production debugging story about one KVM VPS, Linux steal time, and why a running server can still be starved of CPU.</em></p>
<p>This is not a negative review of AVA Hosting.</p>
<p>It is simply a record of what happened to one VPS I used in production, what Linux reported inside the VM, and why those measurements made me cancel the server.</p>
<p>The VPS was small:</p>
<pre><code class="language-text">1 vCPU
2 GB RAM
25 GB NVMe
KVM
</code></pre>
<p>Nothing unusual.</p>
<p>Nginx was running. The backend was running. The machine had available RAM. The disk was not full.</p>
<p>But the server could not keep up with the workload.</p>
<p>So instead of guessing, I measured it.</p>
<h2>The number that changed the diagnosis</h2>
<p>I monitored the VPS for 60 seconds under its normal production traffic.</p>
<p>No CPU stress test.</p>
<p>No synthetic HTTP flood.</p>
<p>No disk benchmark.</p>
<p>The average CPU statistics were:</p>
<pre><code class="language-text">CPU user:     52.52%
CPU system:    8.07%
CPU softirq:   6.69%
CPU steal:    32.73%
CPU iowait:    0.00%
CPU idle:      0.00%

Maximum runnable queue: 11
</code></pre>
<p>The important line was:</p>
<pre><code class="language-text">CPU steal: 32.73%
</code></pre>
<p>At that point, this no longer looked like a simple case of “my application needs a bigger server.”</p>
<h2>What CPU steal actually tells you</h2>
<p><code>steal</code> is a standard Linux CPU metric for virtualized environments.</p>
<p>Conceptually, it describes a situation like this:</p>
<pre><code class="language-text">the guest has runnable work
        ↓
the vCPU wants to execute it
        ↓
the vCPU is not actually running
        ↓
Linux accounts that time as steal
</code></pre>
<p>KVM explicitly supports steal-time accounting for virtual CPUs.</p>
<p>This distinction matters.</p>
<p>If my measurements had looked like this:</p>
<pre><code class="language-text">user/system: ~100%
steal:          0%
idle:           0%
</code></pre>
<p>I would have had a straightforward capacity problem.</p>
<p>The CPU would be available; my workload would simply be consuming all of it.</p>
<p>The solution could then be optimization or more vCPUs.</p>
<p>But that was not what I measured.</p>
<p>Roughly one third of the observed CPU time was being reported as steal.</p>
<h2>It was sustained, not a random spike</h2>
<p>A single ugly sample would not have convinced me.</p>
<p>Virtual machines have scheduler noise. Short spikes happen.</p>
<p>But the per-second measurements repeatedly looked like this:</p>
<pre><code class="language-text">%usr    %sys   %soft   %steal   %idle

53.54   10.10   5.05    31.31     0.00
56.44    6.93   5.94    30.69     0.00
51.52    7.07   6.06    35.35     0.00
55.45    6.93   5.94    31.68     0.00
51.49    8.91   5.94    33.66     0.00
52.53    7.07   7.07    33.33     0.00
52.48    6.93   7.92    32.67     0.00
</code></pre>
<p>The pattern was still there near the end of the observation:</p>
<pre><code class="language-text">50.98   10.78   6.86    31.37     0.00
48.48    8.08   9.09    34.34     0.00
55.00    5.00   7.00    33.00     0.00
</code></pre>
<p>So this was not one strange second.</p>
<p>For most of the observation, CPU steal stayed around 30% or higher while CPU idle remained at zero.</p>
<p>For intuition:</p>
<pre><code class="language-text">60 × 0.3273 ≈ 19.64 seconds
</code></pre>
<p>That does <strong>not</strong> mean the VPS froze for one continuous 19.64-second block.</p>
<p>It means the smaller intervals in which the vCPU was not scheduled accumulated to roughly that amount during one minute.</p>
<h2>The application symptoms matched the CPU metrics</h2>
<p>The backend was alive, but being alive is not the same as keeping up.</p>
<p>Before the observation, its listening queue showed:</p>
<pre><code class="language-text">Recv-Q: 168
Send-Q: 511
</code></pre>
<p>After 60 seconds:</p>
<pre><code class="language-text">Recv-Q: 166
Send-Q: 511
</code></pre>
<p>The exact difference between 168 and 166 is not interesting.</p>
<p>What matters is that the queue essentially did not drain.</p>
<p>The backend had an entire minute to catch up and remained behind.</p>
<p>There were also many <code>CLOSE-WAIT</code> connections. I would not use those alone as evidence of a hosting problem because application-side connection handling can produce them as well.</p>
<p>But taken together, the picture was difficult to ignore:</p>
<pre><code class="language-text">1 vCPU
32.73% CPU steal
0% CPU idle
~99% CPU pressure
runnable queue up to 11
backend queue not draining
</code></pre>
<p>The server was technically running.</p>
<p>It was not healthy.</p>
<h2>I checked RAM and disk before blaming the host</h2>
<p>High load by itself proves very little.</p>
<p>I wanted to make sure I was not blaming virtualization for a problem inside my own system.</p>
<p>Memory looked like this:</p>
<pre><code class="language-text">RAM total:      ~1.9 GiB
RAM available:  ~959 MiB

Swap total:     2 GiB
Swap used:      ~33 MiB
</code></pre>
<p>There was still substantial available memory.</p>
<p>The root filesystem was:</p>
<pre><code class="language-text">24 GB total
14 GB used
9.4 GB available
60% used
</code></pre>
<p>And the average CPU I/O wait was:</p>
<pre><code class="language-text">0.00%
</code></pre>
<p>So the obvious story was not:</p>
<pre><code class="language-text">RAM exhausted
</code></pre>
<p>or:</p>
<pre><code class="language-text">disk full
</code></pre>
<p>or:</p>
<pre><code class="language-text">CPU blocked waiting for storage
</code></pre>
<p>Meanwhile, Linux CPU Pressure Stall Information stayed close to 99%, and the one-vCPU VM had a load average around 3.5–4.</p>
<p>The machine had work waiting for CPU almost continuously.</p>
<h2>Then I checked what AVA Hosting actually promises</h2>
<p>This is where the case became more interesting.</p>
<p>AVA's current VPS page says:</p>
<blockquote>
<p>“Guaranteed resources — no sharing”</p>
</blockquote>
<p>It also says that every VPS receives guaranteed vCPU resources and that performance is never affected by other customers.</p>
<p>Its current Linux VPS page is even more specific. It says that a CPU-intensive workload from another tenant cannot introduce steal time into your instance and describes the vCPU allocation as dedicated.</p>
<p>AVA's unmanaged Linux VPS page currently says:</p>
<blockquote>
<p>“CPU steal time is eliminated at the hypervisor level”</p>
</blockquote>
<p>and describes CPU resources as a fixed allocation rather than a shared or burstable pool affected by noisy-neighbour contention.</p>
<p>Those claims are unusually specific.</p>
<p>This was not simply a VPS provider saying:</p>
<pre><code class="language-text">1 vCPU
</code></pre>
<p>without explaining how CPU resources are managed.</p>
<p>AVA was explicitly making claims about the exact phenomenon I was measuring.</p>
<p>And my VPS showed:</p>
<pre><code class="language-text">Average CPU steal: 32.73%
</code></pre>
<h2>What I can prove — and what I cannot</h2>
<p>This distinction matters.</p>
<p>I <strong>can</strong> say that:</p>
<ul>
<li><p>my AVA Hosting VPS had one vCPU;</p>
</li>
<li><p>under normal production load it averaged 32.73% CPU steal;</p>
</li>
<li><p>CPU idle averaged 0%;</p>
</li>
<li><p>CPU pressure was close to 99%;</p>
</li>
<li><p>the backend queue was not draining;</p>
</li>
<li><p>RAM was available;</p>
</li>
<li><p>the filesystem was only 60% used;</p>
</li>
<li><p>average CPU I/O wait was 0%.</p>
</li>
</ul>
<p>I <strong>cannot</strong> see AVA's physical host from inside the VM.</p>
<p>So I cannot prove:</p>
<ul>
<li><p>the number of neighbouring VMs;</p>
</li>
<li><p>the host CPU utilization;</p>
</li>
<li><p>AVA's actual CPU overcommit ratio;</p>
</li>
<li><p>its CPU pinning configuration;</p>
</li>
<li><p>which workload was consuming physical CPU;</p>
</li>
<li><p>or whether the condition was intentional.</p>
</li>
</ul>
<p>And I am not claiming that every VPS operated by AVA Hosting behaves this way.</p>
<p>I tested one VPS.</p>
<p>But I do think one conclusion is reasonable:</p>
<p><strong>The behaviour I measured was very difficult to reconcile with AVA's specific claims about guaranteed CPU allocation and the absence of CPU steal from neighbouring workloads.</strong></p>
<p>I do not need to make a stronger accusation than that.</p>
<h2>One important caveat on AVA's own site</h2>
<p>AVA's main VPS FAQ also says that VPS resources are not as strictly guaranteed as resources on a dedicated server.</p>
<p>That is a reasonable virtualization caveat.</p>
<p>A VPS is not bare metal, and <code>1 vCPU</code> by itself does not mean that one entire physical CPU core belongs exclusively to one customer.</p>
<p>But that general caveat appears alongside much more specific statements elsewhere on AVA's site:</p>
<pre><code class="language-text">Guaranteed resources — no sharing

Your performance is never affected by other customers

Dedicated vCPU allocation

A co-hosted tenant cannot introduce steal time

CPU steal time is eliminated at the hypervisor level
</code></pre>
<p>That is why the 32.73% figure mattered to me.</p>
<h2>Why I did not simply upgrade</h2>
<p>The obvious fix for a saturated one-vCPU machine is to buy more CPU.</p>
<p>Normally, that makes sense.</p>
<p>But I wanted to distinguish two cases.</p>
<h3>Case 1: I actually consumed the CPU I bought</h3>
<pre><code class="language-text">CPU usage: high
steal:     ~0%
idle:      0%
</code></pre>
<p>Then the resource is being delivered and the workload needs more capacity.</p>
<p>Upgrade.</p>
<h3>Case 2: a large percentage is steal</h3>
<p>My server looked more like this:</p>
<pre><code class="language-text">CPU steal: 32.73%
CPU idle:   0.00%
</code></pre>
<p>Buying another vCPU might have improved total throughput.</p>
<p>But before paying for additional virtual CPUs, I wanted the first one to behave the way I expected from the product description.</p>
<p>So I did not upgrade.</p>
<p>I cancelled the VPS and requested a full refund.</p>
<h2>AVA refunded me quickly</h2>
<p>This part of the experience was good.</p>
<p>AVA Hosting returned the full amount.</p>
<p>They handled the refund quickly and did not spend days arguing with me about the CPU measurements.</p>
<p>I explained the problem, requested my money back, and they refunded it.</p>
<p>I appreciate that.</p>
<p>So my experience has two separate conclusions:</p>
<blockquote>
<p><strong>The VPS I received had a serious CPU-availability problem.</strong></p>
</blockquote>
<p>and:</p>
<blockquote>
<p><strong>AVA handled the refund properly.</strong></p>
</blockquote>
<p>Both can be true.</p>
<h2>What I check on a new VPS now</h2>
<p>This incident changed one part of my VPS deployment routine.</p>
<p>Specifications are still useful:</p>
<pre><code class="language-text">vCPU
RAM
disk
network
virtualization
</code></pre>
<p>But they are not measurements.</p>
<p>For a new Linux VPS, I now want to see what happens under the actual workload.</p>
<p>My first checks include:</p>
<pre><code class="language-bash">mpstat 1 60
vmstat 1 60
cat /proc/pressure/cpu
ss -ltnp
free -h
df -h
iostat -xz 1 60
</code></pre>
<p>I care about the relationship between:</p>
<pre><code class="language-text">%steal
%idle
CPU pressure
run queue
application queues
iowait
available memory
</code></pre>
<p>because different combinations point to very different problems.</p>
<p>High CPU usage with almost zero steal?</p>
<p>My workload may simply need more CPU.</p>
<p>High iowait?</p>
<p>Look at storage.</p>
<p>Memory pressure and heavy swapping?</p>
<p>Look at RAM.</p>
<p>High steal, no idle CPU, continuous CPU pressure and an application queue that cannot drain?</p>
<p>Now I start looking beyond the application.</p>
<h2>The actual lesson</h2>
<p>The main lesson from this incident is not:</p>
<blockquote>
<p>One vCPU is too small.</p>
</blockquote>
<p>It is not:</p>
<blockquote>
<p>Every AVA Hosting VPS has this problem.</p>
</blockquote>
<p>And it is not:</p>
<blockquote>
<p>A high steal value tells me exactly how the provider configured its hypervisor.</p>
</blockquote>
<p>The lesson is much simpler.</p>
<p>A VPS specification tells me what the virtual machine has been provisioned to see.</p>
<p>Linux tells me what the workload is actually experiencing.</p>
<p>In my case, the important numbers were:</p>
<pre><code class="language-text">CPU steal:    32.73%
CPU idle:      0.00%
CPU pressure: ~99%
</code></pre>
<p>AVA Hosting currently advertises guaranteed CPU resources without sharing and explicitly says neighbouring workloads should not introduce CPU steal.</p>
<p>My VPS reported otherwise.</p>
<p>I cancelled it.</p>
<p>AVA returned all of my money quickly and without an argument.</p>
<p>And that was the end of that debugging day.</p>
]]></content:encoded></item><item><title><![CDATA[My REGXA VPS Had 94% CPU Steal With Zero Traffic]]></title><description><![CDATA[How I separated an application bottleneck from hypervisor contention using mpstat, PSI, localhost tests, and one simple control experiment.
I am not writing this as a negative review of REGXA, and I a]]></description><link>https://sergeisolod.hashnode.dev/regxa-vps-94-cpu-steal-zero-traffic</link><guid isPermaLink="true">https://sergeisolod.hashnode.dev/regxa-vps-94-cpu-steal-zero-traffic</guid><category><![CDATA[Linux]]></category><category><![CDATA[Devops]]></category><category><![CDATA[vps]]></category><category><![CDATA[KVM]]></category><category><![CDATA[performance]]></category><dc:creator><![CDATA[Sergei Solod]]></dc:creator><pubDate>Mon, 31 Aug 2026 21:42:24 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6953c791eb36c2149a16c84b/67188c36-73a5-47e6-95ea-aa230f3deded.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p><strong>How I separated an application bottleneck from hypervisor contention using</strong> <code>mpstat</code><strong>, PSI, localhost tests, and one simple control experiment.</strong></p>
<p>I am not writing this as a negative review of REGXA, and I am not trying to tell anyone whether they should or should not buy a VPS from them.</p>
<p>This is simply one of my days as a developer.</p>
<p>I moved a normal workload to a KVM VPS with:</p>
<pre><code class="language-text">2 vCPU
2 GB RAM
60 GB NVMe
</code></pre>
<p>Nginx was running. The backend was running. The server was reachable.</p>
<p>But everything behaved as if the machine were massively overloaded.</p>
<p>Requests accumulated. TLS operations became slow. Connections stayed open for far too long. Some requests eventually returned HTTP 504 after more than two minutes.</p>
<p>My first assumption was obvious:</p>
<p><strong>something inside my VPS was consuming all the CPU.</strong></p>
<p>Then I ran <code>mpstat</code>.</p>
<pre><code class="language-text">Average CPU steal:   92.58%

CPU 0 steal:         90.62%
CPU 1 steal:         94.57%

CPU user:             4.02%
CPU system:           1.47%
CPU iowait:           0.41%
CPU idle:             0.53%
</code></pre>
<p>That changed the investigation completely.</p>
<p>My applications were not consuming 90% of the processor.</p>
<p>They were barely getting processor time at all.</p>
<h2>CPU usage and CPU steal are not the same thing</h2>
<p>This distinction is what made the incident interesting.</p>
<p>If my application were actually saturating the CPUs, I would expect something like:</p>
<pre><code class="language-text">user:    80%
system:  10%
steal:    0%
</code></pre>
<p>That means the virtual machine is receiving CPU time and spending it on my code.</p>
<p>Instead, my VPS looked roughly like this:</p>
<pre><code class="language-text">user:     4%
system:   1%
steal:   93%
</code></pre>
<p>The Linux <code>mpstat</code> documentation defines <code>%steal</code> as time spent in involuntary wait by a virtual CPU while the hypervisor is servicing another virtual processor.</p>
<p>In practical terms, the guest has work ready to execute, but the hypervisor is not giving that vCPU execution time.</p>
<p>That is what I mean when I say CPU time was being taken away from the VPS.</p>
<p>I am <strong>not</strong> claiming that somebody at REGXA deliberately took two physical cores assigned exclusively to me and handed them to another customer. Guest metrics cannot prove that, and they cannot prove intent.</p>
<p>What they can show is much narrower:</p>
<p><strong>my virtual CPUs wanted to execute work and repeatedly were not being scheduled.</strong></p>
<p>Individual samples were repeatedly in the same range:</p>
<pre><code class="language-text">89.85% steal
92.16% steal
96.48% steal
94.87% steal
95.69% steal
95.95% steal
94.57% steal
93.75% steal
</code></pre>
<p>Linux Pressure Stall Information pointed in the same direction:</p>
<pre><code class="language-text">CPU PSI some avg10:   79.47
CPU PSI some avg60:   75.32
CPU PSI some avg300:  76.31

Load average:
5.85 / 5.75 / 5.73
</code></pre>
<p>Linux PSI measures periods in which tasks are stalled waiting on resources; for CPU pressure, the <code>some</code> metric represents time when at least some runnable work is stalled on CPU availability.</p>
<p>On a two-vCPU VM, these were not numbers I wanted to explain away as ordinary application load.</p>
<p>So I tested the obvious alternative explanation.</p>
<h2>I removed all production traffic</h2>
<p>Maybe my workload itself was somehow causing the problem.</p>
<p>Fair enough.</p>
<p>I moved the active workload to another server.</p>
<p>Production traffic stopped reaching the affected VPS.</p>
<p>The connection queues cleared.</p>
<p>Then I repeated the CPU measurement.</p>
<p>This time I got:</p>
<pre><code class="language-text">Average CPU steal:   94.17%

CPU 0 steal:         95.56%
CPU 1 steal:         92.83%

CPU user:             1.95%
CPU system:           0.59%
CPU iowait:           0.59%
CPU idle:             2.18%
</code></pre>
<p>The result became <strong>worse</strong>.</p>
<p>My applications were now barely doing anything:</p>
<pre><code class="language-text">user:    1.95%
system:  0.59%
</code></pre>
<p>But:</p>
<pre><code class="language-text">steal:  94.17%
</code></pre>
<p>Individual <code>vmstat</code> samples still showed:</p>
<pre><code class="language-text">91% steal
98% steal
98% steal
97% steal
97% steal
95% steal
96% steal
95% steal
</code></pre>
<p>At points, as many as 15 runnable processes were waiting for CPU.</p>
<p>For me, this was the strongest test of the entire incident.</p>
<p>I had removed the production workload.</p>
<p>The applications were consuming almost no processor time.</p>
<p>And the VM was still spending roughly 94% of measured CPU time in steal.</p>
<p>At that point, optimizing my backend was no longer a convincing answer.</p>
<h2>Then I tested localhost</h2>
<p>I wanted a second test that removed as much external infrastructure as possible.</p>
<p>So I tested HTTPS through:</p>
<pre><code class="language-text">127.0.0.1
</code></pre>
<p>That removes a lot of possible explanations:</p>
<ul>
<li><p>public DNS;</p>
</li>
<li><p>my ISP;</p>
</li>
<li><p>geographic distance;</p>
</li>
<li><p>internet routing;</p>
</li>
<li><p>transit providers;</p>
</li>
<li><p>the network path between my computer and the data center.</p>
</li>
</ul>
<p>The client and server are inside the same VM.</p>
<p>With production traffic still present, I ran ten localhost HTTPS requests.</p>
<p><strong>Four out of ten failed during the TLS handshake.</strong></p>
<p>The successful requests took:</p>
<pre><code class="language-text">29.30 s
22.77 s
12.25 s
11.87 s
11.12 s
 9.40 s
</code></pre>
<p>Some TLS handshakes alone took roughly nine seconds.</p>
<p>After I removed all production traffic, localhost improved, but it remained wildly inconsistent:</p>
<pre><code class="language-text">0.061 s
0.745 s
0.830 s
0.873 s
1.010 s
1.117 s
1.121 s
2.188 s
3.355 s
</code></pre>
<p>Think about that range.</p>
<p>The same local operation could take approximately:</p>
<pre><code class="language-text">61 ms
</code></pre>
<p>and then:</p>
<pre><code class="language-text">3.355 seconds
</code></pre>
<p>on another attempt.</p>
<p>There was no long-distance network between those two processes.</p>
<p>They were on the same machine.</p>
<h2>The rest of the stack was showing the consequences</h2>
<p>By this point, the application-level symptoms made more sense.</p>
<p>At one stage I saw approximately:</p>
<pre><code class="language-text">450 established connections
122 orphaned connections
110 FIN-WAIT-1
33 CLOSE-WAIT
</code></pre>
<p>The localhost backend listen queue reached roughly:</p>
<pre><code class="language-text">14–15
</code></pre>
<p>while the HTTPS queue reached roughly:</p>
<pre><code class="language-text">40
</code></pre>
<p>Nginx logged HTTP 504 responses with timings such as:</p>
<pre><code class="language-text">142.857 s
138.902 s
135.064 s
129.819 s
128.657 s
</code></pre>
<p>Other requests remained open for roughly 67–130 seconds.</p>
<p>I also saw:</p>
<pre><code class="language-text">secure-connect timeout: ~35 s
secure-connect timeout: ~41 s
database network timeouts
delayed TLS operations
</code></pre>
<p>Looking at those symptoms separately could have sent me in five different directions.</p>
<p>A 504?</p>
<p>Maybe Nginx timeouts.</p>
<p>Database errors?</p>
<p>Maybe MongoDB.</p>
<p>Slow TLS?</p>
<p>Maybe the network.</p>
<p>Growing connection queues?</p>
<p>Maybe backend concurrency.</p>
<p>But increasing an Nginx timeout does not create CPU time that the hypervisor is not scheduling.</p>
<p>It might simply turn a 60-second failure into a 120-second failure.</p>
<h2>RAM and storage were not the explanation</h2>
<p>I checked the obvious alternatives.</p>
<p>Memory:</p>
<pre><code class="language-text">Total RAM:       ~1.9 GiB
Available RAM:   ~1.0–1.1 GiB
Swap usage:      ~1–2 MiB

OOM events:      none
OOM killer:      none
</code></pre>
<p>Storage:</p>
<pre><code class="language-text">Filesystem:      ~58 GB
Used:            ~11 GB
Available:       ~44 GB
Usage:           ~20%
</code></pre>
<p>During the relevant CPU tests, I/O wait was generally below 1%.</p>
<p>So I did not have:</p>
<ul>
<li><p>an exhausted RAM situation;</p>
</li>
<li><p>heavy swapping;</p>
</li>
<li><p>an OOM problem;</p>
</li>
<li><p>a full filesystem;</p>
</li>
<li><p>enough I/O wait to explain the behaviour.</p>
</li>
</ul>
<p>Meanwhile CPU steal remained above 90%.</p>
<h2>A healthy KVM VPS looked completely different</h2>
<p>I then ran the same kind of diagnostic on another KVM VPS.</p>
<p>That machine was actively serving normal traffic.</p>
<p>Its numbers:</p>
<pre><code class="language-text">Average CPU steal:   0.02%
CPU idle:           87.86%

CPU PSI avg10:       0.29
CPU PSI avg60:       0.63
CPU PSI avg300:      0.49

Load average:
0.47 / 0.33 / 0.14
</code></pre>
<p>Ten localhost HTTPS requests completed between roughly:</p>
<pre><code class="language-text">37 ms
and
69 ms
</code></pre>
<p>Later I measured another production VPS that really <strong>was</strong> using substantial CPU:</p>
<pre><code class="language-text">CPU user:     61.71%
CPU system:    5.08%
CPU idle:     24.09%
CPU steal:     0.13%
</code></pre>
<p>This became a useful mental model for me.</p>
<h3>Busy because my code is running</h3>
<pre><code class="language-text">user:   high
system: some
steal:  near zero
</code></pre>
<h3>Busy because the VM is not getting scheduled</h3>
<pre><code class="language-text">user:   low
system: low
steal:  extremely high
</code></pre>
<p>Both machines can feel slow to a user.</p>
<p>The cause is completely different.</p>
<h2>Then REGXA confirmed resource contention</h2>
<p>Until this point, all I had were measurements from inside the guest.</p>
<p>I could see what Linux was experiencing.</p>
<p>I could not see:</p>
<ul>
<li><p>the physical host;</p>
</li>
<li><p>total host CPU utilization;</p>
</li>
<li><p>scheduler weights;</p>
</li>
<li><p>CPU quotas;</p>
</li>
<li><p>neighbouring VMs;</p>
</li>
<li><p>the provider's level of CPU overcommitment.</p>
</li>
</ul>
<p>REGXA could.</p>
<p>Eventually, support told me that my VPS was running on:</p>
<blockquote>
<p><strong>shared CPU infrastructure</strong></p>
</blockquote>
<p>They explained that CPU resources were shared across multiple virtual machines and that performance could vary depending on load on the physical node.</p>
<p>More importantly, support explicitly attributed the elevated CPU steal I was observing to:</p>
<blockquote>
<p><strong>resource contention on the underlying infrastructure</strong></p>
</blockquote>
<p>They also said that the Frankfurt infrastructure was experiencing particularly high demand and that they could not change CPU quotas or scheduling policies or provide additional dedicated CPU resources for that shared VPS.</p>
<p>Their proposed technical solution was to move the VPS somewhere with lower utilization.</p>
<p>At that point, host-side contention was no longer merely my interpretation of Linux metrics.</p>
<p>It was also the explanation given by the provider.</p>
<h2>The wording around CPU resources is what bothered me</h2>
<p>There is one part of this experience I still find difficult to reconcile.</p>
<p>REGXA's current Frankfurt VPS page advertises:</p>
<blockquote>
<p><strong>Dedicated CPU Cores</strong></p>
</blockquote>
<p>for the infrastructure, while its System Policies describe fair CPU scheduling, CPU burst capability, throttling of excessive CPU usage, and dedicated CPU allocation for specific service tiers.</p>
<p>Support, meanwhile, described the VPS involved in my incident as running on shared CPU infrastructure whose performance depended on load on the physical node.</p>
<p>I am not making a legal claim about what the word “dedicated” must mean in every hosting product.</p>
<p>Nor do I know the exact physical-host configuration behind my instance.</p>
<p>But from a developer's point of view, these two facts are difficult to ignore:</p>
<pre><code class="language-text">CPU steal under workload:    92.58%
CPU steal with no traffic:   94.17%
</code></pre>
<p>And the provider itself later attributed those elevated values to resource contention.</p>
<p>That is enough for this case study.</p>
<h2>Getting the refund became a second problem</h2>
<p>Once the infrastructure problem was clear, I did not want to keep moving the VPS between locations and testing whether another node happened to be less busy.</p>
<p>I wanted to cancel it.</p>
<p>Initially, I was offered only a <strong>partial refund</strong>, and the proposed refund was to my <strong>REGXA account balance</strong>, not back to the card I had used to pay.</p>
<p>For me, provider credit is not equivalent to a refund if I am trying to stop using that provider.</p>
<p>So I continued the conversation.</p>
<p>I asked for:</p>
<pre><code class="language-text">100% of the payment
</code></pre>
<p>to be returned to:</p>
<pre><code class="language-text">the original payment method
</code></pre>
<p>I had to keep pushing for it.</p>
<p>Eventually, REGXA agreed and returned the <strong>full amount to the original payment method</strong>, describing the full refund as an exception.</p>
<p>I appreciate that they ultimately refunded everything.</p>
<p>But the fact that I had to keep pursuing the financial resolution after the infrastructure problem had already been acknowledged is part of my experience too.</p>
<h2>The debugging mistake I almost made</h2>
<p>The most valuable lesson here was not that one VPS performed badly.</p>
<p>Servers fail. Nodes become overloaded. Infrastructure has problems.</p>
<p>What matters to me as a developer is how easily I could have spent hours fixing the wrong layer.</p>
<p>I could have:</p>
<ul>
<li><p>increased Nginx timeouts;</p>
</li>
<li><p>changed keepalive settings;</p>
</li>
<li><p>lowered concurrency;</p>
</li>
<li><p>added retries;</p>
</li>
<li><p>investigated MongoDB;</p>
</li>
<li><p>rewritten backend code;</p>
</li>
<li><p>tuned connection pools.</p>
</li>
</ul>
<p>Some of those changes might even have improved individual symptoms.</p>
<p>None of them would answer:</p>
<blockquote>
<p>Why is the guest spending more than 90% of its CPU time waiting for the hypervisor?</p>
</blockquote>
<p>That question lives below my application.</p>
<h2>My VPS smoke test now includes <code>%steal</code></h2>
<p>When I provision a VPS, an HTTP 200 is no longer enough.</p>
<p>I want to see how the machine behaves while idle and under real load.</p>
<p>My basic checks now include:</p>
<pre><code class="language-bash">mpstat -P ALL 1 20
</code></pre>
<pre><code class="language-bash">vmstat 1 20
</code></pre>
<pre><code class="language-bash">cat /proc/pressure/cpu
</code></pre>
<p>And for a web server, I want localhost latency too.</p>
<p>I look at:</p>
<pre><code class="language-text">%user
%system
%iowait
%idle
%steal
CPU PSI
run queue
localhost latency
</code></pre>
<p>Most importantly, when the numbers look suspicious:</p>
<p><strong>I remove the workload and measure again.</strong></p>
<p>In this incident, that one experiment produced the clearest result:</p>
<pre><code class="language-text">CPU user:     1.95%
CPU system:   0.59%
CPU steal:   94.17%
</code></pre>
<p>I am not claiming that every REGXA VPS behaves this way.</p>
<p>I tested one VPS and documented what happened to it.</p>
<p>But on that machine, the evidence was unusually clean: my applications were barely consuming CPU, Linux reported that the overwhelming majority of virtual CPU time was being lost to hypervisor scheduling, and REGXA later attributed that elevated steal to resource contention on its shared infrastructure.</p>
<p>That is why I am writing this.</p>
<p>Not as a rating.</p>
<p>Not as a recommendation.</p>
<p>Just as one of my days as a developer — and one Linux metric I will never ignore on a VPS again.</p>
]]></content:encoded></item><item><title><![CDATA[I Replaced Bunny Storage With a Private Media Origin. The Biggest Win Was Not Bandwidth]]></title><description><![CDATA[For almost two months, I served a large collection of images, video, and audio through a setup that looked reasonable on paper:
Browser
   ↓
Working server
   ↓
Nginx
   ↓
local proxy_cache
   ↓ cache]]></description><link>https://sergeisolod.hashnode.dev/replacing-bunny-storage-with-private-media-origin</link><guid isPermaLink="true">https://sergeisolod.hashnode.dev/replacing-bunny-storage-with-private-media-origin</guid><category><![CDATA[nginx]]></category><category><![CDATA[web performance]]></category><category><![CDATA[Devops]]></category><category><![CDATA[video streaming]]></category><category><![CDATA[infrastructure]]></category><dc:creator><![CDATA[Sergei Solod]]></dc:creator><pubDate>Fri, 28 Aug 2026 19:18:39 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6953c791eb36c2149a16c84b/d5c15bb7-9c4b-4c81-8455-39e7f518c57e.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>For almost two months, I served a large collection of images, video, and audio through a setup that looked reasonable on paper:</p>
<pre><code class="language-text">Browser
   ↓
Working server
   ↓
Nginx
   ↓
local proxy_cache
   ↓ cache MISS
Bunny Storage
</code></pre>
<p>The public server handled the application and HTTP delivery. Bunny Storage held the media. Nginx sat between them and cached remote objects on local SSD.</p>
<p>When a file was already cached, the system was fast.</p>
<p>Very fast.</p>
<p>The problem appeared when a file was not cached.</p>
<p>Old images could suddenly take much longer than popular ones. Some requests stalled before useful data started flowing. MP4 caching became increasingly complicated once byte ranges entered the picture. Nginx Slice introduced another class of failure involving inconsistent ETags.</p>
<p>Eventually I replaced Bunny Storage as the runtime origin with a separate private media server:</p>
<pre><code class="language-text">Browser
   ↓
Working server
   ↓
Nginx + local cache
   ↓ cache MISS
WireGuard
   ↓
Private media server
   ↓
Nginx
   ↓
SSD
</code></pre>
<p>The surprising part was not that a nearby private server could be fast.</p>
<p>The surprising part was how much better the entire system felt once a cache miss became cheap and predictable.</p>
<p>That changed how I think about caching.</p>
<p>I used to focus mainly on the hit ratio.</p>
<p>Now I care just as much about this question:</p>
<blockquote>
<p>What happens when the object is not in cache?</p>
</blockquote>
<hr />
<h2>This was Bunny Storage, not Bunny CDN</h2>
<p>This distinction matters.</p>
<p>I was not benchmarking Bunny CDN.</p>
<p>Users were not fetching media from Bunny edge locations.</p>
<p>My Nginx server was fetching objects directly from Bunny Storage and then serving them itself.</p>
<p>The architecture was effectively:</p>
<pre><code class="language-text">User
   ↓
My server
   ↓
Bunny Storage
</code></pre>
<p>not:</p>
<pre><code class="language-text">User
   ↓
Bunny CDN
   ↓
Bunny Storage
</code></pre>
<p>Those are very different systems.</p>
<p>Bunny CDN is designed specifically for global content delivery, edge caching, and geographically distributed users. Bunny Storage is the storage layer.</p>
<p>I still consider Bunny a strong platform, especially when Storage is combined with its CDN.</p>
<p>This article is about a narrower case:</p>
<blockquote>
<p>using Bunny Storage directly as the remote origin behind my own Nginx proxy cache.</p>
</blockquote>
<p>That distinction is important because I am not comparing a private VPS with a full CDN.</p>
<p>I am comparing two origin architectures.</p>
<hr />
<h2>Why the original architecture made sense</h2>
<p>The workload was mostly static media:</p>
<ul>
<li><p>AVIF images</p>
</li>
<li><p>JPEG and PNG</p>
</li>
<li><p>MP4</p>
</li>
<li><p>WebM</p>
</li>
<li><p>audio files</p>
</li>
<li><p>many different object sizes</p>
</li>
</ul>
<p>The files were effectively immutable after publishing.</p>
<p>I wanted the working server to control public delivery:</p>
<ul>
<li><p>URLs</p>
</li>
<li><p>HTTP headers</p>
</li>
<li><p>cache behavior</p>
</li>
<li><p><code>404</code> and <code>410</code> handling</p>
</li>
<li><p>byte ranges</p>
</li>
<li><p>video seeking</p>
</li>
<li><p>fallback logic</p>
</li>
<li><p>cache eviction</p>
</li>
</ul>
<p>But I did not want all media permanently stored on that machine.</p>
<p>A reverse proxy cache seemed like a good compromise.</p>
<p>For a popular object:</p>
<pre><code class="language-text">request
↓
Nginx cache HIT
↓
local SSD
↓
response
</code></pre>
<p>Bunny Storage was completely removed from the request path after the first cache fill.</p>
<p>That worked exactly as intended.</p>
<p>The architecture looked good whenever I tested popular media.</p>
<p>That was also the trap.</p>
<hr />
<h2>A cache HIT can make a weak origin look excellent</h2>
<p>Consider two files.</p>
<p>The first one is popular:</p>
<pre><code class="language-text">popular-image.avif
↓
requested frequently
↓
still in local cache
↓
HIT
↓
fast
</code></pre>
<p>The second one belongs to an old page:</p>
<pre><code class="language-text">old-image.avif
↓
not requested for a long time
↓
evicted from cache
↓
MISS
↓
remote origin required
</code></pre>
<p>If I test only the first object, I am testing my SSD.</p>
<p>I am not testing Bunny Storage.</p>
<p>The second request exposes the actual cold path.</p>
<p>This explained a strange production pattern I had noticed for a while:</p>
<p>Popular pages felt good.</p>
<p>Older pages sometimes felt dramatically worse.</p>
<p>A page could load most images immediately while one or two remained empty for a noticeable amount of time.</p>
<p>The error logs eventually explained why.</p>
<hr />
<h1>The request could stall before the image even started downloading</h1>
<p>The important production errors were not primarily about reading a large file.</p>
<p>I repeatedly saw errors in the connection stage, including:</p>
<pre><code class="language-text">upstream timed out ... while SSL handshaking to upstream
</code></pre>
<p>That is a very specific failure.</p>
<p>The request flow looked more like this:</p>
<pre><code class="language-text">cache MISS
↓
resolve remote storage
↓
connect to upstream
↓
perform TLS handshake
↓
timeout
</code></pre>
<p>The browser was waiting for an image, but the server had not yet reached the useful object transfer stage.</p>
<p>This matched what I was seeing visually.</p>
<p>The problem was not always:</p>
<pre><code class="language-text">the image downloads slowly
</code></pre>
<p>Sometimes it was:</p>
<pre><code class="language-text">the image has not really started downloading yet
</code></pre>
<p>That distinction matters a lot for perceived performance.</p>
<hr />
<h2>Production logs showed that this was recurring</h2>
<p>One diagnostic snapshot contained:</p>
<pre><code class="language-text">317
</code></pre>
<p>upstream timeout matches in the last:</p>
<pre><code class="language-text">5,000
</code></pre>
<p>lines of the media error log.</p>
<p>Another snapshot from the previous day contained:</p>
<pre><code class="language-text">578
</code></pre>
<p>matches.</p>
<p>These are log matches.</p>
<p>They are not unique users.</p>
<p>They are not unique files.</p>
<p>They are not a calculated failure percentage.</p>
<p>I do not want to turn them into a metric they do not represent.</p>
<p>What they do prove is much simpler:</p>
<p>The timeout pattern was recurring in production.</p>
<p>It was not one unlucky request observed during debugging.</p>
<hr />
<h1>Different storage addresses behaved very differently</h1>
<p>During the investigation, the storage hostname resolved to addresses including:</p>
<pre><code class="language-text">109.61.89.53
109.61.89.54
109.61.89.55
109.61.89.57
79.127.226.193
</code></pre>
<p>Several measured paths were perfectly reasonable:</p>
<pre><code class="language-text">109.61.89.53     total ≈ 29.8 ms
109.61.89.54     total ≈ 28.5 ms
109.61.89.57     total ≈ 42.7 ms
79.127.226.193   total ≈ 43.6 ms
</code></pre>
<p>Then one measurement looked like this:</p>
<pre><code class="language-text">109.61.89.55

TCP    1.017782 s
TLS    1.048306 s
TOTAL  1.054474 s
</code></pre>
<p>This does not prove that Bunny Storage normally has one-second latency.</p>
<p>Clearly it did not.</p>
<p>Most measurements were tens of milliseconds.</p>
<p>What mattered was the variance.</p>
<p>The same storage hostname could lead to something like:</p>
<pre><code class="language-text">29 ms
30 ms
43 ms
1,054 ms
</code></pre>
<p>And the production logs showed worse cases that reached actual connection or TLS timeouts.</p>
<p>For a media-heavy page, that tail matters enormously.</p>
<hr />
<h1>Average latency can hide terrible page-level UX</h1>
<p>Imagine a gallery loading 100 images.</p>
<p>Ninety-eight finish quickly.</p>
<p>Two do not.</p>
<p>The result might look like:</p>
<pre><code class="language-text">████████████
████████████
████████████

[ empty ]

████████████

[ still empty ]

████████████
</code></pre>
<p>The mean request time can still look acceptable.</p>
<p>The page does not feel acceptable.</p>
<p>This is why I stopped looking at media performance only through averages.</p>
<p>For this type of workload, I care more about:</p>
<pre><code class="language-text">MISS p95
MISS p99
upstream connect p95
upstream connect p99
slowest image on the page
</code></pre>
<p>A gallery is often judged by the slowest visible objects, not by the average object.</p>
<p>That is especially true when a user is scrolling quickly.</p>
<hr />
<h1>Why old media exposed the problem first</h1>
<p>The local Nginx cache was large.</p>
<p>One July snapshot showed roughly:</p>
<pre><code class="language-text">cache size: ~36 GB
cache files: &gt;355,000
configured cache limit: ~35 GB
root filesystem usage: ~93%
</code></pre>
<p>Nearby snapshots approached:</p>
<pre><code class="language-text">~400,000 cache entries
</code></pre>
<p>A finite cache cannot keep every object forever.</p>
<p>So the behavior naturally became:</p>
<pre><code class="language-text">popular media
↓
recently used
↓
likely cached
↓
fast
</code></pre>
<p>while old content behaved more like:</p>
<pre><code class="language-text">rare media
↓
not used recently
↓
more likely evicted
↓
cold MISS
↓
remote storage
</code></pre>
<p>The cache was hiding the origin problem on the most popular content.</p>
<p>Rare content revealed it.</p>
<p>That is why testing only the homepage or the most visited gallery would have given me a false sense of confidence.</p>
<hr />
<h1>Video exposed a second problem</h1>
<p>Images revealed the latency problem.</p>
<p>Video revealed an architectural problem.</p>
<p>Browsers often do not download an MP4 from byte zero to the end.</p>
<p>They request ranges.</p>
<p>For example:</p>
<pre><code class="language-http">Range: bytes=0-1048575
</code></pre>
<p>A seek operation may later produce something like:</p>
<pre><code class="language-http">Range: bytes=50000000-51048575
</code></pre>
<p>A server can answer:</p>
<pre><code class="language-http">HTTP/1.1 206 Partial Content
Content-Range: bytes 50000000-51048575/500000000
</code></pre>
<p>This is normal HTTP byte-range behavior.</p>
<p>It is also essential for useful video seeking.</p>
<p>But it complicates caching.</p>
<hr />
<h1>The naive Range cache key creates fragmented objects</h1>
<p>My early approach effectively included the requested browser Range in the cache identity:</p>
<pre><code class="language-nginx">proxy_set_header Range $http_range;
proxy_set_header If-Range $http_if_range;

proxy_cache_key "$scheme|$host|$request_uri|range=$http_range";

proxy_cache_valid 200 206 301 302 30d;
</code></pre>
<p>This protects against serving one cached partial response for a completely different range.</p>
<p>But browser ranges are arbitrary.</p>
<p>One client might request:</p>
<pre><code class="language-text">bytes=0-1048575
</code></pre>
<p>Another:</p>
<pre><code class="language-text">bytes=0-999999
</code></pre>
<p>Another:</p>
<pre><code class="language-text">bytes=50234881-51283456
</code></pre>
<p>So the cache can conceptually become:</p>
<pre><code class="language-text">video.mp4 + range A
video.mp4 + range B
video.mp4 + range C
video.mp4 + range D
</code></pre>
<p>instead of:</p>
<pre><code class="language-text">video.mp4
</code></pre>
<p>I did not measure exactly how much disk space this fragmentation consumed, so I am not going to invent a number.</p>
<p>But the architecture clearly allowed many cache entries for one physical video.</p>
<p>I wanted something more deterministic.</p>
<hr />
<h1>Nginx Slice looked like the correct answer</h1>
<p>Nginx has a Slice module designed for caching large responses as fixed-size segments.</p>
<p>Instead of preserving arbitrary browser ranges:</p>
<pre><code class="language-text">0-734129
917283-1500000
2839123-3421091
</code></pre>
<p>I could normalize everything into predictable chunks:</p>
<pre><code class="language-text">0-1 MB
1-2 MB
2-3 MB
3-4 MB
</code></pre>
<p>The configuration looked roughly like this:</p>
<pre><code class="language-nginx">slice 1m;

proxy_set_header Range $slice_range;

proxy_cache_key "$scheme|$host|$uri|$slice_range";

proxy_cache_valid 200 206 30d;
</code></pre>
<p>This is much cleaner from a cache-reuse perspective.</p>
<p>If multiple users seek into the same region of a file, they can reuse the same normalized slices instead of generating arbitrary Range cache entries.</p>
<p>The design made sense.</p>
<p>Then production started producing:</p>
<pre><code class="language-text">etag mismatch in slice response while reading response header from upstream
</code></pre>
<p>That error changed the direction of the investigation.</p>
<hr />
<h1>What the ETag mismatch actually means</h1>
<p>At first it is tempting to describe the pieces as having different "signatures."</p>
<p>That is too vague.</p>
<p>The relevant HTTP mechanism is the <code>ETag</code>.</p>
<p>An ETag is a validator associated with a representation of a resource.</p>
<p>Conceptually, if Nginx is assembling slices of one file, it expects something like:</p>
<pre><code class="language-text">slice 1
ETag: A

slice 2
ETag: A

slice 3
ETag: A
</code></pre>
<p>Now imagine this:</p>
<pre><code class="language-text">slice 1
ETag: A

slice 2
ETag: B
</code></pre>
<p>Nginx cannot safely assume those slices belong to the same representation.</p>
<p>If it ignored the mismatch, it could theoretically combine data from two different versions:</p>
<pre><code class="language-text">first part from version A
+
second part from version B
</code></pre>
<p>That could create corrupt output.</p>
<p>So rejecting inconsistent slices is the safe behavior.</p>
<hr />
<h1>The ETag problem happened in production</h1>
<p>On July 30, repeated MP4 slice requests generated:</p>
<pre><code class="language-text">etag mismatch in slice response
</code></pre>
<p>while requests were reaching storage addresses including:</p>
<pre><code class="language-text">109.61.89.53
109.61.89.57
79.127.226.193
109.61.89.55
</code></pre>
<p>On July 31, the same class of error appeared again for another MP4.</p>
<p>The important conclusion is limited but solid:</p>
<blockquote>
<p>Nginx received slice responses for the same MP4 whose validators were not consistent enough for safe slice assembly.</p>
</blockquote>
<p>I did not log the literal ETag value returned by every individual subrequest at the time.</p>
<p>Because of that, I cannot honestly prove the exact reason for the difference.</p>
<p>Different upstream responses are a plausible factor.</p>
<p>They are not something I can claim as a fully proven internal Bunny failure mechanism.</p>
<p>The Nginx error itself is the confirmed fact.</p>
<hr />
<h1>Bunny Storage did support byte ranges</h1>
<p>This is another distinction worth making.</p>
<p>I was receiving:</p>
<pre><code class="language-text">206 Partial Content
</code></pre>
<p>responses.</p>
<p>So the problem was not:</p>
<blockquote>
<p>Bunny Storage cannot serve Range requests.</p>
</blockquote>
<p>It could.</p>
<p>The narrower failure involved this combination:</p>
<pre><code class="language-text">Nginx Slice
+
multiple subrequests
+
ETag consistency requirements
</code></pre>
<p>At the same time, video requests could still encounter the independent origin issue:</p>
<pre><code class="language-text">upstream timed out while connecting to upstream
</code></pre>
<p>So video could suffer from two different classes of failure:</p>
<pre><code class="language-text">1. connection / TLS / long-tail origin latency
2. slice / validator consistency
</code></pre>
<p>That made the entire setup harder to reason about.</p>
<hr />
<h1>I eventually removed Slice from the MP4 path</h1>
<p>I moved MP4 delivery back toward ordinary browser Range behavior.</p>
<p>Conceptually:</p>
<pre><code class="language-nginx">proxy_set_header Range $http_range;
proxy_set_header If-Range $http_if_range;

proxy_cache_key "$scheme|$host|$uri";

proxy_no_cache $http_range;

proxy_cache_valid 200 30d;
</code></pre>
<p>The exact production configuration evolved, but the principle was simple:</p>
<p>A partial response should not accidentally become the canonical complete cached object.</p>
<p>Removing remote slice assembly eliminated the ETag mismatch failure mode.</p>
<p>But it exposed the underlying problem again.</p>
<p>A cold range request still had to reach the remote storage origin.</p>
<p>I had improved the caching logic.</p>
<p>I had not simplified the origin.</p>
<hr />
<h1>At some point I realized I was recreating a delivery layer in front of storage</h1>
<p>By then the configuration had accumulated a lot of moving parts:</p>
<pre><code class="language-text">proxy_cache
cache locking
background updates
stale content
custom cache keys
Range forwarding
206 handling
Nginx Slice
1 MB chunks
upstream keepalive
TLS session reuse
retries
connection timeouts
</code></pre>
<p>Every one of these features has a legitimate use.</p>
<p>But together they made me ask a more basic question:</p>
<p>What do I actually need from the origin?</p>
<p>For this workload, the answer was simple:</p>
<pre><code class="language-text">store immutable files
+
return bytes quickly
</code></pre>
<p>I did not need global edge distribution between two servers.</p>
<p>I did not need a complex public storage route.</p>
<p>I needed one working server to retrieve media from one origin server.</p>
<p>That led to a much simpler architecture.</p>
<hr />
<h1>The private origin architecture</h1>
<p>The current model is:</p>
<pre><code class="language-text">Internet
   ↓
Working server
   ↓
Nginx
   │
   ├── HIT -&gt; local SSD cache
   │
   └── MISS
          ↓
      WireGuard
          ↓
    Media origin
          ↓
        Nginx
          ↓
         SSD
</code></pre>
<p>The origin does almost nothing.</p>
<p>It stores static files and serves them.</p>
<p>The origin listener exists only on the private network.</p>
<p>The working server uses a fixed private address.</p>
<p>WireGuard provides the encrypted transport between the two systems.</p>
<p>Because the tunnel itself is encrypted, I can use ordinary HTTP inside it:</p>
<pre><code class="language-text">http://10.x.x.x:port
</code></pre>
<p>That removes another TLS handshake from the application-level origin path.</p>
<hr />
<h1>Static file origins are surprisingly cheap workloads</h1>
<p>The media server does not render pages.</p>
<p>It does not run SSR.</p>
<p>It does not query a database.</p>
<p>It does not execute application business logic.</p>
<p>Its job is mostly:</p>
<pre><code class="language-text">open file
↓
send bytes
</code></pre>
<p>For a small dedicated origin, a machine in roughly this class can be enough for many workloads:</p>
<pre><code class="language-text">1 vCPU
1 GB RAM
hundreds of GB of SSD
</code></pre>
<p>The real constraints are usually more relevant than the amount of application compute:</p>
<ul>
<li><p>network capacity</p>
</li>
<li><p>disk performance</p>
</li>
<li><p>file descriptor limits</p>
</li>
<li><p>concurrent connections</p>
</li>
<li><p>filesystem space</p>
</li>
<li><p>traffic shape</p>
</li>
</ul>
<p>This is not a universal sizing recommendation.</p>
<p>A high-throughput video origin can obviously need much more.</p>
<p>But static media serving is fundamentally a much simpler workload than running an application stack.</p>
<hr />
<h1>Connection limits matter more than they appear</h1>
<p>When I first moved traffic to the private origin, the server had to be configured like an actual media origin rather than a default low-traffic web server.</p>
<p>Important settings included:</p>
<pre><code class="language-nginx">worker_rlimit_nofile 65535;

events {
    worker_connections 16384;
}
</code></pre>
<p>and a larger listener backlog:</p>
<pre><code class="language-nginx">listen 10.x.x.x:8080 backlog=8192;
</code></pre>
<p>At the kernel level:</p>
<pre><code class="language-text">net.core.somaxconn=8192
net.ipv4.tcp_max_syn_backlog=8192
</code></pre>
<p>The systemd file descriptor limit also needed to match:</p>
<pre><code class="language-ini">[Service]
LimitNOFILE=65535
</code></pre>
<p>These settings are not magic numbers for every server.</p>
<p>The important point is consistency.</p>
<p>Raising <code>worker_connections</code> while leaving the real process <code>nofile</code> limit at 1024 does not produce the capacity you think it does.</p>
<p>The same applies to the socket backlog.</p>
<p>The application limit, process limit, and kernel limit need to agree.</p>
<hr />
<h1>After tuning, the origin became extremely boring</h1>
<p>And boring is exactly what I wanted.</p>
<p>Local media-origin requests were roughly:</p>
<pre><code class="language-text">~0.5 to 0.9 ms
</code></pre>
<p>Small real-file requests from the working server through the private path were generally around:</p>
<pre><code class="language-text">CONNECT ≈ 9.5-12.5 ms
TTFB    ≈ 19-23 ms
TOTAL   ≈ 19-23 ms
</code></pre>
<p>I also checked sequential and parallel requests.</p>
<p>Results included:</p>
<pre><code class="language-text">10/10 successful
20/20 sequential successful
20/20 parallel successful
</code></pre>
<p>Connection pressure on the origin looked healthy:</p>
<pre><code class="language-text">SYN_RECV=0
</code></pre>
<p>and the server remained mostly idle while serving production traffic.</p>
<p>This does not mean a complete large media file downloads in 20 ms.</p>
<p>The measurement is about connection cost and time to first useful bytes.</p>
<p>A 500 MB video still needs enough throughput to move 500 MB.</p>
<p>Latency and bandwidth are different metrics.</p>
<hr />
<h1>The old 1-second result was not a benchmark, but it was still useful</h1>
<p>The worst measured old path in that particular test was:</p>
<pre><code class="language-text">1.054474 seconds
</code></pre>
<p>The current small cold-origin path is around:</p>
<pre><code class="language-text">20 ms
</code></pre>
<p>That produces a ratio of roughly:</p>
<pre><code class="language-text">1054 / 20 ≈ 52
</code></pre>
<p>It would be wrong to write:</p>
<blockquote>
<p>My VPS is 52 times faster than Bunny Storage.</p>
</blockquote>
<p>That is not what the data proves.</p>
<p>The normal Bunny measurements in my tests were closer to:</p>
<pre><code class="language-text">29-44 ms
</code></pre>
<p>The 1.054-second request was an outlier.</p>
<p>The meaningful difference was the tail.</p>
<p>The old path could behave like:</p>
<pre><code class="language-text">30 ms
29 ms
43 ms
1,054 ms
timeout
</code></pre>
<p>The new path behaved much more like:</p>
<pre><code class="language-text">19 ms
20 ms
21 ms
20 ms
23 ms
</code></pre>
<p>What improved most was predictability.</p>
<p>For web performance, predictability can matter more than an already-good median.</p>
<hr />
<h1>The cache became smaller and the site still felt faster</h1>
<p>This was one of the most counterintuitive observations.</p>
<p>The old cache had reached roughly:</p>
<pre><code class="language-text">35-36 GB
</code></pre>
<p>with hundreds of thousands of entries.</p>
<p>The newer cache started much smaller.</p>
<p>At one point it was around:</p>
<pre><code class="language-text">3.9 GB
</code></pre>
<p>with limits such as:</p>
<pre><code class="language-text">max_size = 25 GB
min_free = 8 GB
</code></pre>
<p>Yet old, rarely visited pages felt much faster.</p>
<p>Why?</p>
<p>Because the old architecture optimized this:</p>
<pre><code class="language-text">reduce the number of MISSes
</code></pre>
<p>The new architecture also optimized this:</p>
<pre><code class="language-text">reduce the cost of a MISS
</code></pre>
<p>That difference is fundamental.</p>
<p>A cache can make a slow origin appear excellent until an object is missing.</p>
<p>A fast origin makes cache misses less dangerous.</p>
<hr />
<h1>The new MP4 strategy is simpler too</h1>
<p>With a stable private origin, one possible MP4 strategy is full-object cache filling.</p>
<p>Conceptually:</p>
<pre><code class="language-text">first request
↓
cache MISS
↓
fetch complete MP4 from private origin
↓
store one complete object
</code></pre>
<p>Then later browser Range requests can be served from the local cached file:</p>
<pre><code class="language-text">Browser
↓ Range request
Working Nginx
↓
local complete MP4
↓
206 Partial Content
</code></pre>
<p>That gives me:</p>
<pre><code class="language-text">one video
↓
one cache object
</code></pre>
<p>instead of a large collection of arbitrary remote Range fragments.</p>
<p>It also avoids remote slice assembly across multiple storage responses.</p>
<hr />
<h1>Full-object MP4 caching has a real trade-off</h1>
<p>Suppose a video is:</p>
<pre><code class="language-text">500 MB
</code></pre>
<p>A user opens it, watches ten seconds, and leaves.</p>
<p>If the server fills the entire cache object, the origin may still transfer all:</p>
<pre><code class="language-text">500 MB
</code></pre>
<p>even though the user consumed only a fraction.</p>
<p>A pure Range strategy could transfer less.</p>
<p>So full-object caching is not automatically optimal.</p>
<p>It works well when:</p>
<ul>
<li><p>videos are not enormous</p>
</li>
<li><p>users revisit the same files</p>
</li>
<li><p>seeking is common</p>
</li>
<li><p>local disk cache is cheap</p>
</li>
<li><p>the private origin link has enough capacity</p>
</li>
</ul>
<p>It may be a poor fit when:</p>
<ul>
<li><p>files are multi-gigabyte</p>
</li>
<li><p>most videos are viewed once</p>
</li>
<li><p>viewers abandon early</p>
</li>
<li><p>origin bandwidth is expensive</p>
</li>
</ul>
<p>For those workloads I would evaluate:</p>
<ul>
<li><p>fixed-size slicing with a stable origin</p>
</li>
<li><p>HLS</p>
</li>
<li><p>DASH</p>
</li>
<li><p>a video CDN</p>
</li>
<li><p>a dedicated video platform</p>
</li>
</ul>
<p>The important part is that this is now a deliberate media-delivery decision.</p>
<p>It is no longer mixed with an unreliable cold-origin problem.</p>
<hr />
<h1>How I test media infrastructure now</h1>
<p>One of the biggest mistakes I made early was testing the same object repeatedly.</p>
<p>For example:</p>
<pre><code class="language-bash">curl https://example.com/image.avif
curl https://example.com/image.avif
curl https://example.com/image.avif
</code></pre>
<p>After the first request, I may simply be benchmarking:</p>
<pre><code class="language-text">local cache
</code></pre>
<p>That tells me very little about the origin.</p>
<p>A better test set includes:</p>
<pre><code class="language-text">different objects
cold objects
parallel requests
small files
larger files
Range requests
random video seeks
</code></pre>
<p>The distinction between one cold URL and fifty different cold URLs is enormous.</p>
<p><code>proxy_cache_lock</code> can serialize multiple requests for the same cache key.</p>
<p>It does not magically combine fifty different cache misses into one origin request.</p>
<hr />
<h1>The Nginx metrics I care about now</h1>
<p>If I were setting up logging from scratch, I would make sure the access log includes fields such as:</p>
<pre><code class="language-nginx">$upstream_addr
$upstream_connect_time
$upstream_header_time
$upstream_response_time
$upstream_cache_status
$request_time
$status
</code></pre>
<p>Then I would separate:</p>
<pre><code class="language-text">HIT
MISS
BYPASS
STALE
UPDATING
</code></pre>
<p>instead of calculating one global average.</p>
<p>For origin performance, I care about:</p>
<pre><code class="language-text">MISS p50
MISS p95
MISS p99

upstream_connect_time p50
upstream_connect_time p95
upstream_connect_time p99
</code></pre>
<p>A healthy cache can hide a terrible MISS distribution.</p>
<p>That is exactly what happened to me.</p>
<hr />
<h1>Video needs its own test plan</h1>
<p>I would also never treat one successful MP4 request as proof that video delivery is healthy.</p>
<p>I would test:</p>
<h2>Start of file</h2>
<pre><code class="language-http">Range: bytes=0-1048575
</code></pre>
<h2>Middle of file</h2>
<pre><code class="language-http">Range: bytes=50000000-51048575
</code></pre>
<h2>Near the end</h2>
<p>A range close to the file boundary.</p>
<h2>Random seeks</h2>
<p>Several non-sequential ranges.</p>
<p>For each response I would inspect:</p>
<pre><code class="language-text">HTTP status
Content-Range
Content-Length
Accept-Ranges
ETag
</code></pre>
<p>And if I were using Nginx Slice, I would explicitly compare ETags between multiple slices.</p>
<p>That one test could have exposed my production MP4 issue much earlier.</p>
<hr />
<h1>Real production traffic showed that the cache was doing its job</h1>
<p>After the migration, I monitored the working server and private origin at the same time.</p>
<p>In one 60-second production sample:</p>
<pre><code class="language-text">public TX to users:      ~96.84 Mbit/s
traffic from origin:      ~7.07 Mbit/s
</code></pre>
<p>There were no new critical Nginx errors during that window.</p>
<p>In another 30-second sample:</p>
<pre><code class="language-text">public TX:      61.36 Mbit/s
origin RX:       1.40 Mbit/s
</code></pre>
<p>The origin byte volume during that window was approximately:</p>
<pre><code class="language-text">2.28%
</code></pre>
<p>of public TX.</p>
<p>That is not a cache-hit ratio.</p>
<p>A byte ratio and a request hit ratio are different metrics.</p>
<p>Public traffic can also contain HTML, API responses, and other data.</p>
<p>But it demonstrates the behavior I wanted:</p>
<pre><code class="language-text">large public media output
↓
mostly served by local cache
↓
small stream of cold fills from origin
</code></pre>
<p>The private media server does not need to reproduce all public traffic.</p>
<p>It only needs to feed cache misses.</p>
<hr />
<h1>I do not think this proves that self-hosting is better</h1>
<p>That would be the wrong lesson.</p>
<p>A private origin creates new responsibilities.</p>
<p>I now own:</p>
<ul>
<li><p>disk monitoring</p>
</li>
<li><p>storage capacity planning</p>
</li>
<li><p>backups</p>
</li>
<li><p>restore procedures</p>
</li>
<li><p>firewall configuration</p>
</li>
<li><p>Nginx maintenance</p>
</li>
<li><p>kernel tuning</p>
</li>
<li><p>availability</p>
</li>
<li><p>redundancy</p>
</li>
</ul>
<p>If the private origin disappears, cached files may continue working for some time.</p>
<p>Cold objects will not.</p>
<p>So the origin is still a dependency.</p>
<p>If I need high availability, I need:</p>
<ul>
<li><p>replication</p>
</li>
<li><p>another origin</p>
</li>
<li><p>failover</p>
</li>
<li><p>or a managed layer</p>
</li>
</ul>
<p>A managed storage and CDN platform solves many of those operational problems for you.</p>
<p>That can be worth much more than the price of another VPS.</p>
<hr />
<h1>When Bunny would still be my choice</h1>
<p>I would seriously consider Bunny again if I needed:</p>
<ul>
<li><p>global user distribution</p>
</li>
<li><p>edge delivery</p>
</li>
<li><p>managed geographic caching</p>
</li>
<li><p>reduced infrastructure maintenance</p>
</li>
<li><p>rapidly growing storage</p>
</li>
<li><p>managed CDN behavior</p>
</li>
</ul>
<p>And I would evaluate the platform in the architecture it is designed to provide:</p>
<pre><code class="language-text">Bunny Storage
+
Bunny CDN
</code></pre>
<p>rather than using my direct Storage-origin architecture as a proxy for Bunny CDN performance.</p>
<p>They are not the same product layer.</p>
<p>For my specific case, however, the traffic relationship was simple:</p>
<pre><code class="language-text">one working server
↓
one media origin
</code></pre>
<p>A private static server fits that topology extremely well.</p>
<hr />
<h1>The most important lesson: design the MISS path first</h1>
<p>I spent a lot of time thinking about:</p>
<pre><code class="language-text">cache size
cache eviction
cache keys
Range caching
Nginx Slice
stale responses
keepalive
TLS reuse
timeouts
retries
</code></pre>
<p>Those things matter.</p>
<p>But the architectural change that improved the system most was much simpler:</p>
<pre><code class="language-text">I replaced the origin path.
</code></pre>
<p>That changed my caching philosophy.</p>
<p>I no longer start with:</p>
<blockquote>
<p>How high can I make the cache-hit ratio?</p>
</blockquote>
<p>I start with:</p>
<blockquote>
<p>What happens on a cold MISS?</p>
</blockquote>
<p>A healthy answer sounds like:</p>
<pre><code class="language-text">predictable connect time
predictable TTFB
correct Range support
consistent validators
understandable failure modes
</code></pre>
<p>Then the cache makes an already good path cheaper and faster.</p>
<p>That is a much safer architecture than depending on the cache to hide the path underneath it.</p>
<hr />
<h1>A cache should be an optimization, not life support</h1>
<p>The old architecture often behaved like this:</p>
<pre><code class="language-text">HIT
↓
excellent

MISS
↓
unpredictable
</code></pre>
<p>The new architecture behaves much closer to this:</p>
<pre><code class="language-text">HIT
↓
excellent

MISS
↓
still fast
</code></pre>
<p>That difference changed the user experience more than increasing cache size ever did.</p>
<p>Old media now loads quickly even when it has not been requested recently.</p>
<p>Sometimes it is a HIT.</p>
<p>Sometimes it is a MISS.</p>
<p>From the browser, the difference is often difficult to notice.</p>
<p>That is what I wanted from the beginning.</p>
<p>The final origin is not sophisticated:</p>
<pre><code class="language-text">WireGuard
↓
Nginx
↓
SSD
</code></pre>
<p>No global edge network.</p>
<p>No complicated application runtime.</p>
<p>No special storage API in the request path.</p>
<p>Just a predictable place to get bytes when the cache does not have them.</p>
<p>After two months of tuning cache behavior, debugging TLS connection stalls, experimenting with MP4 byte ranges, and tracing <code>etag mismatch in slice response</code>, that boring cold path turned out to be the most valuable performance optimization of the entire system.</p>
]]></content:encoded></item><item><title><![CDATA[Designing a Safer WebP→AVIF Pipeline with SSIMULACRA2]]></title><description><![CDATA[I thought I had a threshold problem.
I actually had a reference problem, followed by an acceptance-logic problem.
The image pipeline was already doing something fairly sophisticated: instead of assign]]></description><link>https://sergeisolod.hashnode.dev/ssimulacra2-webp-avif-transcoding</link><guid isPermaLink="true">https://sergeisolod.hashnode.dev/ssimulacra2-webp-avif-transcoding</guid><category><![CDATA[JavaScript]]></category><category><![CDATA[image processing]]></category><category><![CDATA[web performance]]></category><category><![CDATA[webp]]></category><category><![CDATA[AVIF]]></category><dc:creator><![CDATA[Sergei Solod]]></dc:creator><pubDate>Thu, 27 Aug 2026 08:30:50 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6953c791eb36c2149a16c84b/830cd0de-dcea-4257-84ac-6b505434dcb4.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>I thought I had a threshold problem.</p>
<p>I actually had a <strong>reference problem</strong>, followed by an <strong>acceptance-logic problem</strong>.</p>
<p>The image pipeline was already doing something fairly sophisticated: instead of assigning one fixed AVIF quality to every image, it searched for the lowest encoder quality that still passed a perceptual-quality requirement.</p>
<p>My baseline rule was:</p>
<pre><code class="language-text">target = 60
single allowed worst sample = 58
</code></pre>
<p>For small collections, every sampled image had to reach the main target. For larger collections, I allowed one representative sample to fall to the lower floor while the rest still had to pass 60.</p>
<p>That policy was designed for normal source images.</p>
<p>Then I started receiving inputs with a different history:</p>
<pre><code class="language-text">higher-quality original: ~2 MB
          ↓
      lossy WebP: ~100 KB
          ↓
          AVIF
</code></pre>
<p>The original was no longer available.</p>
<p>Only the WebP remained.</p>
<p>At first, the question seemed simple:</p>
<blockquote>
<p>If SSIMULACRA2 60 is acceptable for source → AVIF, why shouldn't 60 also be acceptable for WebP → AVIF?</p>
</blockquote>
<p>The answer required me to stop thinking about SSIMULACRA2 as a generic "image quality score" and look more carefully at what the metric is actually measuring.</p>
<p>That eventually changed both the quality policy and the implementation.</p>
<hr />
<h2>SSIMULACRA2 measures a pair of images, not their history</h2>
<p>A full-reference metric needs two images:</p>
<pre><code class="language-text">reference
candidate
</code></pre>
<p>SSIMULACRA2 compares those two images and estimates the perceptual distortion between them.</p>
<p>The published SSIMULACRA2 scale gives useful anchors:</p>
<pre><code class="language-text">50  ≈ medium / fair quality
70  ≈ high / good quality
80  ≈ very high quality
85  ≈ excellent quality
90  ≈ visually lossless
100 = mathematically lossless
</code></pre>
<p>The <a href="https://github.com/cloudinary/ssimulacra2/blob/main/README.md">SSIMULACRA2 documentation</a> describes these as approximate perceptual-quality reference points rather than universal codec thresholds.</p>
<p>That distinction matters.</p>
<p><strong>60 is not an official "good quality" anchor.</strong></p>
<p>It sits between the published 50 and 70 reference points.</p>
<p>For a web-oriented compression pipeline, that can be a perfectly reasonable trade-off. But it is still a relatively aggressive one.</p>
<p>Now consider what happens when the reference changes.</p>
<h3>Direct encoding</h3>
<p>If I still have the original:</p>
<pre><code class="language-text">original
   ↓
AVIF
</code></pre>
<p>then the metric compares:</p>
<pre><code class="language-text">original ↔ AVIF
</code></pre>
<p>The resulting score describes the perceptual change introduced by the AVIF conversion relative to the actual source.</p>
<p>That is exactly the question I want answered.</p>
<h3>Encoding from a lossy intermediate</h3>
<p>Now consider:</p>
<pre><code class="language-text">original
   ↓ lossy encode
WebP
   ↓ lossy encode
AVIF
</code></pre>
<p>If the original is gone, the comparison becomes:</p>
<pre><code class="language-text">WebP ↔ AVIF
</code></pre>
<p>The metric has no access to the image that existed before WebP.</p>
<p>That first generation of quality loss does not disappear from the pixels.</p>
<p>It disappears from the <strong>measurement history</strong>.</p>
<p>And that difference is fundamental.</p>
<hr />
<h2>Once a lossy intermediate becomes the reference, its defects become "correct"</h2>
<p>Imagine the true original contains a very smooth gradient.</p>
<p>The first encoder introduces a small amount of banding:</p>
<pre><code class="language-text">smooth gradient
       ↓
minor banding
</code></pre>
<p>The WebP still looks acceptable.</p>
<p>Now that WebP becomes my reference.</p>
<p>When I create the AVIF, SSIMULACRA2 evaluates how faithfully the AVIF reproduces the WebP.</p>
<p>From the perspective of that comparison, the existing banding is no longer an error.</p>
<p>It is reference content.</p>
<p>The metric can detect additional damage:</p>
<pre><code class="language-text">WebP
 ↓
additional blur
ringing
edge changes
other distortion
 ↓
AVIF
</code></pre>
<p>But it cannot say:</p>
<blockquote>
<p>This region was already degraded before the reference file existed.</p>
</blockquote>
<p>That earlier state is simply unavailable.</p>
<p>This is one reason lossy transcoding deserves more care than direct encoding from the best available source.</p>
<p>A discussion in the <a href="https://github.com/AOMediaCodec/libavif/discussions/2640">libavif project</a> makes the same broader point: when the source already contains compression artifacts, those artifacts can be carried into the resulting AVIF rather than somehow disappearing during conversion.</p>
<p>This does <strong>not</strong> mean:</p>
<pre><code class="language-text">WebP → AVIF is always bad
</code></pre>
<p>or:</p>
<pre><code class="language-text">AVIF automatically amplifies every WebP artifact
</code></pre>
<p>It means something narrower:</p>
<blockquote>
<p>When the reference is already lossy, a high SSIMULACRA2 score means "close to this lossy reference," not "close to the original that existed before it."</p>
</blockquote>
<p>That changed the way I defined the quality policy.</p>
<hr />
<h2>Source provenance became part of the quality policy</h2>
<p>I stopped treating every input identically.</p>
<p>The policy I currently prefer is:</p>
<pre><code class="language-text">Canonical / high-quality source
target = 60
floor  = 58

Lossless WebP
target = 60
floor  = 58

Known already-lossy derivative
target = 65
floor  = 63
</code></pre>
<p>The important distinction is <strong>not</strong>:</p>
<pre><code class="language-text">JPEG vs WebP
</code></pre>
<p>It is:</p>
<pre><code class="language-text">best available source
vs
known lossy derivative
</code></pre>
<p>That matters because WebP itself does not imply loss.</p>
<p>WebP supports lossless encoding. The <a href="https://developers.google.com/speed/webp/docs/webp_lossless_bitstream_specification">WebP lossless bitstream specification</a> describes a format that reconstructs pixel values exactly.</p>
<p>So a true lossless WebP does not introduce an earlier lossy generation that needs special treatment.</p>
<p>Likewise, a JPEG might be the best available source, or it might be the fifth recompressed copy of another image.</p>
<p>The extension cannot tell me that.</p>
<p>The provenance can.</p>
<p>So this is too simplistic:</p>
<pre><code class="language-js">if (extension === ".webp") {
  target = 65;
}
</code></pre>
<p>A better model is conceptually:</p>
<pre><code class="language-js">if (sample.isKnownLossyDerivative) {
  target = 65;
} else {
  target = 60;
}
</code></pre>
<p>The exact mechanism used to determine provenance depends on the pipeline.</p>
<p>Sometimes it can be known from ingestion metadata.</p>
<p>Sometimes it is determined by where the source file came from.</p>
<p>Sometimes lossless versus lossy WebP can be detected independently.</p>
<p>What matters is that the quality policy describes the image's <strong>history</strong>, not just its filename.</p>
<hr />
<h2>Why I use 65 for the second lossy generation</h2>
<p>This deserves a careful answer because it is easy to turn an engineering heuristic into fake science.</p>
<p>There is no official SSIMULACRA2 rule saying:</p>
<pre><code class="language-text">already lossy source
→ add exactly five points
</code></pre>
<p>There is no equation proving:</p>
<pre><code class="language-text">60 + generation loss = 65
</code></pre>
<p>SSIMULACRA2 scores should not be treated as a linear distortion budget that can simply be added across encoding generations.</p>
<p>So why 65?</p>
<p>Because I needed a production policy between two different goals.</p>
<p>My original 60 target is intentionally size-oriented:</p>
<pre><code class="language-text">50                  60                  70
|-------------------|-------------------|
fair           my baseline          high/good
</code></pre>
<p>The published scale places 70 around the high/good-quality region.</p>
<p>For a first encode from a good source, I am willing to operate below that point because file size is a major part of the objective.</p>
<p>For a <strong>second lossy generation</strong>, I want the encoder to introduce less additional distortion.</p>
<p>Moving from 60 to 65 does that without automatically pushing every image all the way to 70.</p>
<p>So I treat 65 as a <strong>guardrail</strong>:</p>
<pre><code class="language-text">60
more aggressive
smaller files
larger additional-loss budget

65
stricter second-generation guardrail

70
higher-quality region
potentially larger files
</code></pre>
<p>Could another workload reasonably choose 63?</p>
<p>Yes.</p>
<p>Could an archival pipeline choose 70, 80, or higher?</p>
<p>Absolutely.</p>
<p>The important statement is not:</p>
<blockquote>
<p>65 is universally correct.</p>
</blockquote>
<p>The statement I can defend is:</p>
<blockquote>
<p>I do not want to give a known second-generation lossy encode the same distortion budget I give a direct encode from a good source.</p>
</blockquote>
<p>For my size-versus-quality objective, 65 is where I currently draw that line.</p>
<hr />
<h2>Why the lower bound became 63</h2>
<p>My original large-collection rule was:</p>
<pre><code class="language-text">target = 60
allowed single-sample floor = 58
</code></pre>
<p>That is a two-point exception:</p>
<pre><code class="language-text">60 - 58 = 2
</code></pre>
<p>If I raise the target to 65 while preserving the semantics of the original rule:</p>
<pre><code class="language-text">65 - 2 = 63
</code></pre>
<p>So:</p>
<pre><code class="language-text">target = 65
floor  = 63
</code></pre>
<p>Using 62 would quietly change the policy:</p>
<pre><code class="language-text">65 - 62 = 3
</code></pre>
<p>I would then be doing something strange:</p>
<ol>
<li><p>becoming more conservative because the source is already lossy;</p>
</li>
<li><p>while simultaneously giving the worst sample a larger exception than before.</p>
</li>
</ol>
<p>I could not justify that.</p>
<p>So I kept the same tolerance.</p>
<p>Again:</p>
<pre><code class="language-text">63 is not magic.
</code></pre>
<p>The important property is:</p>
<pre><code class="language-text">target - floor = 2
</code></pre>
<p>for both classes.</p>
<hr />
<h2>File size does not tell me how damaged the WebP is</h2>
<p>A particularly tempting shortcut is to look at the compression ratio.</p>
<p>For example:</p>
<pre><code class="language-text">2 MB → 100 KB
</code></pre>
<p>That is roughly a 20× reduction.</p>
<p>It looks enormous.</p>
<p>It is natural to think:</p>
<blockquote>
<p>That WebP must have been heavily damaged.</p>
</blockquote>
<p>But that conclusion does not follow from file size alone.</p>
<p>Compression efficiency depends heavily on image characteristics:</p>
<ul>
<li><p>dimensions;</p>
</li>
<li><p>noise;</p>
</li>
<li><p>local detail;</p>
</li>
<li><p>gradients;</p>
</li>
<li><p>flat regions;</p>
</li>
<li><p>line art;</p>
</li>
<li><p>chroma content;</p>
</li>
<li><p>entropy;</p>
</li>
<li><p>source format;</p>
</li>
<li><p>encoder settings.</p>
</li>
</ul>
<p>A relatively simple illustration can sometimes compress extremely well while remaining visually convincing.</p>
<p>A noisy photograph may behave very differently.</p>
<p>This is why I do <strong>not</strong> implement logic such as:</p>
<pre><code class="language-js">if (compressionRatio &gt;= 20) {
  target = 70;
} else if (compressionRatio &gt;= 10) {
  target = 65;
} else {
  target = 60;
}
</code></pre>
<p>That mapping looks scientific because it contains numbers.</p>
<p>It isn't.</p>
<p>A compression ratio is not a perceptual metric.</p>
<p>The useful question is:</p>
<pre><code class="language-text">Has this image already passed through a lossy generation?
</code></pre>
<p>Not:</p>
<pre><code class="language-text">How dramatic does the byte reduction look?
</code></pre>
<hr />
<h2>Then I discovered the bigger problem: the new threshold was not actually enforced</h2>
<p>At this point, I thought implementing the policy would be straightforward.</p>
<p>The code already had the concept of format-specific targets.</p>
<p>Simplified:</p>
<pre><code class="language-js">const PNG_TARGET = 60;
const JPEG_TARGET = 60;
const WEBP_TARGET = 65;

function targetForSample(sample) {
  if (sample.name.endsWith(".webp")) {
    return WEBP_TARGET;
  }

  if (sample.name.endsWith(".png")) {
    return PNG_TARGET;
  }

  return JPEG_TARGET;
}
</code></pre>
<p>That creates an attractive illusion.</p>
<p>The code "knows" WebP has a target of 65.</p>
<p>But the adaptive search that actually chose the AVIF quality still evaluated the candidate using the global threshold.</p>
<p>Conceptually:</p>
<pre><code class="language-js">passed =
  worstScore &gt;= GLOBAL_FLOOR &amp;&amp;
  secondWorstScore &gt;= GLOBAL_TARGET;
</code></pre>
<p>with:</p>
<pre><code class="language-js">GLOBAL_TARGET = 60;
GLOBAL_FLOOR = 58;
</code></pre>
<p>Meanwhile, the per-format target was used later for reporting individual samples.</p>
<p>That means one candidate could effectively produce:</p>
<pre><code class="language-text">WebP target = 65
score       = 61.2

log:
BELOW_TARGET
</code></pre>
<p>while the actual search predicate still concluded:</p>
<pre><code class="language-text">61.2 &gt;= 60

PASS
</code></pre>
<p>This kind of bug is dangerous precisely because the system appears to know the correct requirement.</p>
<p>The logs may even display it.</p>
<p>Nothing crashes.</p>
<p>The build succeeds.</p>
<p>The encoder returns an output.</p>
<p>But the optimization process is solving a different problem from the one the logs describe.</p>
<p>The lesson is broader than image compression:</p>
<blockquote>
<p><strong>A policy that exists only in reporting is not an enforced policy.</strong></p>
</blockquote>
<hr />
<h2>The thresholds need to reach the actual acceptance predicate</h2>
<p>The safer model is to make each sample carry the policy that applies to it.</p>
<p>For example:</p>
<pre><code class="language-js">function policyForSample(sample) {
  if (sample.isKnownLossyDerivative) {
    return {
      target: 65,
      floor: 63,
    };
  }

  return {
    target: 60,
    floor: 58,
  };
}
</code></pre>
<p>Then evaluate the actual measured result:</p>
<pre><code class="language-js">function assessSample(result) {
  const policy = policyForSample(result.sample);

  return {
    ...result,
    target: policy.target,
    floor: policy.floor,

    targetMargin: result.score - policy.target,
    floorMargin: result.score - policy.floor,

    meetsTarget: result.score &gt;= policy.target,
    meetsFloor: result.score &gt;= policy.floor,
  };
}
</code></pre>
<p>Now the important information travels with the measurement:</p>
<pre><code class="language-text">score
target
floor
margin
</code></pre>
<p>instead of having the score in one part of the system and the real requirement somewhere else.</p>
<hr />
<h2>The one-outlier rule becomes much clearer this way</h2>
<p>Suppose I want this behavior.</p>
<h3>Small collections</h3>
<p>No exceptions:</p>
<pre><code class="language-text">every sampled image must satisfy its own target
</code></pre>
<h3>Large collections</h3>
<p>At most one representative sample may miss its normal target:</p>
<pre><code class="language-text">one sample:
floor &lt;= score &lt; target

all remaining samples:
score &gt;= target
</code></pre>
<p>Then the implementation can reflect exactly that:</p>
<pre><code class="language-js">function evaluateCandidate(results, allowSingleOutlier) {
  const assessed = results.map(assessSample);

  const belowFloor = assessed.filter(
    (item) =&gt; !item.meetsFloor,
  );

  if (belowFloor.length &gt; 0) {
    return {
      passed: false,
      reason: "sample-below-floor",
      assessed,
    };
  }

  const belowTarget = assessed.filter(
    (item) =&gt; !item.meetsTarget,
  );

  const allowedTargetMisses =
    allowSingleOutlier ? 1 : 0;

  return {
    passed:
      belowTarget.length &lt;= allowedTargetMisses,

    reason:
      belowTarget.length &lt;= allowedTargetMisses
        ? "passed"
        : "too-many-target-misses",

    assessed,
  };
}
</code></pre>
<p>This is much easier to reason about than:</p>
<pre><code class="language-text">sort all scores
take worst
take second-worst
compare both against global constants
</code></pre>
<p>Once thresholds vary between samples, that old shortcut becomes actively misleading.</p>
<hr />
<h2>Mixed thresholds break raw "worst score" ordering</h2>
<p>Imagine two samples.</p>
<h3>Sample A</h3>
<pre><code class="language-text">score  = 63
target = 60
</code></pre>
<p>Margin:</p>
<pre><code class="language-text">+3
</code></pre>
<p>It passes comfortably.</p>
<h3>Sample B</h3>
<pre><code class="language-text">score  = 64
target = 65
</code></pre>
<p>Margin:</p>
<pre><code class="language-text">-1
</code></pre>
<p>It fails.</p>
<p>If I sort by raw score:</p>
<pre><code class="language-text">63
64
</code></pre>
<p>Sample A looks worse.</p>
<p>But relative to the actual policy:</p>
<pre><code class="language-text">Sample A = healthy
Sample B = failing
</code></pre>
<p>The meaningful quantity is no longer:</p>
<pre><code class="language-text">score
</code></pre>
<p>It is something closer to:</p>
<pre><code class="language-text">score - requiredTarget
</code></pre>
<p>or, even better, an explicit pass/fail assessment against each sample's own policy.</p>
<p>That is an important implementation detail.</p>
<p>Once thresholds become heterogeneous, algorithms built around:</p>
<pre><code class="language-js">const scores = results
  .map((r) =&gt; r.score)
  .sort((a, b) =&gt; a - b);
</code></pre>
<p>need to be reconsidered.</p>
<hr />
<h2>A normalized margin is more useful than the raw score</h2>
<p>For heterogeneous policies, I find this representation easier:</p>
<pre><code class="language-js">targetMargin =
  score - target;
</code></pre>
<p>Examples:</p>
<pre><code class="language-text">score 64, target 60
margin +4

score 64, target 65
margin -1
</code></pre>
<p>Now zero has a consistent meaning:</p>
<pre><code class="language-text">margin &gt;= 0
→ normal target passed
</code></pre>
<p>The same can be done for the floor:</p>
<pre><code class="language-js">floorMargin =
  score - floor;
</code></pre>
<p>For the allowed outlier:</p>
<pre><code class="language-text">targetMargin &lt; 0
floorMargin &gt;= 0
</code></pre>
<p>That describes the policy more precisely than reasoning about raw SSIMULACRA2 scores alone.</p>
<hr />
<h2>The adaptive search must use this predicate too</h2>
<p>Fixing only the final validation step is not enough.</p>
<p>An adaptive encoder might test qualities such as:</p>
<pre><code class="language-text">q=35
q=43
q=39
q=41
q=40
</code></pre>
<p>trying to find:</p>
<blockquote>
<p>the minimum encoder quality that passes.</p>
</blockquote>
<p>The word <strong>passes</strong> is doing a lot of work here.</p>
<p>The search is only correct if its pass predicate is correct.</p>
<p>The architecture should be:</p>
<pre><code class="language-text">encode q=N
    ↓
measure SSIMULACRA2
    ↓
apply per-sample policy
    ↓
PASS / FAIL
    ↓
choose next q
</code></pre>
<p>Not:</p>
<pre><code class="language-text">encode q=N
    ↓
compare to global 60
    ↓
choose next q
    ↓
later discover WebP wanted 65
</code></pre>
<p>If the adaptive search uses the wrong predicate, everything downstream can be perfectly implemented and the chosen quality can still be wrong.</p>
<hr />
<h2>History-based quality prediction also needs policy awareness</h2>
<p>Adaptive encoders often try to avoid starting every search from scratch.</p>
<p>Suppose previous results suggest:</p>
<pre><code class="language-text">similar images usually pass around q=38
</code></pre>
<p>The next search might start around 38.</p>
<p>That can save substantial encoding work.</p>
<p>But there is a hidden dependency:</p>
<pre><code class="language-text">"usually pass"
</code></pre>
<p>under which policy?</p>
<p>If the historical result was produced with:</p>
<pre><code class="language-text">target 60
floor 58
</code></pre>
<p>and the new source requires:</p>
<pre><code class="language-text">target 65
floor 63
</code></pre>
<p>the history is no longer describing exactly the same problem.</p>
<p>A correct final predicate protects correctness.</p>
<p>The search simply starts too low and climbs.</p>
<p>But efficiency suffers, and debugging becomes harder because old statistics are being mixed with a new acceptance regime.</p>
<p>I therefore prefer versioning the quality policy used by historical predictors.</p>
<p>Something like:</p>
<pre><code class="language-text">sim-v1-60-58
</code></pre>
<p>becoming:</p>
<pre><code class="language-text">sim-v2-source60-58-lossy65-63
</code></pre>
<p>That may look like housekeeping.</p>
<p>In production optimization code, it is part of reproducibility.</p>
<hr />
<h2>The best fix is still avoiding the second lossy generation</h2>
<p>There is a simpler branch in this decision tree.</p>
<p>Suppose I still have:</p>
<pre><code class="language-text">original: 2 MB
</code></pre>
<p>and also:</p>
<pre><code class="language-text">WebP derivative: 100 KB
</code></pre>
<p>Then I do not want:</p>
<pre><code class="language-text">original
   ↓
WebP
   ↓
AVIF
</code></pre>
<p>I want:</p>
<pre><code class="language-text">original
   ↓
AVIF
</code></pre>
<p>That makes the original the reference again.</p>
<p>And now the normal:</p>
<pre><code class="language-text">60 / 58
</code></pre>
<p>policy applies.</p>
<p>A higher threshold on the second generation cannot recover information lost during the first.</p>
<p>This distinction is worth remembering:</p>
<pre><code class="language-text">WebP → AVIF @ 65
</code></pre>
<p>means:</p>
<blockquote>
<p>Preserve the current WebP more faithfully.</p>
</blockquote>
<p>It does <strong>not</strong> mean:</p>
<blockquote>
<p>Recover detail that disappeared before the WebP was created.</p>
</blockquote>
<p>If the information is no longer in the pixels, a perceptual metric cannot bring it back.</p>
<hr />
<h2>Quality should not be the only gate</h2>
<p>Eventually I realized there was another question that had nothing to do with SSIMULACRA2.</p>
<p>Suppose:</p>
<pre><code class="language-text">source WebP = 100 KB
</code></pre>
<p>The AVIF that passes 65/63 is:</p>
<pre><code class="language-text">AVIF = 96 KB
</code></pre>
<p>The conversion passes the quality policy.</p>
<p>But what did I gain?</p>
<pre><code class="language-text">4 KB
</code></pre>
<p>For that I introduced:</p>
<ul>
<li><p>another encode;</p>
</li>
<li><p>another lossy generation;</p>
</li>
<li><p>additional CPU work;</p>
</li>
<li><p>another transformation step;</p>
</li>
<li><p>more pipeline complexity.</p>
</li>
</ul>
<p>That may not be worth it.</p>
<p>Now consider:</p>
<pre><code class="language-text">WebP = 100 KB
AVIF = 65 KB
</code></pre>
<p>while still passing the same perceptual policy.</p>
<p>That is a much more interesting result.</p>
<p>So I now think of the conversion as having <strong>two gates</strong>.</p>
<h3>Quality gate</h3>
<pre><code class="language-text">Is the additional degradation acceptable?
</code></pre>
<h3>Size gate</h3>
<pre><code class="language-text">Is the size reduction meaningful enough
to justify another conversion?
</code></pre>
<p>Passing the first gate does not imply passing the second.</p>
<p>A pipeline could eventually encode that distinction explicitly:</p>
<pre><code class="language-js">const qualityPassed =
  evaluateCandidate(results, allowSingleOutlier)
    .passed;

const bytesSaved =
  sourceBytes - outputBytes;

const savingRatio =
  bytesSaved / sourceBytes;

const sizeWorthIt =
  savingRatio &gt;= MIN_REQUIRED_SAVING;

const keepAvif =
  qualityPassed &amp;&amp; sizeWorthIt;
</code></pre>
<p>I am not claiming one universal <code>MIN_REQUIRED_SAVING</code>.</p>
<p>That depends on the workload.</p>
<p>The useful architectural point is separating the decisions.</p>
<hr />
<h2>What would actually prove whether 65 is the right target?</h2>
<p>A much stronger answer requires true originals.</p>
<p>I would build a representative test corpus and preserve the master for every image.</p>
<p>Then test:</p>
<pre><code class="language-text">A
original → AVIF
target 60
</code></pre>
<pre><code class="language-text">B
original → lossy WebP → AVIF
target 60
</code></pre>
<pre><code class="language-text">C
original → lossy WebP → AVIF
target 63
</code></pre>
<pre><code class="language-text">D
original → lossy WebP → AVIF
target 65
</code></pre>
<pre><code class="language-text">E
original → lossy WebP → AVIF
target 70
</code></pre>
<p>For every final AVIF I would record:</p>
<pre><code class="language-text">final byte size

selected encoder quality

SSIMULACRA2:
WebP → final AVIF

SSIMULACRA2:
true original → final AVIF
</code></pre>
<p>The second metric is the critical one.</p>
<p>It restores the information that is missing when the WebP intermediate becomes the only reference.</p>
<p>I would also inspect difficult cases manually:</p>
<ul>
<li><p>smooth gradients;</p>
</li>
<li><p>fine line art;</p>
</li>
<li><p>small text;</p>
</li>
<li><p>highly textured images;</p>
</li>
<li><p>dark gradients;</p>
</li>
<li><p>saturated color transitions;</p>
</li>
<li><p>already-visible WebP artifacts.</p>
</li>
</ul>
<p>Objective metrics are extremely useful, but visual inspection is still valuable when deciding whether codec settings are appropriate.</p>
<p>I have <strong>not</strong> run this controlled experiment on a sufficiently representative corpus of preserved originals.</p>
<p>That means I cannot honestly say:</p>
<pre><code class="language-text">65 is scientifically optimal.
</code></pre>
<p>I can say:</p>
<pre><code class="language-text">65/63 is my current engineering policy
for known already-lossy derivatives.
</code></pre>
<p>Those are very different claims.</p>
<p>The second one is defensible.</p>
<hr />
<h2>Tests I would write before trusting the implementation</h2>
<p>Once the quality policy became heterogeneous, I stopped thinking of it as a one-line constant change.</p>
<p>I would want explicit boundary tests.</p>
<h3>Clean source at the exact target</h3>
<pre><code class="language-text">target = 60
score  = 60

→ PASS
</code></pre>
<h3>Clean source just below the target</h3>
<pre><code class="language-text">target = 60
score  = 59.99

→ FAIL
</code></pre>
<p>unless it is the one permitted outlier in a collection where outliers are enabled.</p>
<h3>Lossy derivative at the target</h3>
<pre><code class="language-text">target = 65
score  = 65

→ PASS
</code></pre>
<h3>Lossy derivative below target but above floor</h3>
<pre><code class="language-text">target = 65
floor  = 63
score  = 64
</code></pre>
<p>Result:</p>
<pre><code class="language-text">allowed only if:
- single outlier is enabled
- no other sample misses its target
</code></pre>
<h3>Exact lossy floor</h3>
<pre><code class="language-text">score = 63

→ valid outlier boundary
</code></pre>
<h3>Below the lossy floor</h3>
<pre><code class="language-text">score = 62.99

→ FAIL
</code></pre>
<p>regardless of how the other samples perform.</p>
<h3>Mixed-source collection</h3>
<pre><code class="language-text">PNG
score  = 61
target = 60
→ PASS

lossy WebP
score  = 64
target = 65
→ TARGET MISS
</code></pre>
<p>The WebP must be treated as the failing sample even though its raw score is numerically higher than the PNG score.</p>
<p>That single test catches the flaw in raw-score sorting immediately.</p>
<hr />
<h2>The final mental model</h2>
<p>I started with:</p>
<blockquote>
<p>What SSIMULACRA2 number should I use for WebP?</p>
</blockquote>
<p>That was the wrong abstraction.</p>
<p>The better questions are:</p>
<pre><code class="language-text">What is the best available reference?
</code></pre>
<pre><code class="language-text">Has this reference already gone
through lossy compression?
</code></pre>
<pre><code class="language-text">What additional distortion budget
am I willing to spend from here?
</code></pre>
<pre><code class="language-text">Does the actual search predicate
enforce that budget?
</code></pre>
<pre><code class="language-text">Is the resulting byte saving large
enough to justify another encode?
</code></pre>
<p>Those questions produce a much clearer decision tree.</p>
<hr />
<h2>My current rules</h2>
<p>For this type of web-oriented compression workload, my current policy is:</p>
<pre><code class="language-text">BEST AVAILABLE / CANONICAL SOURCE

target = 60
floor  = 58
</code></pre>
<pre><code class="language-text">LOSSLESS WEBP

target = 60
floor  = 58
</code></pre>
<pre><code class="language-text">KNOWN ALREADY-LOSSY DERIVATIVE

target = 65
floor  = 63
</code></pre>
<p>The implementation rules matter just as much:</p>
<pre><code class="language-text">Do not classify solely by extension.
</code></pre>
<pre><code class="language-text">Use provenance when possible.
</code></pre>
<pre><code class="language-text">Apply per-sample thresholds
inside the actual PASS/FAIL predicate.
</code></pre>
<pre><code class="language-text">Do not compare mixed-source samples
against one global second-worst threshold.
</code></pre>
<pre><code class="language-text">Evaluate a score relative
to that sample's own target.
</code></pre>
<pre><code class="language-text">Make adaptive search use
the same predicate as final validation.
</code></pre>
<pre><code class="language-text">Version historical quality data
when the acceptance policy changes.
</code></pre>
<pre><code class="language-text">If the real original exists,
encode from it directly.
</code></pre>
<pre><code class="language-text">If AVIF barely reduces file size,
consider keeping the existing WebP.
</code></pre>
<p>And finally:</p>
<pre><code class="language-text">65/63 is an engineering policy,
not a universal SSIMULACRA2 law.
</code></pre>
<hr />
<h2>The bug was not in the metric</h2>
<p>SSIMULACRA2 was doing exactly what it was supposed to do.</p>
<p>It compared the reference I gave it with the candidate I gave it.</p>
<p>The first mistake was expecting that score to somehow represent quality loss that occurred <strong>before the reference existed</strong>.</p>
<p>The second mistake was more practical: adding a stricter threshold without tracing whether that threshold actually reached the acceptance condition used by the adaptive search.</p>
<p>Both problems came from the same broader engineering mistake:</p>
<p><strong>I was looking at the number without following the data and decision path around it.</strong></p>
<p>A quality score has context.</p>
<p>A threshold has semantics.</p>
<p>And a configuration value only matters if the code that chooses the output actually obeys it.</p>
<p>That is the rule I trust more than any particular SSIMULACRA2 number.</p>
]]></content:encoded></item><item><title><![CDATA[DeepSeek Raised Its API Prices. I Kept V4 Flash and Changed the Infrastructure Instead]]></title><description><![CDATA[A price increase usually makes you ask one question:
What model should I switch to?
That was my first thought too.
On August 17, I checked my DeepSeek API usage and noticed that the balance was disapp]]></description><link>https://sergeisolod.hashnode.dev/deepseek-v4-flash-cheaper-inference</link><guid isPermaLink="true">https://sergeisolod.hashnode.dev/deepseek-v4-flash-cheaper-inference</guid><category><![CDATA[Deepseek]]></category><category><![CDATA[Artificial Intelligence]]></category><category><![CDATA[llm]]></category><category><![CDATA[api]]></category><category><![CDATA[AI infrastructure]]></category><dc:creator><![CDATA[Sergei Solod]]></dc:creator><pubDate>Mon, 17 Aug 2026 14:57:04 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6953c791eb36c2149a16c84b/c87c6479-2bbd-4350-9cdc-597a626b9088.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>A price increase usually makes you ask one question:</p>
<p><strong>What model should I switch to?</strong></p>
<p>That was my first thought too.</p>
<p>On August 17, I checked my DeepSeek API usage and noticed that the balance was disappearing roughly five times faster than I was used to.</p>
<p>I knew a pricing update was coming, but I had mentally filed it away as a normal increase. Maybe 20%. Maybe 50%. Even twice the old price would have been annoying but manageable.</p>
<p>Once I looked at the actual numbers, I realized this was different.</p>
<p>Instead of immediately benchmarking Qwen, GLM, Kimi, MiniMax, Mistral and every other model I could find, I tried to separate two things that I had previously treated as one:</p>
<p><strong>the model I wanted to use</strong></p>
<p>and</p>
<p><strong>the company selling me the compute to run it</strong></p>
<p>That distinction ended up being much more useful than immediately changing models.</p>
<h2>The pricing change that triggered the search</h2>
<p>Before the change, DeepSeek V4 Flash was extremely inexpensive.</p>
<p>The old rates I had been using were:</p>
<pre><code class="language-text">Input, cache miss:  $0.14 / 1M tokens
Cached input:       $0.0028 / 1M tokens
Output:             $0.28 / 1M tokens
</code></pre>
<p>The current pricing for <code>deepseek-v4-flash</code>, corresponding to <code>DeepSeek-V4-Flash-0731</code>, is split between off-peak and peak periods.</p>
<p>Off-peak:</p>
<pre><code class="language-text">Input, cache miss:  $0.22 / 1M tokens
Cached input:       $0.007 / 1M tokens
Output:             $0.66 / 1M tokens
</code></pre>
<p>Peak:</p>
<pre><code class="language-text">Input, cache miss:  $0.44 / 1M tokens
Cached input:       $0.014 / 1M tokens
Output:             $1.32 / 1M tokens
</code></pre>
<p>DeepSeek lists the peak windows as 01:00 to 04:00 UTC and 06:00 to 10:00 UTC.</p>
<p>So when I say my spending appeared to increase by about five times, I am not claiming that every type of token received an identical 5x increase.</p>
<p>The exact change depends on the workload.</p>
<p>At peak pricing:</p>
<pre><code class="language-text">Cache-miss input
$0.14 -&gt; $0.44
≈ 3.14x

Output
$0.28 -&gt; $1.32
≈ 4.71x

Cached input
$0.0028 -&gt; $0.014
= 5x
</code></pre>
<p>For an application that produces a significant amount of output, seeing the real bill move toward five times its previous level is not particularly surprising.</p>
<p>The question was what to do about it.</p>
<h2>Replacing an LLM is not always the simplest engineering decision</h2>
<p>It is easy to say:</p>
<blockquote>
<p>DeepSeek became expensive. Just use another model.</p>
</blockquote>
<p>Technically, I could.</p>
<p>There are plenty of alternatives now.</p>
<p>Qwen, GLM, Kimi, MiniMax, Mistral and others are all worth looking at.</p>
<p>But an LLM is not always interchangeable infrastructure.</p>
<p>Once you have a model working well inside an application, changing it can create a surprising amount of work.</p>
<p>Prompt behavior can change.</p>
<p>The same instruction may produce longer or shorter answers.</p>
<p>Tone can shift.</p>
<p>Sampling parameters may need adjustment.</p>
<p>Context handling can differ.</p>
<p>Edge cases you already solved can return in different forms.</p>
<p>You may save money on tokens and then spend engineering time trying to make the replacement behave like the model you already had.</p>
<p>I was not against changing models.</p>
<p>I simply did not want to start there.</p>
<p>Instead, I asked a narrower question:</p>
<blockquote>
<p><strong>Can I keep the exact same DeepSeek V4 Flash checkpoint and buy the inference somewhere else?</strong></p>
</blockquote>
<p>For an open-weight model, the answer can be yes.</p>
<h2>The model provider and the inference provider do not have to be the same company</h2>
<p>The checkpoint I was interested in is:</p>
<pre><code class="language-text">deepseek-ai/DeepSeek-V4-Flash-0731
</code></pre>
<p>Its weights are publicly available under the MIT License.</p>
<p>That means DeepSeek is not the only company technically capable of running the model.</p>
<p>This sounds obvious when written out, but I had not really been treating API purchasing this way.</p>
<p>My old mental model was basically:</p>
<pre><code class="language-text">My application
      |
      v
DeepSeek API
      |
      v
DeepSeek V4 Flash
</code></pre>
<p>But once the weights are available, the structure can look more like this:</p>
<pre><code class="language-text">                 DeepSeek V4 Flash
                       weights
                          |
          +---------------+---------------+
          |               |               |
          v               v               v
     DeepSeek API       Runware       Other providers
                                           |
                                           v
                                     Their own GPUs
</code></pre>
<p>And of course there is another option:</p>
<pre><code class="language-text">DeepSeek V4 Flash weights
          |
          +--&gt; DeepSeek API
          |
          +--&gt; Runware
          |
          +--&gt; other inference platforms
          |
          +--&gt; private infrastructure
          |
          +--&gt; your own GPUs
</code></pre>
<p>The checkpoint can remain the same while the infrastructure underneath it changes completely.</p>
<p>That was the market I started researching.</p>
<h2>I searched for the checkpoint, not for another chatbot</h2>
<p>This changed how I approached the research.</p>
<p>I was not searching for:</p>
<pre><code class="language-text">cheapest AI API
</code></pre>
<p>or:</p>
<pre><code class="language-text">best DeepSeek alternative
</code></pre>
<p>Those searches mix together completely different models.</p>
<p>Instead, I wanted providers serving the exact checkpoint I was already using.</p>
<p>My research process was fairly repetitive.</p>
<p>First I asked an AI to help me create a detailed research prompt covering major inference providers, smaller GPU platforms, aggregators, regional providers and companies I had never heard of.</p>
<p>Then I ran the research repeatedly.</p>
<p>Usually I do something like <strong>15 to 20 separate searches</strong>, because one search often surfaces the same popular companies again and again.</p>
<p>After that, I combined the results, compared the candidates and removed providers that did not actually expose the checkpoint I wanted.</p>
<p>Only then did I manually inspect the finalists.</p>
<p>That manual step matters.</p>
<p>I do not want an AI-generated ranking to make the final infrastructure decision for me.</p>
<p>For anything that will carry real traffic, I still want to check the provider myself:</p>
<pre><code class="language-text">Exact checkpoint?
Pricing?
API format?
Documentation?
Latency?
Errors?
Actual billing?
</code></pre>
<p>One of the providers that survived that process was Runware.</p>
<h2>Runware had the exact checkpoint I wanted</h2>
<p>The important part was not simply that Runware supported "DeepSeek."</p>
<p>It offered:</p>
<pre><code class="language-text">DeepSeek-V4-Flash-0731
</code></pre>
<p>That was exactly what I was looking for.</p>
<p>I wanted to remove as many variables as possible.</p>
<p>If I changed both the model and the provider at the same time, then any difference in output quality or behavior would be harder to attribute.</p>
<p>Keeping the same checkpoint makes the experiment much cleaner.</p>
<p>At the time I tested it, the Runware rates were:</p>
<pre><code class="language-text">Input:         $0.076 / 1M tokens
Cached input:  $0.014 / 1M tokens
Output:        $0.153 / 1M tokens
</code></pre>
<p>I checked those numbers more than once because the comparison looked strange.</p>
<p>I expected third-party inference to beat DeepSeek's <strong>new</strong> prices.</p>
<p>I did not expect it to beat DeepSeek's <strong>old</strong> prices too.</p>
<h2>It was about 45% cheaper than the old DeepSeek API</h2>
<p>Before the price increase, DeepSeek input cost:</p>
<pre><code class="language-text">$0.14 / 1M
</code></pre>
<p>Runware:</p>
<pre><code class="language-text">$0.076 / 1M
</code></pre>
<p>The difference is:</p>
<pre><code class="language-text">($0.14 - $0.076) / $0.14
≈ 45.7%
</code></pre>
<p>So Runware was roughly <strong>45.7% cheaper for input</strong> than the old DeepSeek rate.</p>
<p>Output showed almost the same pattern.</p>
<p>Old DeepSeek:</p>
<pre><code class="language-text">$0.28 / 1M
</code></pre>
<p>Runware:</p>
<pre><code class="language-text">$0.153 / 1M
</code></pre>
<p>Difference:</p>
<pre><code class="language-text">($0.28 - $0.153) / $0.28
≈ 45.4%
</code></pre>
<p>Again, roughly <strong>45% cheaper</strong>.</p>
<p>That was more interesting to me than the price increase itself.</p>
<p>DeepSeek's old V4 Flash API already felt cheap.</p>
<p>Yet another company was willing to serve the same public checkpoint for considerably less.</p>
<h2>Against the new DeepSeek prices, the difference becomes much larger</h2>
<p>Once the new DeepSeek rates are used as the baseline, the numbers become much more dramatic.</p>
<p>For cache-miss input during off-peak hours:</p>
<pre><code class="language-text">DeepSeek: $0.22
Runware:  $0.076

$0.22 / $0.076 ≈ 2.9x
</code></pre>
<p>For output:</p>
<pre><code class="language-text">DeepSeek: $0.66
Runware:  $0.153

$0.66 / $0.153 ≈ 4.3x
</code></pre>
<p>During peak hours:</p>
<pre><code class="language-text">Input:
$0.44 / $0.076 ≈ 5.8x

Output:
$1.32 / $0.153 ≈ 8.6x
</code></pre>
<p>So for those token categories, the gap can become very large.</p>
<p>There is one important exception.</p>
<p>DeepSeek's off-peak cached input currently costs:</p>
<pre><code class="language-text">$0.007 / 1M
</code></pre>
<p>Runware cached input costs:</p>
<pre><code class="language-text">$0.014 / 1M
</code></pre>
<p>In that specific case, DeepSeek is cheaper.</p>
<p>This is why I would not choose an inference provider based on one headline number.</p>
<p><strong>Your actual token mix matters.</strong></p>
<p>A workload dominated by cache hits can have very different economics from a conversational application producing lots of fresh output.</p>
<h2>Price was only the first test</h2>
<p>Finding a cheap endpoint is easy.</p>
<p>Finding a cheap endpoint I actually want to send traffic to is harder.</p>
<p>A provider can advertise extremely low token prices and still be useless if requests time out, capacity disappears or the API constantly returns errors.</p>
<p>So after finding Runware, I connected it and started sending real requests.</p>
<p>One of my early dashboard snapshots looked like this:</p>
<pre><code class="language-text">790 total requests
56 successful
734 errors
</code></pre>
<p>That obviously looks terrible.</p>
<p>Later I captured another state:</p>
<pre><code class="language-text">1,570 total requests
836 successful
734 errors
</code></pre>
<p>The difference between those two snapshots is what interested me:</p>
<pre><code class="language-text">+780 total requests
+780 successful requests
+0 additional errors
</code></pre>
<p>I want to be precise about what that means.</p>
<p>It does <strong>not</strong> prove perfect reliability.</p>
<p>It does not prove 99.999% uptime.</p>
<p>And those numbers alone do not explain why the original 734 requests failed.</p>
<p>I am deliberately not pretending they do.</p>
<p>The only conclusion I can draw from those two snapshots is that the next 780 requests increased the success counter by 780 while the error counter stayed exactly where it was.</p>
<p>I continued testing after that.</p>
<p>By the time I was writing this, I had already passed <strong>more than 1,000 successful AI requests</strong> through the Runware endpoint.</p>
<p>For a first day of testing, that was enough for me to continue.</p>
<p>Not enough to call the provider perfect.</p>
<p>Enough to keep using it.</p>
<h2>Latency did not become the tradeoff I expected</h2>
<p>Normally, when I see dramatically cheaper infrastructure, I expect to pay for it somewhere else.</p>
<p>Latency is an obvious place to look.</p>
<p>In my testing, that has not been an obvious problem so far.</p>
<p>The successful requests I inspected generally completed in fractions of a second to around 1.5 seconds.</p>
<p>Some short requests were billed at approximately:</p>
<pre><code class="language-text">$0.000005
</code></pre>
<p>to:</p>
<pre><code class="language-text">$0.000018
</code></pre>
<p>per request.</p>
<p>After hundreds of successful requests, the dashboard was still showing spending around one cent.</p>
<p>That was the point where the experiment became convincing enough for me personally.</p>
<p>I was no longer comparing two pricing pages.</p>
<p>I had real requests going through the endpoint.</p>
<p>I had responses returning.</p>
<p>I could inspect the latency.</p>
<p>And I could see the actual cost accumulating.</p>
<h2>The interesting part is bigger than Runware</h2>
<p>The most interesting thing here is not really one provider.</p>
<p>It is what open weights do to the inference market.</p>
<p>DeepSeek pays to develop a model.</p>
<p>Then the model weights become available under a permissive license.</p>
<p>At that point, other infrastructure companies can compete to run the same model.</p>
<p>They can compete on:</p>
<pre><code class="language-text">GPU cost
GPU utilization
batching
serving software
capacity
networking
geography
margins
latency
reliability
pricing
</code></pre>
<p>DeepSeek may have created the model, but another company may be able to operate the infrastructure more cheaply for a particular workload.</p>
<p>That creates an unusual competitive situation.</p>
<p>DeepSeek can effectively end up competing for inference revenue against companies running <strong>DeepSeek's own checkpoint</strong>.</p>
<p>When official V4 Flash inference cost $0.14 for input and $0.28 for output, that competition was easy to ignore.</p>
<p>The official API was already extremely cheap.</p>
<p>But when peak pricing becomes $0.44 for input and $1.32 for output while another provider offers the same checkpoint at $0.076 and $0.153, the infrastructure market becomes much more relevant.</p>
<p>This is also why I do not think of a provider like Runware as simply reselling DeepSeek API calls.</p>
<p>If a company is running the public weights on separate infrastructure, then the product being sold is the inference layer.</p>
<p>The model came from DeepSeek.</p>
<p>The compute came from somebody else.</p>
<p>Those can now be purchased independently.</p>
<h2>Did DeepSeek make a mistake by releasing the weights?</h2>
<p>I do not think the answer is obvious.</p>
<p>There is an easy argument to make:</p>
<blockquote>
<p>DeepSeek created competitors for its own API business.</p>
</blockquote>
<p>In a narrow sense, yes.</p>
<p>But that is only one side of it.</p>
<p>Making a model broadly deployable also makes it easier for the model to spread.</p>
<p>Developers integrate it.</p>
<p>Researchers use it.</p>
<p>Cloud platforms support it.</p>
<p>Inference companies optimize it.</p>
<p>Businesses can deploy it privately.</p>
<p>Tooling gets built around it.</p>
<p>The model becomes available in environments the original API provider might never serve directly.</p>
<p>That ecosystem has value too.</p>
<p>So I am not claiming that releasing V4 Flash was a bad business decision.</p>
<p>I think the more defensible conclusion is simpler:</p>
<p><strong>Once model weights are publicly available under a permissive license, inference becomes a competitive infrastructure market.</strong></p>
<p>The company that trained the model is no longer necessarily the cheapest company to run it.</p>
<p>When the official API is already cheap, developers may not care.</p>
<p>When the official API gets significantly more expensive, they start looking around.</p>
<p>I did.</p>
<h2>Why I did not just self-host the checkpoint</h2>
<p>Once you accept that the model and infrastructure can be separated, there is an obvious next question:</p>
<p>Why pay an inference provider at all?</p>
<p>Why not download the weights and serve the model yourself?</p>
<p>Because "the weights are available" and "inference is cheap to operate" are completely different statements.</p>
<p>DeepSeek's own deployment material includes an example using <strong>four GB300 GPUs on a single node</strong>.</p>
<p>And hardware is only the beginning.</p>
<p>A production deployment also has to deal with:</p>
<pre><code class="language-text">VRAM
model loading
inference engines
batching
KV cache
request concurrency
monitoring
networking
failover
capacity planning
power
cooling
maintenance
GPU utilization
</code></pre>
<p>The last point matters a lot.</p>
<p>Buying expensive hardware is one thing.</p>
<p>Keeping that hardware busy enough to justify its cost is another.</p>
<p>An inference provider can distribute those costs across many customers.</p>
<p>My own workload cannot do that nearly as efficiently.</p>
<p>If somebody is willing to serve the model to me for:</p>
<pre><code class="language-text">$0.076 / 1M input tokens
</code></pre>
<p>then owning the GPUs myself may be a much worse business decision, even though technically I could run the model.</p>
<p>At sufficiently large scale the calculation changes.</p>
<p>For me, it has not reached that point.</p>
<h2>The API layer became interchangeable before the model did</h2>
<p>This is the biggest change in how I think about LLM infrastructure after this experiment.</p>
<p>Previously I implicitly treated a model API as one product:</p>
<pre><code class="language-text">DeepSeek model + DeepSeek API = DeepSeek
</code></pre>
<p>Now I separate it into layers:</p>
<pre><code class="language-text">Model
  |
Checkpoint
  |
Inference engine
  |
GPU infrastructure
  |
API
</code></pre>
<p>If the model is closed, those layers may effectively be bundled together.</p>
<p>With an open-weight model, they do not have to be.</p>
<p>That means an application can sometimes change several infrastructure layers while leaving the actual model alone.</p>
<p>From an engineering perspective, that is useful.</p>
<p>Instead of doing this:</p>
<pre><code class="language-text">Model migration
        +
Prompt retesting
        +
Behavior changes
        +
New edge cases
</code></pre>
<p>you may be able to do this:</p>
<pre><code class="language-text">Same checkpoint
        +
Different inference provider
</code></pre>
<p>That is a much smaller migration surface.</p>
<h2>This is not a sponsored article</h2>
<p>I want to make this explicit because I have mentioned Runware throughout the article.</p>
<p><strong>This is not an advertisement.</strong></p>
<p>Runware did not contact me.</p>
<p>I did not contact Runware before testing the service.</p>
<p>Nobody asked me to write this article.</p>
<p>There is no affiliate agreement.</p>
<p>There is no referral payment.</p>
<p>There is no discount code.</p>
<p>There were no free credits in exchange for publishing this.</p>
<p>I found the provider while researching alternatives, created an account, added my own money and connected the API.</p>
<p>That is the entire relationship.</p>
<p>I have also only been testing it for <strong>one day</strong>.</p>
<p>A day is enough to form a first impression.</p>
<p>It is not enough to establish long-term reliability.</p>
<p>Something could break next week.</p>
<p>Pricing could change.</p>
<p>Performance could degrade.</p>
<p>Another provider could become cheaper.</p>
<p>If that happens, I have no problem switching again.</p>
<p>I am not trying to find an inference provider to be loyal to.</p>
<p>I am trying to buy reliable compute at a reasonable price.</p>
<p>For now, my experience is simply this:</p>
<pre><code class="language-text">More than 1,000 successful AI requests
Good latency in my testing
Very low token cost
No new errors during the measured 780-request window
One day of real usage
</code></pre>
<p>That is what I know.</p>
<p>Anything beyond that would be speculation.</p>
<h2>The numbers that changed my approach</h2>
<p>For me, these three sets of prices summarize the entire experiment.</p>
<p>Old DeepSeek V4 Flash:</p>
<pre><code class="language-text">Input:  $0.14 / 1M
Output: $0.28 / 1M
</code></pre>
<p>New DeepSeek V4 Flash peak pricing:</p>
<pre><code class="language-text">Input:  $0.44 / 1M
Output: $1.32 / 1M
</code></pre>
<p>Runware when I tested the same checkpoint:</p>
<pre><code class="language-text">Input:  $0.076 / 1M
Output: $0.153 / 1M
</code></pre>
<p>The exact winner will depend on caching, traffic patterns, latency requirements and how the providers change their pricing over time.</p>
<p>But I no longer think the right first question for an open-weight model is:</p>
<blockquote>
<p>Who created this model?</p>
</blockquote>
<p>The next question matters just as much:</p>
<blockquote>
<p><strong>Who can run this exact checkpoint well and cheaply?</strong></p>
</blockquote>
<p>From now on, that is how I plan to evaluate open-weight APIs.</p>
<p>First identify the model.</p>
<p>Then identify the exact checkpoint.</p>
<p>Then find the companies serving it.</p>
<p>Compare the token economics.</p>
<p>Send actual requests.</p>
<p>Measure errors and latency.</p>
<p>And only then decide where the inference should run.</p>
<p>In this case, that approach let me keep DeepSeek V4 Flash while paying substantially less for the compute behind it.</p>
<p>And I did not have to change the model at all.</p>
]]></content:encoded></item><item><title><![CDATA[I Built a Next.js Blog in 21 Languages: Here’s What Happened After the First Week]]></title><description><![CDATA[I recently set myself a personal challenge: build a technical blog that supports 21 languages from day one using Next.js.
The goal was not simply to publish articles in multiple languages. I wanted to]]></description><link>https://sergeisolod.hashnode.dev/i-built-a-next-js-blog-in-21-languages-here-s-what-happened-after-the-first-week</link><guid isPermaLink="true">https://sergeisolod.hashnode.dev/i-built-a-next-js-blog-in-21-languages-here-s-what-happened-after-the-first-week</guid><category><![CDATA[Next.js]]></category><category><![CDATA[SEO]]></category><category><![CDATA[internationalization]]></category><category><![CDATA[Web Development]]></category><category><![CDATA[Build In Public]]></category><dc:creator><![CDATA[Sergei Solod]]></dc:creator><pubDate>Mon, 12 Jan 2026 14:14:46 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6953c791eb36c2149a16c84b/70b8b306-e526-4e26-94bc-05b43c9188a1.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>I recently set myself a personal challenge: build a technical blog that supports <strong>21 languages from day one using Next.js</strong>.</p>
<p>The goal was not simply to publish articles in multiple languages. I wanted to see how a multilingual site behaves in the real world, how quickly search engines discover localized pages, how indexing scales when every article creates dozens of URLs, and where the practical limits start to appear.</p>
<p>It has now been one week since launch, and the first search data is starting to come in.</p>
<p>The numbers are still small, but they are already useful.</p>
<h2>Why I chose Next.js</h2>
<p>I built the project with <strong>Next.js</strong> because it gave me a solid foundation for internationalized routing, server-side rendering, static generation, reusable metadata, and a large number of localized pages.</p>
<p>For a multilingual site, the technical structure matters a lot.</p>
<p>Every language needs a stable URL. Pages need to be crawlable. Metadata has to be correct. Search engines need clear signals about which page belongs to which language.</p>
<p>Next.js made that part of the project much easier to manage.</p>
<p>That does not mean Next.js automatically improves rankings or makes Google index pages faster. It simply gives me the tools to build the structure properly.</p>
<p>Search engines still decide what to crawl, what to index, and what deserves to appear in search results.</p>
<h2>The first week in Google</h2>
<p>After the first week, Google Search Console showed:</p>
<ul>
<li><p><strong>439 impressions</strong></p>
</li>
<li><p><strong>5 clicks</strong></p>
</li>
</ul>
<p>Yandex also started discovering the site and generating some visibility.</p>
<p>Five clicks is obviously not a meaningful amount of traffic yet.</p>
<p>What interested me more was the number of impressions.</p>
<p>For a new personal project, seeing hundreds of search impressions after only a week confirmed that Google had already started discovering and testing pages from the site in search results.</p>
<p>The multilingual structure also means those impressions are not limited to a single language.</p>
<p>That is one of the main reasons I wanted to run this experiment in the first place.</p>
<h2>21 languages create a lot of URLs</h2>
<p>The interesting part of multilingual publishing is how quickly everything scales.</p>
<p>One article does not create one page.</p>
<p>On this site, one article can create <strong>21 separate localized URLs</strong>.</p>
<p>That means:</p>
<ul>
<li><p>5 articles can become 105 pages</p>
</li>
<li><p>10 articles can become 210 pages</p>
</li>
<li><p>20 articles can become 420 pages</p>
</li>
</ul>
<p>The number of URLs grows much faster than the number of articles.</p>
<p>That gives search engines more possible entry points, but it also creates more work for crawling, indexing, internal linking, metadata management, and language targeting.</p>
<p>More pages do not automatically mean more traffic.</p>
<p>They simply create more opportunities to appear for different searches in different languages.</p>
<h2>Indexing is slower than discovery</h2>
<p>A few weeks into the project, the indexing numbers looked like this:</p>
<ul>
<li><p><strong>436 pages indexed</strong></p>
</li>
<li><p><strong>35 pages not indexed yet</strong></p>
</li>
</ul>
<p>So most of the pages in that snapshot had already entered Google's index, but some were still waiting.</p>
<p>This is where the difference between discovery and indexing becomes very noticeable.</p>
<p>My <code>sitemap.xml</code> is generated automatically and submitted through <strong>Google Search Console</strong>.</p>
<p>That helps Google discover the URLs, but submitting a sitemap does not mean every page will be indexed immediately.</p>
<p>Google can know that a URL exists without adding it to the index.</p>
<p>An indexed page can also sit there without receiving meaningful impressions or clicks.</p>
<p>These are separate stages, and I found it much more useful to track them separately instead of treating everything as one SEO metric.</p>
<h2>Multilingual SEO also multiplies mistakes</h2>
<p>There is another side to publishing in 21 languages.</p>
<p>If something is wrong, the problem can also scale very quickly.</p>
<p>A mistake in:</p>
<ul>
<li><p>canonical tags</p>
</li>
<li><p><code>hreflang</code></p>
</li>
<li><p>metadata</p>
</li>
<li><p>internal links</p>
</li>
<li><p>translations</p>
</li>
<li><p>URL structure</p>
</li>
</ul>
<p>can potentially affect many localized pages at once.</p>
<p>With a normal blog, one bad configuration might affect one article.</p>
<p>With 21 language versions, the same mistake can be repeated across 21 URLs.</p>
<p>That makes testing and validation much more important as the site grows.</p>
<h2>What the first week actually tells me</h2>
<p>I do not think 439 impressions and 5 clicks prove that multilingual SEO is already a success.</p>
<p>It is far too early for that.</p>
<p>What the numbers do show is that search engines are discovering the site, indexing a large percentage of its pages, and already showing some of them in search results.</p>
<p>For me, that is enough to make the experiment interesting.</p>
<p>The next question is much more important:</p>
<p><strong>Which languages and articles will continue receiving impressions and eventually turn them into consistent organic traffic?</strong></p>
<p>That is something only more data can answer.</p>
<h2>What I learned so far</h2>
<p>The biggest lesson is that multilingual publishing creates search opportunities very quickly, but visibility, indexing, rankings, and traffic are completely different things.</p>
<p>A page can be discovered without being indexed.</p>
<p>It can be indexed without ranking.</p>
<p>It can rank occasionally without receiving clicks.</p>
<p>And a language that generates impressions during the first week may not necessarily become one of the site's strongest sources of traffic later.</p>
<p>So instead of looking at one big number, I am now paying attention to each stage separately.</p>
<p>For a project that started mainly as a technical challenge, that has probably been the most useful part of the experiment so far.</p>
<p>You can check out the project here:</p>
<p><strong><a href="https://jsvar.com/en/blog">https://jsvar.com/en/blog</a></strong></p>
]]></content:encoded></item></channel></rss>