<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Ali Alp</title>
    <description>The latest articles on DEV Community by Ali Alp (@alialp).</description>
    <link>https://dev.to/alialp</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F75246%2F73159765-d92c-4500-bf44-2ccb5ddca661.jpg</url>
      <title>DEV Community: Ali Alp</title>
      <link>https://dev.to/alialp</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/alialp"/>
    <language>en</language>
    <item>
      <title>Your GPUs Are Lying to You: The Brutal Economics of AI on Kubernetes</title>
      <dc:creator>Ali Alp</dc:creator>
      <pubDate>Tue, 15 Sep 2026 17:19:24 +0000</pubDate>
      <link>https://dev.to/alialp/your-gpus-are-lying-to-you-the-brutal-economics-of-ai-on-kubernetes-id6</link>
      <guid>https://dev.to/alialp/your-gpus-are-lying-to-you-the-brutal-economics-of-ai-on-kubernetes-id6</guid>
      <description>&lt;p&gt;Two numbers, same card, same moment:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;100%&lt;/strong&gt; and &lt;strong&gt;0.76%&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;One of these numbers represents what you are charged for, while the other represents what you should be charged for.&lt;/p&gt;

&lt;p&gt;The first number is &lt;code&gt;GPU utilization&lt;/code&gt;, which is the figure displayed on your dashboard. The second number is &lt;code&gt;SM Active&lt;/code&gt;, indicating the actual portion of the chip that was in use. Both numbers were obtained from an H100 on 9 September 2026, at the same moment.&lt;/p&gt;

&lt;p&gt;Which one of the two do you think the bill follows? Hold that question in mind. I come back to it at the end.&lt;/p&gt;

&lt;h2&gt;
  
  
  I rented an H100 to verify my hypothesis
&lt;/h2&gt;

&lt;p&gt;In July, I wrote a talk description stating: &lt;em&gt;the bill arrives, three times higher than expected, and no one can explain the discrepancy.&lt;/em&gt; In September, I rented a card to see if I could justify that statement.&lt;/p&gt;

&lt;p&gt;I rented it three times, for approximately four hours, at a cost of &lt;strong&gt;$18.71 plus German VAT&lt;/strong&gt;. This was the most affordable part of the entire project. The invoice accurately records the duration of my rental, but it has no knowledge of how I utilized the card. Keep that receipt in mind.&lt;/p&gt;

&lt;p&gt;Prior to renting the H100, I conducted a simple test on the gaming GPU in my own PC: running one thread on a single core out of 36. The gauge indicated that it was 100% busy. That moment confirmed that I had a talk worth giving.&lt;/p&gt;

&lt;p&gt;Let me introduce you to Nadia. She manages GPUs that someone else pays for and has done everything correctly: benchmarked the model, sized the fleet accordingly, and her dashboard has consistently shown utilization of 90% or higher. She is not careless; she represents us.&lt;/p&gt;

&lt;p&gt;A GPU can be thought of as a truck that is rented on an hourly basis. The term "busy" refers to the truck being in use, or on the road, and it never means the truck is full. When benchmarks are run, the trucks are utilized to their full capacity. In production, the trucks are not run full. As a result, the fleet of trucks needs to be tripled in size, prompting the person responsible for finances to inquire: you previously stated that the trucks were 90% busy, so why have we just purchased three times the number of trucks we already had?&lt;/p&gt;

&lt;p&gt;There are three lies that stand between that dashboard and this question.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lie #1 — the metric
&lt;/h2&gt;

&lt;p&gt;What exactly does GPU utilization measure? NVIDIA has provided the answer for us:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Percent of time over the past sample period during which one or more kernels was executing on the GPU.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That's the documentation for nvidia-smi, which describes the NVML field (&lt;code&gt;nvmlUtilization_t.gpu&lt;/code&gt;) that DCGM exports under the name &lt;code&gt;DCGM_FI_DEV_GPU_UTIL&lt;/code&gt;. The time between samples ranges from one second to a sixth of a second, depending on the specific product.&lt;/p&gt;

&lt;p&gt;One. Or more.&lt;/p&gt;

&lt;p&gt;The H100 features 132 SMs, 80 GB of memory, and a very wide bus. Having one thread active on a single SM will show 100% utilization, and the same result is seen when the entire chip is fully utilized. This metric doesn't measure the extent of utilization; it's simply a yes or no inquiry that NVIDIA performs several times per second.&lt;/p&gt;

&lt;p&gt;So I misled it by running one thread on 1 out of 132 SMs — which is less than one percent of the chip's capacity:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;GPU_UTIL&lt;/th&gt;
&lt;th&gt;SM Active&lt;/th&gt;
&lt;th&gt;Tensor&lt;/th&gt;
&lt;th&gt;MFU&lt;/th&gt;
&lt;th&gt;tok/s&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;spin loop&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;100%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0.76%&lt;/td&gt;
&lt;td&gt;~0&lt;/td&gt;
&lt;td&gt;~0&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;0.76% is precisely equivalent to 1/132. The dashboard provides measurements &lt;em&gt;on the road&lt;/em&gt;. Everyone interprets it as &lt;em&gt;full&lt;/em&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Fine, run something serious
&lt;/h3&gt;

&lt;p&gt;That's a fair objection. A spinning thread is merely a toy. So, I'm using a 7B model on vLLM, handling one request at a time, on the same rented card. I say to it "Hi".&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;GPU_UTIL&lt;/th&gt;
&lt;th&gt;SM Active&lt;/th&gt;
&lt;th&gt;Tensor&lt;/th&gt;
&lt;th&gt;DRAM&lt;/th&gt;
&lt;th&gt;MFU&lt;/th&gt;
&lt;th&gt;tok/s&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;batch 1&lt;/td&gt;
&lt;td&gt;90%&lt;/td&gt;
&lt;td&gt;54.59%&lt;/td&gt;
&lt;td&gt;1.62%&lt;/td&gt;
&lt;td&gt;62.3%&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.23%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;148&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The GPU utilization is at 90%, with only 0.23% of its capacity being used for actual work.&lt;/p&gt;

&lt;p&gt;This is because, in order to generate a single word, the chip must access the entire 15 GB model. This process is repeated for every word, and it's equivalent to taking a full trip to the loading dock for just one envelope.&lt;/p&gt;

&lt;p&gt;Performing two operations per weight across 7.62 billion weights results in 15.2 GFLOP per token, which translates to approximately 15 microseconds of mathematical computation. However, reading 15.2 GB of weights at a rate of 2.2 TB/s takes 6.8 milliseconds. The math units are only busy for 0.015 ms out of the total 6.8 ms. This accounts for the 0.23% of the GPU's capacity being utilized for actual computations. An H100 requires around 295 operations per byte read to become compute-bound, and batch-1 decode uses each byte exactly once.&lt;/p&gt;

&lt;p&gt;This limitation is rooted in physics, rather than inefficient coding. The fix isn't a bigger GPU, but rather optimizing the process to handle more envelopes per trip.&lt;/p&gt;

&lt;h3&gt;
  
  
  Batch 64
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;GPU_UTIL&lt;/th&gt;
&lt;th&gt;SM Active&lt;/th&gt;
&lt;th&gt;Tensor&lt;/th&gt;
&lt;th&gt;DRAM&lt;/th&gt;
&lt;th&gt;MFU&lt;/th&gt;
&lt;th&gt;tok/s&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;batch 1&lt;/td&gt;
&lt;td&gt;90%&lt;/td&gt;
&lt;td&gt;54.59%&lt;/td&gt;
&lt;td&gt;1.62%&lt;/td&gt;
&lt;td&gt;62.3%&lt;/td&gt;
&lt;td&gt;0.23%&lt;/td&gt;
&lt;td&gt;148&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;batch 64&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;85%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;62.07%&lt;/td&gt;
&lt;td&gt;11.18%&lt;/td&gt;
&lt;td&gt;57.4%&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;11.6%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;7,485&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;From 148 to 7,485 tokens per second, the workload is fifty times greater.&lt;/p&gt;

&lt;p&gt;The lie-number actually decreased, dropping from 89% to 85%.&lt;/p&gt;

&lt;p&gt;The trip and fuel remained the same, with sixty-four envelopes. The MFU shifted from 0.23% to 11.6%, which is a desirable number for inference, as the literature suggests a healthy inference workload falls within the 5–15% range, representative of a fully loaded truck, unlike most dashboards that describe an empty one.&lt;/p&gt;

&lt;p&gt;SM Active only increases from 54.59% to 62.07% as the workload rises fifty times. Even the second-most honest metric is nearly oblivious to batching. DCGM's own documentation says why:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Note that "active" does not necessarily mean a warp is actively computing. For instance, warps waiting on memory requests are considered active.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;SM Active catches the spin loop. For inference, you need to measure Tensor Core activity and MFU.&lt;/p&gt;

&lt;h3&gt;
  
  
  The fields that don't lie
&lt;/h3&gt;

&lt;p&gt;These fields are included with DCGM, but you won't find them on the standard display. Consider a kitchen in a restaurant:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;DCGM_FI_PROF_SM_ACTIVE&lt;/code&gt; — are cooks present at their workstations?&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;DCGM_FI_PROF_SM_OCCUPANCY&lt;/code&gt; — what's the workload for each cook in terms of concurrent tasks?&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;DCGM_FI_PROF_PIPE_TENSOR_ACTIVE&lt;/code&gt; — is the specialized cooking equipment in use?&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;DCGM_FI_PROF_DRAM_ACTIVE&lt;/code&gt; — how frequently are cooks accessing the pantry?&lt;/li&gt;
&lt;li&gt;MFU — the ratio of actual meals served to the kitchen's theoretical hourly capacity.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Position these next to the misleading information:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# dcgm-counters.csv
# --- The lie ---
DCGM_FI_DEV_GPU_UTIL,      gauge, GPU utilization (in %) — fraction of time at least one kernel was running.
# --- The truth (Datacenter Profiling fields; need --cap-add SYS_ADMIN and a datacenter GPU) ---
DCGM_FI_PROF_SM_ACTIVE,          gauge, Ratio of cycles an SM has at least 1 warp assigned.
DCGM_FI_PROF_SM_OCCUPANCY,       gauge, Ratio of number of warps resident on an SM.
DCGM_FI_PROF_PIPE_TENSOR_ACTIVE, gauge, Ratio of cycles the tensor (HMMA) pipe is active.
DCGM_FI_PROF_DRAM_ACTIVE,        gauge, Ratio of cycles the device memory interface is active.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;MFU is the metric that equates to financial cost: the actual work performed, divided by the theoretical work the GPU could perform within the same hour.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The rule.&lt;/strong&gt; If GPU utilization exceeds 80%:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;SM Active is below 10% → no work is being executed.&lt;/li&gt;
&lt;li&gt;SM Active is high, but MFU is under 5% → a process is running, however it is not performing computations.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In terms of business: financial losses are being incurred.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lie #2 — the scheduler
&lt;/h2&gt;

&lt;p&gt;NVIDIA is not the only one in this situation. Kubernetes has also provided its response on the "Schedule GPUs" page:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Containers (and Pods) do not share GPUs. There's no overcommitting of GPUs. Each container can request one or more GPUs. It is not possible to request a fraction of a GPU.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The two sentences about sharing were later dropped from the page. The behaviour remained unchanged.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;limits&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;nvidia.com/gpu&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;     &lt;span class="c1"&gt;# integer. request == limit. no fractions. no overcommit.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A whole number. A pod that requires 1% of a card takes the card.&lt;/p&gt;

&lt;p&gt;Observe the process. &lt;code&gt;pod-a&lt;/code&gt; runs one spinning thread and gets the GPU. &lt;code&gt;pod-b&lt;/code&gt; is an exact duplicate and requests one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;pod-a   Running
pod-b   Pending    0/1 nodes are available: Insufficient nvidia.com/gpu
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The node indicates that &lt;code&gt;nvidia.com/gpu 1/1&lt;/code&gt; is allocated. The card is utilized at 1% busy.&lt;/p&gt;

&lt;p&gt;It is 99% unutilized and 100% unavailable. One envelope has booked the entire truck.&lt;/p&gt;

&lt;p&gt;Kubernetes did not misallocate the card's resources; it blocked the card instead. The fix involves setting one configuration flag:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# device-plugin time-slicing config&lt;/span&gt;
&lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;v1&lt;/span&gt;
&lt;span class="na"&gt;flags&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;migStrategy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;none&lt;/span&gt;
&lt;span class="na"&gt;sharing&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;timeSlicing&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;renameByDefault&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
    &lt;span class="na"&gt;failRequestsGreaterThanOne&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
    &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;nvidia.com/gpu&lt;/span&gt;
        &lt;span class="na"&gt;replicas&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;4&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The README for the device plugin explains precisely what it does:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The NVIDIA device plugin allows oversubscription of GPUs through a set of extended options in its configuration file. […] If this configuration were applied to a node with 8 GPUs on it, the plugin would now advertise 80 &lt;code&gt;nvidia.com/gpu&lt;/code&gt; resources to Kubernetes instead of 8.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The node is now advertising &lt;code&gt;nvidia.com/gpu: 4&lt;/code&gt;. The status of &lt;code&gt;pod-b&lt;/code&gt; changes from Pending to Running in about ten seconds. The card and pod are the same, with no other changes made.&lt;/p&gt;

&lt;h3&gt;
  
  
  Three honest ways to share a card
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Time-slicing.&lt;/strong&gt; Workloads alternate. There is no isolation - one workload can cause delays for the others, and there is nothing to prevent this. This method is suitable for development and bursty work.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;MPS.&lt;/strong&gt; Multiple workloads share one context. Since there are no barriers, a single problematic tenant can affect all others. This option should not be oversold.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;MIG.&lt;/strong&gt; Actual barriers are implemented in hardware. This method is rigid, requiring the node to be drained to re-partition, and it is limited to A100/H100-class only. On the card I rented, it was divided into seven slices, and the node advertised them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;nvidia.com/mig-1g.10gb&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;7&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;All three options require explicit enrollment. The standard configuration is one pod and one card.&lt;/p&gt;

&lt;p&gt;The direction of this development is as follows: DRA became generally available in Kubernetes 1.34, while partitionable devices and consumable capacity remain in beta, and NVIDIA contributed its DRA driver to CNCF at KubeCon EU 2026, indicating the path forward, although not for the current quarter.&lt;/p&gt;

&lt;p&gt;In commercial terms, by default, you are buying it twice. Kubernetes is concerned with the number of trucks, without examining their contents.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lie #3 — the bill
&lt;/h2&gt;

&lt;p&gt;This particular statement is factual, which poses a problem.&lt;/p&gt;

&lt;p&gt;Recall the invoice? My longest test lasted 2 hours 30 minutes, utilizing one H100, and cost &lt;strong&gt;$11.01&lt;/strong&gt;, which translates to $4.41 per hour.&lt;/p&gt;

&lt;p&gt;Cloud billing calculations are based on the duration and cost. There is no provision for accounting for actual usage.&lt;/p&gt;

&lt;p&gt;Consider the batch-1 hour. The dashboard reported 90% GPU utilization. However, the genuine metric for the same hour was 0.23% MFU.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$4.41 per hour
= $0.01 that did work
+ $4.40 that bought idle silicon     (99.8%)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One H100 operating continuously costs approximately $3,200 per month. For batch 1, this translates to &lt;strong&gt;$7 spent on actual work and $3,193 spent on unused processing power&lt;/strong&gt;. This is what the extra GPUs would buy. Now, multiply this by the number of cards you have.&lt;/p&gt;

&lt;p&gt;Here is the detailed version of the billed hour, recorded from 15:45–16:45 UTC, which includes half of batch 1 and half of batch 64.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"the_lie"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"gpu_util_pct_avg"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;86.2&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="nl"&gt;"the_truth"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"sm_active_pct_avg"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;57.9&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"tensor_active_pct_avg"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;6.8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"mfu_pct"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;5.89&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="nl"&gt;"the_bill"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"cost_usd"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;4.42&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"cost_that_did_work_usd"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;0.26&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
               &lt;/span&gt;&lt;span class="nl"&gt;"cost_of_idle_silicon_usd"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;4.16&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"idle_pct"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;94.1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
               &lt;/span&gt;&lt;span class="nl"&gt;"tokens_generated"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;13791321&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"usd_per_1m_tokens"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;0.32&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;86% is displayed on the dashboard. The MFU is 5.89%. Of that hour, $0.26 was spent on actual work and $4.16 was spent on idle silicon. I conducted this measurement twice, using two separate droplets, and obtained 5.9% both times.&lt;/p&gt;

&lt;p&gt;You might argue that I'm not configuring them correctly, or that your workload is unique and you have a better understanding of how to optimize resources.&lt;/p&gt;

&lt;p&gt;Maybe. However, consider Cast AI's report, which covers the period from January 2025 to April 2026 and encompasses approximately 23,000 Kubernetes clusters. The average GPU utilization is &lt;strong&gt;5%&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;This figure is based on the dashboard number, and the actual values are even lower.&lt;/p&gt;

&lt;h3&gt;
  
  
  Which metric does the bill follow?
&lt;/h3&gt;

&lt;p&gt;Neither of them.&lt;/p&gt;

&lt;p&gt;The bill is based on hours, not work. It provides an honest but useless measure, as it only indicates what was spent without revealing what was achieved.&lt;/p&gt;

&lt;p&gt;In business terms, it is impossible to prove that any of the expenses were justified.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why does this issue persist in 2026?
&lt;/h2&gt;

&lt;p&gt;The metric is not flawed; it is simply answering a question from 2007.&lt;/p&gt;

&lt;p&gt;At that time, a GPU could only run one task at a time, and determining whether it was busy was the only relevant consideration. However, as the chip evolved to 132 cores, the question was never updated.&lt;/p&gt;

&lt;p&gt;Kubernetes counts entire units as a single entity for a similar reason: the fact that GPUs lack a native operating system. CPUs have had sixty years of development in schedulers, preemption, and isolation, while GPUs have only had a driver. Time-sharing was introduced in 1961, but hardware partitioning on a GPU was not available until 2020. Time-slicing, MPS, and MIG are essentially the missing OS, arriving late to the scene.&lt;/p&gt;

&lt;p&gt;While the CPU has spent fifty years evolving, the GPU has been primarily focused on gaming.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to do on Monday
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Plot MFU alongside GPU utilization on the same dashboard, on the same panel if you can.&lt;/strong&gt; The instant the two lines diverge, you will have a specific value to take action on.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Scale on queue depth, never on utilization.&lt;/strong&gt; The utilization level is 90% regardless of whether the truck is full or empty, which means autoscaling based on this metric will either result in perpetual scaling or none at all. Using KEDA with &lt;code&gt;vllm:num_requests_waiting&lt;/code&gt; adds a truck only when there are actually envelopes waiting, ensuring that the trucks you are already paying for are utilized to their full capacity first. If autoscaling is not an option, cap and batch instead. In a continuous batching setup, a queue represents the truck filling up, rather than a failure. The added latency cost per token is minimal — ranging from 6.8 ms to 8.6 ms when increasing the batch size from 1 to 64.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Stop relying on the default scheduler settings.&lt;/strong&gt; Choose time-slicing, MPS, or MIG based on the actual isolation requirements.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Price inference per million tokens, not per GPU-hour&lt;/strong&gt;, and present this number to the person responsible for signing the checks. For the same silicon and hour, my calculation is:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;$ per million tokens&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;batch 1&lt;/td&gt;
&lt;td&gt;$8.28&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;batch 64&lt;/td&gt;
&lt;td&gt;$0.16&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The price is fifty times higher. The silicon is the same. The gauge remains unchanged.&lt;/p&gt;

&lt;p&gt;Or, as it goes in German: &lt;em&gt;Zeit ist Geld, aber das Finanzamt will trotzdem beides.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The last thing
&lt;/h2&gt;

&lt;p&gt;The dashboard never lied to you with the intention of hurting you, and neither did the benchmark. They both provided honest answers to their respective questions — &lt;em&gt;is the GPU busy?&lt;/em&gt; and &lt;em&gt;what can this chip do when it's full?&lt;/em&gt; However, neither of these questions was what I was actually paying for, which was &lt;em&gt;is the GPU doing my work?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Utilization does not equate to work.&lt;/p&gt;

&lt;p&gt;GPUs are in the process of evolving to meet our current demands, but this development has only just begun. The immature aspect is not the silicon itself, but rather the software that surrounds it. Additionally, these cards consume a significant amount of power, resulting in wasted resources beyond just financial costs.&lt;/p&gt;

&lt;p&gt;If you get it right, you will receive a 98% discount. If you get it wrong, you will incur costs that are 50× higher, and you will also have a negative impact on the environment as an added consequence.&lt;/p&gt;

&lt;p&gt;You must measure the question that you are actually paying for.&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>ai</category>
      <category>gpu</category>
      <category>devops</category>
    </item>
    <item>
      <title>HTTP QUERY: Everything a Developer Needs to Know About HTTP's Newest Method</title>
      <dc:creator>Ali Alp</dc:creator>
      <pubDate>Sun, 16 Aug 2026 15:17:59 +0000</pubDate>
      <link>https://dev.to/alialp/http-query-everything-a-developer-needs-to-know-about-https-newest-method-1hkc</link>
      <guid>https://dev.to/alialp/http-query-everything-a-developer-needs-to-know-about-https-newest-method-1hkc</guid>
      <description>&lt;p&gt;For as long as most of us have been writing APIs, there has been one awkward question with no good answer: where do I put a complex search query?&lt;/p&gt;

&lt;p&gt;In June 2026 the IETF finally answered it. RFC 10008 defines a new HTTP method, &lt;code&gt;QUERY&lt;/code&gt;: safe and idempotent like &lt;code&gt;GET&lt;/code&gt;, cacheable like &lt;code&gt;GET&lt;/code&gt;, but with a request body like &lt;code&gt;POST&lt;/code&gt;. It's the first new general-purpose method HTTP has gained in about twenty years.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem: GET vs POST was always a bad trade
&lt;/h2&gt;

&lt;p&gt;Say you're building a product search endpoint. You have two classic options, and both are compromises.&lt;/p&gt;

&lt;p&gt;The first is &lt;code&gt;GET&lt;/code&gt; with a query string:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="nf"&gt;GET&lt;/span&gt; &lt;span class="nn"&gt;/products?category=books&amp;amp;maxPrice=30&amp;amp;sort=rating&amp;amp;fields=title,price&lt;/span&gt; &lt;span class="k"&gt;HTTP&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="m"&gt;1.1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You get safety, idempotency, caching, bookmarks. You also get practical URL length limits; servers and proxies commonly cap URLs somewhere between 2 KB and 8 KB, which starts to hurt once your filter UI grows or you need to pass a long list of IDs. The whole query also lands in access logs and browser history, bad news if it contains personal data. And no, you can't just put a body on the GET instead: the spec gives a GET body no defined semantics, so servers and intermediaries do unpredictable things with it.&lt;/p&gt;

&lt;p&gt;The second option is &lt;code&gt;POST&lt;/code&gt; with a body:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="nf"&gt;POST&lt;/span&gt; &lt;span class="nn"&gt;/products/search&lt;/span&gt; &lt;span class="k"&gt;HTTP&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="m"&gt;1.1&lt;/span&gt;
&lt;span class="na"&gt;Content-Type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;application/json&lt;/span&gt;

&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"category"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"books"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"maxPrice"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"sort"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"rating"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now there are no size limits and nothing sensitive in the URL. But POST is neither safe nor idempotent by definition. Caches have to assume it changes state, so responses can't be reused. A client that hits a network failure can't retry automatically, because it has no way to know whether that's safe. And the message itself no longer says "this is just a read".&lt;/p&gt;

&lt;p&gt;Every REST API that grew a &lt;code&gt;/search&lt;/code&gt; POST endpoint made this trade. QUERY exists so you don't have to.&lt;/p&gt;

&lt;h2&gt;
  
  
  Meet QUERY
&lt;/h2&gt;

&lt;p&gt;From RFC 10008, the QUERY method:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"requests that the request target process the enclosed content in a safe and idempotent manner and then respond with the result of that processing."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Unpacked: &lt;em&gt;safe&lt;/em&gt; means the client neither requests nor expects any state change on the target resource. It's a read. &lt;em&gt;Idempotent&lt;/em&gt; means it can be repeated or retried, say after a connection failure, with no concern about partial state changes. &lt;em&gt;Cacheable&lt;/em&gt; means a cache MAY use the response to satisfy later equivalent QUERY requests. And unlike GET, the query itself travels in the request body, in whatever media type the resource supports.&lt;/p&gt;

&lt;p&gt;Here is a minimal exchange (adapted from the RFC's own example):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="nf"&gt;QUERY&lt;/span&gt; &lt;span class="nn"&gt;/contacts&lt;/span&gt; &lt;span class="k"&gt;HTTP&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="m"&gt;1.1&lt;/span&gt;
&lt;span class="na"&gt;Host&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;example.org&lt;/span&gt;
&lt;span class="na"&gt;Content-Type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;application/x-www-form-urlencoded&lt;/span&gt;
&lt;span class="na"&gt;Accept&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;application/json&lt;/span&gt;

select=surname,givenname,email&amp;amp;limit=10&amp;amp;match="email=*@example.*"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="k"&gt;HTTP&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="m"&gt;1.1&lt;/span&gt; &lt;span class="m"&gt;200&lt;/span&gt; &lt;span class="ne"&gt;OK&lt;/span&gt;
&lt;span class="na"&gt;Content-Type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;application/json&lt;/span&gt;

&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"surname"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Smith"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nl"&gt;"givenname"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"John"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="nl"&gt;"email"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"smith@example.org"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"surname"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Jones"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nl"&gt;"givenname"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Sally"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nl"&gt;"email"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"sally.jones@example.com"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"surname"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Dubois"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"givenname"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Camille"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"email"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"camille.dubois@example.net"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The body isn't limited to form encoding. The RFC's examples use &lt;code&gt;application/x-www-form-urlencoded&lt;/code&gt;, JSONPath, SQL and even XSLT as query languages. The media type defines what the query &lt;em&gt;means&lt;/em&gt;; the resource defines what it's evaluated against.&lt;/p&gt;

&lt;h2&gt;
  
  
  GET vs QUERY vs POST at a glance
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Property&lt;/th&gt;
&lt;th&gt;GET&lt;/th&gt;
&lt;th&gt;QUERY&lt;/th&gt;
&lt;th&gt;POST&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Safe&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;potentially no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Idempotent&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;potentially no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Request body&lt;/td&gt;
&lt;td&gt;no defined semantics&lt;/td&gt;
&lt;td&gt;expected&lt;/td&gt;
&lt;td&gt;expected&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cacheable response&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;only for future GET/HEAD&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Automatic retry OK&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;URI for the query itself&lt;/td&gt;
&lt;td&gt;yes, by definition&lt;/td&gt;
&lt;td&gt;optional (&lt;code&gt;Location&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;URI for the result&lt;/td&gt;
&lt;td&gt;optional (&lt;code&gt;Content-Location&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;optional (&lt;code&gt;Content-Location&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;optional (&lt;code&gt;Content-Location&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  The rules of the road
&lt;/h2&gt;

&lt;p&gt;A few things the RFC is strict about.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;Content-Type&lt;/code&gt; is mandatory. Servers &lt;em&gt;must&lt;/em&gt; fail a QUERY request whose &lt;code&gt;Content-Type&lt;/code&gt; is missing or inconsistent with the actual content. No content sniffing.&lt;/p&gt;

&lt;p&gt;Error signaling is well defined:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Situation&lt;/th&gt;
&lt;th&gt;Status&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Missing media type&lt;/td&gt;
&lt;td&gt;&lt;code&gt;400 Bad Request&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Media type not supported for queries&lt;/td&gt;
&lt;td&gt;&lt;code&gt;415 Unsupported Media Type&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Content doesn't match its declared type&lt;/td&gt;
&lt;td&gt;&lt;code&gt;400 Bad Request&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Query parses but is semantically invalid&lt;/td&gt;
&lt;td&gt;&lt;code&gt;422 Unprocessable Content&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Server can't produce a response type the client accepts&lt;/td&gt;
&lt;td&gt;&lt;code&gt;406 Not Acceptable&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Server doesn't support QUERY at all&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;405 Method Not Allowed&lt;/code&gt; (with an &lt;code&gt;Allow&lt;/code&gt; header)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;And the URI's query string still exists. &lt;code&gt;QUERY /contacts?active=true&lt;/code&gt; is legal; the query component still participates in identifying the resource, while the body carries the query &lt;em&gt;content&lt;/em&gt;. How the two combine is up to the resource.&lt;/p&gt;

&lt;h2&gt;
  
  
  Discovery: &lt;code&gt;Allow&lt;/code&gt; and the new &lt;code&gt;Accept-Query&lt;/code&gt; header
&lt;/h2&gt;

&lt;p&gt;RFC 10008 also registers a new response header, &lt;code&gt;Accept-Query&lt;/code&gt;, which advertises the media types a resource accepts for QUERY bodies. It uses Structured Fields syntax:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="nf"&gt;HEAD&lt;/span&gt; &lt;span class="nn"&gt;/contacts&lt;/span&gt; &lt;span class="k"&gt;HTTP&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="m"&gt;1.1&lt;/span&gt;
&lt;span class="na"&gt;Host&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;example.org&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="k"&gt;HTTP&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="m"&gt;1.1&lt;/span&gt; &lt;span class="m"&gt;200&lt;/span&gt; &lt;span class="ne"&gt;OK&lt;/span&gt;
&lt;span class="na"&gt;Accept-Query&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;application/x-www-form-urlencoded, "application/sql"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There are two more discovery paths. Send &lt;code&gt;OPTIONS /contacts&lt;/code&gt; and look for QUERY in the &lt;code&gt;Allow&lt;/code&gt; response header. Or just try it: an unsupported method gets you a 405 with &lt;code&gt;Allow&lt;/code&gt;, and an unsupported query format gets you a 415, ideally with &lt;code&gt;Accept-Query&lt;/code&gt; telling you what would have worked.&lt;/p&gt;

&lt;p&gt;One subtlety: &lt;code&gt;Accept-Query&lt;/code&gt; applies to every URI on the server that shares the same &lt;em&gt;path&lt;/em&gt;. The URI's query component is ignored for its scope.&lt;/p&gt;

&lt;h2&gt;
  
  
  Caching: the killer feature
&lt;/h2&gt;

&lt;p&gt;This is the part that makes QUERY an actual improvement over tunneling reads through POST.&lt;/p&gt;

&lt;p&gt;Because QUERY is safe, its responses are cacheable. Unlike GET, though, the URL alone isn't enough to identify a response, so the RFC requires:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The cache key for a QUERY request &lt;strong&gt;must incorporate the request content&lt;/strong&gt; and related metadata (like &lt;code&gt;Content-Type&lt;/code&gt;).&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;In other words: same URL plus same query body (plus the relevant headers) equals a cache hit. A CDN or shared proxy can serve your repeated dashboard query without ever touching the origin, which was structurally impossible with POST-based search.&lt;/p&gt;

&lt;p&gt;Caches are also allowed to normalize the request content when building the key, for example by stripping content encodings or applying known conventions of the format (JSON's insignificant whitespace, say), so trivially different bodies can still hit the same cache entry. Normalization only ever affects the cache key; the request itself is left untouched. The RFC warns caches to be conservative here, because bad normalization means wrong responses served. Clients can send &lt;code&gt;Cache-Control: no-transform&lt;/code&gt; to discourage it, though the directive is advisory.&lt;/p&gt;

&lt;p&gt;The practical takeaway for API authors: emit the same caching headers on QUERY responses that you would on GET (&lt;code&gt;Cache-Control&lt;/code&gt;, &lt;code&gt;ETag&lt;/code&gt;, &lt;code&gt;Last-Modified&lt;/code&gt;, &lt;code&gt;Vary&lt;/code&gt;) and you get HTTP caching on complex searches for free as caches roll out support.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;code&gt;Location&lt;/code&gt;, &lt;code&gt;Content-Location&lt;/code&gt;, and the "equivalent resource"
&lt;/h2&gt;

&lt;p&gt;The RFC introduces a concept called the &lt;strong&gt;equivalent resource&lt;/strong&gt;: the hypothetical resource that would answer a GET with the results of this exact query. Servers may give that resource a real URI, and two response headers let them tell you about it.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;Location&lt;/code&gt; on a 2xx response means: you can GET this URI later to re-run the query without resending the body. Think saved queries. &lt;code&gt;Content-Location&lt;/code&gt; means: you can GET this URI to retrieve this specific result snapshot, which may be temporary.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="k"&gt;HTTP&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="m"&gt;1.1&lt;/span&gt; &lt;span class="m"&gt;200&lt;/span&gt; &lt;span class="ne"&gt;OK&lt;/span&gt;
&lt;span class="na"&gt;Content-Type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;application/json&lt;/span&gt;
&lt;span class="na"&gt;Location&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/contacts/stored-queries/42          (re-runs the query)&lt;/span&gt;
&lt;span class="na"&gt;Content-Location&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/contacts/stored-results/17  (this specific result set)&lt;/span&gt;

&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;...results...&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A server can even respond &lt;code&gt;303 See Other&lt;/code&gt; with a &lt;code&gt;Location&lt;/code&gt;, meaning the answer to your query is available as a plain GET over there. That's a useful pattern for expensive queries: QUERY once, then poll or share a cheap, cacheable GET URL.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conditional requests work too
&lt;/h2&gt;

&lt;p&gt;Because QUERY is a read, the whole conditional-request machinery from RFC 9110 applies. Send &lt;code&gt;If-None-Match&lt;/code&gt; or &lt;code&gt;If-Modified-Since&lt;/code&gt; with a repeated QUERY and the server can answer &lt;code&gt;304 Not Modified&lt;/code&gt;, skipping both the query execution and the response body. For heavy analytical queries over slowly changing data, that's a big deal.&lt;/p&gt;

&lt;h2&gt;
  
  
  Redirects
&lt;/h2&gt;

&lt;p&gt;Worth knowing because this differs from POST's legacy behavior. On 301, 308, 302 and 307, the client resends the QUERY, body included, to the new URI; the old browser habit of demoting redirected POSTs to GETs explicitly does not apply. On &lt;code&gt;303 See Other&lt;/code&gt;, the client performs a GET on the &lt;code&gt;Location&lt;/code&gt; URI instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  Using it today
&lt;/h2&gt;

&lt;p&gt;QUERY is just an HTTP method token, and most HTTP stacks can carry it already. What varies is framework sugar and intermediary awareness.&lt;/p&gt;

&lt;h3&gt;
  
  
  curl
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;--request&lt;/span&gt; QUERY https://api.example.org/products &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Accept: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--data&lt;/span&gt; &lt;span class="s1"&gt;'{ "category": "books", "maxPrice": 30 }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  JavaScript (fetch)
&lt;/h3&gt;

&lt;p&gt;The Fetch Standard forbids only &lt;code&gt;CONNECT&lt;/code&gt;, &lt;code&gt;TRACE&lt;/code&gt;, and &lt;code&gt;TRACK&lt;/code&gt;, so QUERY is allowed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/products&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;QUERY&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// uppercase: only the classic six methods are auto-normalized&lt;/span&gt;
  &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;category&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;books&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;maxPrice&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;products&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two caveats. Cross-origin QUERY always triggers a CORS preflight, since it's not a safelisted method. And browser and runtime support has been landing gradually through 2026, so verify in your targets before shipping.&lt;/p&gt;

&lt;h3&gt;
  
  
  .NET 10 / ASP.NET Core 10
&lt;/h3&gt;

&lt;p&gt;.NET 10 shipped support on both sides:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="c1"&gt;// client&lt;/span&gt;
&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;var&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;HttpRequestMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;HttpMethod&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"https://api.example.org/products"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;Content&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;JsonContent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;ProductFilter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"books"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;MaxPrice&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;30&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;var&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;httpClient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;SendAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// server (minimal APIs; use MapMethods, there's no MapQuery yet)&lt;/span&gt;
&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;MapMethods&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/products"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;HttpMethods&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Query&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;HttpContext&lt;/span&gt; &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ICatalog&lt;/span&gt; &lt;span class="n"&gt;catalog&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;filter&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ReadFromJsonAsync&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;ProductFilter&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;();&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;filter&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;Results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;BadRequest&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;catalog&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;SearchAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Node.js / Express
&lt;/h3&gt;

&lt;p&gt;Routing frameworks generally expose custom methods once the underlying HTTP parser accepts them. Check your Node version's method allowlist, then route with something like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/products&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;method&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;QUERY&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;next&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="c1"&gt;// req body = the query&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Graceful fallback
&lt;/h3&gt;

&lt;p&gt;During the transition, attempt QUERY and fall back to your existing POST search endpoint when you get a &lt;code&gt;405 Method Not Allowed&lt;/code&gt; or a blocked request. Both can share the same handler server-side.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gotchas and sharp edges
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;WAFs, load balancers and API gateways with method allowlists may reject or mangle QUERY. Audit your edge before rolling it out.&lt;/li&gt;
&lt;li&gt;The semantics allow shared caches and CDNs to cache QUERY, but the products need body-aware cache keys first. Treat CDN-level QUERY caching as a roadmap item for now.&lt;/li&gt;
&lt;li&gt;Every cross-origin browser use pays a CORS preflight. Cache it with &lt;code&gt;Access-Control-Max-Age&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;OpenAPI can't describe QUERY endpoints yet; ASP.NET Core 10, for instance, simply omits them from generated specs. Document them manually for now.&lt;/li&gt;
&lt;li&gt;Moving query parameters out of URLs is a privacy win, since less leaks into logs and history. But if your server mints &lt;code&gt;Location&lt;/code&gt; or &lt;code&gt;Content-Location&lt;/code&gt; URIs for queries, the RFC says those URIs SHOULD NOT embed sensitive parts of the query content. Don't undo the win.&lt;/li&gt;
&lt;li&gt;Idempotence is a promise you make. Marking an endpoint QUERY tells every client and intermediary "retry me freely, cache me". If the handler secretly writes state, you'll get all the classic broken-cache and double-execution bugs. Reads only.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  When to reach for QUERY
&lt;/h2&gt;

&lt;p&gt;Use it when a read doesn't fit comfortably in a URL:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;complex filter and search endpoints (the &lt;code&gt;/search&lt;/code&gt; POST you already have)&lt;/li&gt;
&lt;li&gt;queries written in a real query language (JSONPath, SQL subsets, GraphQL documents)&lt;/li&gt;
&lt;li&gt;lookups with large ID lists ("give me these 5,000 SKUs")&lt;/li&gt;
&lt;li&gt;reads whose parameters are sensitive and shouldn't live in URLs and logs&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Keep plain &lt;code&gt;GET&lt;/code&gt; for anything that fits in a URL; bookmarks and universal caching are still unbeatable. Keep &lt;code&gt;POST&lt;/code&gt; for what it always meant: requests that change state.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrap-up
&lt;/h2&gt;

&lt;p&gt;QUERY closes a twenty-year-old gap in HTTP's vocabulary: a read with a body. The parts beyond that headline are what make it worth adopting, especially the body-aware caching and the stored-query pattern via &lt;code&gt;Location&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The ecosystem is still catching up. Middleboxes and CDN cache keys will take a while, and OpenAPI has no way to describe QUERY endpoints yet. But the standard is done and the first frameworks have shipped, and keeping a POST fallback around costs almost nothing. Next time you're about to add a &lt;code&gt;POST /search&lt;/code&gt; endpoint, try &lt;code&gt;QUERY&lt;/code&gt; first.&lt;/p&gt;

&lt;h3&gt;
  
  
  References
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://www.rfc-editor.org/rfc/rfc10008.html" rel="noopener noreferrer"&gt;RFC 10008 — The HTTP QUERY Method&lt;/a&gt; (Proposed Standard, June 2026)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://datatracker.ietf.org/doc/draft-ietf-httpbis-safe-method-w-body/" rel="noopener noreferrer"&gt;IETF datatracker history — draft-ietf-httpbis-safe-method-w-body&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.rfc-editor.org/rfc/rfc9110.html" rel="noopener noreferrer"&gt;RFC 9110 — HTTP Semantics&lt;/a&gt; (method properties, conditional requests)&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.rfc-editor.org/rfc/rfc9651.html" rel="noopener noreferrer"&gt;RFC 9651 — Structured Field Values&lt;/a&gt; (the &lt;code&gt;Accept-Query&lt;/code&gt; syntax)&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>webdev</category>
      <category>http</category>
      <category>api</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>"Where.Was.I" — a task board built around the moment you switch</title>
      <dc:creator>Ali Alp</dc:creator>
      <pubDate>Mon, 20 Jul 2026 13:06:16 +0000</pubDate>
      <link>https://dev.to/alialp/wherewasi-a-task-board-built-around-the-moment-you-switch-25lj</link>
      <guid>https://dev.to/alialp/wherewasi-a-task-board-built-around-the-moment-you-switch-25lj</guid>
      <description>&lt;p&gt;&lt;strong&gt;Live app:&lt;/strong&gt; &lt;a href="https://alicommit-malp.github.io/wherewasi/" rel="noopener noreferrer"&gt;https://alicommit-malp.github.io/wherewasi/&lt;/a&gt; · &lt;strong&gt;Source:&lt;/strong&gt; &lt;a href="https://github.com/alicommit-malp/wherewasi" rel="noopener noreferrer"&gt;https://github.com/alicommit-malp/wherewasi&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Most task apps are built around &lt;em&gt;what you have to do&lt;/em&gt;. wherewasi is built around a different moment: the one where you stop doing one thing and start doing another, and your entire mental context evaporates. It answers exactly one question, and it answers it fast — &lt;em&gt;where was I?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F49y5yik59gns6rhc25mj.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F49y5yik59gns6rhc25mj.gif" alt="wherewasi in use" width="760" height="570"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;&lt;a href="https://raw.githubusercontent.com/alicommit-malp/wherewasi/main/docs/demo.mp4" rel="noopener noreferrer"&gt;Watch the same clip as MP4&lt;/a&gt; — 40 seconds, no audio.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem it solves
&lt;/h2&gt;

&lt;p&gt;You have five things in flight. You're deep in one of them when something interrupts you. Two days later you come back and spend fifteen minutes reconstructing what you'd already figured out: what you'd finished, what you were blocked on, what the next move was going to be.&lt;/p&gt;

&lt;p&gt;That reconstruction is pure waste, and it's avoidable — the knowledge existed in your head at the moment you left. wherewasi is a place to dump it in five seconds, and to find it again instantly.&lt;/p&gt;

&lt;p&gt;Three rules define the whole app:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Exactly one task is active at a time.&lt;/strong&gt; Not a philosophy about focus — just an honest record of where your attention actually is.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Every task carries a stack of short notes.&lt;/strong&gt; Newest first. "renewed the cert", "waiting on DNS", "next: draft the outline". Free text, no schema, no ceremony.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Switching is instant.&lt;/strong&gt; One click or one keystroke. No dialog, no prompt, no "are you sure" — because anything that adds friction to switching means you'll stop recording your switches, and then the app is worthless.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  What it deliberately does &lt;em&gt;not&lt;/em&gt; do
&lt;/h2&gt;

&lt;p&gt;Design is mostly subtraction, and this app had a lot subtracted:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No timers, no time tracking.&lt;/strong&gt; The first draft measured how long each task was active. It got cut, because the value was in restoring context, not producing timesheets — and a running clock creates a low-grade obligation to "start" and "stop" things correctly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No tags or categories.&lt;/strong&gt; An earlier version tagged each note as &lt;em&gt;did&lt;/em&gt; / &lt;em&gt;waiting&lt;/em&gt; / &lt;em&gt;next&lt;/em&gt;. In practice the tag was one more decision at the exact moment you want zero decisions. Plain text carries the same meaning: just type "waiting on DNS".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No forced note on switch.&lt;/strong&gt; The original design popped a modal on every switch asking what happened. It was correct in theory and unbearable in practice.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No accounts, no server, no network.&lt;/strong&gt; Your data never leaves your browser.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each of those removals came from actually using the thing and noticing the friction.&lt;/p&gt;

&lt;h2&gt;
  
  
  Using it
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The board
&lt;/h3&gt;

&lt;p&gt;One narrow column. Each row is a task: a dot, the title, and its three most recent notes underneath in muted text. The active task is highlighted and is the only row with a note input. That's the whole interface.&lt;/p&gt;

&lt;p&gt;Clicking a task title switches to it instantly and &lt;strong&gt;moves it to the top of the list&lt;/strong&gt;, so the board self-sorts by recency: what you're on is first, what you just left is right below it, and stale work sinks to the bottom on its own.&lt;/p&gt;

&lt;h3&gt;
  
  
  Naming the workspace
&lt;/h3&gt;

&lt;p&gt;Click the &lt;strong&gt;wherewasi&lt;/strong&gt; title in the header to give the board a name — &lt;em&gt;wherewasi — work&lt;/em&gt;, &lt;em&gt;wherewasi — side project&lt;/em&gt;, whatever. It also becomes the browser-tab title, so if you keep separate boards in separate browser profiles they stay easy to tell apart. Leave it blank and the header is just &lt;em&gt;wherewasi&lt;/em&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Writing notes
&lt;/h3&gt;

&lt;p&gt;The active task's input says &lt;em&gt;What happened?&lt;/em&gt;. Type, press Enter, done. Notes stack newest-first, each with a small timestamp so you can see whether a thought is from ten minutes ago or last week.&lt;/p&gt;

&lt;p&gt;Click any note to edit it inline (Enter saves, Escape cancels). If a task has more than three notes, a &lt;code&gt;+N more&lt;/code&gt; link expands the full history — and that expansion collapses automatically the moment you switch tasks, so the board never grows cluttered behind your back.&lt;/p&gt;

&lt;h3&gt;
  
  
  Undo, because instant shouldn't mean irreversible
&lt;/h3&gt;

&lt;p&gt;Archiving a task or deleting a note happens with no confirmation dialog — but neither is a dead end. Both drop a five-second &lt;strong&gt;Undo&lt;/strong&gt; toast (click it, or press &lt;code&gt;u&lt;/code&gt;), and undo restores the item &lt;em&gt;exactly&lt;/em&gt; where it was: an archived task returns to its old board position rather than the bottom, and a deleted note slots back into its original place in the stack. It fits the whole design: act instantly, take it back if you didn't mean it. Let the five seconds lapse and it's committed.&lt;/p&gt;

&lt;h3&gt;
  
  
  Staying in sync across tabs
&lt;/h3&gt;

&lt;p&gt;Open wherewasi in two tabs and they stay consistent — a change in one appears in the other within a moment, because every tab reads and writes the same local database and pings its siblings to refresh. No stale second tab quietly overwriting your work.&lt;/p&gt;

&lt;h3&gt;
  
  
  Keyboard
&lt;/h3&gt;

&lt;p&gt;The app is fully keyboard-driven; press &lt;code&gt;?&lt;/code&gt; at any time for this list.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Key&lt;/th&gt;
&lt;th&gt;Action&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;↓&lt;/code&gt;/&lt;code&gt;j&lt;/code&gt;, &lt;code&gt;↑&lt;/code&gt;/&lt;code&gt;k&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Move the selection&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Enter&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Switch to selected — on the &lt;em&gt;active&lt;/em&gt; task, jump into the note input&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;a&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Write a note on the active task, from anywhere&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;n&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;New task&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;e&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Rename selected task&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;d&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Done — archive selected task&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;u&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Undo the last archive or note deletion (while the prompt shows)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;Space&lt;/code&gt; / &lt;code&gt;m&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Expand / collapse selected task's notes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;v&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Toggle the archive&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Search (in the archive)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Esc&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Leave input / close view / clear selection&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The fastest possible loop is &lt;code&gt;j&lt;/code&gt; &lt;code&gt;j&lt;/code&gt; &lt;code&gt;Enter&lt;/code&gt; to land on a task, then &lt;code&gt;Enter&lt;/code&gt; again to start typing what happened. Single-letter shortcuts are suppressed while you're typing, so notes never trigger commands.&lt;/p&gt;

&lt;h3&gt;
  
  
  On a phone
&lt;/h3&gt;

&lt;p&gt;It's desktop-first, but it works on touch: the per-row actions (archive, rename, delete) are always visible instead of hiding behind hover, and it installs as a real PWA — proper maskable and Apple-touch icons, so the home-screen tile looks right on both Android and iOS, and it runs fully offline once installed.&lt;/p&gt;

&lt;h3&gt;
  
  
  Finishing and finding things
&lt;/h3&gt;

&lt;p&gt;Pressing &lt;code&gt;d&lt;/code&gt; (or clicking the &lt;code&gt;✓&lt;/code&gt; that appears on the row) archives a task. It leaves the board but keeps its entire note stack, browsable under &lt;strong&gt;Archive&lt;/strong&gt; and reopenable at any time. The archive has full-text search across both task titles and note contents — so months later, searching "headcount" surfaces the task where you wrote "travel numbers in, headcount still missing", even if the title never mentioned it.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it's built
&lt;/h2&gt;

&lt;p&gt;A deliberately small stack, chosen so the whole app stays readable in one sitting:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Vite 8 + Svelte 5&lt;/strong&gt; (runes) &lt;strong&gt;+ TypeScript&lt;/strong&gt; — small bundle, minimal boilerplate&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dexie 4&lt;/strong&gt; over IndexedDB — typed local persistence&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;vite-plugin-pwa&lt;/strong&gt; — generated service worker and manifest; installable and fully offline&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GitHub Actions → GitHub Pages&lt;/strong&gt; — every push to &lt;code&gt;main&lt;/code&gt; builds and publishes&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  The data model
&lt;/h3&gt;

&lt;p&gt;Three tables, all with UUIDs and timestamps:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;Task&lt;/span&gt;     &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;open&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;archived&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;createdAt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;archivedAt&lt;/span&gt;&lt;span class="p"&gt;?,&lt;/span&gt; &lt;span class="nx"&gt;sortOrder&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nx"&gt;Note&lt;/span&gt;     &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;taskId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;createdAt&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nx"&gt;AppState&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;app&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;activeTaskId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;   &lt;span class="c1"&gt;// exactly one row&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The most important detail is the smallest one: &lt;strong&gt;"one active task" is a single pointer in &lt;code&gt;AppState&lt;/code&gt;, not a boolean flag on each task.&lt;/strong&gt; Two tasks being active simultaneously isn't a bug that has to be prevented — it's unrepresentable. Whole classes of state-sync bugs simply don't exist.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;sortOrder&lt;/code&gt; is what makes switching bubble a task to the top: activating a task rewrites its order to just below the current minimum, and that reordering is persisted like everything else. That same persisted order is why undo can drop an un-archived task back into its exact former place.&lt;/p&gt;

&lt;h3&gt;
  
  
  State flow
&lt;/h3&gt;

&lt;p&gt;A single store class (&lt;code&gt;src/lib/store.svelte.ts&lt;/code&gt;) holds &lt;code&gt;$state&lt;/code&gt; runes, hydrated from IndexedDB once at startup. Every mutation writes to Dexie &lt;strong&gt;first&lt;/strong&gt;, then updates in-memory state, then broadcasts a one-line "changed" ping to any other open tabs. There is no memory-only state anywhere, so a refresh, a crash, or a closed laptop lid loses nothing. Empty titles and empty notes throw rather than silently no-op.&lt;/p&gt;

&lt;p&gt;The UI is a handful of small components — the board, a task row, the note form, and a help overlay — none of which own any persistent state of their own.&lt;/p&gt;

&lt;h3&gt;
  
  
  Local-first, on purpose
&lt;/h3&gt;

&lt;p&gt;Everything lives in your browser's IndexedDB. No account, no sync server, no telemetry, no network calls at all. On load the app calls &lt;code&gt;navigator.storage.persist()&lt;/code&gt; to ask the browser not to evict the board under storage pressure. That local-first choice has real consequences worth being honest about:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It's single-device.&lt;/strong&gt; Tabs in the same browser stay in sync, but a different machine is a different board. The data model was built sync-ready (UUIDs and timestamps everywhere), so a sync layer can be added later without remodeling.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Browser storage still isn't a vault.&lt;/strong&gt; Persistent storage makes eviction far less likely, but clearing site data wipes it. A JSON export is the obvious next hardening step.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For a tool whose whole job is holding your working context, that trade is deliberate: zero setup, zero latency, and nothing to trust.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it goes next
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Already shipped since the first cut:&lt;/strong&gt; five-second undo for archiving and note deletion; multi-tab consistency via &lt;code&gt;BroadcastChannel&lt;/code&gt;; persistent storage via &lt;code&gt;navigator.storage.persist()&lt;/code&gt;; a nameable workspace; and clean PWA installation on iOS and Android with maskable and Apple-touch icons.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Still ahead&lt;/strong&gt;, roughly in priority order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;JSON export / import&lt;/strong&gt; — the rest of durability: a backup you own and can move.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sync&lt;/strong&gt; — a self-hosted backend (PocketBase or CouchDB/PouchDB) the static frontend talks to, giving multi-&lt;em&gt;device&lt;/em&gt; use without handing task contents to a third party.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tests&lt;/strong&gt; — the store's invariants (one active task, archiving clears the pointer, switching reorders, undo restores in place) are exactly the logic worth pinning down with Vitest and &lt;code&gt;fake-indexeddb&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://alicommit-malp.github.io/wherewasi/" rel="noopener noreferrer"&gt;https://alicommit-malp.github.io/wherewasi/&lt;/a&gt; — it installs as a PWA from the browser menu and works with the network off. Add a couple of the things you're juggling right now, switch between them, and leave yourself a note on the way out. The payoff arrives the next time you come back.&lt;/p&gt;

</description>
      <category>productivity</category>
    </item>
    <item>
      <title>[Boost]</title>
      <dc:creator>Ali Alp</dc:creator>
      <pubDate>Mon, 06 Jul 2026 20:37:33 +0000</pubDate>
      <link>https://dev.to/alialp/-4iic</link>
      <guid>https://dev.to/alialp/-4iic</guid>
      <description>&lt;div class="ltag__link--embedded"&gt;
  &lt;div class="crayons-story "&gt;
  &lt;a href="https://dev.to/alialp/why-event-driven-architecture-isnt-about-speed-and-when-you-actually-need-it-2pol" class="crayons-story__hidden-navigation-link"&gt;Why Event-Driven Architecture Isn’t About Speed (and When You Actually Need It)&lt;/a&gt;


  &lt;div class="crayons-story__body crayons-story__body-full_post"&gt;
    &lt;div class="crayons-story__top"&gt;
      &lt;div class="crayons-story__meta"&gt;
        &lt;div class="crayons-story__author-pic"&gt;

          &lt;a href="/alialp" class="crayons-avatar  crayons-avatar--l  "&gt;
            &lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F75246%2F73159765-d92c-4500-bf44-2ccb5ddca661.jpg" alt="alialp profile" class="crayons-avatar__image"&gt;
          &lt;/a&gt;
        &lt;/div&gt;
        &lt;div&gt;
          &lt;div&gt;
            &lt;a href="/alialp" class="crayons-story__secondary fw-medium m:hidden"&gt;
              Ali Alp
            &lt;/a&gt;
            &lt;div class="profile-preview-card relative mb-4 s:mb-0 fw-medium hidden m:inline-block"&gt;
              
                Ali Alp
                
              
              &lt;div id="story-author-preview-content-4082657" class="profile-preview-card__content crayons-dropdown branded-7 p-4 pt-0"&gt;
                &lt;div class="gap-4 grid"&gt;
                  &lt;div class="-mt-4"&gt;
                    &lt;a href="/alialp" class="flex"&gt;
                      &lt;span class="crayons-avatar crayons-avatar--xl mr-2 shrink-0"&gt;
                        &lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F75246%2F73159765-d92c-4500-bf44-2ccb5ddca661.jpg" class="crayons-avatar__image" alt=""&gt;
                      &lt;/span&gt;
                      &lt;span class="crayons-link crayons-subtitle-2 mt-5"&gt;Ali Alp&lt;/span&gt;
                    &lt;/a&gt;
                  &lt;/div&gt;
                  &lt;div class="print-hidden"&gt;
                    
                      Follow
                    
                  &lt;/div&gt;
                  &lt;div class="author-preview-metadata-container"&gt;&lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
            &lt;/div&gt;

          &lt;/div&gt;
          &lt;a href="https://dev.to/alialp/why-event-driven-architecture-isnt-about-speed-and-when-you-actually-need-it-2pol" class="crayons-story__tertiary fs-xs"&gt;&lt;time&gt;Jul 6&lt;/time&gt;&lt;span class="time-ago-indicator-initial-placeholder"&gt;&lt;/span&gt;&lt;/a&gt;
        &lt;/div&gt;
      &lt;/div&gt;

    &lt;/div&gt;

    &lt;div class="crayons-story__indention"&gt;
      &lt;h2 class="crayons-story__title crayons-story__title-full_post"&gt;
        &lt;a href="https://dev.to/alialp/why-event-driven-architecture-isnt-about-speed-and-when-you-actually-need-it-2pol" id="article-link-4082657"&gt;
          Why Event-Driven Architecture Isn’t About Speed (and When You Actually Need It)
        &lt;/a&gt;
      &lt;/h2&gt;
        &lt;div class="crayons-story__tags"&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/architecture"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;architecture&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/devops"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;devops&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/microservices"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;microservices&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/systemdesign"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;systemdesign&lt;/a&gt;
        &lt;/div&gt;
      &lt;div class="crayons-story__bottom"&gt;
        &lt;div class="crayons-story__details"&gt;
          &lt;a href="https://dev.to/alialp/why-event-driven-architecture-isnt-about-speed-and-when-you-actually-need-it-2pol" class="crayons-btn crayons-btn--s crayons-btn--ghost crayons-btn--icon-left"&gt;
            &lt;div class="multiple_reactions_aggregate"&gt;
              &lt;span class="multiple_reactions_icons_container"&gt;
                  &lt;span class="crayons_icon_container"&gt;
                    &lt;img src="https://assets.dev.to/assets/exploding-head-daceb38d627e6ae9b730f36a1e390fca556a4289d5a41abb2c35068ad3e2c4b5.svg" width="18" height="18"&gt;
                  &lt;/span&gt;
                  &lt;span class="crayons_icon_container"&gt;
                    &lt;img src="https://assets.dev.to/assets/multi-unicorn-b44d6f8c23cdd00964192bedc38af3e82463978aa611b4365bd33a0f1f4f3e97.svg" width="18" height="18"&gt;
                  &lt;/span&gt;
                  &lt;span class="crayons_icon_container"&gt;
                    &lt;img src="https://assets.dev.to/assets/sparkle-heart-5f9bee3767e18deb1bb725290cb151c25234768a0e9a2bd39370c382d02920cf.svg" width="18" height="18"&gt;
                  &lt;/span&gt;
              &lt;/span&gt;
              &lt;span class="aggregate_reactions_counter"&gt;6&lt;span class="hidden s:inline"&gt;&amp;nbsp;reactions&lt;/span&gt;&lt;/span&gt;
            &lt;/div&gt;
          &lt;/a&gt;
            &lt;a href="https://dev.to/alialp/why-event-driven-architecture-isnt-about-speed-and-when-you-actually-need-it-2pol#comments" class="crayons-btn crayons-btn--s crayons-btn--ghost crayons-btn--icon-left flex items-center"&gt;
              

              12&lt;span class="hidden s:inline"&gt;&amp;nbsp;comments&lt;/span&gt;
            &lt;/a&gt;
        &lt;/div&gt;
        &lt;div class="crayons-story__save"&gt;
          &lt;small class="crayons-story__tertiary fs-xs mr-2"&gt;
            3 min read
          &lt;/small&gt;
            
              &lt;span class="bm-initial crayons-icon c-btn__icon"&gt;
                

              &lt;/span&gt;
              &lt;span class="bm-success crayons-icon c-btn__icon"&gt;
                

              &lt;/span&gt;
            
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;/div&gt;


</description>
    </item>
    <item>
      <title>Why Event-Driven Architecture Isn’t About Speed (and When You Actually Need It)</title>
      <dc:creator>Ali Alp</dc:creator>
      <pubDate>Mon, 06 Jul 2026 20:37:16 +0000</pubDate>
      <link>https://dev.to/alialp/why-event-driven-architecture-isnt-about-speed-and-when-you-actually-need-it-2pol</link>
      <guid>https://dev.to/alialp/why-event-driven-architecture-isnt-about-speed-and-when-you-actually-need-it-2pol</guid>
      <description>&lt;p&gt;We’ve all seen it happen. A engineering team wants to feel like they belong in the "cool kids" club, so they grab Apache Kafka or a massive event broker, throw it at a straightforward CRUD application, and suddenly a simple database write is wrapped in a complex web of event producers, consumers, and brokers. &lt;/p&gt;

&lt;p&gt;Before you introduce that level of massive operational overhead into your stack, we need to talk about what Event-Driven Architecture (EDA) actually delivers—and what it charges you in return. &lt;/p&gt;

&lt;p&gt;I recently sat down for a panel discussion on the reality of building real-time enterprises. If you want to skip the hype and hear the unvarnished truth about architectural trade-offs from practicing architects, you can &lt;a href="https://www.youtube.com/watch?v=rQYKLfI_bnw" rel="noopener noreferrer"&gt;watch the full panel discussion on YouTube here&lt;/a&gt;. &lt;/p&gt;

&lt;p&gt;Here is the breakdown of the core architectural realities we discussed.&lt;/p&gt;




&lt;h2&gt;
  
  
  1. "Real-Time" is a Spectrum, Not Pure Speed
&lt;/h2&gt;

&lt;p&gt;When stakeholders or product managers say they want "real-time data," they usually just mean they want their system to feel fast. But real-time isn't a single setting; it's a massive spectrum. &lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Hard Real-Time:&lt;/strong&gt; Think medical instrumentation, radar tracking, or high-frequency trading systems. Milliseconds or microseconds dictate success, and missing a deadline means total system failure.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Soft Real-Time:&lt;/strong&gt; A Grafana metrics dashboard or an activity feed. If an update takes an extra two seconds to show up, the business survives.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Here is the hard truth that catches many off guard: &lt;strong&gt;EDA does not give you more speed.&lt;/strong&gt; Every broker, event log, network hop, and serialization boundary you insert adds infrastructure complexity and transport latency. You don't adopt EDA for raw single-stream performance; you adopt it for &lt;strong&gt;decoupling&lt;/strong&gt;. If your system cannot tolerate eventual consistency, forcing an EDA into your core transaction loop is the wrong choice.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. The Overkill Trap: When to Actually Deploy EDA
&lt;/h2&gt;

&lt;p&gt;Architectural patterns should evolve organically based on hard constraints, not because you want to copy LinkedIn’s or Netflix's infrastructure blogs. Attempting to build a brand-new service around a heavy event broker on day one is the definition of over-engineering. &lt;/p&gt;

&lt;p&gt;So, when is it actually time to shift to an event-driven model? &lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The Litmus Test:&lt;/strong&gt; You need EDA the moment your system emits a single &lt;strong&gt;fact&lt;/strong&gt; (e.g., &lt;code&gt;OrderPlaced&lt;/code&gt;) that multiple, completely independent business domains need to natively consume and react to at the same time.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;If an order placement requires immediate, distinct actions from Shipping, Billing, Inventory management, and Fraud Detection—all handled by different microservices or separate teams—EDA rescues you from tight coupling and nasty orchestration bottlenecks. If it’s just one service talking directly to another, stick to a simple, synchronous API call or a lightweight point-to-point queue until the system forces you to change.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Conway’s Law Wins Every Time
&lt;/h2&gt;

&lt;p&gt;Software architecture is socio-technical. You can design the most elegant, fully decoupled event-driven ecosystem on a whiteboard, but if your organization is managed by a rigid, top-down hierarchy where every cross-team decision requires centralized synchronization and global locks, your technical architecture will rot.&lt;/p&gt;

&lt;p&gt;Before rewriting your stack to use events, look at your org chart:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Do your individual teams have the actual operational autonomy to own micro-domains? &lt;/li&gt;
&lt;li&gt;Can your engineers shift their mental model from immediate, synchronous database states to handling asynchronous flows, retries, and out-of-order events? &lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If the organizational maturity isn't there to support decentralized data ownership, forcing EDA into the codebase will only create technical friction and team frustration.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Why AI and EDA Are Perfect Partners
&lt;/h2&gt;

&lt;p&gt;While human developers often struggle to reason through sprawling, asynchronous event loops, artificial intelligence thrives in them. &lt;/p&gt;

&lt;p&gt;Parallelism is baked into the very DNA of modern AI orchestration. If you are building multi-agent AI frameworks, those autonomous agents spend a massive amount of time executing parallel background tasks, waiting on inputs from collaborator agents, and reacting to changing context states. Because an AI ecosystem is inherently asynchronous and distributed, an event-driven backbone is one of the most effective ways to cleanly coordinate intelligent systems without blocking execution threads.&lt;/p&gt;




&lt;h3&gt;
  
  
  Cut Through the Noise
&lt;/h3&gt;

&lt;p&gt;Architecture is always a game of trade-offs. EDA gives you incredible flexibility, system resilience, and structural decoupling, but it taxes you heavily in complexity and latency. Make sure your business domain actually requires what it's selling before you sign the check.&lt;/p&gt;

&lt;p&gt;For the deeper, unfiltered debate on these concepts—including how data governance and organizational maturity factor into the mix—check out the full session here:&lt;/p&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/rQYKLfI_bnw"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>devops</category>
      <category>microservices</category>
      <category>systemdesign</category>
    </item>
    <item>
      <title>How I built one Manifest V3 extension that runs on Chrome, Edge, and Firefox</title>
      <dc:creator>Ali Alp</dc:creator>
      <pubDate>Thu, 14 May 2026 10:54:12 +0000</pubDate>
      <link>https://dev.to/alialp/how-i-built-one-manifest-v3-extension-that-runs-on-chrome-edge-and-firefox-262m</link>
      <guid>https://dev.to/alialp/how-i-built-one-manifest-v3-extension-that-runs-on-chrome-edge-and-firefox-262m</guid>
      <description>&lt;p&gt;I built a browser extension called &lt;strong&gt;Silenzio&lt;/strong&gt; that blurs or blacks out videos and images, mutes audio, and lets me set rules about where and when it applies. The motivation is a bit personal: scrolling feeds, search results, and "people you may know" lists kept stealing minutes I didn't actually want to spend. A blurred feed turns out to be a surprisingly effective speed bump.&lt;/p&gt;

&lt;p&gt;The technically interesting part is that the whole thing — Chrome, Edge, and Firefox — runs from a single source folder. No build step, no per-browser variants, no Webpack. Here's how that works, plus a handful of MV3-specific tricks I had to find along the way.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it does
&lt;/h2&gt;

&lt;p&gt;Two independent toggles — videos and images — each with Off / Blur / Blackout. Audio follows the video toggle. Three more knobs on top: an allowlist or blocklist of sites, per-site or global pause timers, and an optional "working hours" schedule so the whole thing only runs during a daily window you pick.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fnc9srrauc2i3siucy8ae.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fnc9srrauc2i3siucy8ae.png" alt="YouTube with video thumbnails blurred and the Silenzio popup open" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  One manifest, three engines
&lt;/h2&gt;

&lt;p&gt;Manifest V3 is technically the same across Chrome, Edge, and Firefox 115+. In practice you can write a single &lt;code&gt;manifest.json&lt;/code&gt; that all three load, because:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Chromium engines ignore unknown manifest fields.&lt;/li&gt;
&lt;li&gt;The MV3 fields Silenzio actually uses (&lt;code&gt;content_scripts&lt;/code&gt;, &lt;code&gt;host_permissions&lt;/code&gt;, &lt;code&gt;action&lt;/code&gt;, &lt;code&gt;options_page&lt;/code&gt;, &lt;code&gt;permissions&lt;/code&gt;) are part of the standard that Firefox now implements.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The Firefox-specific block goes at the bottom and Chromium ignores it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"browser_specific_settings"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"gecko"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"silenzio@silenzio.local"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"strict_min_version"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"115.0"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it. The same files load in three browsers with zero conditional code.&lt;/p&gt;

&lt;p&gt;The one gotcha you can't solve in the manifest is that Firefox MV3 treats &lt;code&gt;&amp;lt;all_urls&amp;gt;&lt;/code&gt; host permissions as &lt;strong&gt;user-granted post-install&lt;/strong&gt;. After temporary-loading the add-on, you have to click the toolbar icon → puzzle-piece menu → Silenzio → "Always allow on all websites" once. Until then, the content script won't actually inject. Easy to miss the first time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Catching short-form feeds
&lt;/h2&gt;

&lt;p&gt;The hard case for a media-filtering extension is short-form video — Shorts, Reels, the LinkedIn feed. Those surfaces aggressively mount and unmount &lt;code&gt;&amp;lt;video&amp;gt;&lt;/code&gt; elements as you scroll. A naive &lt;code&gt;document.querySelectorAll("video")&lt;/code&gt; at load time finds nothing useful, because most of the videos haven't been mounted yet.&lt;/p&gt;

&lt;p&gt;The fix is a &lt;code&gt;MutationObserver&lt;/code&gt; rooted at &lt;code&gt;document.documentElement&lt;/code&gt;, started at &lt;code&gt;document_start&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;observer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;MutationObserver&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;mutations&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;eff&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;effectiveModes&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;mutations&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;node&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;addedNodes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;nodeType&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;matches&lt;/span&gt;&lt;span class="p"&gt;?.(&lt;/span&gt;&lt;span class="nx"&gt;SELECTOR&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="nf"&gt;applyTo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;eff&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;querySelectorAll&lt;/span&gt;&lt;span class="p"&gt;?.(&lt;/span&gt;&lt;span class="nx"&gt;SELECTOR&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;applyTo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;eff&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="nx"&gt;observer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;observe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;documentElement&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;childList&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;subtree&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because the manifest sets &lt;code&gt;"run_at": "document_start"&lt;/code&gt;, the observer is in place before the body even exists. Newly-mounted videos and images get the filter class within a tick of being inserted.&lt;/p&gt;

&lt;p&gt;There's a small but important detail in the order: I start the observer &lt;strong&gt;before&lt;/strong&gt; loading the saved config from &lt;code&gt;chrome.storage.local&lt;/code&gt;. Storage is asynchronous, and the round trip easily takes long enough for an autoplay video to mount and start playing. So the observer runs with defaults immediately, then re-applies once storage resolves. The visible result is that pages never flash an unblurred frame.&lt;/p&gt;

&lt;h2&gt;
  
  
  One CSS path for videos and images
&lt;/h2&gt;

&lt;p&gt;The CSS for the blur and blackout is dumb on purpose:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nc"&gt;.silenzio-blur&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;blur&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;40px&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="cp"&gt;!important&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nc"&gt;.silenzio-blackout&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;brightness&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="cp"&gt;!important&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;filter&lt;/code&gt; is an element-level property. It applies to whatever the box renders — video frames, image content, even async-loaded image bytes that arrive after the class is set. That means &lt;code&gt;&amp;lt;video&amp;gt;&lt;/code&gt; and &lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt; share exactly one apply path. The element-type dispatch happens once, in &lt;code&gt;modeFor&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;modeFor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;eff&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;el&lt;/span&gt; &lt;span class="k"&gt;instanceof&lt;/span&gt; &lt;span class="nx"&gt;HTMLVideoElement&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;eff&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;video&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;el&lt;/span&gt; &lt;span class="k"&gt;instanceof&lt;/span&gt; &lt;span class="nx"&gt;HTMLImageElement&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;eff&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;image&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;el&lt;/span&gt; &lt;span class="k"&gt;instanceof&lt;/span&gt; &lt;span class="nx"&gt;HTMLMediaElement&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;eff&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;video&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// &amp;lt;audio&amp;gt; follows video&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;off&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Adding new media types later is a two-line change in this function and one selector update. The CSS doesn't need to know anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  Re-muting hostile sites
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;el.muted = true&lt;/code&gt; is fine 90% of the time. The 10% case is sites that programmatically unmute on a player event, or swap the &lt;code&gt;src&lt;/code&gt; on the same element when you scroll to the next short. So I attach two capture-phase listeners on &lt;code&gt;document&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;volumechange&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;effectiveModes&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nx"&gt;video&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;off&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
      &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;target&lt;/span&gt; &lt;span class="k"&gt;instanceof&lt;/span&gt; &lt;span class="nx"&gt;HTMLMediaElement&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
      &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;muted&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;muted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;play&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;target&lt;/span&gt; &lt;span class="k"&gt;instanceof&lt;/span&gt; &lt;span class="nx"&gt;HTMLMediaElement&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;applyTo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;effectiveModes&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Capture phase matters here. By the time &lt;code&gt;volumechange&lt;/code&gt; bubbles to the document the site's own handler has already run; capture-phase intercepts before. This is enough to keep YouTube, LinkedIn, and Reels muted reliably.&lt;/p&gt;

&lt;h2&gt;
  
  
  The decision flow
&lt;/h2&gt;

&lt;p&gt;Every time the extension applies a mode it goes through &lt;code&gt;effectiveModes()&lt;/code&gt;:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Scope&lt;/strong&gt; — if the current hostname doesn't match the user's allowlist or blocklist, both modes return &lt;code&gt;off&lt;/code&gt;. Match is exact-or-suffix, so adding &lt;code&gt;youtube.com&lt;/code&gt; covers &lt;code&gt;www.youtube.com&lt;/code&gt; and &lt;code&gt;m.youtube.com&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pause&lt;/strong&gt; — if there's a global or site-specific pause active right now, both modes return &lt;code&gt;off&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Schedule&lt;/strong&gt; — if working hours are enabled and the current time is outside the window, both modes return &lt;code&gt;off&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Otherwise return the user's configured &lt;code&gt;{ video, image }&lt;/code&gt; modes.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This is the only place precedence is decided, and the order matters: I want a one-click pause to override the user's other rules without needing to think about them.&lt;/p&gt;

&lt;p&gt;Time-based state is re-checked via a single &lt;code&gt;setTimeout&lt;/code&gt; per page that targets the next relevant boundary — the exact pause expiry, or the next minute boundary if a schedule is active. Doing exact schedule-edge math with days-of-week and midnight crossings is fussy; minute granularity is more than enough for a "working hours" feature and avoids a category of bugs.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I didn't (yet) solve
&lt;/h2&gt;

&lt;p&gt;Three known gaps, all on the someday list:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;CSS &lt;code&gt;background-image&lt;/code&gt;&lt;/strong&gt; — many avatars and decorative banners are painted via &lt;code&gt;background-image&lt;/code&gt; rather than &lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt;. Filtering them requires walking computed styles and either injecting per-element CSS or patching stylesheets. I skipped it for v1.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Inline &lt;code&gt;&amp;lt;svg&amp;gt;&lt;/code&gt;&lt;/strong&gt; — mostly icons, rarely worth blurring. (&lt;code&gt;&amp;lt;img src="*.svg"&amp;gt;&lt;/code&gt; &lt;em&gt;is&lt;/em&gt; filtered because the selector catches &lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt;.)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Web Audio API graphs&lt;/strong&gt; — &lt;code&gt;HTMLMediaElement.muted&lt;/code&gt; doesn't stop a Web Audio graph that pulls samples directly. Ordinary playback on YouTube, LinkedIn, etc. uses &lt;code&gt;HTMLMediaElement&lt;/code&gt; and is muted correctly; some games and audio editors aren't.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each is solvable, just not at v1 scope.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;Silenzio is on the &lt;a href="https://chromewebstore.google.com/detail/pnlbpemienlnbiofmdjldbbnpfkfpndd" rel="noopener noreferrer"&gt;Chrome Web Store&lt;/a&gt; — works in Edge too. The source, including the Firefox load instructions, lives at &lt;a href="https://github.com/alicommit-malp/silenzio" rel="noopener noreferrer"&gt;github.com/alicommit-malp/silenzio&lt;/a&gt;. No build step — &lt;code&gt;Load unpacked&lt;/code&gt; and go.&lt;/p&gt;

&lt;p&gt;If you've built a cross-browser MV3 extension and have a cleaner answer for Firefox's host-permission UX, the Web Audio mute, or background-image filtering, I'd love to hear it.&lt;/p&gt;

</description>
      <category>webextensions</category>
      <category>javascript</category>
      <category>chrome</category>
      <category>firefox</category>
    </item>
    <item>
      <title>Did you ever wondered to see your whole life in one page calendar ?</title>
      <dc:creator>Ali Alp</dc:creator>
      <pubDate>Thu, 07 May 2026 20:36:38 +0000</pubDate>
      <link>https://dev.to/alialp/did-you-ever-wondered-to-see-your-whole-life-in-one-page-calendar--ecc</link>
      <guid>https://dev.to/alialp/did-you-ever-wondered-to-see-your-whole-life-in-one-page-calendar--ecc</guid>
      <description>&lt;div class="ltag__link--embedded"&gt;
  &lt;div class="crayons-story "&gt;
  &lt;a href="https://dev.to/alialp/i-built-a-life-in-weeks-poster-generator-in-one-html-file-40hp" class="crayons-story__hidden-navigation-link"&gt;I built a 'life in weeks' poster generator in one HTML file&lt;/a&gt;


  &lt;div class="crayons-story__body crayons-story__body-full_post"&gt;
    &lt;div class="crayons-story__top"&gt;
      &lt;div class="crayons-story__meta"&gt;
        &lt;div class="crayons-story__author-pic"&gt;

          &lt;a href="/alialp" class="crayons-avatar  crayons-avatar--l  "&gt;
            &lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F75246%2F73159765-d92c-4500-bf44-2ccb5ddca661.jpg" alt="alialp profile" class="crayons-avatar__image" width="732" height="976"&gt;
          &lt;/a&gt;
        &lt;/div&gt;
        &lt;div&gt;
          &lt;div&gt;
            &lt;a href="/alialp" class="crayons-story__secondary fw-medium m:hidden"&gt;
              Ali Alp
            &lt;/a&gt;
            &lt;div class="profile-preview-card relative mb-4 s:mb-0 fw-medium hidden m:inline-block"&gt;
              
                Ali Alp
                
              
              &lt;div id="story-author-preview-content-3629078" class="profile-preview-card__content crayons-dropdown branded-7 p-4 pt-0"&gt;
                &lt;div class="gap-4 grid"&gt;
                  &lt;div class="-mt-4"&gt;
                    &lt;a href="/alialp" class="flex"&gt;
                      &lt;span class="crayons-avatar crayons-avatar--xl mr-2 shrink-0"&gt;
                        &lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F75246%2F73159765-d92c-4500-bf44-2ccb5ddca661.jpg" class="crayons-avatar__image" alt="" width="732" height="976"&gt;
                      &lt;/span&gt;
                      &lt;span class="crayons-link crayons-subtitle-2 mt-5"&gt;Ali Alp&lt;/span&gt;
                    &lt;/a&gt;
                  &lt;/div&gt;
                  &lt;div class="print-hidden"&gt;
                    
                      Follow
                    
                  &lt;/div&gt;
                  &lt;div class="author-preview-metadata-container"&gt;&lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
            &lt;/div&gt;

          &lt;/div&gt;
          &lt;a href="https://dev.to/alialp/i-built-a-life-in-weeks-poster-generator-in-one-html-file-40hp" class="crayons-story__tertiary fs-xs"&gt;&lt;time&gt;May 7&lt;/time&gt;&lt;span class="time-ago-indicator-initial-placeholder"&gt;&lt;/span&gt;&lt;/a&gt;
        &lt;/div&gt;
      &lt;/div&gt;

    &lt;/div&gt;

    &lt;div class="crayons-story__indention"&gt;
      &lt;h2 class="crayons-story__title crayons-story__title-full_post"&gt;
        &lt;a href="https://dev.to/alialp/i-built-a-life-in-weeks-poster-generator-in-one-html-file-40hp" id="article-link-3629078"&gt;
          I built a 'life in weeks' poster generator in one HTML file
        &lt;/a&gt;
      &lt;/h2&gt;
        &lt;div class="crayons-story__tags"&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/productivity"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;productivity&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/javascript"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;javascript&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/life"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;life&lt;/a&gt;
        &lt;/div&gt;
      &lt;div class="crayons-story__bottom"&gt;
        &lt;div class="crayons-story__details"&gt;
          &lt;a href="https://dev.to/alialp/i-built-a-life-in-weeks-poster-generator-in-one-html-file-40hp" class="crayons-btn crayons-btn--s crayons-btn--ghost crayons-btn--icon-left"&gt;
            &lt;div class="multiple_reactions_aggregate"&gt;
              &lt;span class="multiple_reactions_icons_container"&gt;
                  &lt;span class="crayons_icon_container"&gt;
                    &lt;img src="https://assets.dev.to/assets/exploding-head-daceb38d627e6ae9b730f36a1e390fca556a4289d5a41abb2c35068ad3e2c4b5.svg" width="24" height="24"&gt;
                  &lt;/span&gt;
                  &lt;span class="crayons_icon_container"&gt;
                    &lt;img src="https://assets.dev.to/assets/multi-unicorn-b44d6f8c23cdd00964192bedc38af3e82463978aa611b4365bd33a0f1f4f3e97.svg" width="24" height="24"&gt;
                  &lt;/span&gt;
                  &lt;span class="crayons_icon_container"&gt;
                    &lt;img src="https://assets.dev.to/assets/sparkle-heart-5f9bee3767e18deb1bb725290cb151c25234768a0e9a2bd39370c382d02920cf.svg" width="24" height="24"&gt;
                  &lt;/span&gt;
              &lt;/span&gt;
              &lt;span class="aggregate_reactions_counter"&gt;5&lt;span class="hidden s:inline"&gt;&amp;nbsp;reactions&lt;/span&gt;&lt;/span&gt;
            &lt;/div&gt;
          &lt;/a&gt;
            &lt;a href="https://dev.to/alialp/i-built-a-life-in-weeks-poster-generator-in-one-html-file-40hp#comments" class="crayons-btn crayons-btn--s crayons-btn--ghost crayons-btn--icon-left flex items-center"&gt;
              

              &lt;span class="hidden s:inline"&gt;Add&amp;nbsp;Comment&lt;/span&gt;
            &lt;/a&gt;
        &lt;/div&gt;
        &lt;div class="crayons-story__save"&gt;
          &lt;small class="crayons-story__tertiary fs-xs mr-2"&gt;
            4 min read
          &lt;/small&gt;
            
              &lt;span class="bm-initial"&gt;
                

              &lt;/span&gt;
              &lt;span class="bm-success"&gt;
                

              &lt;/span&gt;
            
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;/div&gt;


</description>
    </item>
    <item>
      <title>I built a 'life in weeks' poster generator in one HTML file</title>
      <dc:creator>Ali Alp</dc:creator>
      <pubDate>Thu, 07 May 2026 20:35:24 +0000</pubDate>
      <link>https://dev.to/alialp/i-built-a-life-in-weeks-poster-generator-in-one-html-file-40hp</link>
      <guid>https://dev.to/alialp/i-built-a-life-in-weeks-poster-generator-in-one-html-file-40hp</guid>
      <description>&lt;p&gt;I made a tool that draws every week of your life on a single sheet of paper.&lt;/p&gt;

&lt;p&gt;A long human life is roughly 5,200 weeks. That's a 100-row × 52-column grid — small enough to fit on one A4 page, big enough that you instinctively try to count them. The tool fills in the weeks you've already lived, leaves the rest empty, and exports a PDF you can print.&lt;/p&gt;

&lt;p&gt;Live: &lt;a href="https://alicommit-malp.github.io/life-in-weeks/" rel="noopener noreferrer"&gt;https://alicommit-malp.github.io/life-in-weeks/&lt;/a&gt;&lt;br&gt;
Source: &lt;a href="https://github.com/alicommit-malp/life-in-weeks" rel="noopener noreferrer"&gt;https://github.com/alicommit-malp/life-in-weeks&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;It's also a quiet experiment in restraint:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;One file (&lt;code&gt;index.html&lt;/code&gt; — HTML, CSS, JS, all of it)&lt;/li&gt;
&lt;li&gt;No framework, no bundler, no &lt;code&gt;package.json&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Two CDN deps: jsPDF and Google Fonts&lt;/li&gt;
&lt;li&gt;No backend, no analytics, no &lt;code&gt;localStorage&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;$0 hosting, $0 forever&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This post is about the design decisions. There's just enough code to show what's interesting; the rest is on GitHub.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why one file?
&lt;/h2&gt;

&lt;p&gt;Because the surface area is small. The whole product is "type your name and birthdate, click two buttons." A build step buys nothing — and "no build step" buys something real: anyone can clone the repo, double-click &lt;code&gt;index.html&lt;/code&gt;, and run it offline. No &lt;code&gt;npm install&lt;/code&gt;, no Node version mismatch, no broken Vite plugin two years from now.&lt;/p&gt;

&lt;p&gt;This wasn't dogma. I genuinely tried to imagine a v2 that needed React. I couldn't justify it.&lt;/p&gt;
&lt;h2&gt;
  
  
  Drawing 5,200 circles fast
&lt;/h2&gt;

&lt;p&gt;The naive approach is &lt;code&gt;&amp;lt;circle&amp;gt;&lt;/code&gt; × 5,200. It works, but that's a lot of DOM nodes.&lt;/p&gt;

&lt;p&gt;The trick: &lt;strong&gt;two &lt;code&gt;&amp;lt;path&amp;gt;&lt;/code&gt; elements&lt;/strong&gt;, one for filled circles and one for empty ones. Build the path data as a single string of moveto + arc commands.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;circlePath&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;cy&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;`M&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;cx&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;,&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;cy&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; m&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;,0 a&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;,&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; 0 1,0 &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;,0 a&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;,&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; 0 1,0 &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;,0 `&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;filledD&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;emptyD&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;YEARS&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cy&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;gridTop&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mf"&gt;0.5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;ROW_H&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;w&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;w&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;WEEKS&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;w&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;leftX&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;w&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mf"&gt;0.5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;CELL&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;idx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;WEEKS&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;w&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;idx&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;totalWeeks&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;filledD&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nf"&gt;circlePath&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;cy&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;CIRCLE_R&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="nx"&gt;emptyD&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nf"&gt;circlePath&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;cy&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;CIRCLE_R&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;M cx,cy m -r,0 a r,r 0 1,0 2r,0 a r,r 0 1,0 -2r,0&lt;/code&gt; pattern draws a circle as two half-arcs from a moveto point. Subsequent &lt;code&gt;M&lt;/code&gt; commands inside the same &lt;code&gt;d&lt;/code&gt; attribute start a new disconnected subpath, so one &lt;code&gt;&amp;lt;path&amp;gt;&lt;/code&gt; element ends up containing 5,000+ circles.&lt;/p&gt;

&lt;p&gt;Two DOM nodes instead of 5,200. Render is instant.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keeping the PDF honest
&lt;/h2&gt;

&lt;p&gt;The on-screen preview is SVG. The download is a real PDF, generated by &lt;strong&gt;jsPDF&lt;/strong&gt; from &lt;code&gt;circle()&lt;/code&gt;, &lt;code&gt;line()&lt;/code&gt;, and &lt;code&gt;text()&lt;/code&gt; primitives. They have to look identical — if the preview lies, the user feels cheated the moment they hit "Download."&lt;/p&gt;

&lt;p&gt;The fix is shared coordinate math. jsPDF in &lt;code&gt;pt&lt;/code&gt; units uses A4 = 595.27 × 841.89, top-left origin, y growing down — exactly like SVG. So both renderers consume the same constants:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;PAGE_W&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;595.27&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;PAGE_H&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;841.89&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;MM&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;2.83465&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// 1 mm in pt&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;computeLayout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;years&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cellWMax&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;PAGE_W&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;LEFT_M&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;RIGHT_M&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;52&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cellHMax&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;PAGE_H&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;TOP_M&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;BOTTOM_M&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;years&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cell&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cellWMax&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;cellHMax&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="c1"&gt;// ... gridW, gridH, leftX, gridTop, circleR, font sizes, tick step&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both &lt;code&gt;renderSVG()&lt;/code&gt; and &lt;code&gt;renderPDF()&lt;/code&gt; call &lt;code&gt;computeLayout(years)&lt;/code&gt; and then walk identical loops. Change a margin in one place, both renderers update. No divergence.&lt;/p&gt;

&lt;h2&gt;
  
  
  The layout is height-constrained — and that's a feature
&lt;/h2&gt;

&lt;p&gt;100 rows × 52 columns of &lt;em&gt;square&lt;/em&gt; circles on a single A4 page means cell size is capped by &lt;strong&gt;page height&lt;/strong&gt;, not width. The grid ends up about 135 mm wide — there are ~24 mm of empty margin on the left and right. People keep wanting to "fix" this.&lt;/p&gt;

&lt;p&gt;Earlier iterations tried:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Two-page A4&lt;/strong&gt; — wider circles, but you have to tape the pages together&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Landscape A4&lt;/strong&gt; — counterintuitively gives &lt;em&gt;smaller&lt;/em&gt; circles, because height becomes the new constraint&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stretched ovals&lt;/strong&gt; — looks bad&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The empty side margins are correct. They're the price of the proportions. I kept them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Adding pets
&lt;/h2&gt;

&lt;p&gt;Recently I added a Human / Dog / Cat selector. Dogs get 20 rows × 52 columns; cats, 25. For shorter spans, the cell size becomes width-constrained instead, so circles get bigger, and the tick axis switches from a 10-year step to a 5-year step:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;SPECIES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;human&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;years&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;placeholder&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;e.g. Ali&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;dog&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;   &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;years&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="na"&gt;placeholder&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;e.g. Spot&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;cat&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;   &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;years&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;25&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="na"&gt;placeholder&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;e.g. Mochi&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="c1"&gt;// inside computeLayout:&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;tickStep&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;years&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because all the layout state went through one function, adding species was a small diff plus a CSS segmented control. I didn't have to touch the rendering loops at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  Going against the SaaS look
&lt;/h2&gt;

&lt;p&gt;Most "free tool" landing pages use the same recipe: Inter, gradient backgrounds, rounded cards with shadows, a Lucide icon next to every heading. Safe and forgettable.&lt;/p&gt;

&lt;p&gt;I went the other way:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Fraunces&lt;/strong&gt; for the display serif (italic accent on &lt;em&gt;in weeks&lt;/em&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;JetBrains Mono&lt;/strong&gt; for everything else&lt;/li&gt;
&lt;li&gt;Warm off-white paper background with a faint radial-gradient grain&lt;/li&gt;
&lt;li&gt;One emphasis color — terracotta — used sparingly&lt;/li&gt;
&lt;li&gt;A magazine-style masthead bar at the top&lt;/li&gt;
&lt;li&gt;Inputs are typewriter-style: serif text, single underline, no boxed fields&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It looks more like a printed thing than a webapp. Which is the point — the output &lt;em&gt;is&lt;/em&gt; a printed thing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Privacy is the simple kind
&lt;/h2&gt;

&lt;p&gt;There's no backend, no &lt;code&gt;fetch&lt;/code&gt; to any server I control, no analytics, no cookies, no &lt;code&gt;localStorage&lt;/code&gt;. Your name and birthdate never leave the browser tab.&lt;/p&gt;

&lt;p&gt;The only network requests are Google Fonts and jsPDF from cdnjs. Neither receives the form data.&lt;/p&gt;

&lt;p&gt;I didn't need a privacy policy because there's nothing to disclose.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cost
&lt;/h2&gt;

&lt;p&gt;GitHub Pages serves the file. The fonts are free. jsPDF is MIT. Total monthly cost: &lt;strong&gt;$0&lt;/strong&gt;. I expect it to keep being.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Live: &lt;a href="https://alicommit-malp.github.io/life-in-weeks/" rel="noopener noreferrer"&gt;https://alicommit-malp.github.io/life-in-weeks/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Source: &lt;a href="https://github.com/alicommit-malp/life-in-weeks" rel="noopener noreferrer"&gt;https://github.com/alicommit-malp/life-in-weeks&lt;/a&gt; (MIT)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Fork it, print it, gift it.&lt;/p&gt;

</description>
      <category>productivity</category>
      <category>javascript</category>
      <category>life</category>
    </item>
    <item>
      <title>Introducing Tunnel Whisperer: Surgical Connectivity for Networks That Say "No"</title>
      <dc:creator>Ali Alp</dc:creator>
      <pubDate>Fri, 06 Mar 2026 15:54:27 +0000</pubDate>
      <link>https://dev.to/alialp/introducing-tunnel-whisperer-surgical-connectivity-for-networks-that-say-no-4hoe</link>
      <guid>https://dev.to/alialp/introducing-tunnel-whisperer-surgical-connectivity-for-networks-that-say-no-4hoe</guid>
      <description>&lt;p&gt;&lt;em&gt;Your MRI scanner needs to talk to a cloud AI platform. Your vendor needs to reach a single maintenance port on a factory-floor PLC. Your data scientist needs to query an on-premise database from a cloud notebook. The firewall says no to all of them.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Tunnel Whisperer says yes.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/Tunnel-Whisperer/Tunnel-Whisperer" rel="noopener noreferrer"&gt;Github page&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The Problem Nobody Talks About
&lt;/h2&gt;

&lt;p&gt;Enterprise networks are getting stricter. Firewalls block everything except port 443. Deep Packet Inspection kills anything that doesn't look like genuine HTTPS. Legacy devices — hospital scanners, industrial controllers, aging servers — can't run modern VPN clients. And even if you could install one, IT won't open a single inbound port.&lt;/p&gt;

&lt;p&gt;This is the &lt;strong&gt;Connectivity Gap&lt;/strong&gt;: the space between what your applications need and what your network allows.&lt;/p&gt;

&lt;p&gt;Traditional solutions don't fit:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;VPNs&lt;/strong&gt; (WireGuard, Tailscale) require UDP ports or custom protocols — blocked by DPI.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reverse proxies&lt;/strong&gt; (ngrok) expose services to the public internet — a non-starter for healthcare and manufacturing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SSH tunnels&lt;/strong&gt; get flagged and dropped by next-gen firewalls.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We built Tunnel Whisperer to close this gap.&lt;/p&gt;




&lt;h2&gt;
  
  
  What Is Tunnel Whisperer?
&lt;/h2&gt;

&lt;p&gt;Tunnel Whisperer is an open-source tool that creates &lt;strong&gt;resilient, port-to-port bridges&lt;/strong&gt; across separated private networks. It wraps your TCP traffic inside a genuine TLS-encrypted HTTPS stream using Xray's VLESS+XHTTP protocol. To the network, it looks exactly like someone browsing a website.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Client Network             Public Cloud               Server Network
+--------------+        +------------------+        +--------------+
|  tw connect  |--443--&amp;gt;|     Relay VM     |&amp;lt;--443--|   tw serve   |
|              |  HTTPS |  Caddy + Xray    |  HTTPS |              |
| local ports  |        |  (reverse proxy) |        | SSH server   |
| :5432 :3389  |        |  Firewall: 80+443|        | port fwd     |
+--------------+        +------------------+        +--------------+
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Your PostgreSQL client connects to &lt;code&gt;localhost:5432&lt;/code&gt;. Your RDP client connects to &lt;code&gt;localhost:3389&lt;/code&gt;. The traffic flows through an HTTPS tunnel to the server network, where it reaches the actual services. The applications on both ends have no idea a tunnel exists.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why We Built It
&lt;/h2&gt;

&lt;p&gt;We kept running into the same scenario: a customer with a legitimate connectivity need, a network that blocks everything, and no tool that threads the needle between "too broad" (VPN) and "too public" (ngrok).&lt;/p&gt;

&lt;p&gt;Tunnel Whisperer is designed around three principles:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Surgical access, not broad connectivity.&lt;/strong&gt; Each user gets access to exactly the ports they need — nothing more. No host-to-host networking, no route tables, no exposure of adjacent services.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Invisible to the network.&lt;/strong&gt; The traffic is indistinguishable from regular HTTPS. No special ports, no unusual protocols, no signatures for DPI to flag.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Zero trust at the relay.&lt;/strong&gt; The relay VM is a dumb pipe. It stores no SSH keys, no passwords, no application data. If it gets compromised, the attacker gets nothing useful — all plaintext remains encrypted by the SSH layer inside the tunnel.&lt;/p&gt;




&lt;h2&gt;
  
  
  How It Works: Three Layers of Encryption
&lt;/h2&gt;

&lt;p&gt;Tunnel Whisperer stacks three encryption layers, each serving a different purpose:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;What It Does&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;TLS 1.3&lt;/strong&gt; (outer)&lt;/td&gt;
&lt;td&gt;Makes the traffic look like HTTPS. Caddy handles automatic Let's Encrypt certificates on the relay.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;Xray VLESS + XHTTP&lt;/strong&gt; (middle)&lt;/td&gt;
&lt;td&gt;Routes users by UUID through the relay. XHTTP splits data into standard HTTP requests for resilience against connection timeouts.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;SSH Ed25519&lt;/strong&gt; (inner)&lt;/td&gt;
&lt;td&gt;End-to-end encryption between client and server. Public-key only — no passwords, no brute-force surface. Per-user &lt;code&gt;permitopen&lt;/code&gt; restrictions lock each client to specific ports.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The relay terminates TLS but never sees the SSH-encrypted payload. Even if someone owns the relay, they can't read your data.&lt;/p&gt;




&lt;h2&gt;
  
  
  Real-World Use Cases
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Healthcare: DICOM Through a Firewall
&lt;/h3&gt;

&lt;p&gt;A hospital's MRI scanner needs to send images to a cloud AI platform for analysis. The scanner speaks DICOM on port 104 and can't install any software. Tunnel Whisperer runs on a gateway machine on the scanner's LAN, forwarding port 104 through the HTTPS tunnel to the cloud. The scanner sends to &lt;code&gt;localhost:104&lt;/code&gt; as if the AI platform were next door.&lt;/p&gt;

&lt;h3&gt;
  
  
  Manufacturing: Vendor Access to a Single Port
&lt;/h3&gt;

&lt;p&gt;A machine vendor needs remote access to a PLC's maintenance port for diagnostics. Instead of a VPN that exposes the entire factory network, Tunnel Whisperer gives them access to exactly one port on one device. When the job is done, revoke the key — access gone.&lt;/p&gt;

&lt;h3&gt;
  
  
  Data Science: Cloud Notebook to On-Premise Database
&lt;/h3&gt;

&lt;p&gt;A data scientist running Jupyter in the cloud needs to query a PostgreSQL database behind a corporate firewall. With Tunnel Whisperer, they connect to &lt;code&gt;localhost:5432&lt;/code&gt; and run queries as if the database were local. No VPN client, no firewall exceptions, no IT tickets.&lt;/p&gt;




&lt;h2&gt;
  
  
  Getting Started in 5 Minutes
&lt;/h2&gt;

&lt;p&gt;Tunnel Whisperer ships as a single binary for Linux, Windows, and macOS. Build from source with Go 1.25+:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;make build          &lt;span class="c"&gt;# builds bin/tw&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Server Side
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;tw dashboard                &lt;span class="c"&gt;# open the web UI&lt;/span&gt;
tw create relay-server      &lt;span class="c"&gt;# 8-step wizard: provision a relay VM&lt;/span&gt;
tw create user              &lt;span class="c"&gt;# 5-step wizard: create a client with port restrictions&lt;/span&gt;
tw serve                    &lt;span class="c"&gt;# start the server&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The relay provisioning wizard handles everything: spinning up a VM on Hetzner, DigitalOcean, or AWS, configuring Caddy for TLS, installing Xray, and locking down the firewall to ports 80 and 443 only.&lt;/p&gt;

&lt;h3&gt;
  
  
  Client Side
&lt;/h3&gt;

&lt;p&gt;The server admin sends you a config bundle (a zip file with your config and SSH keys). Then:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;tw dashboard                &lt;span class="c"&gt;# drag-and-drop the zip file&lt;/span&gt;
tw connect                  &lt;span class="c"&gt;# you're in&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it. Your configured ports are now available on localhost with automatic reconnection if the connection drops.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Web Dashboard
&lt;/h2&gt;

&lt;p&gt;Both server and client modes come with a browser-based dashboard for managing everything visually:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Server dashboard&lt;/strong&gt; — create users, provision relays, monitor connected clients, stream logs in real-time, and open an interactive SSH terminal to the relay — all from the browser.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Client dashboard&lt;/strong&gt; — upload config bundles, connect/disconnect with one click, and see your active tunnels with bandwidth stats.&lt;/p&gt;

&lt;p&gt;No separate web app to deploy. The dashboard is embedded in the same &lt;code&gt;tw&lt;/code&gt; binary.&lt;/p&gt;




&lt;h2&gt;
  
  
  Per-User Access Control
&lt;/h2&gt;

&lt;p&gt;Every user gets:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A &lt;strong&gt;unique Xray UUID&lt;/strong&gt; for relay routing&lt;/li&gt;
&lt;li&gt;An &lt;strong&gt;Ed25519 key pair&lt;/strong&gt; for SSH authentication&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;&lt;code&gt;permitopen&lt;/code&gt; directive&lt;/strong&gt; restricting them to specific ports
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# authorized_keys (auto-generated)
permitopen="127.0.0.1:5432",permitopen="127.0.0.1:3389" ssh-ed25519 AAAA... alice
permitopen="127.0.0.1:104" ssh-ed25519 AAAA... vendor-dicom
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Alice can reach PostgreSQL and RDP. The vendor can reach the DICOM port. Neither can reach anything else. Revoking access is instant — remove the key from &lt;code&gt;authorized_keys&lt;/code&gt; and the UUID from the relay config. No server restart needed.&lt;/p&gt;




&lt;h2&gt;
  
  
  Built for Resilience
&lt;/h2&gt;

&lt;p&gt;Connections drop. Networks hiccup. Tunnel Whisperer handles it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Auto-reconnection&lt;/strong&gt; with exponential backoff (2s up to 30s max)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SSH keepalive&lt;/strong&gt; every 15 seconds to detect dead connections early&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;XHTTP transport&lt;/strong&gt; splits data into HTTP requests, surviving connection resets that would kill WebSocket-based tunnels&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;System service mode&lt;/strong&gt; (&lt;code&gt;tw service install&lt;/code&gt;) for auto-start on boot — Linux systemd, Windows SCM, or macOS launchd&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Performance
&lt;/h2&gt;

&lt;p&gt;We won't pretend there's zero overhead. Two relay hops and three encryption layers have a cost. Here are real numbers from our benchmarks (relay on Hetzner, ~30ms RTT):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Direct SSH&lt;/th&gt;
&lt;th&gt;Tunnel Whisperer&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;Throughput&lt;/strong&gt; (100 MB transfer)&lt;/td&gt;
&lt;td&gt;112 MB/s&lt;/td&gt;
&lt;td&gt;21 MB/s (~168 Mbps)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;Database TPS&lt;/strong&gt; (pgbench, 50 clients)&lt;/td&gt;
&lt;td&gt;~1,790&lt;/td&gt;
&lt;td&gt;~80-95&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Database latency&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;~28 ms&lt;/td&gt;
&lt;td&gt;~530-630 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Tunnel Whisperer excels at &lt;strong&gt;bulk transfers, streaming, and interactive sessions&lt;/strong&gt; where human-speed interaction or continuous data flow hides the latency. For high-frequency RPC workloads with many small round-trips, you'll want to use connection pooling and place the relay geographically close to both endpoints.&lt;/p&gt;

&lt;p&gt;The tradeoff is clear: you lose raw speed, but you gain connectivity where no other tool can get through.&lt;/p&gt;




&lt;h2&gt;
  
  
  How It Compares
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;&lt;strong&gt;Tunnel Whisperer&lt;/strong&gt;&lt;/th&gt;
&lt;th&gt;
&lt;strong&gt;VPNs&lt;/strong&gt; (Tailscale/WireGuard)&lt;/th&gt;
&lt;th&gt;
&lt;strong&gt;Reverse Proxies&lt;/strong&gt; (ngrok)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Scope&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Surgical (port-to-port)&lt;/td&gt;
&lt;td&gt;Broad (host-to-host)&lt;/td&gt;
&lt;td&gt;Public (port-to-web)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;DPI Resistance&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;High (genuine HTTPS)&lt;/td&gt;
&lt;td&gt;Low (custom protocols)&lt;/td&gt;
&lt;td&gt;Medium (standard HTTPS)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Deployment&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Gateway/sidecar&lt;/td&gt;
&lt;td&gt;Agent on every host&lt;/td&gt;
&lt;td&gt;Dev/test&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Infrastructure&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Self-hosted&lt;/td&gt;
&lt;td&gt;SaaS/hybrid&lt;/td&gt;
&lt;td&gt;SaaS&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Relay Compromise&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;No credential exposure&lt;/td&gt;
&lt;td&gt;Varies&lt;/td&gt;
&lt;td&gt;Provider-dependent&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  What's Next
&lt;/h2&gt;

&lt;p&gt;Tunnel Whisperer is currently in &lt;strong&gt;alpha&lt;/strong&gt;. The core tunnel, user management, dashboard, and multi-cloud relay provisioning are all working. Here's where we're heading:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;UDP support&lt;/strong&gt; for protocols that need it&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Multi-relay&lt;/strong&gt; topologies for geographic distribution&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Observability&lt;/strong&gt; — OpenTelemetry-format logging is already in progress&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pre-built binaries&lt;/strong&gt; and package manager distribution&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Get Involved
&lt;/h2&gt;

&lt;p&gt;Tunnel Whisperer is MIT-licensed and open source.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;GitHub:&lt;/strong&gt; &lt;a href="https://github.com/Tunnel-Whisperer/Tunnel-Whisperer" rel="noopener noreferrer"&gt;github.com/Tunnel-Whisperer/Tunnel-Whisperer&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Documentation:&lt;/strong&gt; &lt;a href="https://tunnel-whisperer.github.io/Tunnel-Whisperer" rel="noopener noreferrer"&gt;tunnel-whisperer.github.io/Tunnel-Whisperer&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Video Tutorial:&lt;/strong&gt; &lt;a href="https://www.youtube.com/watch?v=cIe0-C1IMe4" rel="noopener noreferrer"&gt;Watch the walkthrough on YouTube&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you've ever stared at a firewall rule that blocks everything and thought "there has to be a better way" — give Tunnel Whisperer a try. We'd love your feedback, bug reports, and contributions.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Tunnel Whisperer — because sometimes the only way through is to whisper.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>productivity</category>
      <category>security</category>
      <category>opensource</category>
    </item>
    <item>
      <title>A practical, governance-first framework for turning your CI/CD pipeline into a secure, reproducible, and audit-defensible supply chain control point.</title>
      <dc:creator>Ali Alp</dc:creator>
      <pubDate>Fri, 13 Feb 2026 13:56:25 +0000</pubDate>
      <link>https://dev.to/alialp/a-practical-governance-first-framework-for-turning-your-cicd-pipeline-into-a-secure-4eaa</link>
      <guid>https://dev.to/alialp/a-practical-governance-first-framework-for-turning-your-cicd-pipeline-into-a-secure-4eaa</guid>
      <description>&lt;div class="ltag__link"&gt;
  &lt;a href="/alialp" class="ltag__link__link"&gt;
    &lt;div class="ltag__link__pic"&gt;
      &lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F75246%2F73159765-d92c-4500-bf44-2ccb5ddca661.jpg" alt="alialp"&gt;
    &lt;/div&gt;
  &lt;/a&gt;
  &lt;a href="https://dev.to/alialp/scripted-ci-governing-your-build-pipeline-as-critical-infrastructure-5gaf" class="ltag__link__link"&gt;
    &lt;div class="ltag__link__content"&gt;
      &lt;h2&gt;SCRIPTED CI: Governing Your Build Pipeline as Critical Infrastructure&lt;/h2&gt;
      &lt;h3&gt;Ali Alp ・ Feb 13&lt;/h3&gt;
      &lt;div class="ltag__link__taglist"&gt;
        &lt;span class="ltag__link__tag"&gt;#devops&lt;/span&gt;
        &lt;span class="ltag__link__tag"&gt;#cicd&lt;/span&gt;
        &lt;span class="ltag__link__tag"&gt;#security&lt;/span&gt;
        &lt;span class="ltag__link__tag"&gt;#productivity&lt;/span&gt;
      &lt;/div&gt;
    &lt;/div&gt;
  &lt;/a&gt;
&lt;/div&gt;


</description>
      <category>devops</category>
      <category>cicd</category>
      <category>security</category>
      <category>productivity</category>
    </item>
    <item>
      <title>SCRIPTED CI: Governing Your Build Pipeline as Critical Infrastructure</title>
      <dc:creator>Ali Alp</dc:creator>
      <pubDate>Fri, 13 Feb 2026 13:50:52 +0000</pubDate>
      <link>https://dev.to/alialp/scripted-ci-governing-your-build-pipeline-as-critical-infrastructure-5gaf</link>
      <guid>https://dev.to/alialp/scripted-ci-governing-your-build-pipeline-as-critical-infrastructure-5gaf</guid>
      <description>&lt;p&gt;CI/CD pipelines are amazing.&lt;/p&gt;

&lt;p&gt;They build, test, package, sign, and ship our software in minutes. They automate what used to take days. They make modern development possible.&lt;/p&gt;

&lt;p&gt;They also sit at one of the most dangerous control points in your entire system.&lt;/p&gt;

&lt;p&gt;If you build regulated, safety-critical, or security-sensitive software, your CI pipeline is not “just automation.” It executes code, holds secrets, produces artifacts, and pushes to production.&lt;/p&gt;

&lt;p&gt;That makes it part of your product’s trust boundary.&lt;/p&gt;

&lt;p&gt;So the real question isn’t:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Is our application secure?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;It’s this:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Is our build system defensible?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That’s where &lt;strong&gt;SCRIPTED CI&lt;/strong&gt; comes in.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why CI Is a Supply Chain Control Point
&lt;/h2&gt;

&lt;p&gt;Most teams spend their security energy on:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Application security testing&lt;/li&gt;
&lt;li&gt;API authentication&lt;/li&gt;
&lt;li&gt;Infrastructure hardening&lt;/li&gt;
&lt;li&gt;Runtime monitoring&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;All important.&lt;/p&gt;

&lt;p&gt;But CI pipelines quietly:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Execute third-party code (GitHub Actions, plugins, integrations)&lt;/li&gt;
&lt;li&gt;Access privileged credentials (cloud roles, signing keys, tokens)&lt;/li&gt;
&lt;li&gt;Produce signed release artifacts&lt;/li&gt;
&lt;li&gt;Modify repository state&lt;/li&gt;
&lt;li&gt;Pull dependencies dynamically&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If an attacker compromises your CI, they don’t need to break your runtime.&lt;/p&gt;

&lt;p&gt;They can modify your build.&lt;/p&gt;

&lt;p&gt;And if your build is compromised, your product is compromised.&lt;/p&gt;

&lt;p&gt;That’s a supply chain problem.&lt;/p&gt;




&lt;h2&gt;
  
  
  The SCRIPTED Model
&lt;/h2&gt;

&lt;p&gt;SCRIPTED is a governance-first way to think about CI security.&lt;br&gt;
It turns abstract “best practices” into enforceable controls.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Letter&lt;/th&gt;
&lt;th&gt;Principle&lt;/th&gt;
&lt;th&gt;What It Means&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;S&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Secure Secrets&lt;/td&gt;
&lt;td&gt;Minimize and scope credentials in CI&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;C&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Control Execution&lt;/td&gt;
&lt;td&gt;Only run approved, immutable code&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;R&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Repeatable Builds&lt;/td&gt;
&lt;td&gt;Make builds deterministic&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;I&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Isolated Runtime&lt;/td&gt;
&lt;td&gt;Harden where builds execute&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;P&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Policy Enforcement&lt;/td&gt;
&lt;td&gt;Automate guardrails&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;T&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Traceability&lt;/td&gt;
&lt;td&gt;Make artifacts verifiable&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;E&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Evidence&lt;/td&gt;
&lt;td&gt;Be able to prove your controls&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;D&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Defensible Design&lt;/td&gt;
&lt;td&gt;Encode security in the system, not the process&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Let’s walk through it.&lt;/p&gt;


&lt;h2&gt;
  
  
  S — Secure Secrets
&lt;/h2&gt;

&lt;p&gt;CI pipelines often accumulate powerful credentials:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;GITHUB_TOKEN&lt;/code&gt; with write access&lt;/li&gt;
&lt;li&gt;Cloud deployment roles&lt;/li&gt;
&lt;li&gt;Container registry credentials&lt;/li&gt;
&lt;li&gt;Code signing certificates&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The default mistake? Over-permissioned tokens.&lt;/p&gt;

&lt;p&gt;Instead of this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;permissions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;write-all&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Do this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;permissions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;contents&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;read&lt;/span&gt;
  &lt;span class="na"&gt;packages&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;write&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Other guardrails:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Don’t expose secrets to fork-based PR workflows&lt;/li&gt;
&lt;li&gt;Use short-lived identities (OIDC) instead of static cloud keys&lt;/li&gt;
&lt;li&gt;Separate build credentials from deployment credentials&lt;/li&gt;
&lt;li&gt;Audit token usage regularly&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If a job doesn’t need a permission, it shouldn’t have it.&lt;/p&gt;

&lt;p&gt;Simple. Structural. Defensible.&lt;/p&gt;




&lt;h2&gt;
  
  
  C — Control Execution
&lt;/h2&gt;

&lt;p&gt;CI runs code. Sometimes that code is yours. Sometimes it’s not.&lt;/p&gt;

&lt;p&gt;Marketplace actions and plugins are convenient. They’re also external code entering your trust boundary.&lt;/p&gt;

&lt;p&gt;This is unsafe:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Why? Because tags can move.&lt;/p&gt;

&lt;p&gt;This is safer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@8f4b7f84864484a7bf31766abe9204da3cbe65b3&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pin to a commit SHA.&lt;/p&gt;

&lt;p&gt;That makes the reference immutable.&lt;/p&gt;

&lt;p&gt;Stronger moves:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Mirror critical actions into your own org&lt;/li&gt;
&lt;li&gt;Vendor high-risk actions locally&lt;/li&gt;
&lt;li&gt;Maintain an allowlist of approved actions&lt;/li&gt;
&lt;li&gt;Automatically fail builds that reference unpinned actions&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Immutable references eliminate silent drift.&lt;/p&gt;




&lt;h2&gt;
  
  
  R — Repeatable Builds
&lt;/h2&gt;

&lt;p&gt;If you rebuild a release from two years ago, do you get the same binary?&lt;/p&gt;

&lt;p&gt;If not, you don’t have reproducibility.&lt;/p&gt;

&lt;p&gt;And without reproducibility, you don’t have defensibility.&lt;/p&gt;

&lt;p&gt;Instead of:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="s"&gt; node:latest&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Use:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="s"&gt; node@sha256:...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Also:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Enforce lockfiles (&lt;code&gt;go.sum&lt;/code&gt;, &lt;code&gt;package-lock.json&lt;/code&gt;, etc.)&lt;/li&gt;
&lt;li&gt;Pin toolchain versions&lt;/li&gt;
&lt;li&gt;Eliminate dynamic dependency resolution&lt;/li&gt;
&lt;li&gt;Avoid runtime internet downloads during build&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Deterministic inputs must produce deterministic outputs.&lt;/p&gt;

&lt;p&gt;Anything else is guesswork.&lt;/p&gt;




&lt;h2&gt;
  
  
  I — Isolated Runtime
&lt;/h2&gt;

&lt;p&gt;Where your CI runs matters.&lt;/p&gt;

&lt;p&gt;Public runners are convenient, but they are shared infrastructure.&lt;/p&gt;

&lt;p&gt;For higher-assurance systems:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Use self-hosted runners&lt;/li&gt;
&lt;li&gt;Make them ephemeral (destroy after each job)&lt;/li&gt;
&lt;li&gt;Restrict outbound network access&lt;/li&gt;
&lt;li&gt;Segregate runners by trust boundary&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Treat CI runners as production infrastructure.&lt;/p&gt;

&lt;p&gt;Because they are.&lt;/p&gt;




&lt;h2&gt;
  
  
  P — Policy Enforcement
&lt;/h2&gt;

&lt;p&gt;Here’s a hard truth:&lt;/p&gt;

&lt;p&gt;Guidelines are not controls.&lt;/p&gt;

&lt;p&gt;A control that does not automatically fail is not a control.&lt;/p&gt;

&lt;p&gt;If you say:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;“All actions must be SHA pinned.”&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Then enforce it.&lt;/p&gt;

&lt;p&gt;Add a validation step that fails when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;An action is not pinned&lt;/li&gt;
&lt;li&gt;Permissions are too broad&lt;/li&gt;
&lt;li&gt;An unapproved marketplace action is referenced&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Combine this with:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Branch protection&lt;/li&gt;
&lt;li&gt;Required status checks&lt;/li&gt;
&lt;li&gt;Policy-as-Code (OPA, Conftest, etc.)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Security must be encoded into the pipeline itself.&lt;/p&gt;

&lt;p&gt;Otherwise, it will erode.&lt;/p&gt;




&lt;h2&gt;
  
  
  T — Traceability
&lt;/h2&gt;

&lt;p&gt;For every artifact deployed to production, you should be able to answer:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;What source commit built this?&lt;/li&gt;
&lt;li&gt;What dependencies were included?&lt;/li&gt;
&lt;li&gt;What toolchain was used?&lt;/li&gt;
&lt;li&gt;Has it been modified since creation?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you can’t answer those questions, incident response becomes speculation.&lt;/p&gt;

&lt;p&gt;To fix that:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Generate an SBOM for every build&lt;/li&gt;
&lt;li&gt;Produce cryptographic provenance (SLSA-style attestations)&lt;/li&gt;
&lt;li&gt;Sign artifacts (Sigstore/Cosign)&lt;/li&gt;
&lt;li&gt;Keep immutable build logs&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This turns builds from opaque processes into verifiable records.&lt;/p&gt;




&lt;h2&gt;
  
  
  E — Evidence
&lt;/h2&gt;

&lt;p&gt;Security that cannot be demonstrated does not exist.&lt;/p&gt;

&lt;p&gt;In regulated environments, you will be asked to show:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;How you control third-party execution&lt;/li&gt;
&lt;li&gt;How you manage credentials&lt;/li&gt;
&lt;li&gt;How you ensure reproducibility&lt;/li&gt;
&lt;li&gt;How you maintain artifact lineage&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So keep:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Architecture diagrams&lt;/li&gt;
&lt;li&gt;Policy definitions&lt;/li&gt;
&lt;li&gt;SBOM archives&lt;/li&gt;
&lt;li&gt;Signature records&lt;/li&gt;
&lt;li&gt;Audit logs&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Evidence is not paperwork.&lt;br&gt;
It’s proof of structural control.&lt;/p&gt;




&lt;h2&gt;
  
  
  D — Defensible Design
&lt;/h2&gt;

&lt;p&gt;This is the outcome.&lt;/p&gt;

&lt;p&gt;Not “we follow best practices.”&lt;/p&gt;

&lt;p&gt;Not “we trust our team.”&lt;/p&gt;

&lt;p&gt;Not “it should be fine.”&lt;/p&gt;

&lt;p&gt;But:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Controls are enforced technically&lt;/li&gt;
&lt;li&gt;Third-party execution is bounded&lt;/li&gt;
&lt;li&gt;Privileges are minimized&lt;/li&gt;
&lt;li&gt;Builds are deterministic&lt;/li&gt;
&lt;li&gt;Artifacts are verifiable&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You replace process-based assurances with structural guarantees.&lt;/p&gt;

&lt;p&gt;That’s defensibility.&lt;/p&gt;




&lt;h2&gt;
  
  
  Phased Adoption (Don’t Boil the Ocean)
&lt;/h2&gt;

&lt;p&gt;You don’t need to do everything at once.&lt;/p&gt;

&lt;h2&gt;
  
  
  Phase 1 — Baseline Risk Reduction (Weeks 1–4)
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Pin all actions to SHAs&lt;/li&gt;
&lt;li&gt;Reduce token permissions&lt;/li&gt;
&lt;li&gt;Enable branch protection&lt;/li&gt;
&lt;li&gt;Add basic workflow linting&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Low effort. Immediate risk reduction.&lt;/p&gt;




&lt;h2&gt;
  
  
  Phase 2 — Structural Governance (Months 1–3)
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Mirror critical actions internally&lt;/li&gt;
&lt;li&gt;Introduce “Golden Workflows”&lt;/li&gt;
&lt;li&gt;Enforce Policy-as-Code validation&lt;/li&gt;
&lt;li&gt;Deploy ephemeral runners&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Medium effort. Major risk reduction.&lt;/p&gt;




&lt;h2&gt;
  
  
  Phase 3 — Regulatory-Grade Assurance (Months 3–6)
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Hermetic builds (no external network dependency)&lt;/li&gt;
&lt;li&gt;SBOM for every artifact&lt;/li&gt;
&lt;li&gt;Signed provenance attestations&lt;/li&gt;
&lt;li&gt;Hardware-backed signing for releases&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;High effort. Audit-ready posture.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why This Matters
&lt;/h2&gt;

&lt;p&gt;CI pipelines:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Execute external code&lt;/li&gt;
&lt;li&gt;Hold powerful secrets&lt;/li&gt;
&lt;li&gt;Produce signed artifacts&lt;/li&gt;
&lt;li&gt;Sit between source and production&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;They are part of your threat model.&lt;/p&gt;

&lt;p&gt;In modern systems, the integrity of your product is inseparable from the integrity of its build process.&lt;/p&gt;

&lt;p&gt;SCRIPTED CI isn’t about paranoia.&lt;/p&gt;

&lt;p&gt;It’s about recognizing that the build is no longer a convenience layer.&lt;/p&gt;

&lt;p&gt;It’s infrastructure.&lt;/p&gt;

&lt;p&gt;And infrastructure must be governed.&lt;/p&gt;




&lt;p&gt;If you’re operating in regulated environments, or just want to level up your supply chain security, I’d be curious:&lt;/p&gt;

&lt;p&gt;Which part of SCRIPTED would be hardest to implement in your organization?&lt;/p&gt;

</description>
      <category>devops</category>
      <category>cicd</category>
      <category>security</category>
      <category>productivity</category>
    </item>
    <item>
      <title>[Boost]</title>
      <dc:creator>Ali Alp</dc:creator>
      <pubDate>Mon, 12 Jan 2026 17:39:36 +0000</pubDate>
      <link>https://dev.to/alialp/-14b5</link>
      <guid>https://dev.to/alialp/-14b5</guid>
      <description>&lt;div class="ltag__link"&gt;
  &lt;a href="/alialp" class="ltag__link__link"&gt;
    &lt;div class="ltag__link__pic"&gt;
      &lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F75246%2F73159765-d92c-4500-bf44-2ccb5ddca661.jpg" alt="alialp"&gt;
    &lt;/div&gt;
  &lt;/a&gt;
  &lt;a href="https://dev.to/alialp/a-technical-documentation-to-ultimate-freedom-in-life-5cn7" class="ltag__link__link"&gt;
    &lt;div class="ltag__link__content"&gt;
      &lt;h2&gt;A technical documentation to ultimate freedom in life&lt;/h2&gt;
      &lt;h3&gt;Ali Alp ・ Jan 12&lt;/h3&gt;
      &lt;div class="ltag__link__taglist"&gt;
        &lt;span class="ltag__link__tag"&gt;#freedom&lt;/span&gt;
      &lt;/div&gt;
    &lt;/div&gt;
  &lt;/a&gt;
&lt;/div&gt;


</description>
      <category>freedom</category>
    </item>
  </channel>
</rss>
