<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0" xmlns:cc="http://cyber.law.harvard.edu/rss/creativeCommonsRssModule.html">
    <channel>
        <title><![CDATA[uninterrupted engineering - Medium]]></title>
        <description><![CDATA[We are a specialized DevOps engineering team focused on e-commerce infrastructure. With deep expertise in MedusaJS and modern cloud technologies, we build, optimize, and manage high-performance online stores that scale. - Medium]]></description>
        <link>https://medium.com/u11d-tech-blog?source=rss----24bcdeab33ef---4</link>
        <image>
            <url>https://cdn-images-1.medium.com/proxy/1*TGH72Nnw24QL3iV9IOm4VA.png</url>
            <title>uninterrupted engineering - Medium</title>
            <link>https://medium.com/u11d-tech-blog?source=rss----24bcdeab33ef---4</link>
        </image>
        <generator>Medium</generator>
        <lastBuildDate>Thu, 05 Nov 2026 18:32:46 GMT</lastBuildDate>
        <atom:link href="https://medium.com/feed/u11d-tech-blog" rel="self" type="application/rss+xml"/>
        <webMaster><![CDATA[yourfriends@medium.com]]></webMaster>
        <atom:link href="http://medium.superfeedr.com" rel="hub"/>
        <item>
            <title><![CDATA[Boosting GPU Performance by Reordering Unstructured Mesh Cells]]></title>
            <link>https://medium.com/u11d-tech-blog/boosting-gpu-performance-by-reordering-unstructured-mesh-cells-1f8432e591c1?source=rss----24bcdeab33ef---4</link>
            <guid isPermaLink="false">https://medium.com/p/1f8432e591c1</guid>
            <category><![CDATA[gpu]]></category>
            <category><![CDATA[optimization]]></category>
            <category><![CDATA[software-engineering]]></category>
            <dc:creator><![CDATA[Aleksy Bohdziul]]></dc:creator>
            <pubDate>Wed, 04 Nov 2026 13:50:05 GMT</pubDate>
            <atom:updated>2026-11-04T13:50:05.389Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*fqmPlkphuj71dId7wrEczg.png" /></figure><p>We’re building a GPU flood solver for unstructured 2D meshes. On every step, each cell reads its neighbors’ state and turns those values into fluxes across its edges. Before optimizing the solver, we wanted to answer one question: how much does the order of cells in memory affect the speed of those neighbor reads?</p><p>To find out, we swapped the solver for Game of Life on the same kind of mesh. The memory access pattern is the same (read your neighbors, write yourself), but there’s almost no math, so the result mostly measures memory performance.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/770/0*hTPODevZ0pBIQ-UD.gif" /></figure><p>Storing cells along a space-filling curve instead of in random order made the benchmark 1.9–3x faster. We didn’t change the kernel or the mesh. The simulation computes the same result, only the cell numbering changed.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*5NfPoWHNdPh02ZQC.png" /></figure><p>Cell reordering assigns nearby mesh cells nearby positions in memory so GPU neighbor reads can reuse cache lines more effectively.</p><h3>Why Cell Ordering Affects GPU Performance</h3><p>A GPU memory system fetches data in cache-line-sized transactions. If a cell’s neighbors live next to it in memory, one fetch can cover many of them. If they’re scattered, neighbor reads can require separate trips to device memory.</p><p>Meshes don’t always come with a useful order. Our Voronoi mesh numbers its cells in the order the random sites were generated, so random order is our baseline: it’s what you get if you do nothing. The hex grids naturally come out row by row, which already has decent locality, so we shuffled them to create the same baseline.</p><p>A space-filling curve — Hilbert, Morton, or Peano — snakes through the 2D domain. Cells that are near each other in space then tend to be near each other in memory.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*Kss30DpwflgMDLYU.png" /></figure><p>Each dot is colored by the cell’s position in memory. With random order (left), you get noise. With Hilbert order (right), memory sweeps through the domain in compact blocks.</p><h3>Benchmark Setup</h3><ul><li><strong>Hardware:</strong> an M1 Pro running Taichi 1.7.4, a Python framework that compiles kernels to the GPU, on the Vulkan backend. On a Mac, MoltenVK translates Vulkan to Metal. Every timed run reported backend: vulkan, so nothing quietly fell back to the CPU.</li><li><strong>Meshes:</strong> stored as CSR adjacency graphs. A 768x768 hex grid (589,824 cells, 3.53M directed edges), a 512x512 hex grid (262,144 cells), and a Voronoi mesh built from 200,000 random sites with scipy.</li><li><strong>Orderings:</strong> each one is a permutation applied to the cells before the run. Random uses rng.permutation. For the curves, we snap cell centers to a grid, compute each cell&#39;s position along the curve, and sort with argsort. Neighbor indices get remapped to match. Geometry, initial state, and rules stay the same.</li><li><strong>Kernel:</strong> two i32 buffers, one for the current state and one for the next. One kernel counts live neighbors along each CSR row and looks up birth/survive tables, then a second one copies the new state back. Standard B3/S23 rules.</li><li><strong>Timing:</strong> an untimed warmup for each ordering, ti.sync() on both sides of the timed region, and no reads back to the host inside that region. Each number is the median of 5 runs.</li></ul><h3>GPU Performance Results</h3><p>Hex 768x768, 1000 steps, 100 warmup steps:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*73FqgCQFdyZC386-3RT1WA.png" /></figure><p>Smaller meshes, 500 steps, 50 warmup steps:</p><figure><img alt="" src="https://cdn-images-1.medium.com/proxy/1*gZ9KAu2pF_l6WSAHYAoBGw.png" /></figure><h3>The choice of curve has little effect</h3><p>Peano came out on top for both hex meshes in this run, while Hilbert won on Voronoi. In a repeat run, Morton took first place on the 512-cell hex mesh. The curves stayed within 9% of one another, and each beat random order by about 1.9–3x. All three were effective. Our solver uses Hilbert, but Morton is the simplest to implement, so the example below uses it.</p><h3>Random ordering limits throughput</h3><p>Under random order, every mesh runs at about 0.9 billion cell updates per second, whatever its size or shape. Random ordering causes poor cache reuse, so mesh layout has little effect. With a curve, the same meshes reach 1.8–2.5 billion updates per second because neighbors are stored closer together.</p><h3>Memory is the bottleneck</h3><p>If you count only the bytes each step has to move (edge list, row offsets, state reads, and writes), the curve orders move about 105–111 GB/s on the big hex mesh. That’s more than half of the M1 Pro’s 200 GB/s spec. Random order manages 37 GB/s of useful traffic and spends the rest of its time waiting on cache misses.</p><h3>How to Reorder Cells in Your Own Mesh</h3><p>You need two NumPy functions. morton_order takes cell centers and returns a permutation, and reorder_csrrenumbers a CSR adjacency graph to match it. Here, order maps each new cell position to its old cell ID; new_id is the inverse mapping used to rewrite neighbor indices. Apply the same permutation to every other per-cell array you have (state, geometry, boundary flags).</p><pre>import numpy as np<br><br>def morton_order(xy, bits=10):<br>    &quot;&quot;&quot;Permutation that sorts cells along a Z-order (Morton) curve.&quot;&quot;&quot;<br>    lo, hi = xy.min(0), xy.max(0)<br>    g = ((xy - lo) / np.where(hi &gt; lo, hi - lo, 1) * (2**bits - 1)).astype(np.uint64)<br>    key = np.zeros(len(xy), np.uint64)<br>    for b in range(bits):<br>        key |= ((g[:, 0] &gt;&gt; b) &amp; 1) &lt;&lt; (2 * b)<br>        key |= ((g[:, 1] &gt;&gt; b) &amp; 1) &lt;&lt; (2 * b + 1)<br>    return np.argsort(key, kind=&quot;stable&quot;)<br>def reorder_csr(indptr, indices, order):<br>    &quot;&quot;&quot;Renumber cells so that new cell k is old cell order[k].&quot;&quot;&quot;<br>    new_id = np.empty_like(order)<br>    new_id[order] = np.arange(len(order))<br>    deg = np.diff(indptr)[order]<br>    new_indptr = np.concatenate([[0], np.cumsum(deg)])<br>    src = np.repeat(indptr[order] - new_indptr[:-1], deg) + np.arange(new_indptr[-1])<br>    return new_indptr, new_id[indices[src]]<br>order = morton_order(positions)<br>indptr, indices = reorder_csr(indptr, indices, order)  # int64; cast with .astype(np.int32) for i32 GPU buffers<br>state = state[order]</pre><p>With bits=10, the domain is split into a 1024x1024 grid. Cells that land in the same grid square keep their original relative order, so if your mesh is much finer than that, increase bits.</p><h3>What it costs</h3><p><strong>Reordering is a one-time cost.</strong> On the 768 hex mesh, the code above runs in about 110 ms: 74 ms to compute the keys and sort, 33 ms to rebuild the CSR graph. Each step saves about 450 µs, so it pays for itself after roughly 240 steps. You only do it when you load the mesh.</p><p><strong>Reading state back to the CPU is slow.</strong> If we copy the full state to the host after every step, the 768 hex mesh with Hilbert order drops from 4033 to 565 steps/s (median of 5 runs of 1000 steps, ranging from 510 to 826). Each readback costs about 1.5 ms, six times the step time. A flood run still needs periodic output, so how often you read back matters about as much as how you order the cells.</p><h3>Caveats</h3><p>All of these numbers come from a single M1 Pro going through MoltenVK. A discrete GPU with a different cache setup will give different ratios. Game of Life also does almost no math per neighbor, while a real flux kernel does plenty, so the gap should shrink. Treat 2–3x as the most you can expect from reordering on this hardware. Your own kernel will probably gain less.</p><h3>Conclusion</h3><p>If your GPU code gathers neighbor data on an unstructured mesh, check the order of your cells. If the mesher or loader hands them over in arbitrary order, you’re probably leaving speed on the table. Sorting them along a Morton curve takes about 20 lines of NumPy and a tenth of a second when the mesh loads, and on this benchmark it made every step 2–3x faster.</p><p>For us, the real test is the flood solver itself. It already orders its cells along a Hilbert curve, and next we’ll time its flux kernel the same way to see how much of that speedup survives once the kernel does real math.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=1f8432e591c1" width="1" height="1" alt=""><hr><p><a href="https://medium.com/u11d-tech-blog/boosting-gpu-performance-by-reordering-unstructured-mesh-cells-1f8432e591c1">Boosting GPU Performance by Reordering Unstructured Mesh Cells</a> was originally published in <a href="https://medium.com/u11d-tech-blog">uninterrupted engineering</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Beyond Prompts: Enforcing Repository Rules With Git Hooks]]></title>
            <link>https://medium.com/u11d-tech-blog/beyond-prompts-enforcing-repository-rules-with-git-hooks-32cc2ff4c694?source=rss----24bcdeab33ef---4</link>
            <guid isPermaLink="false">https://medium.com/p/32cc2ff4c694</guid>
            <category><![CDATA[agent-harness]]></category>
            <category><![CDATA[ai]]></category>
            <category><![CDATA[git-hooks]]></category>
            <category><![CDATA[quality-assurance]]></category>
            <dc:creator><![CDATA[Daniel Kraszewski]]></dc:creator>
            <pubDate>Wed, 21 Oct 2026 07:19:47 GMT</pubDate>
            <atom:updated>2026-10-21T07:19:47.638Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*cL-eNSvDPbT_qlqv_fcZOQ.png" /></figure><p>You have probably already tried the obvious thing. You wrote the rules down — in AGENTS.md, CLAUDE.md, .cursor/rules, or whatever file your agent reads first. Do not commit without tests. Do not leave a component without a story. Run the type checker before you say you are done. The agent agreed, enthusiastically, and then went ahead and did something else anyway.</p><p>This is not a prompt-quality problem, and it is not specific to any one model. Large language models optimize for the next plausible token, not for your repository’s conventions. In a small codebase you can review every diff and catch the drift by hand. In a large one you cannot: the agent touches a dozen files across a monorepo, the formatting is off, a component is missing its story, a test that used to pass now does not, and the commit lands anyway because nobody is running the checks at the moment the change is written.</p><p>The fix is not a better prompt; it is a gate. A Git hook runs your checks on every change, at git commit and git push, whoever - or whatever - wrote it. The tool I use to manage them is <a href="https://github.com/evilmartians/lefthook">Lefthook</a>: a single, dependency-free Go binary, driven by one YAML file, able to run jobs in parallel and re-stage the files it fixes. It reaches past Git, too: an ai: block (currently beta) lets Claude Code, Cursor, Codex, and Copilot run Lefthook hooks on their own events, so your checks fire when the agent stops working - before anything is ever committed. What follows is a working setup: the Git hooks first, then the same checks wired into the agent.</p><h3>Quick Reference</h3><p>If you want the short answer before the walkthrough, this is the whole tool at a glance:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*kmqe-RqfWZ6585e6IDK5dw.png" /></figure><p>The config we build in the next sections wires up five checks on commit and two on push; later, the same checks are attached to your coding agent’s events.</p><h3>Why Instructions Are Not Enforcement</h3><p>A written rule is advisory. The agent may follow it, may partially follow it, or may follow it for the first three files and forget by the fourth — and you will not know which until you read the diff. A hook is deterministic. It runs the same command on the same files every single time, it fails closed, and it does not care who authored the change. If the check fails, the commit does not happen.</p><p>That difference matters more, not less, as codebases grow. Instructions have to be read, remembered, and applied; enforcement is automatic. It also gives you a single place to put the rules that are specific to your project — the ones no linter ships with, like “every component must have a story” — instead of repeating them across agent instruction files and hoping.</p><p>Lefthook fits here because it is small. It is a Go binary with no runtime to install, and it reads lefthook.yml on every hook run, so you never reinstall after editing the config - for Git hooks or for the agent hooks we add later.</p><h3>Install Lefthook</h3><p>The most common route is the package manager you already use. For a Node project that is the lefthook npm package:</p><pre>pnpm add -D lefthook     # or: npm install --save-dev lefthook / yarn add --dev lefthook</pre><p>There are equivalents for almost every ecosystem, all documented in the <a href="https://lefthook.dev/install/">installation guide</a>: brew install lefthook on macOS and Linux, go install github.com/evilmartians/lefthook/v2@v2.1.17, pipx install lefthook, gem install lefthook, plus apt, rpm, apk, winget, scoop, snap, and mise. If you would rather not use a package manager at all, download the binary from the <a href="https://github.com/evilmartians/lefthook/releases">latest release</a> and put it on your PATH; lefthook self-update keeps it current.</p><p>For CI the mechanics are slightly different, because Git hooks are a local, client-side feature. Nothing runs your pre-commit hook in a container that just cloned the repo - there is no .git/hooks/pre-commit doing work unless you install it. So the CI job installs Lefthook and then calls the hook explicitly:</p><pre>pnpm add -D lefthook<br>pnpm exec lefthook run pre-commit --all-files</pre><p>--all-files replaces {staged_files} with every tracked file, which is what you want when there is no index to speak of. There is no official Lefthook GitHub Action and no official Docker image, so in CI you install the binary yourself - brew, curl, npm, or your base image&#39;s package manager - and call the hook explicitly. One detail to know: when the npm package is installed with CI=true set, its postinstall script deliberately skips hook installation, so a CI run will not try to wire up .git/hooks that nothing will execute. Your CI step calling lefthook run is what does the work.</p><h3>Step 1: Write the Config</h3><p>The whole configuration is one file at the repository root. This is the shape I use on a TypeScript/React project, and it is the config the runs below use:</p><pre># lefthook.yml<br>min_version: 2.1.10<br>lefthook: pnpm exec lefthook<br>glob_matcher: doublestar</pre><pre>pre-commit:<br>  jobs:<br>    - name: format staged files<br>      glob: &quot;**/*.{js,jsx,ts,tsx,json,md,yml,yaml,css}&quot;<br>      run: pnpm format {staged_files}<br>      stage_fixed: true</pre><pre>    - name: lint staged files<br>      glob: &quot;**/*.{js,jsx,ts,tsx}&quot;<br>      run: pnpm lint {staged_files}<br>      stage_fixed: true</pre><pre>    - name: typecheck<br>      run: pnpm check:types</pre><pre>    - name: stories-present<br>      glob: &quot;src/components/**/*.{ts,tsx}&quot;<br>      exclude: &quot;**/*.stories.{ts,tsx}&quot;<br>      run: pnpm check:stories {staged_files}</pre><pre>    - name: unit tests<br>      run: pnpm test:unit</pre><pre>pre-push:<br>  parallel: true<br>  jobs:<br>    - name: full verification<br>      run: pnpm verify</pre><pre>    - name: storybook tests<br>      run: pnpm test:storybook</pre><p>Seven jobs in total, split across the two hooks:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*9qBe5oLgpp6LBrKKsqCp3w.png" /></figure><p>A few keys carry most of the weight. The format, lint, check:*, and test:* targets are your own package.json scripts, so the config stays tool-agnostic: replacing Prettier with Biome is a change to one script body, not to seven hook entries. jobs is the modern list form (the older commands: map still works, but jobs also support scripts and groups). glob filters the file list a job receives, and exclude removes files from that filtered list. run is the command, executed with sh. The {staged_files} template expands to the files staged for the current commit - for a pre-push job you would use {push_files}, and {all_files} gives you everything tracked.</p><p>stage_fixed: true is the one that changes how it feels to use. When a job rewrites its files - Prettier reformatting, ESLint applying an autofix - Lefthook runs git add on them afterward, so the fixes land in the same commit instead of leaving a dirty working tree behind. It only works on pre-commit, and because each of those jobs mutates the index, I keep the auto-fixing jobs sequential by leaving parallel at its default (false). If the git add fails, Lefthook fails the hook rather than let the commit go through with unfixed content. If you would rather fail than let a hook rewrite files, invert the two auto-fixing jobs: run prettier --check and eslint without --fix, drop stage_fixed, and the pre-commit hook becomes a pure, parallel-safe gate.</p><p>The typecheck, stories-present, and unit tests jobs do not use {staged_files}; they run against the whole project, because a change in one file can break a type or a test somewhere else. The stories-present job is the interesting one for the agent narrative: it is a rule that exists only in this repository. Its glob is the part that matters, and it is also the part that is easy to get wrong - see <a href="https://u11d.com/blog/beyond-prompts-enforcing-rules-git-hooks/#the-glob-that-silently-disables-your-hook">The Glob That Silently Disables Your Hook</a>.</p><p>The pre-push hook is deliberately broader and parallel. It runs pnpm verify (types, unit tests, and the custom story check) alongside the component tests. This is the layer that catches what a distracted --no-verify commit slipped past, and it is the right place for the slower checks you do not want on every keystroke.</p><h3>Step 2: Install and Run the Hooks</h3><p>Install the hooks once per clone:</p><pre>pnpm exec lefthook install</pre><pre>sync hooks: ✔️(pre-push, pre-commit)</pre><p>That command writes small shell scripts into .git/hooks/ for each configured hook; each script simply calls lefthook run &lt;hook&gt;. Because the config is read fresh every time a hook runs, editing lefthook.yml never requires reinstalling. You can confirm the hooks are present and synchronized with:</p><pre>pnpm exec lefthook validate     # config is well-formed<br>pnpm exec lefthook check-install # exit 0 if hooks are installed and in sync</pre><p>To run a hook without committing, lefthook run is the entry point. lefthook run pre-commit runs the hook as-is, lefthook run pre-commit --all-files forces every tracked file through it, --file path/to/file forces a specific list, and --job or --tag narrows it to specific jobs. Any hook name is a valid target, so a custom block such as verify: becomes lefthook run verify.</p><p>Two escape hatches are worth knowing. LEFTHOOK=0 git commit disables hooks for a single command, and git commit --no-verify bypasses hooks at the Git level. I mention them because they are necessary - but they are also exactly why the push hook and CI matter: a rule enforced only at commit time is a rule that a tired developer or a confident agent can skip.</p><p>Finally, teams rarely want identical hooks on every machine. A lefthook-local.yml file (git-ignored) merges over lefthook.yml, so an individual can set skip: true on a slow job or re-tag a command without touching the shared config.</p><h3>What It Looks Like When a Commit Is Blocked</h3><p>Here is a real run, on a small demo repository, of an agent finishing a change and committing it. The change adds a greet helper written in double quotes with a let that is never reassigned, and a Modal component with no story file. The first attempt:</p><pre>$ git commit -m &quot;feat: add greeting helper and modal component&quot;<br>╭───────────────────────────────────────╮<br>│ lefthook  v2.1.17   hook:  pre-commit │<br>╰───────────────────────────────────────╯<br>┃  format staged files ❯ <br>$ prettier --write lefthook.yml src/components/Modal.ts src/greeting.ts<br>lefthook.yml 12ms<br>src/components/Modal.ts 18ms (unchanged)<br>src/greeting.ts 3ms</pre><pre>┃  lint staged files ❯ <br>$ eslint --fix src/components/Modal.ts src/greeting.ts</pre><pre>┃  typecheck ❯ <br>$ tsc --noEmit</pre><pre>┃  stories-present ❯ <br>$ node scripts/check-stories.mjs src/components/Modal.ts<br>Missing story files for:<br>  src/components/Modal.ts<br>[ELIFECYCLE] Command failed with exit code 1.</pre><pre>exit status 1<br>...</pre><pre>  ────────────────────────────────────<br>summary: (done in 1.24 seconds)<br>✓ format staged files (0.26 seconds)<br>✓ lint staged files (0.44 seconds)<br>✓ typecheck (0.37 seconds)<br>✓ unit tests (0.11 seconds)<br>✗ stories-present (0.05 seconds)</pre><p>The commit does not happen. Two different things occurred in that run, and they are the two halves of why this works. First, format and lint silently fixed the sloppy code: Prettier rewrote the quotes and style, ESLint turned the let into a const, and stage_fixed re-staged both. You can prove the fixed content is what is staged, not just present in the working tree:</p><pre>$ git show :src/greeting.ts<br>export function greet(name: string) {<br>  const message = &#39;Hello, &#39; + name + &#39;!&#39;<br>  return message<br>}</pre><p>Second, stories-present refused the change. The component exists, the story does not, and no amount of instruction-following on the agent&#39;s part makes the commit land. Add the missing story and the same commit passes, every job green:</p><pre>$ git add src/components/Modal.stories.ts<br>$ git commit -m &quot;feat: add greeting helper and modal component&quot;<br>...</pre><pre>  ────────────────────────────────────<br>summary: (done in 1.17 seconds)<br>✓ format staged files (0.14 seconds)<br>✓ lint staged files (0.47 seconds)<br>✓ typecheck (0.38 seconds)<br>✓ stories-present (0.05 seconds)<br>✓ unit tests (0.12 seconds)<br>[main de227fe] feat: add greeting helper and modal component<br> 4 files changed, 17 insertions(+), 4 deletions(-)</pre><p>git push runs the second gate. Both jobs run in parallel:</p><pre>$ git push origin main<br>╭─────────────────────────────────────╮<br>│ lefthook  v2.1.17   hook:  pre-push │<br>╰─────────────────────────────────────╯<br>┃  storybook tests ❯ <br>$ node scripts/check-stories.mjs<br>Story check passed for 2 component file(s).</pre><pre>┃  full verification ❯ <br>$ pnpm check:types &amp;&amp; pnpm test:unit &amp;&amp; pnpm check:stories<br>$ tsc --noEmit<br>$ node --test &quot;src/**/*.test.ts&quot;<br>✔ adds two numbers (0.467667ms)<br>ℹ pass 1<br>ℹ fail 0<br>$ node scripts/check-stories.mjs<br>Story check passed for 2 component file(s).</pre><pre>  ────────────────────────────────────<br>summary: (done in 0.65 seconds)<br>✓ storybook tests (0.06 seconds)<br>✓ full verification (0.55 seconds)</pre><h3>The Glob That Silently Disables Your Hook</h3><p>The config above includes glob_matcher: doublestar, and that one line is doing more than it looks. A pattern like src/components/**/*.{ts,tsx} reads as correct, but without doublestar it never matches a file sitting directly in src/components/. Here is what that looks like on a commit that adds src/components/Modal.ts with no story:</p><pre>$ git commit -m &quot;feat: add modal component&quot;<br>...<br>│  stories-present (skip) no files for inspection<br>  ────────────────────────────────────<br>summary: (done in 1.10 seconds)<br>✓ format staged files (0.11 seconds)<br>✓ lint staged files (0.49 seconds)<br>✓ typecheck (0.38 seconds)<br>✓ unit tests (0.12 seconds)<br>[main 511f599] feat: add modal component<br> 1 file changed, 3 insertions(+)</pre><p>The check did not fail. It did not run at all. In Lefthook’s default matcher (gobwas), ** matches <strong>one or more</strong> directories, not zero or more, so src/components/**/*.ts requires at least one directory between src/components/ and the file - it matches src/components/Button/Button.ts but not src/components/Modal.ts. With no matching files, Lefthook prints (skip) no files for inspection and moves on, and the commit succeeds. A guard you believe is watching is worse than no guard, because you stop checking by hand.</p><p>There are two ways out. The recommended one is to switch to standard glob semantics for the whole config:</p><pre>glob_matcher: doublestar</pre><p>With doublestar, **/ matches zero or more directories, so src/components/**/*.{ts,tsx} matches Modal.ts as expected - this is the reason the setting appears at the top of the config above. The catch is that the same setting also makes * separator-aware: a bare *.ts now matches only files in the repository root, where the default matcher had let * cross /. That is why every file glob in the config above uses **/*.{...}: with doublestar, that is how you say &quot;at any depth&quot;.</p><p>The second option is to keep the default matcher and write the pattern for its rules: because * crosses / there, src/components/*.{ts,tsx} already matches at any depth. That works, but it depends on a matcher behavior most people do not expect, so I prefer the explicit switch. Either way, the lesson generalizes: after you add a file-filtering rule, verify it actually fires. Lefthook&#39;s (skip) no files for inspection line in the output is the tell.</p><h3>Make the Agent Run the Checks Too</h3><p>The hooks above fire on git commit and git push. An agent that edits twenty files and stops - without committing - never triggers them. Lefthook has a beta feature for exactly this: an ai: block that declares which of your hooks an agent should call on which of its own events. During lefthook install, Lefthook generates the provider&#39;s settings file for you.</p><pre>ai:<br>  claude:<br>    Stop: verify<br>  cursor:<br>    stop: verify</pre><pre>verify:<br>  jobs:<br>    - name: typecheck<br>      run: pnpm check:types<br>    - name: unit tests<br>      run: pnpm test:unit</pre><p>Stop is Claude Code&#39;s event for &quot;the agent finished responding&quot;, and stop is the Cursor equivalent; both map to the verify hook that Lefthook then runs. Supported providers are claude, codex, cursor, and copilot. Running lefthook install with the config above updates .claude/settings.json:</p><pre>{<br>  &quot;hooks&quot;: {<br>    &quot;Stop&quot;: [<br>      {<br>        &quot;hooks&quot;: [{ &quot;type&quot;: &quot;command&quot;, &quot;command&quot;: &quot;pnpm exec lefthook run verify&quot; }]<br>      }<br>    ]<br>  }<br>}</pre><p>Note the command. Generated hook commands use the top-level lefthook: value when it is set - here pnpm exec lefthook - and otherwise fall back to the absolute path of the binary that ran install. Since that absolute path is machine-specific and would poison a committed settings file, setting lefthook: to a portable command is the difference between a file your teammates can share and a file that only works on your laptop. This feature is marked beta, so treat the generated output as version-sensitive and keep an eye on it when you upgrade.</p><h3>Lefthook vs. pre-commit vs. Husky</h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*sN5i3ySZIh3xbXazG5KW0g.png" /></figure><p>There is no objective winner here; pick the one that fits how your repository already works. If you rely on pre-commit’s managed, per-language hook environments, stay there — that isolation is real value. If your repo is Node-based, Husky is familiar and perfectly capable. I reach for Lefthook for three reasons: it is one binary so nothing new has to be installed at runtime, its jobs run in parallel so the hook stays fast, and {staged_files}, stage_fixed, and glob filtering are built into the config rather than pushed into scripts. As always with tools, the right answer is the one your team will actually keep running.</p><h3>Troubleshooting &amp; Gotchas</h3><p><strong>A glob that matches nothing silently skips the job.</strong> This is the failure from the section above, and it is the most dangerous one because the hook reports success. ** in the default gobwas matcher means one or more directories; (skip) no files for inspection in the output means your filter is excluding everything. Set glob_matcher: doublestar and use **/ where you mean any depth.</p><p><strong>stage_fixed only works on </strong><strong>pre-commit, and it needs the index.</strong> It calls git add after the job rewrites files, so it cannot fix anything on pre-push or a custom hook. Keep auto-fixing jobs on pre-commit and sequential: if several jobs run in parallel and rewrite the index at the same time, keep them out of that batch.</p><p><strong>--no-verify and </strong><strong>LEFTHOOK=0 bypass everything.</strong> Hooks are a convenience gate, not a security boundary. For rules that must hold, run the same checks in CI (lefthook run pre-commit --all-files) and make that job required. Do not rely on the client-side hook as your only enforcement.</p><p><strong>pnpm gates the postinstall script.</strong> The lefthook npm package installs hooks from a postinstall script, but pnpm blocks dependency build scripts by default. In pnpm 11/12 the setting moved from pnpm.onlyBuiltDependencies in package.json to an allowBuilds map in pnpm-workspace.yaml; approve it and the postinstall runs. Failing that, lefthook install explicitly does the same thing.</p><p><strong>Fresh releases can be held back by supply-chain policies.</strong> pnpm refused to install the just-published lefthook@2.1.17 because it was inside the default minimumReleaseAge window; minimumReleaseAge: 0 (or waiting out the window) is the adjustment. Useful to know when a version you just saw released will not install yet.</p><p><strong>A long file list is split into several commands.</strong> Command-line length is capped on every OS, so Lefthook runs your command in batches when {staged_files} is large. A linter that prints per-run summaries will print one per batch.</p><p><strong>Check that the hooks are actually installed.</strong> A fresh clone has no hooks until lefthook install runs - the npm postinstall usually handles it, but clones that skip install scripts will not, and CI=true makes the postinstall skip on purpose. lefthook check-install returns exit code 0 only when the hooks are present and in sync, which makes it a cheap guard in a setup script.</p><h3>Conclusion</h3><p>Agentic coding has not changed the need for quality gates; it has made them more valuable, because the volume of change goes up while the attention per change goes down. Writing the rules in an instruction file is still worth doing, but it is not enforcement. A Lefthook config is a few dozen lines of YAML that makes the same checks run on every commit and push, whoever — or whatever — wrote the code, and the beta ai: block extends the same jobs to the agent&#39;s own events. Start with one hook: format and lint the staged files, re-stage the fixes, and refuse the commit if the tests fail. In a large codebase, that one gate prevents more bad diffs than any prompt.</p><h3>Resources</h3><ul><li><a href="https://lefthook.dev/">Lefthook documentation</a></li><li><a href="https://lefthook.dev/configuration/">Configuration reference</a></li><li><a href="https://lefthook.dev/install/">Installation guide</a></li><li><a href="https://lefthook.dev/configuration/glob_matcher">glob_matcher</a></li><li><a href="https://lefthook.dev/configuration/stage_fixed">stage_fixed</a></li><li><a href="https://lefthook.dev/configuration/ai">ai - agent hooks (beta)</a></li><li><a href="https://github.com/evilmartians/lefthook">GitHub repository</a></li><li><a href="https://pre-commit.com/">pre-commit</a></li></ul><h3>FAQ</h3><h3>My AI coding agent keeps committing code that fails lint and tests. How do I stop it?</h3><p>Install Git hooks with Lefthook so the checks run before the commit is created. Add a lefthook.yml with a pre-commit hook whose jobs run your formatter, linter, type checker, and tests, then run lefthook install once per clone. If any Lefthook job exits non-zero, the commit is aborted, so code that fails your checks cannot be committed even if an agent ignored the same rules written in an instruction file.</p><h3>How do I make Git hooks run automatically after every clone without asking each developer to install them?</h3><p>Add Lefthook to your project’s dependencies — for example pnpm add -D lefthook or npm install --save-dev lefthook. The package&#39;s postinstall script runs lefthook install, which writes the hooks into .git/hooks for each developer automatically. Note that pnpm blocks dependency build scripts by default, so you must approve Lefthook&#39;s build in pnpm-workspace.yaml, and a fresh clone can always run lefthook install manually.</p><h3>Why did my Lefthook job not run and print “no files for inspection”?</h3><p>Lefthook’s default glob matcher treats ** as one or more directories, so a pattern like src/components/**/*.ts does not match src/components/Modal.ts - it matches nothing, and the job is skipped instead of failing. Set glob_matcher: doublestar in lefthook.yml so **/ matches zero or more directories, and remember that with doublestar a bare *.ts only matches the repository root, so use **/*.ts for files at any depth.</p><h3>How do I make Lefthook commit the files that a formatter fixed?</h3><p>Set stage_fixed: true on the Lefthook job. After the job&#39;s command rewrites the staged files, Lefthook runs git add on them, so the corrected content is included in the same commit. stage_fixed works only on the pre-commit hook, and jobs that use it should run sequentially rather than in parallel because they modify the Git index.</p><h3>Can I skip Git hooks for a single commit?</h3><p>Yes. Run LEFTHOOK=0 git commit to disable Lefthook for that one command, or git commit --no-verify to skip Git hooks entirely. Both are intentional escape hatches; because either can bypass a pre-commit hook, rules that must always hold should also be enforced in CI by running lefthook run pre-commit --all-files.</p><h3>How do I run Lefthook hooks in CI when hooks are a local Git feature?</h3><p>Install the Lefthook binary in your CI job, then invoke the hook explicitly instead of relying on .git/hooks: pnpm exec lefthook run pre-commit --all-files. The --all-files flag replaces {staged_files} with every tracked file, since there is no staging area to speak of. Setting CI=true makes the Lefthook npm package skip its postinstall hook installation, which is the desired behavior in CI.</p><h3>Does Lefthook work with Claude Code, Cursor, Codex, and GitHub Copilot?</h3><p>Yes, through the beta ai: block in lefthook.yml. You map a provider&#39;s event names - for example Claude Code&#39;s Stop or Cursor&#39;s stop - to a Lefthook hook, and lefthook install generates the provider&#39;s settings file so the agent calls lefthook run &lt;hook&gt; when that event fires. The feature supports claude, codex, cursor, and copilot, and because it is beta its generated files are worth re-checking after a Lefthook upgrade.</p><h3>Is Lefthook faster than pre-commit or Husky?</h3><p>Lefthook is distributed as a single Go binary with no runtime to load, and it can run a hook’s jobs in parallel, so it does not pay the startup cost of a Python framework or a Node process. pre-commit’s advantage is its isolated per-language hook environments, and Husky’s is familiarity in Node projects, so the choice usually comes down to which model fits your repository rather than raw speed.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=32cc2ff4c694" width="1" height="1" alt=""><hr><p><a href="https://medium.com/u11d-tech-blog/beyond-prompts-enforcing-repository-rules-with-git-hooks-32cc2ff4c694">Beyond Prompts: Enforcing Repository Rules With Git Hooks</a> was originally published in <a href="https://medium.com/u11d-tech-blog">uninterrupted engineering</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[A Complete Guide to AWS CloudFront Origin Request Policies | u11d]]></title>
            <link>https://medium.com/u11d-tech-blog/a-complete-guide-to-aws-cloudfront-origin-request-policies-u11d-fbdf75270312?source=rss----24bcdeab33ef---4</link>
            <guid isPermaLink="false">https://medium.com/p/fbdf75270312</guid>
            <category><![CDATA[aws]]></category>
            <category><![CDATA[aws-cloudfront]]></category>
            <category><![CDATA[cdn]]></category>
            <category><![CDATA[devops]]></category>
            <dc:creator><![CDATA[Maciej Lopalewski]]></dc:creator>
            <pubDate>Wed, 14 Oct 2026 07:28:56 GMT</pubDate>
            <atom:updated>2026-10-14T07:28:56.247Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*Z_KOhiRw_q82R1AgRCv4lA.jpeg" /></figure><p>If your origin needs data the cache key shouldn’t include, an origin request policy is the right tool. It controls what CloudFront forwards to your backend on a cache miss, separately from what makes a request “unique” for caching. Use a managed policy unless you have a specific reason not to — AWS ships eight of them, and most distributions can pick one off the shelf.</p><p>The short version, before the rest of the article:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*a1KgbVUG90_wsMQXF99FPw.png" /></figure><p>The rest of this article covers what these policies actually do, how they interact with the cache key (where most bugs live), and when to skip the managed list and write a custom one.</p><p>This is a follow-up to an earlier post that covered cache, origin request, and response policies as a trio — read that first if you want the broader context.</p><h3>What an origin request policy actually does</h3><p>CloudFront builds the request it sends to your origin from two sources. The first is everything in the cache key, controlled by your cache policy. The second is whatever your origin request policy adds on top. Without an origin request policy attached, the only headers your origin sees are the ones already in the cache key, plus a small set CloudFront automatically adds (Host, User-Agent, X-Amz-Cf-Id, and a few others).</p><p>That default is intentional. If a value isn’t in the cache key, CloudFront treats requests with different versions of that value as the same cached object. Forwarding the value to the origin would make the response depend on something the cache doesn’t see — and you’d get inconsistent results because the first response wins and gets served to everyone else. Origin request policies let you forward extra data to the origin while keeping the cache blind to it. That’s only safe when the origin uses that data for things that don’t change the response body: logging, analytics, downstream auth, or branching that returns the same content regardless.</p><p>I’ve seen teams reach for an origin request policy when what they actually needed was to add a value to the cache key. If your application reads User-Agent to decide between a mobile and a desktop response, forwarding it via an origin request policy isn&#39;t enough — the cached response is shared across both. The fix is to put User-Agent in the cache key, or to use a CloudFront-supplied header like CloudFront-Is-Mobile-Viewer, which is designed exactly for that case.</p><h3>How it interacts with the cache policy</h3><p>Anything in the cache key is automatically forwarded to the origin. The origin request policy can only <em>add</em> to that — it can’t subtract. If your cache policy includes Authorization in the cache key, the origin will see it on a cache miss whether or not your origin request policy mentions it. This often surprises people debugging &quot;why is this header reaching my origin when I didn&#39;t list it?&quot;</p><p>There’s also a hard requirement worth knowing up front: a cache behavior cannot have an origin request policy attached without also having a cache policy. AWS rejects the configuration. So in practice, every distribution that uses origin request policies pairs each one with a cache policy on the same behavior.</p><p>The working rule: put values in the cache key when the response actually varies on them. Put values in the origin request policy when the origin needs them but the response is the same regardless.</p><h3>What’s inside an origin request policy</h3><p>Three knobs: headers, cookies, and query strings. Each one accepts a behavior, plus an optional list when the behavior calls for one.</p><p>For headers, the available behaviors are:</p><ul><li>none — no viewer headers forwarded (cache-key headers still go through, plus the small CloudFront-added set)</li><li>whitelist — only the listed headers</li><li>allViewer — every viewer header</li><li>allViewerAndWhitelistCloudFront — every viewer header plus specific CloudFront headers (geolocation, device-type, TLS info)</li><li>allExcept — every viewer header except a listed set</li></ul><p>Cookies and query strings have a similar shape but a smaller set: none, all, whitelist, or allExcept. There&#39;s no allViewerAndWhitelistCloudFront equivalent because CloudFront doesn&#39;t add its own cookies or query strings.</p><p>A subtlety worth knowing before you read the managed policies below: when a policy uses allExcept to remove the viewer&#39;s Host header, CloudFront automatically replaces it with a new Host header containing the origin&#39;s domain name. That&#39;s exactly what makes AllViewerExceptHostHeader work for API Gateway — the gateway sees its own domain in the Host header and routes correctly, instead of seeing the CloudFront distribution&#39;s domain and rejecting the request.</p><h3>The eight managed policies</h3><p>Each managed policy has a stable ID that you reference from CloudFormation, the AWS CLI, the SDKs, or your IaC tool of choice. The IDs don’t change, so it’s safe to hardcode them.</p><h3>AllViewer</h3><p>Forwards every header, cookie, and query string from the viewer request to the origin.</p><p>ID: 216adef6-5c7f-47e4-b989-5492eafa07d3</p><p>Use this when your backend genuinely needs the full request — typically for legacy applications, complex authenticated apps, or anything where you can’t easily enumerate which headers and cookies matter. The trade-off is that it forwards the viewer’s Host header too, which means it doesn&#39;t work with API Gateway or Lambda function URLs (use AllViewerExceptHostHeader for those). It also forwards anything sensitive that the client happens to send, so think about whether your origin should see, for instance, a long-lived authentication cookie that some unrelated part of your app sets.</p><h3>AllViewerAndCloudFrontHeaders-2022–06</h3><p>Same as AllViewer plus a fixed set of CloudFront-added headers: CloudFront-Forwarded-Proto, the device-type series (-Is-Android-Viewer, -Is-Mobile-Viewer, -Is-Tablet-Viewer, etc.), the geolocation series (-Viewer-Country, -Viewer-City, -Viewer-Latitude, -Viewer-Longitude, -Viewer-Time-Zone, -Viewer-Postal-Code, -Viewer-ASN, and a few more), CloudFront-Viewer-Http-Version, and CloudFront-Viewer-TLS.</p><p>ID: 33f36d7e-f396-46d9-90e0-52428a34d9dc</p><p>The -2022-06 suffix is significant. The set of headers this policy forwards is frozen as of June 2022 — any CloudFront viewer header added after that date isn&#39;t included. AWS does this so existing distributions don&#39;t change behavior when new headers ship. If you want a newer CloudFront header in your origin requests, write a custom policy.</p><p>Use this when your origin uses CloudFront’s enriched headers for personalization, analytics, or routing. Geolocation-based content selection is the common case: your backend reads CloudFront-Viewer-Country and serves a localized response, without doing GeoIP lookups itself.</p><h3>AllViewerExceptHostHeader</h3><p>Forwards everything except the Host header. CloudFront then injects the origin&#39;s domain name as the new Host.</p><p>ID: b689b0a8-53d0-40ab-baf2-68738e2966ac</p><p>This is the policy you almost certainly want when your origin is API Gateway, a Lambda function URL, or any AWS-managed endpoint that does virtual hosting based on the Host header. These services check Host against their own domain. If CloudFront forwards the viewer&#39;s Host (which is your CloudFront distribution&#39;s domain or your custom domain), they don&#39;t recognize it and respond with a 403.</p><p>It also includes the same set of CloudFront viewer headers as AllViewerAndCloudFrontHeaders-2022-06, which means you get geolocation and device-type metadata for free. For most API Gateway-backed CloudFront distributions, this is a one-line answer to a problem that used to require fiddling with cache behaviors and Lambda@Edge.</p><h3>CORS-CustomOrigin</h3><p>Forwards only the Origin header. No cookies, no query strings.</p><p>ID: 59781a5b-3903-41f3-afcb-af62929ccde1</p><p>Use this when your origin is anything other than S3 — an ALB, an EC2 instance, a containerized service, or a third-party HTTP server — and it serves cross-origin requests. The Origin header is what the server inspects to decide whether to return CORS-allowing response headers. Anything more just bloats the origin request without any CORS benefit, and risks forwarding data the origin doesn&#39;t need to see.</p><p>This policy pairs naturally with the CORS-with-preflight-and-SecurityHeadersPolicy response headers policy when your backend doesn&#39;t reliably set CORS headers itself.</p><h3>CORS-S3Origin</h3><p>Forwards Origin, Access-Control-Request-Headers, and Access-Control-Request-Method. No cookies, no query strings.</p><p>ID: 88a5eaf4-2fd4-4709-b370-b4c650ea3fcf</p><p>S3 is fussier about CORS than a custom server. To return the right CORS response headers, S3 needs to see all three of these request headers — not just Origin. The standard CORS forwarding policy (CORS-CustomOrigin) doesn&#39;t work because it omits the other two. If you&#39;ve ever spent time debugging &quot;CORS works locally but not through CloudFront&quot; with an S3 origin, this is almost certainly the missing piece.</p><p>Note that this policy is for S3-as-origin scenarios where you’ve also configured a CORS rule on the bucket itself. Without the bucket-side CORS configuration, S3 won’t return the Access-Control-Allow-* headers regardless of what gets forwarded.</p><h3>HostHeaderOnly</h3><p>Forwards just the Host header. Nothing else.</p><p>ID: bf0718e1-ba1e-49d1-88b1-f726733018ae</p><p>A narrow policy with a specific use case: multi-tenant origins that route based on the original viewer Host. If your backend serves multiple custom domains and uses the Host header to figure out which tenant a request belongs to, you need that header to come through unchanged. Most other managed policies either rewrite Host or don&#39;t forward enough information to make it useful.</p><p>If you’re using this with anything other than a multi-tenant routing scenario, double-check whether you actually want it. For API Gateway origins, HostHeaderOnly will <em>not</em> work — API Gateway needs the rewritten Host, which is what AllViewerExceptHostHeader gives you instead.</p><h3>UserAgentRefererHeaders</h3><p>Forwards only User-Agent and Referer.</p><p>ID: acba4595-bd28-49b8-b9fe-13317c0390fa</p><p>This is the policy for cases where the origin needs to know “what kind of client is this?” or “where did the request come from?” without seeing the rest of the headers, cookies, or query strings. Two common uses: hotlink protection (block requests where Referer doesn&#39;t match an allowed list) and analytics that bucket traffic by browser or operating system.</p><p>It pairs well with a tight cache policy — typically CachingOptimized — because neither header is in the cache key. The cache stays efficient and the origin still gets enough data to do its filtering or counting.</p><h3>Elemental-MediaTailor-PersonalizedManifests</h3><p>Forwards Origin, Access-Control-Request-Headers, Access-Control-Request-Method, User-Agent, X-Forwarded-For, and all query strings.</p><p>ID: 775133bc-15f2-49f9-abea-afb2e0bf67d2</p><p>A specialized policy for AWS Elemental MediaTailor, which serves personalized HLS/DASH manifests for ad insertion in video streaming. MediaTailor needs the CORS triplet for browser playback, User-Agent and X-Forwarded-For for ad-decisioning logic, and the full query string because session and personalization tokens are encoded there. If you&#39;re not using MediaTailor, you don&#39;t need this policy — but if you are, it&#39;s the difference between manifests that work and manifests that don&#39;t.</p><h3>Comparison: what each managed policy forwards</h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*piJYNX6ZRihIbt9-NO4Qqw.png" /></figure><h3>When to write a custom origin request policy</h3><p>The managed policies cover most cases. A custom policy is needed when:</p><ul><li>Your origin needs a specific application header (like X-Tenant-ID or X-Request-Trace) that isn&#39;t in any managed policy</li><li>You want CloudFront viewer headers added after June 2022 — the managed AllViewerAndCloudFrontHeaders-2022-06 is frozen at that date</li><li>A specific cookie or query string needs to pass through while others stay blocked (no managed policy uses the whitelist behavior for cookies or query strings)</li><li>The requirements get oddly mixed — e.g., “everything except Authorization, plus a few CloudFront geolocation headers&quot;</li></ul><p>Here’s a Terraform example for a custom policy that forwards a tenant header, geolocation, and a session cookie, while excluding everything else:</p><pre>resource &quot;aws_cloudfront_origin_request_policy&quot; &quot;tenant_api&quot; {<br>  name    = &quot;tenant-api-policy&quot;<br>  comment = &quot;Forwards tenant routing data and CF geolocation&quot;<br><br>  headers_config {<br>    header_behavior = &quot;whitelist&quot;<br>    headers {<br>      items = [<br>        &quot;X-Tenant-ID&quot;,<br>        &quot;CloudFront-Viewer-Country&quot;,<br>        &quot;CloudFront-Viewer-City&quot;,<br>      ]<br>    }<br>  }<br>  cookies_config {<br>    cookie_behavior = &quot;whitelist&quot;<br>    cookies {<br>      items = [&quot;session_id&quot;]<br>    }<br>  }<br>  query_strings_config {<br>    query_string_behavior = &quot;none&quot;<br>  }<br>}</pre><p>A subtle gotcha worth knowing: under allViewerAndWhitelistCloudFront, the headers.items list isn&#39;t the <em>only</em> set of headers forwarded — every viewer header is forwarded too, and the list just adds CloudFront-specific headers on top. The behavior name is more literal than it looks. To restrict to a specific allowlist, use whitelist (as in the example above) and list both the viewer headers and any CloudFront headers you want.</p><p>To list all available origin request policies (managed and custom) from the AWS CLI:</p><pre># Returns the 8 managed policies with their IDs<br>aws cloudfront list-origin-request-policies --type managed<br><br># Or list only your custom policies<br>aws cloudfront list-origin-request-policies --type custom</pre><p>To attach a managed policy by ID, set OriginRequestPolicyId on the relevant cache behavior in your distribution config:</p><pre>aws cloudfront update-distribution \<br>  --id E1A2B3C4D5E6F7 \<br>  --distribution-config file://config.json</pre><h3>Common pitfalls</h3><h3>A header isn’t reaching the origin in the form you expect</h3><p>Check whether the same header is configured under Origin Custom Headers on the distribution. AWS specifically warns against setting the same header in both places. Origin custom headers are applied before CloudFront forwards the request, and if the header is already present in the viewer request, CloudFront overwrites the value. The origin sees one deterministic value, but it may not be the one you set in the origin request policy. Pick one mechanism per header and you avoid the ambiguity.</p><h3>API Gateway returns 403 even though Host isn&#39;t in the allowlist</h3><p>If you wrote a custom policy and used whitelist for headers without including Host in the allowlist, the viewer&#39;s Host header isn&#39;t forwarded — but it also isn&#39;t <em>replaced</em>. CloudFront&#39;s automatic Host rewrite only kicks in with the allExcept behavior when Host is in the excluded list. For API Gateway, either use AllViewerExceptHostHeader (the easiest path), or use allExcept with Host in the exclusion list.</p><h3>Authorization reaches the origin even though it&#39;s not in the policy</h3><p>Your cache policy is including it. Cache-key headers are automatically forwarded; the origin request policy can only add to that. Open the cache policy attached to the same cache behavior and check whether Authorization is listed under headers there. If you actually want the header to stay out of the origin requests, you&#39;ll need to remove it from the cache key — and accept the implications for cache hit ratio, since responses will no longer be keyed on it.</p><h3>A CloudFront-added header isn’t arriving</h3><p>Two different causes look the same from the origin side. First, the header may simply not be in your policy: a bare AllViewer doesn&#39;t include any CloudFront-added headers, and AllViewerAndCloudFrontHeaders-2022-06 is frozen at the set of headers AWS released through June 2022. Anything added later — CloudFront-Viewer-JA3-Fingerprint and CloudFront-Viewer-JA4-Fingerprint for TLS fingerprinting, CloudFront-Viewer-Header-Order and CloudFront-Viewer-Header-Count for bot detection, CloudFront-Error-Uri and CloudFront-Error-Args for error-handling flows — is not in the managed -2022-06 policy. To get those, write a custom origin request policy and list them explicitly.</p><p>Second, even when the header is included, AWS notes that CloudFront-Viewer-City, CloudFront-Viewer-Metro-Code, and CloudFront-Viewer-Postal-Code may not be available for every IP address — some addresses don&#39;t geolocate with enough precision. If those fields are blank for some traffic but populated for others, the policy is fine; the geolocation data just isn&#39;t there.</p><h3>Cookies aren’t reaching a Lambda function URL even with AllViewerExceptHostHeader</h3><p>Three things to check, in order. First, confirm the browser actually sent the cookie to your CloudFront domain — Secure, HttpOnly, SameSite=Strict, and Domain mismatches all stop browsers from including the cookie in the first place, and no policy can rescue you if the cookie never crossed the wire. Second, confirm the policy you chose includes cookies: AllViewerExceptHostHeader does forward all cookies, but if you wrote a custom policy and set cookie_behavior = &quot;none&quot;, you&#39;ll see no cookies at the origin regardless of what was in the request. Third, when you do receive the request at the function, remember that Lambda function URLs use the API Gateway v2.0 payload format — cookies arrive in the event&#39;s cookies array, not in headers[&quot;Cookie&quot;]. Code that reads the Cookie header directly will appear to receive nothing even when the cookies are present in the event.</p><p>If the issue is specific to cross-origin browser requests, the function URL’s CORS configuration matters too — specifically the AllowCredentials setting, which has to be true for credentialed requests to be allowed at all. But that&#39;s only one of three things that need to align: the cookie has to permit cross-origin sending (typically SameSite=None; Secure), the client-side fetch has to opt into credentials (credentials: &#39;include&#39;), and the function URL has to allow credentials. Miss any one and the cookie won&#39;t reach the function — and none of that is something CloudFront can fix from the edge.</p><h3>Picking the right policy</h3><p>A short decision tree:</p><ol><li>Is your origin API Gateway or a Lambda function URL? → AllViewerExceptHostHeader</li><li>Is your origin S3 and you need CORS? → CORS-S3Origin</li><li>Is your origin a custom server (ALB, EC2, ECS) with CORS? → CORS-CustomOrigin</li><li>Are you using AWS Elemental MediaTailor? → Elemental-MediaTailor-PersonalizedManifests</li><li>Do you only need to identify the client? → UserAgentRefererHeaders</li><li>Are you running multi-tenant routing on Host? → HostHeaderOnly</li><li>Does your origin need everything? → AllViewer, or AllViewerAndCloudFrontHeaders-2022-06 if you want geolocation too</li><li>None of the above? → Write a custom policy</li></ol><p>If none of the managed policies match exactly but you’re close, custom policies are cheap to create and easy to version. There’s no API quota concern for normal use, and you can test changes by attaching the new policy to a non-production cache behavior first.</p><p>The next post in this series will go through CloudFront response headers policies in the same depth — what each managed policy adds, when the browser actually honors it, and where custom policies are worth the maintenance cost.</p><h3>Need help optimizing your CDN?</h3><p>If you are struggling with complex CloudFront configurations or cache behavior, reach out to <a href="https://u11d.com">u11d</a> for expert engineering guidance.</p><p><em>Originally published at </em><a href="https://u11d.com/blog/aws-cloudfront-origin-request-policies/"><em>https://u11d.com</em></a><em> on September 30, 2026.</em></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=fbdf75270312" width="1" height="1" alt=""><hr><p><a href="https://medium.com/u11d-tech-blog/a-complete-guide-to-aws-cloudfront-origin-request-policies-u11d-fbdf75270312">A Complete Guide to AWS CloudFront Origin Request Policies | u11d</a> was originally published in <a href="https://medium.com/u11d-tech-blog">uninterrupted engineering</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Achieving Instant Navigations in Next.js 16.3]]></title>
            <link>https://medium.com/u11d-tech-blog/achieving-instant-navigations-in-next-js-16-3-35751dceaa08?source=rss----24bcdeab33ef---4</link>
            <guid isPermaLink="false">https://medium.com/p/35751dceaa08</guid>
            <category><![CDATA[nextjs]]></category>
            <dc:creator><![CDATA[u11d]]></dc:creator>
            <pubDate>Wed, 30 Sep 2026 13:34:16 GMT</pubDate>
            <atom:updated>2026-09-30T13:34:16.726Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*i5EKi48yp_O-zKWQms2AQQ.png" /></figure><p>Server Components can reduce client-side JavaScript, keep sensitive logic on the server, and prevent data-fetching waterfalls. But they also introduced a frustrating user experience in many dynamic applications: after clicking a link, the user sometimes had to wait for the server before seeing the next screen.</p><p>Next.js 16.3 addresses this problem with Instant Navigations, an opt-in set of rendering, prefetching, development, and testing tools. The goal is not to make every data request finish immediately. It is to make sure the application always has meaningful UI ready when navigation begins.</p><p>This is particularly useful for e-commerce platforms, dashboards, and account areas. These applications cannot prerender every personalized state at build time, but they should still respond to clicks without a visible pause.</p><p>In this guide, I will explain how Instant Navigations work, how to choose between streaming and caching, and how to protect navigation performance with Playwright tests.</p><h3>Why Dynamic Next.js Navigations Can Feel Slow</h3><p>In a traditional server-driven navigation, the sequence often looks like this:</p><ol><li>The user clicks a link.</li><li>The browser requests the target route from the server.</li><li>The server reads data and renders the route.</li><li>The browser finally displays the next UI.</li></ol><p>The architecture can be efficient, but the delay between the first and fourth steps is visible. A client-driven SPA usually feels more responsive because it already has enough code in the browser to display the next screen or its loading state immediately.</p><p>Before Next.js 16.3, teams had two common ways to reduce this delay:</p><ul><li>prerender the full page at build time, which does not work for every dynamic or personalized route</li><li>fully prefetch each destination with &lt;Link prefetch={true}&gt;, which can generate unnecessary requests and increase server load</li></ul><p>A loading.tsx file could provide an immediate loading state, but it was easy to forget and applied at the route-segment level rather than around the exact data dependency.</p><p>Instant Navigations provide a more precise model. Instead of asking whether the whole route is static or dynamic, you decide which parts can be ready before the click and which parts should arrive later.</p><h3>Enabling Instant Navigations in Next.js 16.3</h3><p>Instant Navigations are opt-in in Next.js 16.3. Enable Cache Components and Partial Prefetching in next.config.ts:</p><pre>import type { NextConfig } from &#39;next&#39;<br><br>const nextConfig: NextConfig = {<br>  cacheComponents: true,<br>  partialPrefetching: true,<br>}<br><br>export default nextConfig</pre><p>cacheComponents enables the explicit caching model built around &#39;use cache&#39;, cacheLife, and cacheTag. It also allows routes to combine prerendered UI with dynamic content behind Suspense boundaries.</p><p>partialPrefetching changes how Next.js prepares routes on the client. Instead of treating prefetching as an all-or-nothing decision, Next.js can prefetch only the reusable UI needed to start a navigation.</p><p>Enabling cacheComponents is a migration step, not just a performance flag. During production builds, Next.js attempts to prerender as much of a route as possible and can report an error when runtime or uncached data is accessed outside a Suspense boundary. Each case must be streamed behind Suspense, cached with &#39;use cache&#39; when appropriate, or explicitly allowed to block with export const instant = false. Existing applications may also need to remove incompatible route segment configuration such as dynamic, revalidate, or fetchCache.</p><p>These behaviors are planned to become defaults in a future major version. In 16.3, enabling them explicitly gives teams time to review their asynchronous boundaries and migration requirements.</p><h3>The Core Model: Stream, Cache, or Block</h3><p>When a route depends on asynchronous data, Next.js 16.3 asks you to make the intended behavior explicit. There are three useful choices.</p><h4>1. Stream dynamic content with Suspense</h4><p>Use &lt;Suspense&gt; when the data must stay fresh or depends on the active request. The fallback becomes part of the route shell, so it can appear immediately while the server renders the final content.</p><p>This is a good fit for:</p><ul><li>inventory and delivery estimates</li><li>cart state</li><li>account-specific data</li><li>live analytics</li><li>personalized recommendations</li></ul><p>The navigation is instant because the user sees the fallback without waiting for the dynamic operation. The final data still arrives from the server later.</p><h4>2. Cache reusable content with &#39;use cache&#39;</h4><p>Use &#39;use cache&#39; when the result can be safely reused. Cached UI can be included in the prefetched response and displayed immediately during navigation.</p><p>This works well for:</p><ul><li>product descriptions</li><li>category content</li><li>CMS blocks</li><li>public pricing shared by a region</li><li>configuration that changes infrequently</li></ul><p>The cache boundary should follow the business meaning of the data. Content that is safe to share globally should not be mixed with customer-specific prices or session state.</p><h4>3. Allow the route to block</h4><p>Not every route needs to navigate instantly. If displaying a loading state would be misleading and the route should wait for complete server output, mark that decision explicitly:</p><pre>export const instant = false</pre><p>This can be appropriate for a simple content page where the team prefers to show the complete article at once, or for a flow where a partial state would create confusion.</p><p>The important change is that blocking becomes an intentional route decision rather than an accidental result of one unresolved data dependency.</p><h3>How Partial Prefetching Changes the Network Cost</h3><p>The previous prefetching model could issue a request for every visible link. Consider an account sidebar with twenty links to /orders/[id]. Even though all links share the same route structure, the browser could prefetch each destination separately.</p><p>With Partial Prefetching, Next.js can generate one reusable shell for /orders/[id], store it in the browser, and use it for multiple links during the session. This is closer to route-level code splitting in a SPA: the browser receives enough UI to react immediately, but it does not need the complete data for every possible destination.</p><p>If a particular link deserves a deeper prefetch, you can still opt in:</p><pre>&lt;Link href={`/products/${product.slug}`} prefetch={true}&gt;<br>  {product.title}<br>&lt;/Link&gt;</pre><p>Next.js then prerenders as much of that destination as it safely can. Synchronous values from the URL and content marked with &#39;use cache&#39; can be included, while request-bound work remains behind its Suspense boundary.</p><p>This avoids a common tradeoff in dynamic applications: either prefetch nothing and make the user wait, or prefetch complete pages and make the server do work that may never be used.</p><h3>A Practical E-Commerce Example</h3><p>A product page usually combines data with very different freshness requirements:</p><ul><li>product copy and media are broadly reusable</li><li>inventory changes often</li><li>customer pricing may depend on identity, region, or sales channel</li><li>the cart belongs to the current session</li></ul><p>Making the whole page dynamic means one slow operation can block navigation. Caching the whole page is more dangerous because it can expose stale or private data.</p><p>A better boundary looks like this:</p><pre>import { Suspense } from &#39;react&#39;<br>import { cacheLife, cacheTag } from &#39;next/cache&#39;<br><br>async function ProductSummary({ slug }: { slug: string }) {<br>  &#39;use cache&#39;<br><br>cacheTag(`product:${slug}`)<br>  cacheLife(&#39;hours&#39;)<br>  const product = await getProduct(slug)<br>  return (<br>    &lt;section&gt;<br>      &lt;h1&gt;{product.title}&lt;/h1&gt;<br>      &lt;p&gt;{product.description}&lt;/p&gt;<br>    &lt;/section&gt;<br>  )<br>}<br>async function Inventory({ slug }: { slug: string }) {<br>  const inventory = await getLiveInventory(slug)<br>  return &lt;p&gt;{inventory.available} items available&lt;/p&gt;<br>}<br>export default async function ProductPage({<br>  params,<br>}: PageProps&lt;&#39;/products/[slug]&#39;&gt;) {<br>  const { slug } = await params<br>  return (<br>    &lt;main&gt;<br>      &lt;ProductSummary slug={slug} /&gt;<br>      &lt;Suspense fallback={&lt;p&gt;Checking availability...&lt;/p&gt;}&gt;<br>        &lt;Inventory slug={slug} /&gt;<br>      &lt;/Suspense&gt;<br>    &lt;/main&gt;<br>  )<br>}</pre><p>The product summary can be cached and reused. Live inventory remains dynamic, but it no longer prevents the route from showing useful content. During navigation, the browser can display the product UI or its prepared shell first and fill in availability when the server responds.</p><p>For a real storefront, customer-specific pricing should follow the same principle as inventory: isolate it in a private or request-time boundary instead of adding cookies or session access to the entire page.</p><h3>Finding Slow Routes with Instant Insights</h3><p>Performance problems are difficult to fix when they only appear as a vague feeling that the application is slow. Next.js 16.3 adds Instant Insights to the development tools to make the problem visible.</p><p>When you encounter a non-instant navigation during development, Instant Insights identifies the route and points to the asynchronous work that blocks its shell. In most cases, the fix is one of the following:</p><ul><li>move request-specific work below a Suspense boundary</li><li>mark reusable work with &#39;use cache&#39;</li><li>move a blocking dependency out of a shared layout</li><li>explicitly accept blocking with export const instant = false</li></ul><p>The Navigation Inspector complements this by pausing navigation at the prefetched shell. This lets you inspect exactly what the user can see before dynamic content arrives. It is useful because production prefetching is normally disabled in development, where loading behavior can otherwise be difficult to reproduce accurately.</p><h3>Testing Instant Navigations with Playwright</h3><p>A route that feels instant today can become blocking after a small refactor. A new cookies() call in a shared component or a moved Suspense boundary may be enough to change the result.</p><p>Next.js 16.3 includes the instant() helper in @next/playwright. It temporarily holds back dynamic content, allowing the test to assert which UI is available without waiting for the network:</p><pre>import { expect, test } from &#39;@playwright/test&#39;<br>import { instant } from &#39;@next/playwright&#39;<br><br>test(&#39;product navigation shows useful UI instantly&#39;, async ({ page }) =&gt; {<br>  await page.goto(&#39;/products/shoes&#39;)<br>  await instant(page, async () =&gt; {<br>    await page.click(&#39;a[href=&quot;/products/hats&quot;]&#39;)<br>    await page.waitForURL((url) =&gt; url.pathname === &#39;/products/hats&#39;)<br>    await expect(page.locator(&#39;h1&#39;)).toContainText(&#39;Baseball Cap&#39;)<br>    await expect(page.getByText(&#39;Checking availability...&#39;)).toBeVisible()<br>  })<br>  await expect(page.getByText(/items available/)).toBeVisible()<br>})</pre><p>The test describes a user-facing contract:</p><ul><li>the product title must be available immediately</li><li>the inventory fallback must be visible while live data is pending</li><li>the final inventory must appear after the dynamic work completes</li></ul><p>This is more useful than testing an arbitrary duration such as 100 milliseconds. Network and CI timing can vary, but the boundary between prefetched UI and network-dependent UI is deterministic.</p><p>The v0 team used this property to improve real routes with a coding agent. The agent wrote a failing instant() test, moved or cached the blocking work, rebuilt the application, and repeated the process until the test passed. The resulting 16 tests were kept in CI to prevent future regressions.</p><h3>Where Teams Usually Get It Wrong</h3><h4>Mistake 1: Treating “instant” as “fully loaded”</h4><p>Instant Navigations do not make databases or APIs respond with zero latency. They guarantee that useful UI can respond to the click without waiting for those systems.</p><p>The distinction matters. A complete page may still stream in over time, but the application should never look unresponsive.</p><h4>Mistake 2: Placing Suspense too high in the tree</h4><p>Wrapping an entire page in one Suspense boundary may technically make navigation instant, but the user will see a generic full-page skeleton. Place boundaries close to the data that actually needs to wait so stable navigation, headings, and controls remain visible.</p><h4>Mistake 3: Reading dynamic data in a shared layout</h4><p>A session or request-dependent operation near the root can block many routes at once. Keep shared layouts focused on reusable UI and move dynamic reads into the components that need them.</p><h4>Mistake 4: Enabling full prefetching on every link</h4><p>prefetch={true} is still useful, but it should represent a deliberate choice to prepare more than the route shell. Applying it everywhere can recreate the server and network cost that Partial Prefetching is designed to avoid.</p><h4>Mistake 5: Using meaningless loading states</h4><p>An instant blank rectangle is not a good user experience. The shell should preserve the layout and provide enough context for the user to understand where the navigation is going.</p><p>For a product page, that may include the title, gallery frame, price area, and an inventory placeholder. For an account page, it may include the navigation, page heading, and form structure.</p><h3>A Practical Adoption Strategy</h3><p>For an existing Next.js application, avoid changing every route at once. Start with the journeys where a delay has the largest user or business impact.</p><ol><li>Enable cacheComponents and partialPrefetching in a branch.</li><li>Use Instant Insights to identify blocking navigations.</li><li>Choose one important journey, such as category to product or cart to checkout.</li><li>Write a failing test with instant() that describes the minimum UI required after the click.</li><li>Stream request-bound data and cache only content that is safe to reuse.</li><li>Inspect the shell with Navigation Inspector.</li><li>Keep the test in CI after the route passes.</li></ol><p>This process turns perceived performance into an architectural requirement that can be reviewed and tested. It also creates a clear stopping point: the work is complete when the intended shell is visible without network-dependent content.</p><h3>Final Thoughts</h3><p>The most important part of Instant Navigations is not another prefetch option. It is the clearer boundary between UI that must be ready for a click and data that is allowed to arrive later.</p><p>For dynamic applications, this removes the need to choose between a server-driven architecture and SPA-like responsiveness. Server Components can continue to handle rendering and sensitive data, while Suspense, Cache Components, and reusable route shells keep navigation responsive.</p><p>The best result comes from combining three decisions:</p><ul><li>stream data that must remain dynamic</li><li>cache content that is genuinely reusable</li><li>block only when waiting is an intentional product choice</li></ul><p>Once those decisions are protected by instant() tests, navigation performance stops being something that can disappear unnoticed during the next refactor.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=35751dceaa08" width="1" height="1" alt=""><hr><p><a href="https://medium.com/u11d-tech-blog/achieving-instant-navigations-in-next-js-16-3-35751dceaa08">Achieving Instant Navigations in Next.js 16.3</a> was originally published in <a href="https://medium.com/u11d-tech-blog">uninterrupted engineering</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[WooCommerce, Shopify, or Medusa: Choosing an E-Commerce Platform for Bullion Dealers]]></title>
            <link>https://medium.com/u11d-tech-blog/woocommerce-shopify-or-medusa-choosing-an-e-commerce-platform-for-bullion-dealers-b4aca8db07b6?source=rss----24bcdeab33ef---4</link>
            <guid isPermaLink="false">https://medium.com/p/b4aca8db07b6</guid>
            <category><![CDATA[precious-metals]]></category>
            <category><![CDATA[medusajs]]></category>
            <category><![CDATA[ecommerce-web-development]]></category>
            <category><![CDATA[ecommerce]]></category>
            <dc:creator><![CDATA[Michal Miler]]></dc:creator>
            <pubDate>Mon, 28 Sep 2026 15:38:15 GMT</pubDate>
            <atom:updated>2026-09-28T15:38:15.156Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*cShMwVkUpV0IN8yUvWI2kg.png" /></figure><p>A bullion dealer’s product catalog doesn’t have prices. It has formulas. A 1 oz gold coin isn’t “2,100” the way a t-shirt is “25” — it’s weight × today&#39;s spot price × your spread, plus whatever fixed minting or handling premium you charge on top. Spot prices for gold and silver move throughout the trading day, sometimes several times a minute, which means the &quot;correct&quot; price for every SKU in the catalog is technically wrong again a few seconds after you last calculated it.</p><p>Most e-commerce platforms were not built around that assumption. They were built around the idea that a product has a price, someone sets it, and it stays put until someone changes it again. That’s a completely reasonable default for almost every category of goods sold online — until the goods themselves are priced off a market that never stops moving.</p><p>This creates a specific set of requirements that a general-purpose store doesn’t have to think about:</p><ul><li><strong>Live pricing tied to a market feed</strong>, not a value someone typed into an admin panel.</li><li><strong>A pricing formula per product or product line</strong> — spread, fixed premiums, percentage premiums — layered on top of that feed.</li><li><strong>Frequent updates</strong>, ideally continuous during market hours, not a batch job that runs once an hour.</li><li><strong>Checkout price locking</strong> — the price a customer sees when they start checkout has to hold for a defined window, or the order has to be rejected, otherwise either the dealer or the customer eats the difference.</li><li><strong>Reliability when the feed itself fails</strong> — a stale or missing spot price can’t silently turn into a wrong price or a broken checkout.</li><li><strong>Integrations</strong> with ERP systems, inventory/vaulting software, and payment processors that are comfortable underwriting a high-value, high-risk category.</li><li><strong>Room to customize</strong> the pricing logic as the business’s spreads, premium structure, or product mix changes.</li></ul><p>WooCommerce, Shopify, and Medusa can all run a bullion store. The difference is how much of the above you get by default, how much you build yourself, and how much friction you hit doing it. This article compares all three specifically against that list — not as a generic “which platform is better” question, but as the specific decision a bullion or precious-metals dealer is actually facing.</p><h3>Quick Reference</h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*xInQrYNPaH4AY0gSFUm3mA.png" /></figure><h3>Why Bullion E-Commerce Doesn’t Behave Like a Normal Store</h3><p>Before comparing platforms, it’s worth being precise about what’s actually different, because it’s easy to under-scope this as “just update the price more often.”</p><p><strong>The price is derived, not entered.</strong> A normal product’s price is a fact you record. A bullion product’s price is the output of a calculation: current spot price for that metal, times the product’s weight, times a spread factor, plus any fixed or percentage premium (minting costs, rarity, packaging). Change the spot price and every affected SKU’s correct price changes with it — instantly, in principle.</p><p><strong>Update frequency is high and continuous.</strong> Gold and silver spot prices move throughout market hours. A store that only updates prices a few times a day is either giving away margin between updates (selling below the current market) or overcharging (selling above it) — and both outcomes cost the dealer customers over time, just in different ways.</p><p><strong>Checkout needs a quote, not a live price.</strong> A customer needs the price to hold still for the few minutes it takes to fill in an address and pay. If the price is still tied to a live feed while payment is being processed, the amount charged can differ from the amount the customer agreed to. The standard fix is a “lock”: freeze the computed price the moment checkout starts, attach an expiry to it, and refuse to complete the order if that lock has expired by the time payment is submitted.</p><p><strong>A feed outage can’t become a wrong price.</strong> If the spot-price provider goes down for ten minutes, the store has to keep functioning — showing the last known good price and continuing to accept orders — rather than either breaking checkout or, worse, silently pricing products at $0 or a stale multi-day-old value.</p><p><strong>The integration surface is wider than checkout.</strong> Bullion dealers commonly need to sync with an ERP or inventory/vaulting system, and payment processing for precious metals is frequently treated as a reviewed or higher-risk category by processors — worth confirming with your payment provider early, regardless of which platform you choose.</p><p>None of this is exotic engineering. But it’s also not something any of the three platforms below solves out of the box.</p><h3>WooCommerce for Bullion Dealers</h3><p>WooCommerce’s strength here is exactly its strength everywhere else: it’s open PHP, self-hosted, and has an enormous plugin ecosystem, so there’s a real, mature market of niche plugins for this exact use case. Plugins like <a href="https://wordpress.org/plugins/precious-metals-automated-product-pricing-pro/">Precious Metals Product Prices for WooCommerce</a> advertise real-time precious-metals pricing, product sync, and wholesale drop-ship order automation specifically for bullion and coin stores, and simpler options like <a href="https://wordpress.org/plugins/gold-price-based-on-weight/">Gold Price Based on Weight</a> calculate a product’s price from a global price-per-gram figure and the product’s weight. These plugins genuinely solve the “pull a live price and multiply it out” problem.</p><p>Where WooCommerce runs into friction is checkout behavior. WooCommerce recalculates the cart and checkout totals from each product’s <em>currently stored</em> price every time the cart is loaded — there’s no built-in concept of “hold this exact quoted price for the next 10 minutes, then reject the order if it expires.” A dealer who wants that has to build it as custom logic on top of one of the pricing plugins: snapshotting the computed price into cart or session data, and validating it again before the order is allowed to complete.</p><p>Real-time storefront updates (a price that visibly ticks upward or downward while a customer is looking at the cart, without a page reload) also aren’t native. Even with WooCommerce’s newer Cart and Checkout blocks, the built-in reactivity is around cart operations like changing a quantity — it won’t push an externally changing spot price into an already-open page without custom JavaScript polling a price endpoint yourself.</p><p><strong>WooCommerce is a good fit</strong> for a smaller dealer already running WordPress, with a catalog that doesn’t need sub-minute pricing precision, and a technical budget that can stretch to some custom development for checkout locking.</p><h3>Shopify for Bullion Dealers</h3><p>Shopify’s case is speed and operational simplicity: managed hosting, PCI compliance handled for you, a fast path to launch, and by far the largest app ecosystem of the three. For a dealer who wants to be selling quickly and isn’t chasing sub-minute pricing precision, that’s a legitimate, low-friction starting point.</p><p>The friction shows up in how Shopify models price and checkout. A product variant’s price is a stored field, not a computed value — a pricing app updates it by calling the Admin API on a schedule or in response to a webhook, which is fundamentally a batch update loop, not a live per-request formula tied to a streaming feed. Checkout customization today runs through <a href="https://shopify.dev/docs/apps/build/functions">Shopify Functions</a>: discount functions, cart/checkout validation functions, delivery and payment-method customization functions, and similar. Every documented Function category is scoped to adjusting discounts, validation, or checkout behavior <em>around</em> an existing price — none of them is designed to recompute a commodity-linked unit price from a live formula on the fly.</p><p>There’s also a plan gate to be aware of: per Shopify’s own documentation, stores on any plan can install a public app from the Shopify App Store that happens to contain Functions, but building and running a <em>custom</em> app with Shopify Function APIs requires a Shopify Plus plan. That matters for a dealer planning to build bespoke pricing logic in-house rather than relying entirely on a third-party app.</p><p><strong>Shopify is a good fit</strong> for a dealer prioritizing managed infrastructure and fast launch, where pricing updates on the order of minutes-via-API (rather than a continuous live feed) and standard discount/checkout customization are acceptable trade-offs.</p><h3>Medusa’s Extensibility: Building Only the Part That’s Actually Custom</h3><p><a href="https://medusajs.com">Medusa</a> doesn’t ship dynamic commodity pricing, ERP connectors, or a high-risk-friendly payment processor out of the box — no platform in this comparison does. The difference is what it costs to add them. Medusa’s core is organized as modules (self-contained data and logic for one domain — pricing, tax, payments, inventory), workflows (business logic composed of retryable, rollback-safe steps), and module links (which connect data across modules without collapsing everything into one table). That structure means a dealer-specific requirement gets built as an addition that plugs into Medusa’s existing provider interfaces and event system, not as a fork of the platform or a rebuild of checkout, promotions, multi-region support, or order management.</p><p>This isn’t a theoretical benefit. u11d’s own <a href="https://www.npmjs.com/package/@u11d/medusa-avalara">@u11d/medusa-avalara</a> plugin is a working example of the same pattern applied to tax instead of pricing. It installs into an existing Medusa store, registers itself through Medusa’s tax-provider interface, and takes over the full transaction lifecycle: it creates a sales invoice in Avalara when an order is placed, commits it when the order completes, and voids it if the order is canceled — all without any change to Medusa’s own cart, checkout, or order code. A dealer picks Avalara as their tax provider in the Medusa admin the same way they’d pick any other provider, and everything else in the store keeps working exactly as it did before.</p><p>The same pattern generalizes to the other systems a bullion dealer actually needs to connect:</p><ul><li><strong>ERP and vaulting/inventory sync.</strong> Medusa emits events for order placement, fulfillment, and inventory changes. A module can subscribe to those events, and a workflow can push the relevant data out to an ERP or a vaulting/inventory system — the integration lives in its own module, not scattered across checkout code.</li><li><strong>Payment processors.</strong> Medusa’s payment system is provider-based, with Stripe, PayPal, Klarna, and Adyen supported out of the box. Bullion dealers often need a processor comfortable underwriting a high-value, high-risk category rather than a default consumer gateway — adding one means implementing Medusa’s payment provider interface, not replacing the checkout flow.</li><li><strong>Tiered and wholesale pricing.</strong> This one doesn’t even require an add-on: Medusa’s core Pricing Module natively supports quantity-based tiers (a lower unit price once a customer buys 10+ or 50+ ounces) and rule-based pricing by customer group, region, or cart total — genuinely useful for a dealer serving both retail customers and wholesale/dealer accounts from the same catalog.</li><li><strong>Market-driven pricing.</strong> The same module pattern is what lets a plugin compute a product’s final price at read time from a live spot price plus a merchant-configured formula, without ever writing that value into Medusa’s own price records — covered in detail below with Fluctum.</li></ul><p>None of this requires forking Medusa or building a bullion-specific commerce platform from zero. It’s the difference between building the tax logic, the ERP sync, the payment integration, and the pricing engine all from scratch, and building only the one piece that’s actually specific to the business on top of a platform that already handles everything else.</p><h3>The Checkout Price-Locking Problem</h3><p>Across all three platforms, this is the piece that consistently doesn’t come for free: freezing a computed price at the moment a customer starts checkout, and refusing to let the order complete if that lock has expired. It’s not a hard problem conceptually, but getting it right requires the platform to support:</p><ol><li>Recording the exact price, and the inputs that produced it (spot price, spread, premium), at the moment checkout begins.</li><li>An expiry window on that record.</li><li>A hard check, at order completion, that rejects the order if the lock is missing or expired — not just a soft warning.</li></ol><p>WooCommerce and Shopify both leave this entirely to custom development or a third-party app’s own implementation of it. Neither has a first-class “price lock” primitive in its core commerce model. This is precisely the gap Fluctum was built to close on Medusa.</p><h3>Fluctum: Market-Driven Dynamic Pricing for Medusa</h3><p><a href="https://www.fluctum.io/">Fluctum</a> is an open-source, MIT-licensed Medusa plugin built by u11d that implements the full pricing pipeline described above, so a Medusa store doesn’t need a custom-built pricing engine to sell spot-priced goods.</p><p>It fetches live spot prices on a schedule, and computes each product’s final price from the current spot price, the product’s weight, and a merchant-configured formula — a spread factor, a fixed spread fee, a percentage premium, and a fixed premium (for minting or handling costs, for example) — without ever writing that computed value back into a Medusa product’s own price fields. The storefront reflects price changes in real time via Server-Sent Events, so a customer watching the cart sees the price move without a page reload.</p><p>At checkout, Fluctum locks the computed price into an auditable record and gives it an expiry. If a customer completes payment before that lock expires, the order proceeds at the locked price. If the lock has expired — because checkout took too long, or the price moved and wasn’t refreshed — order completion is rejected rather than silently charging a stale amount. If the spot-price feed itself goes down, the storefront and checkout keep working off the last known good price instead of breaking.</p><p>Because it’s a Medusa plugin rather than a separate system, installing Fluctum on an existing Medusa store keeps everything else Medusa already provides — multi-region and multi-currency support, promotions, customer accounts, taxes, shipping, and payments — and adds market-driven pricing on top of it. It’s free to use and, being open-source, can be extended by any developer to sync with an ERP, add payment providers, or add business logic specific to a given dealer.</p><p>We’ve written about Fluctum from two angles: a <a href="https://u11d.com/blog/fluctum-open-source-dynamic-pricing/">business-facing overview of the build-vs-buy decision</a> for store owners deciding whether to build this themselves, and a <a href="https://u11d.com/blog/medusa-live-pricing-fluctum-developer-guide/">developer guide</a> covering the pricing formula, the price-lock lifecycle, and a full local setup walkthrough. A live demo is available at <a href="https://fluctum.medusajs.site">fluctum.medusajs.site</a>.</p><h3>Cost Comparison</h3><p>A full cost-of-ownership model for a bullion dealer is more than just the monthly subscription fee. It’s a mix of platform fees, transaction fees as a percentage of gross merchandise value (GMV), app/plugin costs, and hosting or infrastructure overhead.</p><p><strong>WooCommerce:</strong> The core software is free, so there’s no monthly platform fee. Costs are a combination of:</p><ul><li><strong>Hosting:</strong> A reliable WordPress host capable of handling e-commerce can range from <strong>25 to 100+ per month</strong>.</li><li><strong>Transaction Fees:</strong> These are charged by your payment gateway (e.g., WooCommerce Payments, Stripe, PayPal) and are typically around <strong>2.9% + $0.30 per transaction</strong> for US cards. These are often negotiable for high-volume merchants.</li><li><strong>Plugin Costs:</strong> A robust “Pro” version of a precious metals pricing plugin is essential and can cost <strong>100–300 per year</strong>.</li><li><strong>Operational Overhead:</strong> You are responsible for all maintenance, security, and updates.</li></ul><p><strong>Shopify:</strong> Pricing is based on a monthly subscription, but the need for custom checkout logic creates a significant cost hurdle.</p><ul><li><strong>Monthly Subscription:</strong> To implement the required checkout customizations for live pricing, a dealer must use the <strong>Shopify Plus plan, which starts at $2,000/month</strong>.</li><li><strong>Transaction Fees:</strong> If you don’t use Shopify Payments, Shopify adds its own fee on top of your payment gateway’s charge. This fee is <strong>0.15% on Shopify Plus</strong>-a direct tax on your GMV.</li><li><strong>App Costs:</strong> You’ll need a third-party app for the pricing logic itself, which is an additional monthly cost.</li><li><strong>Development Costs:</strong> You’ll need to hire developers to build a custom app using Shopify Functions to handle the live pricing at checkout.</li></ul><p><strong>Medusa:</strong> As an open-source framework, Medusa has no platform or transaction fees, offering the most control over your cost structure.</p><ul><li><strong>Hosting:</strong> You can self-host on services like AWS or Vercel (typically <strong>50–200+ per month</strong> depending on scale) or use <strong>Medusa Cloud</strong> (plans start from $29/month and scale up).</li><li><strong>Transaction Fees:</strong> You only pay the fees from your chosen payment processor (e.g., Stripe), with <strong>no additional platform tax on GMV</strong>.</li><li><strong>Development Costs:</strong> This is the primary investment. You need development resources to build and maintain the live pricing logic, integrations, and storefront. However, you only build the parts that are unique to your business, leveraging Medusa’s core commerce features for the rest. Fluctum can significantly reduce this initial development cost.</li></ul><h3>Which Platform Should You Choose?</h3><p><strong>If your catalog is small and your pricing updates a handful of times a day is acceptable</strong>, WooCommerce or Shopify are both legitimate, lower-friction choices — pick based on whether you’d rather own a WordPress-based stack (WooCommerce) or hand hosting and PCI compliance to a managed platform (Shopify).</p><p><strong>If you need pricing that genuinely tracks the live market, or you know you’ll need to connect a specific ERP, tax provider, payment processor, or wholesale pricing model beyond what a plugin already covers</strong>, that combination pushes hard against how WooCommerce and Shopify are built, and it’s the situation Medusa’s modular architecture was built to accommodate. The same module/workflow/provider pattern that lets Fluctum add live pricing and u11d’s own Avalara plugin handle tax automation is available for whatever a dealer needs to connect next — a dealer in that situation doesn’t have to build each of those pieces from scratch.</p><h3>Conclusion</h3><p>For a bullion dealer, the choice between WooCommerce, Shopify, and Medusa is a decision about where you want to spend your money and how much control you need.</p><ul><li><strong>Shopify</strong> is the highest-cost option due to the non-negotiable requirement of the <strong>Shopify Plus plan ($2,000+/month)</strong> to achieve true dynamic pricing at checkout. You’re paying a premium for managed infrastructure, but also a platform tax on every transaction.</li><li><strong>WooCommerce</strong> offers a much lower entry cost, with no monthly platform fees. It’s a viable option if your pricing logic is relatively simple and you have the technical resources to manage a WordPress/WooCommerce stack securely.</li><li><strong>Medusa</strong> presents the lowest long-term platform cost, with <strong>zero platform fees on your GMV</strong>. The investment is in development, giving you complete ownership and control over the critical pricing and checkout logic that defines your business. For a serious dealer planning for scale, avoiding a platform tax on a high-GMV business is a significant financial advantage.</li></ul><p>Medusa’s modular architecture is what makes this control possible and cost-effective. The same pattern that lets Fluctum add live spot pricing is what lets u11d’s own Avalara plugin automate tax, and it’s the same pattern available for any other integration a dealer needs. u11d builds and maintains these open-source tools and works with bullion dealers on their Medusa storefronts precisely because of this flexibility. If live market pricing and long-term cost of ownership are your primary concerns, that’s the problem Medusa, and by extension Fluctum, is built to solve.</p><p><em>Originally published at </em><a href="https://u11d.com/blog/woocommerce-shopify-medusa-bullion-dealers/"><em>https://u11d.com</em></a><em> on September 9, 2026.</em></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=b4aca8db07b6" width="1" height="1" alt=""><hr><p><a href="https://medium.com/u11d-tech-blog/woocommerce-shopify-or-medusa-choosing-an-e-commerce-platform-for-bullion-dealers-b4aca8db07b6">WooCommerce, Shopify, or Medusa: Choosing an E-Commerce Platform for Bullion Dealers</a> was originally published in <a href="https://medium.com/u11d-tech-blog">uninterrupted engineering</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[MinIO Is Deprecated: Best Alternatives for S3-Compatible Storage]]></title>
            <link>https://medium.com/u11d-tech-blog/minio-is-deprecated-best-alternatives-for-s3-compatible-storage-660f2ad9aebd?source=rss----24bcdeab33ef---4</link>
            <guid isPermaLink="false">https://medium.com/p/660f2ad9aebd</guid>
            <category><![CDATA[minio]]></category>
            <category><![CDATA[rustfs]]></category>
            <category><![CDATA[s3]]></category>
            <dc:creator><![CDATA[Bartek Galezowski]]></dc:creator>
            <pubDate>Mon, 28 Sep 2026 10:24:38 GMT</pubDate>
            <atom:updated>2026-09-28T10:24:38.811Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*WjI_kH4Qx8c_ixSb4ENvTw.png" /></figure><p>For years, MinIO was the obvious answer whenever an engineering team needed S3-compatible object storage outside AWS. You wanted an S3-compatible API on Kubernetes? You needed object storage for backups? You wanted something you could run locally with Docker Compose? The answer was the same — MinIO.</p><p>That recommendation has changed.</p><blockquote><em>In 2026, the open-source MinIO project is no longer a maintained project in the way engineers historically expected it to be. The upstream GitHub repository was archived on April 25, 2026, and its README explicitly states that the repository is no longer maintained.</em></blockquote><p>That raises an obvious question:</p><blockquote><em>What should replace MinIO?</em></blockquote><p>There isn’t a single answer. Three projects are particularly worth looking at: <strong>Garage, RustFS, and SeaweedFS</strong>. All three provide S3-compatible storage, but they are built around quite different ideas. Garage focuses heavily on decentralized and geographically distributed object storage, RustFS is particularly interesting for engineers looking for something close to the MinIO model, while SeaweedFS takes a much broader approach and combines object storage with distributed file storage and other storage interfaces.</p><p>The important thing is therefore not simply to find “the next MinIO”, but to understand what each project is actually designed to do.</p><h3>What actually happened to MinIO?</h3><p>The MinIO repository on GitHub is now archived and read-only. Its current README states:</p><blockquote><em>“THIS REPOSITORY IS NO LONGER MAINTAINED.”</em></blockquote><p>For any production engineer, this should immediately raise a question: what are the alternatives? But let’s be realistic. Thousands of projects are still running MinIO, and many of them are running perfectly fine on the latest version they deployed. A running MinIO cluster does not magically stop serving objects because the GitHub repository was archived. If your cluster is healthy and you already have the required binaries or container images, it can continue operating. The bigger problem is the <strong>future lifecycle</strong> of the installation.</p><p>In September 2026, the minio/minio repository on Docker Hub was archived, and users started reporting that both minio/minio and minio/mc could no longer be pulled from Docker Hub. This is exactly the kind of failure that makes infrastructure engineers nervous. Imagine that your application hasn&#39;t changed at all. Your Kubernetes manifests are still valid, your infrastructure code is still valid, and your production cluster is still running. Then a node fails. You need to recreate the workload, and Kubernetes tries to pull:</p><pre>image: minio/minio:latest</pre><p>But the image is no longer available.</p><p>If you haven’t mirrored the image to your own registry, your deployment is blocked. You may end up relying on a colleague who still has the image locally, or trying to recover an old image from somewhere else.</p><h3>Alternative #1: Garage</h3><p>Garage is a distributed object storage system written in Rust that implements the S3 API.</p><p>Its philosophy is somewhat different from MinIO. Garage is particularly interesting if your goal isn’t simply “I want a server that looks like MinIO”, but rather “I want distributed object storage that can operate across multiple locations.”</p><p>Garage uses a decentralized architecture where nodes participate in the distributed storage system. This makes it particularly interesting for deployments where you might have several relatively small nodes spread across different locations instead of one large storage cluster.</p><p>For example, you could have infrastructure distributed across several sites:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/632/1*Upbgy1Z_mzTj98nlEBSYiw.png" /></figure><p>This is one of the areas where Garage differs significantly from the traditional MinIO mental model.</p><h3>Alternative #2: RustFS</h3><p>RustFS takes a different approach from Garage, although it shares some important characteristics. It is also written in Rust and provides an AWS S3-compatible API.</p><p>For MinIO users, the interesting part is its design philosophy. RustFS explicitly describes its architecture as being inspired by MinIO’s design philosophy, which makes it particularly interesting when your starting point is: “I already know MinIO, and I want something that feels familiar.”</p><p>RustFS uses a distributed model consisting of nodes and drives. Conceptually, it looks like:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/622/1*KImpYToLf64bwsWe4zefDQ.png" /></figure><p>That doesn’t mean that migration is completely transparent. S3 compatibility needs to be tested against the actual application workload, and the operational model of the new cluster still needs to be understood.</p><h3>Alternative #3: SeaweedFS</h3><p>SeaweedFS takes a broader approach than Garage and RustFS. While both are primarily distributed object-storage systems, SeaweedFS is designed as a distributed storage platform supporting S3, FUSE, HDFS, WebDAV, SFTP, and other interfaces.</p><p>For MinIO users, this makes SeaweedFS interesting when S3 is only part of the requirement. If you simply need an S3-compatible API, SeaweedFS may provide more infrastructure than necessary. But if you need multiple storage interfaces or have workloads involving very large numbers of small objects, its architecture becomes more compelling.</p><p>Conceptually, it looks like:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/749/1*KkKsDN-3wkig-3YOwtcXQg.png" /></figure><p>SeaweedFS separates storage into master servers, volume servers, and the Filer. The master manages cluster metadata, volume servers store the data, and the Filer provides a filesystem-like namespace. The S3 gateway sits on top of this architecture and can be scaled independently.</p><p>Another notable feature is its support for both replication and erasure coding, together with a volume-based design that works well with large numbers of small objects. This makes SeaweedFS worth considering for datasets, thumbnails, generated files, and other high-object-count workloads.</p><p>The trade-off is complexity. SeaweedFS offers considerably more functionality than a simple S3-compatible object store, so the additional flexibility needs to be weighed against the operational overhead.</p><h3>Garage vs RustFS vs SeaweedFS</h3><p>At this point, it should be clear that these three projects aren’t simply three interchangeable MinIO clones.</p><p>They all provide S3-compatible storage, but they target somewhat different requirements.</p><p><strong>Garage</strong> is focused on decentralized object storage and is particularly interesting for geographically distributed or multi-site deployments.</p><p><strong>RustFS</strong> is the most natural project to investigate when your primary requirement is replacing MinIO with another distributed S3-compatible object store while retaining a relatively familiar architecture.</p><p><strong>SeaweedFS</strong> takes a broader approach and makes sense when object storage is only one part of the problem. Its ability to expose the same underlying storage through S3, filesystem, HDFS, and other interfaces can be valuable for larger or more diverse storage environments.</p><p>The important distinction is therefore not simply performance or feature count. It is the architecture you want to operate.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*RdNzPPnoB4YeYvAqR9cBGQ.png" /></figure><p>This table is not intended to declare a winner. The projects solve different problems, and the right choice depends on the workload and architecture you need to support.</p><p>If you have multiple locations and want decentralized storage that treats geographic distribution as a fundamental part of the architecture, Garage is worth investigating.</p><p>If your main requirement is replacing MinIO with a modern, Rust-based, distributed S3-compatible object store that follows a relatively familiar model, RustFS is an obvious candidate.</p><p>If you need more than S3 — for example, filesystem access, HDFS, huge numbers of small objects, or multiple storage interfaces — SeaweedFS deserves serious consideration.</p><p>The important thing is that you don’t have to choose the project with the longest feature list. You have to choose the architecture that matches your workload.</p><h3>Conclusion</h3><p>MinIO may no longer be the default choice it once was, but that doesn’t mean there is a single project waiting to take its place. Garage, RustFS, and SeaweedFS represent three different directions for self-hosted object storage. Garage focuses on decentralized, multi-site storage. RustFS is the closest fit for teams looking for a familiar distributed S3 model. SeaweedFS goes beyond object storage and provides a broader storage platform with multiple interfaces and a design particularly suited to large numbers of objects. The era of treating MinIO as the automatic default for self-hosted S3 is over. The right replacement depends on the operational model, workload, and architecture you want to maintain long after the migration is complete.</p><p>For an existing MinIO deployment, the decision should not start with <strong>“Which project is the best?”</strong>. It should start with <strong>“What do we actually need from our storage layer?”</strong></p><p>Once you answer that question, choosing between Garage, RustFS, and SeaweedFS becomes much less about finding the next MinIO and much more about choosing the storage architecture that actually fits your infrastructure.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=660f2ad9aebd" width="1" height="1" alt=""><hr><p><a href="https://medium.com/u11d-tech-blog/minio-is-deprecated-best-alternatives-for-s3-compatible-storage-660f2ad9aebd">MinIO Is Deprecated: Best Alternatives for S3-Compatible Storage</a> was originally published in <a href="https://medium.com/u11d-tech-blog">uninterrupted engineering</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[TypeSafe AI Jev: Structured Decision Models Explained]]></title>
            <link>https://medium.com/u11d-tech-blog/typesafe-ai-jev-structured-decision-models-explained-0b0407c1c235?source=rss----24bcdeab33ef---4</link>
            <guid isPermaLink="false">https://medium.com/p/0b0407c1c235</guid>
            <category><![CDATA[ai]]></category>
            <category><![CDATA[jevs]]></category>
            <dc:creator><![CDATA[Pawel Swiridow]]></dc:creator>
            <pubDate>Thu, 24 Sep 2026 09:52:21 GMT</pubDate>
            <atom:updated>2026-09-24T09:52:21.699Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*ZfN6aY4gWLdzpmdJuKfKnQ.png" /></figure><p>TypeSafe AI Jev explained: examples, use cases, and LLM comparisons</p><p>TypeSafe AI Jev is a model for making bounded decisions inside software. You give it text or structured state plus questions whose valid answers you define. It returns typed choices, scores, probabilities, and confidence values instead of generating prose.</p><p>That makes Jev useful for classification, routing, scoring, and review gates. It does not replace GPT, Claude, or another generative model when you need writing, code, explanations, or open-ended reasoning. Think of it as a fuzzy decision function, not a chatbot wearing a smaller hat.</p><h3>Quick answer</h3><p>Use Jev when:</p><ul><li>Every valid answer can be defined before the call.</li><li>The hard part is semantic judgment, not arithmetic or deterministic policy.</li><li>Software needs probabilities or confidence to decide whether to act, retry, or escalate.</li><li>The workload repeats often enough for latency and per-call cost to matter.</li></ul><p>Use something else when:</p><ul><li>The output must contain newly written text, code, or an explanation.</li><li>Correctness depends on exact math, dates, permissions, or database state.</li><li>The task requires long, multi-step reasoning.</li><li>The model would be the only security or authorization boundary.</li></ul><h3>What is TypeSafe AI Jev?</h3><p>TypeSafe calls Jev its first System One model. That is the company’s product category for models built to make fast, structured decisions that software can consume directly. It is not a new chat interface and it is not an industry-standard model category.</p><p>The contract is simple:</p><pre>state + typed questions -&gt; Jev -&gt; typed answers + probabilities -&gt; application code</pre><p>The state can be a string, JSON object, or array containing text. Each question defines its answer space. Jev evaluates independent questions against the same state in parallel, then your code decides what happens next.</p><p>At the time of writing, jev-latest resolves to jev-1.13.0. TypeSafe&#39;s <a href="https://docs.typesafe.ai/models">model documentation</a> lists it at $0.042 per million input tokens with free output tokens. The documented context limit is 64,000 tokens per request, with a separate 32,000-token limit for the state plus the longest question. Input is text only, so images, audio, and video must be converted before they reach Jev.</p><p>Pin a version when tuning thresholds and rerun evaluation before upgrading. A moving alias is convenient until it changes a production route at 02:00.</p><p>TypeSafe’s <a href="https://typesafe.ai/blog/introducing-system-one-models-and-jev">launch material</a> describes Jev as unable to hallucinate because it cannot emit a value outside the schema. That claim needs a narrower reading. Jev cannot invent a fourth result when your Choice defines three, but it can choose the wrong one. Type safety protects the shape of an answer. It does not make the judgment correct. <a href="https://arize.com/blog/typesafe-jev-llm-judge/">Independent analysis from Arize</a> makes the same distinction.</p><h3>How Choice, Score, and Noul work</h3><p>Jev exposes three question types:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*Ur2gmyg1JDKuS-91NWmS_Q.png" /></figure><p>A Choice can contain up to 255 options. A Score accepts between two and ten ordered levels. Choice and Score return a confidence value derived from their probability distributions; Noul returns the probability of yes without a separate confidence field.</p><p>Questions in one request are independent. If question B depends on question A, use another call or express the dependency in code. Do not assume that two differently phrased questions will obey arithmetic identities either. TypeSafe documents cases where a Noul and an equivalent-looking Choice produce different numbers because they ask different statistical questions.</p><p>Keep questions atomic. Ask separately whether a refund was requested, score impact, classify intent, then let code apply policy. Models judge fuzzy meaning; code does not reinterpret accounting rules overnight.</p><h3>Run a Jev decision in Python</h3><h3>Install the SDK</h3><p>The Python SDK requires an API key in TYPESAFE_API_KEY:</p><pre>python3 -m pip install typesafe-sdk<br>export TYPESAFE_API_KEY=&#39;replace-with-a-key-from-console.typesafe.ai&#39;</pre><p>Use your normal secret manager in CI and production. Committing the key beside the example would turn a quick start into an incident report.</p><h3>Classify and route a support ticket</h3><p>This example asks one Choice, one Score, and one Noul in a single call. It then applies routing thresholds in ordinary Python.</p><pre>import os<br>import time</pre><pre>from typesafe_sdk import Choice, Noul, Score, TypeSafeClient</pre><pre>if &quot;TYPESAFE_API_KEY&quot; not in os.environ:<br>    raise SystemExit(&quot;Set TYPESAFE_API_KEY before running this example&quot;)</pre><pre>INPUT_USD_PER_MTOK = 0.042  # TypeSafe list price at the time of writing</pre><pre>ticket = {<br>    &quot;subject&quot;: &quot;Checkout fails after payment&quot;,<br>    &quot;message&quot;: (<br>        &quot;Our card was charged twice and checkout still shows an error. &quot;<br>        &quot;We need this fixed today because customers cannot place orders.&quot;<br>    ),<br>    &quot;plan&quot;: &quot;business&quot;,<br>}</pre><pre>started = time.perf_counter()</pre><pre>with TypeSafeClient(model=&quot;jev-1.13.0&quot;) as client:<br>    response = client.system_one(<br>        state=ticket,<br>        questions={<br>            &quot;department&quot;: Choice(<br>                instructions=&quot;Which team should own this ticket first?&quot;,<br>                criteria={<br>                    &quot;billing&quot;: &quot;Charges, invoices, refunds, or subscriptions&quot;,<br>                    &quot;technical&quot;: &quot;Bugs, outages, failed integrations, or broken product behavior&quot;,<br>                    &quot;account&quot;: &quot;Login, identity, permissions, or account access&quot;,<br>                    &quot;other&quot;: &quot;The ticket does not fit the other categories&quot;,<br>                },<br>            ),<br>            &quot;severity&quot;: Score(<br>                instructions=&quot;How severely does this issue block the customer&#39;s work?&quot;,<br>                criteria=[<br>                    &quot;Minor inconvenience with a clear workaround&quot;,<br>                    &quot;Important feature is degraded but work can continue&quot;,<br>                    &quot;Critical workflow is blocked or customers are losing money&quot;,<br>                ],<br>            ),<br>            &quot;requests_refund&quot;: Noul(<br>                instructions=&quot;Does the customer explicitly ask for a refund?&quot;,<br>                criteria={<br>                    &quot;true&quot;: &quot;The customer directly requests money back&quot;,<br>                    &quot;false&quot;: &quot;The customer reports a charge but does not request a refund&quot;,<br>                },<br>            ),<br>        },<br>    )</pre><pre>elapsed = time.perf_counter() - started</pre><pre>department = response.choices[&quot;department&quot;]<br>severity = response.scores[&quot;severity&quot;]<br>refund_probability = response.nouls[&quot;requests_refund&quot;].noul</pre><pre>if department.confidence &lt; 0.65 or severity.confidence &lt; 0.55:<br>    destination = &quot;human-review&quot;<br>elif severity.score &gt; 1.5:<br>    destination = &quot;incident-response&quot;<br>else:<br>    destination = department.choice</pre><pre>input_tokens = response.usage.input_tokens or 0<br>estimated_cost = input_tokens / 1_000_000 * INPUT_USD_PER_MTOK</pre><pre>print(f&quot;model={response.model}&quot;)<br>print(f&quot;destination={destination}&quot;)<br>print(f&quot;department_confidence={department.confidence:.2f}&quot;)<br>print(f&quot;severity={severity.score:.2f}&quot;)<br>print(f&quot;refund_probability={refund_probability:.2f}&quot;)<br>print(f&quot;input_tokens={input_tokens}&quot;)<br>print(f&quot;output_tokens={response.usage.output_tokens}&quot;)<br>print(f&quot;elapsed_seconds={elapsed:.2f}&quot;)<br>print(f&quot;estimated_cost_usd={estimated_cost:.8f}&quot;)</pre><p>The SDK exposes separate collections for Choice, Score, and Noul answers. The thresholds are examples, not TypeSafe defaults. Set them from errors and review costs measured on your own data.</p><p>Pinning jev-1.13.0 prevents alias drift. In production, log the criteria version, probability distributions, chosen route, and human outcome.</p><h3>Sample output and cost</h3><p>A real run of the example above against jev-1.13.0 printed this:</p><pre>model=jev-1.13.0<br>destination=incident-response<br>department_confidence=0.95<br>severity=2.00<br>refund_probability=0.06<br>input_tokens=528<br>output_tokens=77<br>elapsed_seconds=0.64<br>estimated_cost_usd=0.00002218</pre><p>The usage block in the response is what makes the cost measurable without opening a billing dashboard. That call moved 528 input tokens. TypeSafe bills 0.042 per million input tokens and does not bill output tokens, so the run cost 528 / 1,000,000 * 0.042, or roughly 0.000022 at current list prices. A thousand calls of this size cost about two cents, and one dollar buys roughly 45,000 of them.</p><p>elapsed_seconds covers client setup plus the round trip, not just server time. Values move with the input, criteria, and model version, so treat the transcript as a real example rather than a fixed contract.</p><h3>Practical Jev use cases</h3><h3>Route support requests</h3><p>Support routing is the natural first pilot. Put the ticket text and relevant account context in state. Use Choice for the owning queue, Score for impact, and Noul for independent conditions such as an explicit refund request:</p><pre>{<br>  &quot;state&quot;: {<br>    &quot;channel&quot;: &quot;email&quot;,<br>    &quot;subject&quot;: &quot;Wrong item delivered, replacement needed before Friday&quot;,<br>    &quot;message&quot;: &quot;Order 48213 arrived with two left boots. I need the replacement before the conference on Friday or I want to cancel.&quot;,<br>    &quot;account_tier&quot;: &quot;enterprise&quot;,<br>    &quot;earlier_tickets&quot;: 2<br>  },<br>  &quot;model&quot;: &quot;jev-1.13.0&quot;,<br>  &quot;questions&quot;: {<br>    &quot;queue&quot;: {<br>      &quot;type&quot;: &quot;choice&quot;,<br>      &quot;instructions&quot;: &quot;Which queue should own this ticket first?&quot;,<br>      &quot;criteria&quot;: {<br>        &quot;returns&quot;: &quot;Wrong, damaged, or missing items, replacements, and shipping problems&quot;,<br>        &quot;billing&quot;: &quot;Charges, invoices, refunds, and subscriptions&quot;,<br>        &quot;technical&quot;: &quot;Product failures, errors, and defects&quot;,<br>        &quot;account&quot;: &quot;Access, identity, and permissions&quot;,<br>        &quot;other&quot;: &quot;None of the other queues fit&quot;<br>      }<br>    },<br>    &quot;impact&quot;: {<br>      &quot;type&quot;: &quot;score&quot;,<br>      &quot;instructions&quot;: &quot;How much does the problem block the customer?&quot;,<br>      &quot;criteria&quot;: [<br>        &quot;Minor inconvenience with a workaround&quot;,<br>        &quot;The item or order cannot be used as delivered&quot;,<br>        &quot;Time-critical situation or total loss of the purchase&quot;<br>      ]<br>    },<br>    &quot;wants_cancel&quot;: {<br>      &quot;type&quot;: &quot;noul&quot;,<br>      &quot;instructions&quot;: &quot;Is the customer asking to cancel an order or close the account?&quot;<br>    }<br>  }<br>}</pre><p>Code can accept a confident route, send uncertain tickets to a person, and apply exact entitlement rules from the account database.</p><h3>Review agent actions</h3><p>An agent can propose a tool call while Jev supplies a bounded semantic risk signal:</p><pre>{<br>  &quot;state&quot;: {<br>    &quot;user_goal&quot;: &quot;Remove generated build artifacts&quot;,<br>    &quot;tool&quot;: &quot;shell&quot;,<br>    &quot;command&quot;: &quot;rm -rf dist&quot;<br>  },<br>  &quot;model&quot;: &quot;jev-1.13.0&quot;,<br>  &quot;questions&quot;: {<br>    &quot;risk&quot;: {<br>      &quot;type&quot;: &quot;choice&quot;,<br>      &quot;instructions&quot;: &quot;Classify the operational risk of the proposed action&quot;,<br>      &quot;criteria&quot;: {<br>        &quot;read_only&quot;: &quot;Does not modify state&quot;,<br>        &quot;reversible_write&quot;: &quot;Changes state but has a reliable rollback&quot;,<br>        &quot;irreversible_write&quot;: &quot;Deletes or overwrites state without a reliable rollback&quot;,<br>        &quot;unclear&quot;: &quot;The available state is insufficient&quot;<br>      }<br>    },<br>    &quot;matches_goal&quot;: {<br>      &quot;type&quot;: &quot;noul&quot;,<br>      &quot;instructions&quot;: &quot;Does the proposed action directly match the user&#39;s stated goal?&quot;<br>    }<br>  }<br>}</pre><p>This is a review signal, not permission to execute. Allowlist enforcement, path validation, sandboxing, and user confirmation still belong in code. Jev’s own documentation warns that adversarial text in state can move an answer, so it must not become the sole security boundary.</p><h3>Check RAG evidence before generation</h3><p>Jev can classify whether a retrieved passage supports a claim before a generative model writes the answer:</p><pre>{<br>  &quot;state&quot;: {<br>    &quot;question&quot;: &quot;Does the service retain customer prompts for training?&quot;,<br>    &quot;claim&quot;: &quot;Customer requests are not used to train Jev.&quot;,<br>    &quot;passage&quot;: &quot;Jev is not trained on customer requests or responses.&quot;<br>  },<br>  &quot;model&quot;: &quot;jev-1.13.0&quot;,<br>  &quot;questions&quot;: {<br>    &quot;support&quot;: {<br>      &quot;type&quot;: &quot;choice&quot;,<br>      &quot;instructions&quot;: &quot;How well does the passage support the claim?&quot;,<br>      &quot;criteria&quot;: {<br>        &quot;supported&quot;: &quot;The passage directly supports the claim&quot;,<br>        &quot;contradicted&quot;: &quot;The passage conflicts with the claim&quot;,<br>        &quot;insufficient&quot;: &quot;The passage does not settle the claim&quot;<br>      }<br>    },<br>    &quot;contains_instruction&quot;: {<br>      &quot;type&quot;: &quot;noul&quot;,<br>      &quot;instructions&quot;: &quot;Does the passage contain an instruction aimed at changing the evaluator&#39;s behavior?&quot;<br>    }<br>  }<br>}</pre><p>The application still needs to preserve the source URL and quote. Jev judges the relationship between claim and passage; it does not browse, establish provenance, or write the final cited answer.</p><h3>Triage moderation and operations queues</h3><p>Moderation reports, invoices, incidents, and agent traces all end up in queues. Jev can classify the queue, score severity, and flag missing evidence:</p><pre>{<br>  &quot;state&quot;: {<br>    &quot;source&quot;: &quot;monitoring alert&quot;,<br>    &quot;service&quot;: &quot;checkout-api&quot;,<br>    &quot;summary&quot;: &quot;p99 latency rose from 180 ms to 2.4 s over the last 20 minutes while the error rate stayed near 0.2%&quot;,<br>    &quot;context&quot;: &quot;Deploy checkout-api v2.31.0 finished 25 minutes ago&quot;<br>  },<br>  &quot;model&quot;: &quot;jev-1.13.0&quot;,<br>  &quot;questions&quot;: {<br>    &quot;owner&quot;: {<br>      &quot;type&quot;: &quot;choice&quot;,<br>      &quot;instructions&quot;: &quot;Which team should take the first response for this alert?&quot;,<br>      &quot;criteria&quot;: {<br>        &quot;service_team&quot;: &quot;The reporting service&#39;s own code or configuration&quot;,<br>        &quot;platform&quot;: &quot;Shared infrastructure, networking, or managed dependencies&quot;,<br>        &quot;database&quot;: &quot;Storage, replication, or query performance&quot;,<br>        &quot;unclear&quot;: &quot;The available evidence does not point to one owner&quot;<br>      }<br>    },<br>    &quot;severity&quot;: {<br>      &quot;type&quot;: &quot;score&quot;,<br>      &quot;instructions&quot;: &quot;How much customer impact does the summary describe?&quot;,<br>      &quot;criteria&quot;: [<br>        &quot;No confirmed customer impact, worth watching&quot;,<br>        &quot;Degraded latency for some customers&quot;,<br>        &quot;Customers blocked or data at risk&quot;<br>      ]<br>    },<br>    &quot;missing_evidence&quot;: {<br>      &quot;type&quot;: &quot;noul&quot;,<br>      &quot;instructions&quot;: &quot;Is the alert missing context a first responder would need before acting?&quot;<br>    }<br>  }<br>}</pre><p>Low-confidence or high-impact items, and anything flagged for missing evidence, go to a human reviewer. Exact totals, due dates, and account permissions stay in deterministic code because Jev 1.13 is explicitly weak at counting, arithmetic, and date comparison.</p><h3>Route work to the right model or human</h3><p>Use Jev in front of more expensive handlers. A Choice can select deterministic lookup, a small generative model, a reasoning model, or human review:</p><pre>{<br>  &quot;state&quot;: {<br>    &quot;request&quot;: &quot;Summarize the attached vendor contract and flag the clauses that change our data-retention obligations&quot;,<br>    &quot;attachments&quot;: [&quot;vendor-contract-v7.pdf&quot;],<br>    &quot;requester&quot;: &quot;procurement&quot;<br>  },<br>  &quot;model&quot;: &quot;jev-1.13.0&quot;,<br>  &quot;questions&quot;: {<br>    &quot;route&quot;: {<br>      &quot;type&quot;: &quot;choice&quot;,<br>      &quot;instructions&quot;: &quot;Which handler should receive this request?&quot;,<br>      &quot;criteria&quot;: {<br>        &quot;deterministic&quot;: &quot;A lookup, calculation, or template can answer it exactly&quot;,<br>        &quot;small_model&quot;: &quot;Short generation or extraction a cheap model handles well&quot;,<br>        &quot;reasoning_model&quot;: &quot;Long-document analysis or multi-step reasoning&quot;,<br>        &quot;human&quot;: &quot;Judgment, negotiation, or accountability beyond the model stack&quot;<br>      }<br>    },<br>    &quot;in_scope&quot;: {<br>      &quot;type&quot;: &quot;noul&quot;,<br>      &quot;instructions&quot;: &quot;Does the request fall inside the documented scope of the available handlers?&quot;<br>    }<br>  }<br>}</pre><p>Confidence determines whether the route is safe to accept. Below the threshold, send the request to a human instead of accepting a cheap guess that the matching handler will only have to redo later. This architecture spends generation tokens only when the task needs generation, rather than asking a novelist to sort every envelope.</p><h3>Jev vs LLMs, classifiers, and rules</h3><p>Jev is not automatically better than the alternatives. It occupies a specific slot:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*lS4OFQodZcpn2kEKTl9hXg.png" /></figure><p>Plain JSON mode guarantees valid JSON, not adherence to a supplied schema. Strict structured-output APIs can enforce supported JSON schemas, including required keys and enum values.</p><p>Schema enforcement is therefore not unique to Jev. Its distinction is a decision-specific interface built around Choice, Score, and Noul, with probability distributions and confidence for bounded judgments.</p><p>A conventional classifier may be the better choice for a stable, high-volume task when you already have good labels, training infrastructure, and predictable categories. In one early spam experiment, Jev reached 98.6% accuracy on a 5,733-email ham, spam, and phishing test without task-specific fine-tuning or labeled examples in its requests, while a TF-IDF logistic-regression baseline trained on labeled messages reached 98.9% (<a href="https://github.com/bitnovus/jev-spam-eval">full writeup</a>). The criteria had been refined after reviewing labeled examples, so this was useful early evidence, not magic zero-data victory.</p><p>Rules win whenever the answer can be computed exactly. Do not ask any model whether an invoice is overdue if two parsed dates and a comparison operator can settle it.</p><p>The cost and latency story is still notable.</p><p>TypeSafe reports 70 to 500 ms end-to-end latency and $0.042 per million input tokens for suitable requests.</p><p>Its launch workflow evaluation produced headline claims of 193.6 times faster and 444.6 times cheaper than its selected LLM comparison, but TypeSafe says those figures are likely near the high end of real-world gains and notes possible benchmark bias.</p><h3>When not to use TypeSafe AI Jev</h3><p>Jev 1.13 is not trained to write text. It will not draft a customer reply, produce code, summarize a report, or explain why it chose an answer. Use a generative model when the output itself must be created. The missing explanation also matters for evaluation work: an LLM judge can describe a failure, while Jev returns probabilities that tell you where to inspect, not how to fix the problem.</p><p>Keep arithmetic, counting, numeric interpolation, and date comparisons in code. TypeSafe’s <a href="https://docs.typesafe.ai/model-jaggedness/jev-1.13">limitations page</a> says Jev can read dates as text but does not reliably treat them as ordered values. It also warns about literal phrasing, multi-hop indirection, irrelevant context, contradictory criteria, and adversarial content.</p><p>More context is not automatically better. Retrieve and filter first, then send the fields needed for the decision. A 64,000-token limit is capacity, not a challenge.</p><p>Test non-English workloads separately. TypeSafe says English is Jev’s strongest training language and other languages are not handled equally well.</p><p>Most importantly, separate a valid answer from a correct answer. A typed output prevents parser failures and out-of-schema labels. It does not prevent a confident, valid, expensive mistake.</p><h3>How to evaluate Jev on your workload</h3><p>Start in shadow mode:</p><ol><li>Pick one reversible decision with a closed answer set.</li><li>Freeze a representative labeled dataset and the exact criteria text.</li><li>Run Jev and the current baseline on identical records.</li><li>Measure per-class errors, false automatic actions, human-review rate, calibration, end-to-end latency, and cost per completed decision.</li><li>Tune thresholds by consequence. Sending a ticket to the wrong queue and approving a destructive action do not deserve the same confidence floor.</li><li>Pin the model version and rerun the suite before changing either model or criteria.</li><li>Keep permissions, arithmetic, and state changes in deterministic code.</li></ol><p>Do not optimize only for overall accuracy. A model can look good while failing badly on a rare queue that matters. Track confusion by class and inspect probability bands: cases scored near 0.9 should be correct more often than cases scored near 0.6. If that relationship does not hold on your data, the threshold is decoration.</p><p>Include review capacity in the evaluation. A cautious threshold that sends 70 percent of cases to a human may be safe, but it has not automated much. The useful operating point balances wrong automatic actions against the cost of review.</p><h3>Should you use TypeSafe AI Jev?</h3><p>Pilot TypeSafe AI Jev when the answer space is bounded, the judgment is semantic, and software consumes the result often enough for decision latency and cost to matter. Support routing, relevance checks, moderation labels, model routing, and broad evaluation coverage are credible starting points.</p><p>Keep an LLM when the output must be written, explained, or reasoned through. Keep code when the answer can be computed exactly. Keep a human in the path when a wrong but valid decision has serious consequences and your own calibration data is thin.</p><p>Jev’s useful idea is not that every LLM should be replaced. It is that generation and judgment do not have to be the same model call. Split them, measure them independently, and make code responsible for the final action.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=0b0407c1c235" width="1" height="1" alt=""><hr><p><a href="https://medium.com/u11d-tech-blog/typesafe-ai-jev-structured-decision-models-explained-0b0407c1c235">TypeSafe AI Jev: Structured Decision Models Explained</a> was originally published in <a href="https://medium.com/u11d-tech-blog">uninterrupted engineering</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[CloudFront Policy Patterns for Production: SPA+API, JWT Auth, Multi-Tenant SaaS, and Image CDN |…]]></title>
            <link>https://medium.com/u11d-tech-blog/cloudfront-policy-patterns-for-production-spa-api-jwt-auth-multi-tenant-saas-and-image-cdn-1408a77e98b0?source=rss----24bcdeab33ef---4</link>
            <guid isPermaLink="false">https://medium.com/p/1408a77e98b0</guid>
            <category><![CDATA[cdn]]></category>
            <category><![CDATA[aws]]></category>
            <category><![CDATA[devops]]></category>
            <category><![CDATA[aws-cloudfront]]></category>
            <dc:creator><![CDATA[Maciej Lopalewski]]></dc:creator>
            <pubDate>Fri, 18 Sep 2026 08:27:34 GMT</pubDate>
            <atom:updated>2026-09-18T08:27:33.958Z</atom:updated>
            <content:encoded><![CDATA[<h3>CloudFront Policy Patterns for Production: SPA+API, JWT Auth, Multi-Tenant SaaS, and Image CDN | u11d</h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*efwmyAW6Mvp41lepb9Qg_A.jpeg" /></figure><p>The hard part of CloudFront policies isn’t picking one off the managed list. It’s combining the three policy types — cache, origin request, response headers — for an actual application. The right cache policy for a SPA isn’t right for its API. Forwarding Authorization to your origin without poisoning the cache turns out to be impossible with a custom origin request policy. Multi-tenant routing on Host doesn&#39;t partition the cache by default, and most tutorials don&#39;t mention it.</p><p>Four architectures cover most of what teams actually build on CloudFront: a SPA on S3 with an API behind a separate path, an authenticated API with JWT or session cookies, multi-tenant SaaS with Host-based routing, and an image CDN with on-the-fly transformations. Each one has a canonical policy stack, a set of gotchas that bite in production, and one or two places where CloudFront Functions or Lambda@Edge become necessary - because some of these patterns can&#39;t be solved with policies alone.</p><p>This builds on three earlier deep dives that cover the per-policy fundamentals: cache policies, origin request policies, and response headers policies. I’ll assume you’ve read them or know the equivalents — this article is about combinations, not basics.</p><h3>Table of contents</h3><ul><li>The four CloudFront events and where edge functions fit</li><li>Pattern 1: SPA on S3 + API behind a separate path</li><li>Pattern 2: Authenticated API with JWT or session cookies</li><li>Pattern 3: Multi-tenant SaaS with Host-based routing</li><li>Pattern 4: Image CDN with on-the-fly transformations</li><li>What to read next</li></ul><h3>The four CloudFront events and where edge functions fit</h3><p>Every viewer request that reaches CloudFront passes through four lifecycle events: <strong>viewer request</strong> (when CloudFront receives the request, before the cache lookup), <strong>origin request</strong> (only on cache miss, before forwarding to origin), <strong>origin response</strong> (after CloudFront receives the origin’s response, before caching it), and <strong>viewer response</strong> (just before CloudFront sends the response to the viewer). The two origin events fire only on cache misses; the two viewer events fire every time. Cached responses skip both origin events entirely. Per the AWS Lambda@Edge trigger-events docs, CloudFront also skips viewer-response functions when the origin returns HTTP ≥ 400, and skips all four events on automatic HTTP→HTTPS redirects.</p><p>Two compute primitives can run code at these events: <strong>CloudFront Functions</strong> and <strong>Lambda@Edge</strong>. The choice usually isn’t subtle — they’re built for different things.</p><p>The biggest practical constraints: CloudFront Functions can’t make network calls and only run on viewer events. Neither service can inspect the response body returned by the origin — Lambda@Edge can <em>replace</em> a response body or generate one from scratch in request triggers, but the origin-response trigger doesn’t expose the origin’s body to the function (you can overwrite it but not read it). For true read-and-transform body manipulation, you need a real origin compute service like a Lambda function URL or S3 Object Lambda. Lambda@Edge is regional, slower than CloudFront Functions, and harder to debug, but it can read request bodies (with the include-body opt-in for POST/PUT methods) and run on all four events. AWS publishes a direct comparison — worth bookmarking.</p><p>A 2023 addition matters here: CloudFront KeyValueStore, accessible from CloudFront Functions running the cloudfront-js-2.0 runtime, lets you store configuration data at the edge without redeploying function code. For multi-tenant scenarios where per-tenant config has to be looked up at the edge, this often replaces what would have been a Lambda@Edge function with a DynamoDB call.</p><h3>Pattern 1: SPA on S3 + API behind a separate path</h3><p>A single CloudFront distribution with two cache behaviors: /api/* routes to API Gateway, an ALB, or a Lambda function URL; everything else routes to an S3 bucket holding the SPA shell and assets. Each behavior gets its own three-policy stack.</p><p><strong>Static path (default </strong><strong>* behavior → S3):</strong> <strong>API path (</strong><strong>/api/* → API Gateway / ALB / Lambda function URL):</strong></p><p>Cache</p><p>Managed-CachingDisabled for fully dynamic, or a custom policy if some endpoints are cacheable</p><p>Origin request</p><p>Managed-AllViewerExceptHostHeader for API Gateway / Lambda function URLs; Managed-AllViewer for ALBs that accept any host</p><p>Response headers</p><p>None if same-origin; Managed-CORS-with-preflight-and-SecurityHeadersPolicy if the SPA loads from a different domain</p><h3>The custom error response trap</h3><p>The trap: <strong>custom error responses are configured at the distribution level, not per cache behavior.</strong> A 404 → /index.html rule applied for the SPA also rewrites genuine API 404s to the SPA shell with HTTP 200, which silently breaks every client expecting a real 404 from the API. There are two real fixes:</p><p>The other fix is Lambda@Edge on origin-response, scoped to the S3 cache behavior only — but that’s heavier, costs more, and adds a regional dependency to what should be edge logic. The CloudFront Function path is the right call almost every time. Whichever fix you pick, test API 404 and 403 responses explicitly after deploying — silent rewrites are easy to miss until a client complains about always getting HTML back from /api/users/9999.</p><h3>OAC, not OAI</h3><p>For the S3 origin, use <strong>Origin Access Control</strong> rather than the legacy Origin Access Identity. OAC supports SigV4 signing, all S3 regions launched after December 2022, KMS server-side encryption, and dynamic methods like PUT and DELETE. OAI is in maintenance mode. The CloudFront restrict-S3-access docs mark OAC as the current recommendation. Existing distributions on OAI keep working — there’s no urgent migration — but new distributions should use OAC.</p><p>One more S3-specific constraint to know: caching based on the Authorization header isn&#39;t supported for S3 origins, per the CloudFront header-caching docs. If you need per-user authenticated access to S3 content, use signed URLs or signed cookies - that pattern shows up again in the next section.</p><h3>Pattern 2: Authenticated API with JWT or session cookies</h3><p>Caching a JWT-authenticated API is where CloudFront’s policy model gets uncomfortable. The hard constraint: <strong>Authorization cannot be allow-listed in a custom origin request policy.</strong> AWS&#39;s CloudFormation API rejects it at deploy time. The CDK issue #28883 is the canonical bug report, and the AWS re:Post Q&amp;A on forwarding the Authorization header is the official workaround page.</p><p>This means there’s no supported way to forward only Authorization to your origin while keeping it out of the cache key. Your options collapse to a small set:</p><p>The first is to include Authorization in the <strong>cache policy</strong> header list, which auto-forwards it to the origin (anything in the cache key is automatically forwarded). The cost is that every distinct token becomes its own cache entry - for a JWT-authenticated app, that&#39;s one cache entry per active user. Cache hit ratio approaches zero on shared resources.</p><p>The third is a CloudFront Function on viewer-request that copies Authorization to a custom header (e.g. X-Internal-Auth). A custom header name <em>can</em> be allow-listed in a custom origin request policy. The function is small - a single line of JavaScript - but the indirection is real and worth flagging in your distribution config.</p><h3>The minimum-TTL silent leak</h3><p>The fix is either CachingDisabled (minimum TTL = 0, ignoring origin Cache-Control for caching is disabled by definition) or a custom cache policy with MinTTL = 0. For authenticated APIs that should never be cached at the edge, CachingDisabled is the safe default.</p><h3>Signed URLs for cacheable per-user content</h3><p>For cacheable, access-controlled content, signed URLs and signed cookies are the pattern AWS documents most directly. If your app fits the shape of “per-resource auth that’s cacheable” rather than “per-request auth on dynamic data,” signed URLs are usually simpler than anything you can build with the three-policy model.</p><h3>Lightweight token validation at the edge</h3><p>CloudFront Functions can validate a JWT signature at viewer-request and reject unauthenticated traffic before it ever reaches your origin. The AWS JWT validation example shows the pattern: parse the token, verify the signature with a hardcoded secret, allow or 401. With the cloudfront-js-2.0 runtime, crypto.createHmac is available, which makes this practical for HS256-signed tokens.</p><p>The honest limitation: CloudFront Functions can’t fetch JWKS, can’t call KMS or Secrets Manager, and can’t introspect tokens against an OAuth server. The signing key has to be embedded in the function code or a KeyValueStore entry. For RS256 tokens with key rotation, Lambda@Edge with a JWKS fetch is still the right tool — but it’s a meaningfully heavier dependency than a CloudFront Function with KVS.</p><h3>Pattern 3: Multi-tenant SaaS with Host-based routing</h3><p>The architecture sounds clean, but CloudFront’s default behavior breaks it in a way that’s easy to miss until production traffic exposes it.</p><h3>Host is not in the cache key by default</h3><p>Per the AWS re:Post answer on default cache key composition and the understanding-the-cache-key docs, CloudFront’s default cache key consists of the distribution’s domain name plus the URL path (plus the OPTIONS method when applicable). The viewer’s Host header - the alternate domain name that determined which tenant they were trying to reach - is <strong>not</strong> part of the cache key unless you explicitly add it.</p><h3>HostHeaderOnly origin request policy</h3><p>If your origin needs the viewer’s Host for its own tenant routing - a multi-tenant ALB, an EKS ingress that maps Host to namespace, a reverse proxy doing virtual hosts - the Managed-HostHeaderOnly origin request policy forwards just the Host header and nothing else. Per the managed origin request policies docs, it&#39;s the inverse of AllViewerExceptHostHeader.</p><p>Be careful not to confuse the two. AllViewerExceptHostHeader strips Host and forwards everything else, used when origin is API Gateway and shouldn&#39;t see the viewer&#39;s domain. HostHeaderOnly forwards Host and nothing else, used when origin needs Host for routing and you want a clean origin request without cookies or query strings. Pairing the right one with the right cache policy is the whole pattern.</p><h3>Per-tenant response headers can’t be done with policies</h3><p>Response headers policies are configured per cache behavior, not per request. Cache behaviors match on path patterns, not on Host. So a single cache behavior shared across tenants gets one response headers policy - every tenant receives identical CSP, identical security headers, identical CORS. There&#39;s no built-in way to vary response headers by tenant.</p><p>If your tenants need different CSPs (one allowlists Stripe, another allowlists PayPal), different CORS origins, or different security headers, you need a CloudFront Function on viewer-response that reads the request’s Host and rewrites the response headers. With CloudFront KeyValueStore for per-tenant config lookup, this is a practical pattern - define the per-tenant headers as KVS entries keyed by tenant domain, look them up in the function, and rewrite. AWS&#39;s aws-samples/sample-cloudfront-saas-manager-nginx-ingress-migration repo demonstrates the broader pattern.</p><p>One caveat from the docs: viewer-response functions don’t fire when the origin returns HTTP ≥ 400. Your error responses won’t get the per-tenant header treatment. For a tenant-specific 500 page, you need either custom error responses pointing to per-tenant content (configured at the distribution level — ouch, see Pattern 1) or Lambda@Edge on origin-response.</p><h3>CloudFront SaaS Manager and multi-tenant distributions</h3><p>In April 2025, AWS announced the general availability of CloudFront SaaS Manager, which introduces a new distribution type: the <strong>multi-tenant distribution</strong>. A multi-tenant distribution is a template defining shared origins, cache behaviors, and policies; each <strong>distribution tenant</strong> represents a domain. Per the multi-tenant distribution docs, tenants can override AWS WAF web ACLs, TLS certificates, and geographic restrictions individually.</p><p>This solves the certificate scaling problem that previously capped multi-tenant designs at the alternate domain name limit. Standard distributions allow 100 alternate domain names per distribution by default (requestable for increase), with hard ALB-side limits when using ALBs as origins. With multi-tenant distributions, the quotas shift: the default limit is 10,000 distribution tenants per AWS account (across up to 20 multi-tenant distributions per account), with up to 100 aliases per tenant. Per the tenant certificate docs, a tenant inherits the shared ACM certificate from the multi-tenant distribution by default — or, for tenants that need their own custom domain, you can attach a tenant-level managed CloudFront certificate that ACM provisions and renews automatically.</p><p>What SaaS Manager does <strong>not</strong> change is the policy story. Cache, origin request, and response headers policies are still attached at the multi-tenant distribution (template) level, and they’re not tenant-overridable in the current GA. Per-tenant response headers still require a CloudFront Function. The “Host not in cache key by default” rule still applies. SaaS Manager is an operational and certificate-scaling improvement, not a policy-model change.</p><p>If you’re starting a new multi-tenant CloudFront deployment in 2026, SaaS Manager is the right path. If you’re maintaining an existing multi-tenant distribution that hasn’t hit certificate or CNAME limits, there’s no urgent reason to migrate — the underlying policy model is the same.</p><h3>Pattern 4: Image CDN with on-the-fly transformations</h3><p>Query strings drive the transform: /photo.jpg?w=300&amp;format=webp returns a 300px-wide WebP version of the original. The naive policy combination breaks two ways: caching by &quot;all query strings&quot; fragments the cache by every UTM and fbclid, and naive Accept header forwarding fragments it by every browser&#39;s distinct Accept string.</p><p>AWS publishes a reference implementation for this pattern that’s worth reading in full. The architecture: S3 stores transformed images with a TTL lifecycle policy, a Lambda function URL with OAC handles cache misses by transforming the image and writing it back to S3, and CloudFront uses Origin Failover — primary origin is the S3 bucket, secondary is the Lambda function URL. On a cache miss, CloudFront tries S3 first, gets a 403 if the transformed image doesn’t exist, and falls back to the Lambda function URL. The Lambda transforms, writes to S3, and returns. Subsequent requests for the same transform hit S3 directly.</p><p>The complete sample is at aws-samples/image-optimization — CDK code plus the Lambda function. The AWS-published Dynamic Image Transformation for CloudFront solution is the heavier production-grade variant.</p><h3>The cache policy: whitelist, not all</h3><p>The cache policy whitelists exactly the transform parameters: w, h, format, q, and whatever else your URL scheme supports. Setting query string behavior to all is the cardinal mistake - it adds tracking parameters ( utm_*, fbclid, gclid, etc.) to the cache key, fragmenting the cache by analytics noise.</p><p>To enforce canonical URLs at the edge, the standard pattern is a CloudFront Function on viewer-request that sorts query parameters alphabetically, drops anything not on the allow-list, and lowercases values. AWS publishes this exact function as an example in the docs. The result: ?w=300&amp;format=webp and ?format=webp&amp;w=300&amp;utm_source=email produce the same cache key.</p><h3>Accept vs Accept-Encoding: only one is normalized</h3><p>CloudFront has dedicated, normalized handling for Accept-Encoding (the cache policy&#39;s EnableAcceptEncodingGzip and EnableAcceptEncodingBrotli flags collapse Accept-Encoding to a small set of values before adding it to the cache key, per the cache policy docs). It does <strong>not</strong> do this for Accept. Putting Accept in the cache key directly fragments by every distinct viewer string.</p><h3>Lambda function URL with OAC, not Lambda@Edge</h3><p>The current AWS reference architecture uses <strong>Lambda function URLs with Origin Access Control</strong>, not Lambda@Edge, for image transformation work. This is a 2023–2024 change worth flagging because most older tutorials still recommend Lambda@Edge.</p><p>The reasons: Lambda function URLs with OAC are private (only signed CloudFront requests can invoke them), execute in a single region with full SDK access including S3 writes, and — critically — function URLs are origins, which means they can sit in an origin group as the secondary failover target. Lambda@Edge is a hook that runs <em>on</em> origins, not an origin itself, so the failover-to-transformer pattern requires a real second origin to attach the hook to.</p><p>There’s still a place for Lambda@Edge in image processing — if you specifically need request-body access (e.g. for image upload transformations), Lambda@Edge on viewer-request with the body opt-in is the only way. But for read-side resize work, the function URL pattern is the modern recommendation.</p><h3>What to read next</h3><p>Cache, origin request, and response headers policies are the foundation of most CloudFront configurations. The production patterns above show where those policies are enough on their own, and where they need to be combined with other CloudFront features — CloudFront Functions, Lambda@Edge, Origin Access Control, custom error responses, Origin Failover, signed URLs, or SaaS Manager. That is usually where the interesting design work starts: not in choosing one policy, but in understanding how the policies interact with the rest of the distribution.</p><p>If one of these patterns matches what you’re building, the next step is to read the relevant AWS source directly. The CloudFront Functions documentation is worth reading if you’re doing URI rewrites, header manipulation, JWT checks, or query normalization at the edge. For multi-tenant SaaS, start with the CloudFront SaaS Manager announcement and the multi-tenant distribution docs. For image transformation, the aws-samples/image-optimization repo is the most useful starting point.</p><p>CloudFront has a lot of old tutorials, partial examples, and conflicting advice floating around. I’ve tried to keep the patterns here tied to current AWS behavior and primary sources, but the safest habit is the same one you should use for any edge configuration: verify the specific feature, quota, and policy interaction against the AWS docs before you ship it.</p><p><em>Originally published at </em><a href="https://u11d.com/blog/combining-cloudfront-cache-origin-request-response-headers-policies/"><em>https://u11d.com</em></a><em> on July 8, 2026.</em></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=1408a77e98b0" width="1" height="1" alt=""><hr><p><a href="https://medium.com/u11d-tech-blog/cloudfront-policy-patterns-for-production-spa-api-jwt-auth-multi-tenant-saas-and-image-cdn-1408a77e98b0">CloudFront Policy Patterns for Production: SPA+API, JWT Auth, Multi-Tenant SaaS, and Image CDN |…</a> was originally published in <a href="https://medium.com/u11d-tech-blog">uninterrupted engineering</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Building Reliable Checkouts for Spot-Priced Products]]></title>
            <link>https://medium.com/u11d-tech-blog/building-reliable-checkouts-for-spot-priced-products-8a2c12d6db19?source=rss----24bcdeab33ef---4</link>
            <guid isPermaLink="false">https://medium.com/p/8a2c12d6db19</guid>
            <category><![CDATA[ecommerce-web-development]]></category>
            <category><![CDATA[gold]]></category>
            <category><![CDATA[precious-metals]]></category>
            <category><![CDATA[ecommerce]]></category>
            <dc:creator><![CDATA[Michal Miler]]></dc:creator>
            <pubDate>Mon, 14 Sep 2026 06:31:38 GMT</pubDate>
            <atom:updated>2026-09-14T06:31:38.715Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*8L8p5pbI8zL4vt7fgeP6Yw.png" /></figure><h3>Why selling products tied to a moving market requires a transaction model, not merely a price feed</h3><p>Most e-commerce systems are built around an assumption so fundamental that it rarely needs to be stated: a product has a price.</p><p>That price may vary between regions, currencies, customer groups, or promotional periods, but at any given moment it behaves like a stable property of the product. A customer sees it, adds the product to a cart, proceeds through checkout, and completes the order. The architecture assumes that the value presented at the beginning of this journey will still make sense at the end.</p><p>For products tied directly to a live market, this assumption breaks down.</p><p>A gold coin, an industrial metal component, or another commodity-linked product does not have a price in the traditional catalog sense. Its value is continuously reconstructed from an external quote, the amount of material contained in the product, the merchant’s spread, additional premiums, currency conversion, and other commercial rules.</p><p>The displayed number is therefore not a permanent attribute. It is a temporary interpretation of the market.</p><p>Once this distinction becomes visible, dynamic pricing stops looking like a storefront feature and starts looking like transaction infrastructure.</p><h3>The hidden interval between seeing a price and paying it</h3><p>Imagine a customer adding a gold product to their cart at 10:04. They compare delivery options, enter their address, create an account, and complete payment eight minutes later.</p><p>During those eight minutes, the underlying market may move.</p><p>The difference may be small enough to ignore on a single order. Across a large catalog, a volatile trading session, or a high-value transaction, it can materially affect the merchant’s margin. The store must then decide whether to honor the earlier value, recalculate the order during checkout, or prevent the transaction from completing until a new quote is accepted.</p><p>None of these choices is purely technical.</p><p>Keeping the original value transfers market risk to the merchant. Recalculating it without warning transfers uncertainty to the customer. Allowing the total to move continuously makes checkout difficult to understand and even harder to trust.</p><p>This is the real problem of spot-priced commerce. It is not simply that prices change. It is that the market continues to move while the transaction is already in progress.</p><p>Traditional commerce platforms are usually very good at representing fixed prices. They are considerably less prepared to represent a price as something that has a source, a calculation, a timestamp, a period of validity, and an eventual expiration.</p><p>Yet this is precisely what a market-linked price is.</p><h3>A market-data API only solves the visible part</h3><p>The most obvious response is to connect the store to a market-data provider. The backend retrieves the latest quote, applies a margin, and sends the result to the storefront.</p><p>This creates a live price, but it does not create a reliable transaction.</p><p>The quote still needs to be refreshed and stored. The product price must be calculated consistently. The storefront and backend must agree on the result. The system must decide what happens when the external provider becomes unavailable. Most importantly, there must be a moment at which the moving price becomes a stable checkout commitment.</p><p>Without that final step, a store may be accurate while the customer is browsing and still complete an order using a value that no longer reflects the market.</p><p>The distinction is subtle but important. A live price describes the present. A transaction needs a controlled bridge between the present and the moment of payment.</p><p>This bridge is the price lock.</p><h3>A price is better understood as a lifecycle</h3><p>In static commerce, pricing can often be reduced to a database lookup. In spot-priced commerce, the final amount emerges from a sequence of states.</p><p>The system first receives a market quote. It then combines that quote with product and merchant-specific rules. The result is displayed to the customer and may continue changing while they browse. When checkout begins, the calculated value is temporarily locked. Before the order is completed, the backend verifies that the lock is still valid. Finally, the system preserves enough information to explain how the amount was produced.</p><p>What appears to the customer as a single number is therefore the outcome of an entire lifecycle:</p><p><strong>market quote → commercial calculation → live display → checkout lock → validation → auditable order</strong></p><p>The reliability of the transaction depends on every stage.</p><p>A current quote processed using an incorrect spread still creates the wrong price. A correct calculation displayed by the storefront but not enforced by the backend remains vulnerable. A valid price lock that is never checked before order completion provides no actual protection. A final amount stored without its inputs may be impossible to explain later.</p><p>The architecture must preserve the continuity of the price from the market-data source to the completed order.</p><h3>From market value to commercial value</h3><p>The external quote is rarely the amount a customer ultimately pays.</p><p>A merchant may need to account for the weight of the underlying material, percentage and fixed spreads, manufacturing premiums, handling costs, regional rules, currency conversion, or product-specific adjustments.</p><p>A one-ounce coin and a half-ounce coin refer to the same underlying market but require different calculations. Two products with identical material content may have different premiums because of minting, availability, brand, or operational cost. A business selling internationally may also need to translate the market quote into several currencies while preserving consistent rounding and tax behavior.</p><p>This makes pricing logic part of the commercial model of the business.</p><p>It should be explicit, deterministic, and controlled by the backend. A merchant should be able to answer not only what the product cost, but how that amount was derived from the market conditions and rules in effect at the time.</p><p><a href="https://fluctum.io">Fluctum</a> reflects this separation by keeping spot prices and merchant pricing rules outside <a href="https://medusajs.com">Medusa</a> ‘s standard static price tables. The final value is calculated dynamically using the current quote, the product’s weight, and configured spread and premium factors.</p><p>This approach matters because it avoids pretending that a rapidly changing value is a permanent catalog property. The product retains its commercial rules, while the current market provides the changing input.</p><h3>Live storefronts need stable checkouts</h3><p>While a customer is browsing, there is value in keeping prices visibly aligned with the market. A product page or cart can update without a full reload, helping the customer understand that the displayed amount reflects current conditions.</p><p>But checkout serves a different purpose.</p><p>Once the customer begins entering personal information, choosing delivery, and authorizing payment, the total must become predictable. A price that continues changing throughout these steps may be technically accurate, but it is commercially unusable.</p><p>The system therefore needs a clear boundary between browsing and transacting.</p><p>In Fluctum, prices remain live while the customer browses. When they proceed to checkout, the system creates fresh locks for the dynamically priced cart items. The total then remains fixed across the checkout stages even if the underlying spot price continues to move. Before the order is completed, the backend validates that the necessary locks exist and have not expired.</p><p>This creates a temporary agreement between the customer and the merchant. The customer receives enough time to complete the transaction at a known price. The merchant defines how long that commitment remains acceptable.</p><p>The lock is not an inconvenience added to the buying process. It is the mechanism that makes the process understandable.</p><h3>The duration of trust</h3><p>Every price lock introduces a business decision: how long should the merchant honor the quote?</p><p>A very short duration reduces market exposure, but may expire before legitimate customers finish entering their details or completing external payment authorization. A long duration creates a comfortable checkout experience, but increases the period during which the merchant carries the risk of a moving market.</p><p>The correct answer cannot be determined from architecture alone.</p><p>It depends on the volatility of the underlying asset, typical order value, payment method, checkout complexity, and the actual time customers need to complete the process. A store selling relatively low-value products may accept a longer window. A merchant handling large bullion orders may require a much tighter policy.</p><p>This is why lock duration should be treated as an operational parameter rather than a hard-coded technical constant.</p><p>The system should also make expiration understandable to the customer. If a quote is no longer valid, checkout should not simply fail. The customer should be told that the market price has changed, shown the refreshed amount, and given an opportunity to accept it.</p><p>A controlled repricing event preserves trust. An unexplained error destroys it.</p><h3>Failure reveals the real architecture</h3><p>Dynamic pricing systems are easy to demonstrate when every dependency is healthy. The market feed responds, the network is stable, the customer moves quickly, and payment completes on time.</p><p>Production systems are defined by what happens when these assumptions fail.</p><p>A market-data provider may become temporarily unavailable. A customer may leave checkout open and return later. The cart may change after the lock is created. A payment provider may introduce an external redirect that lasts longer than expected. Two backend instances may briefly operate with different information. A pricing configuration may change while transactions are in progress.</p><p>Each of these cases forces the system to answer a question about authority.</p><p>Which quote is valid? Which calculation should be trusted? Does an existing lock still correspond to the current cart? Can the last received market value still be used? At what point must the store refuse to create a new transaction?</p><p>The answers should be encoded in the system, not improvised during an incident.</p><p>Fluctum preserves the last successfully received spot price when a configured provider is temporarily unavailable, allowing the existing pricing and locking flow to continue as long as a stored price exists. The scheduled ingestion process continues attempting to retrieve new data.</p><p>For a production implementation, however, the merchant should also define the commercial freshness of that stored quote. A value may be technically available in the database while no longer being acceptable for new orders. The maximum permitted age of a quote, the possibility of pausing new locks, and any fallback-provider strategy should reflect the business’s exposure to volatility.</p><p>The goal is not uninterrupted selling at any cost. It is controlled behavior when certainty is temporarily reduced.</p><h3>The backend must remain the authority</h3><p>A live storefront may perform calculations to update the interface quickly, but the browser cannot be the final authority over a transactional price.</p><p>Client-side state can be outdated, interrupted, or manipulated. Even without malicious behavior, the storefront and backend may diverge because they received different market updates or applied different rounding rules.</p><p>The backend must therefore decide whether the amount is eligible to become an order.</p><p>When the customer attempts to complete checkout, the server should verify that every dynamically priced item has a corresponding lock, that the lock still represents the cart, and that it has not expired. It should rely on stored transactional data rather than simply accepting a number submitted by the frontend.</p><p>This principle extends beyond dynamic pricing. Interfaces present state; servers protect commitments.</p><p>In market-linked commerce, the distinction becomes particularly important because even a small inconsistency can be multiplied across high-value transactions.</p><h3>Auditability is part of pricing, not an afterthought</h3><p>When an order is questioned later, the final total alone may not be enough.</p><p>Customer support may need to explain why a product cost a particular amount. Finance may need to reconcile orders against market conditions. Engineers may need to investigate an apparent pricing discrepancy. A merchant may want to analyze whether spreads adequately protected margin during volatile periods.</p><p>To support these needs, the system should preserve the context of the calculation.</p><p>Fluctum’s price locks store the calculated unit price together with the spot price and the spread and premium factors used to produce it. This makes the locked value reconstructable after the transaction rather than leaving it as an unexplained number.</p><p>This is more than useful logging.</p><p>A static price can often be explained by pointing to a catalog configuration. A dynamic price must be explained by reconstructing a moment in time. The market input, merchant rules, and validity period are all part of the meaning of the final amount.</p><p>Auditability therefore begins when the price is created, not when someone later asks a question about it.</p><h3>The limitations of manual updates</h3><p>Many merchants begin by updating prices manually or by running a script every few hours. At small scale, this may be entirely reasonable.</p><p>The approach becomes fragile when the catalog grows, the market becomes more volatile, or margins become thinner. Increasing the update frequency reduces the gap between the catalog and the market, but it does not eliminate the transactional interval between cart creation and payment.</p><p>A product can be updated every minute and still be sold using a price that became outdated during an eight-minute checkout.</p><p>Manual and scheduled catalog updates solve a synchronization problem. They do not solve the problem of temporary price commitment.</p><p>This is why the transition to real-time pricing is not simply a matter of running the same update mechanism more often. It requires a different model of how prices behave inside the transaction.</p><h3>The true scope of building it yourself</h3><p>A custom pricing engine may be the correct choice for businesses with proprietary trading systems, complex hedging workflows, unusual regulatory constraints, or an existing commerce platform that cannot be changed.</p><p>But the engineering scope is often underestimated.</p><p>The visible requirement may sound simple: connect a market feed and show a live price. The complete system must also manage scheduled ingestion, provider errors, quote storage, pricing formulas, storefront synchronization, cart behavior, checkout locks, expiration, server-side validation, payment timing, audit records, monitoring, and operational recovery.</p><p>None of these components is individually exotic. Their interaction is what creates complexity.</p><p>The most expensive mistakes are rarely dramatic system failures. They are transactions that appear successful while using the wrong assumptions: an old quote, an invalid lock, an inconsistent calculation, or a cart that no longer matches the price being honored.</p><p>Building from scratch offers full control, but it also means owning every one of these failure modes.</p><h3>Where Fluctum fits</h3><p>Fluctum was created as an open-source dynamic-pricing foundation for Medusa stores selling products connected to live market values.</p><p>It combines scheduled spot-price ingestion, dynamic calculation rules, real-time storefront updates, and checkout price locking in one coherent flow. It is available both as an installable Medusa plugin and as a starter project containing a Medusa backend, a Next.js storefront, and the supporting local infrastructure needed to run the process end to end.</p><p>The included implementation focuses on precious metals, but the architectural model is broader. Any product whose price can be expressed as a changing external value combined with stable commercial rules can potentially follow the same lifecycle.</p><p>Fluctum does not remove the need for business-specific decisions. A production deployment may still require a chosen market-data provider, custom pricing formulas, ERP or inventory integration, payment configuration, storefront adaptation, monitoring, and operational policies for periods when market data is unavailable.</p><p>What it removes is the need to begin with an empty codebase.</p><p>The software is available under the MIT license, allowing businesses to run, modify, self-host, and extend it without a per-store or per-transaction licensing model.</p><h3>The business value is control, not movement</h3><p>Dynamic pricing is often described as a way to make product prices move automatically.</p><p>That description misses the most important benefit.</p><p>The value does not come from movement itself. It comes from controlling when a price may move, when it must stop moving, how long it remains valid, and what evidence survives after the order is completed.</p><p>For the merchant, this means better protection against stale-price transactions and more consistent application of commercial rules. For the customer, it means seeing a current price while browsing and receiving a stable, understandable quote during checkout. For the organization operating the store, it means less dependence on manual updates and a clearer path for investigating pricing decisions.</p><p>Automation is useful, but predictability is what makes it commercially valuable.</p><h3>From static catalog to transactional pricing</h3><p>Selling market-linked products requires a conceptual shift.</p><p>The price is no longer a field attached permanently to a product. It is a time-sensitive state created from external data and commercial policy. It remains fluid while the customer browses, becomes temporarily fixed when the transaction begins, and must be validated before it can become an order.</p><p>A reliable system must preserve this state from the market feed to the payment boundary.</p><p>It must know where the price came from, how it was calculated, when it was locked, how long it remains acceptable, and what should happen when any of those assumptions no longer hold.</p><p>This is why live prices alone are not enough.</p><p>A live feed can tell a store what the market is doing now. A well-designed pricing lifecycle determines whether the business can safely transact on that information.</p><p>Fluctum provides an open-source foundation for implementing that lifecycle in Medusa. It connects the changing reality of the market with the stability a customer expects from checkout-without turning either side of the transaction into an afterthought.</p><p><em>Originally published at </em><a href="https://u11d.com/blog/building-reliable-checkouts-spot-priced/"><em>https://u11d.com</em></a><em> on August 5, 2026.</em></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=8a2c12d6db19" width="1" height="1" alt=""><hr><p><a href="https://medium.com/u11d-tech-blog/building-reliable-checkouts-for-spot-priced-products-8a2c12d6db19">Building Reliable Checkouts for Spot-Priced Products</a> was originally published in <a href="https://medium.com/u11d-tech-blog">uninterrupted engineering</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Kubernetes HPA Scale to Zero Without KEDA: Native Autoscaling for Idle Workloads]]></title>
            <link>https://medium.com/u11d-tech-blog/kubernetes-hpa-scale-to-zero-without-keda-native-autoscaling-for-idle-workloads-94c691d22387?source=rss----24bcdeab33ef---4</link>
            <guid isPermaLink="false">https://medium.com/p/94c691d22387</guid>
            <category><![CDATA[kubernetes]]></category>
            <category><![CDATA[cloud-computing]]></category>
            <category><![CDATA[devops]]></category>
            <dc:creator><![CDATA[Daniel Kraszewski]]></dc:creator>
            <pubDate>Thu, 10 Sep 2026 12:59:19 GMT</pubDate>
            <atom:updated>2026-09-10T12:59:23.197Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*mOHaUb6_8bhmingtca823w.png" /></figure><p>If you run queue processors, batch workers, or event-driven workloads that sit idle for hours between bursts, you’re paying for compute you don’t need. Kubernetes HPA can scale these deployments to zero replicas — no KEDA, no Knative, no external controllers required. You need one feature gate, an external metrics source, and about twenty minutes of setup. When the queue is empty your pods disappear, and if you pair this with cluster autoscaler, the nodes disappear too. Real scale-to-zero, using nothing but native Kubernetes primitives.</p><h3>Quick Reference</h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/1023/1*QxMRjmfrUxXsNoQVde38CA.png" /></figure><h3>Local Setup with kind</h3><p>Since HPAScaleToZero requires an explicit feature gate, we need a cluster that has it enabled on both the API server and the controller manager. kind makes this straightforward - especially on Kubernetes 1.36, which at the time of writing is too new for most managed providers.</p><p>If you haven’t already built the node image:</p><pre>kind build node-image --type release v1.36.0</pre><p>Create a cluster config that enables the feature gate:</p><pre># kind-config.yaml<br>kind: Cluster<br>apiVersion: kind.x-k8s.io/v1alpha4<br>featureGates:<br>  HPAScaleToZero: true<br>nodes:<br>  - role: control-plane<br>  - role: worker</pre><pre>kind create cluster --name hpa-scale-to-zero --config kind-config.yaml --image kindest/node:v1.36.0</pre><p>The featureGates field at the cluster level propagates the flag to all relevant components (kube-apiserver, kube-controller-manager, kubelet). No need to manually patch component configs.</p><h3>The Metrics Pipeline</h3><p>The HPA controller can’t scale from zero based on CPU or memory — there are no pods to measure. You need a metric that exists outside the workload itself. For queue-based workloads, the natural choice is the queue length: how many items are waiting to be processed.</p><p>We’ll use Redis as the queue (specifically a Redis list), expose its length via a redis-exporter sidecar, scrape it with Prometheus, and surface it to the Kubernetes metrics API through prometheus-adapter. This is the same pipeline pattern you’d use in production — the only difference is we’re running everything in a single kind cluster.</p><h3>Deploy Redis with an Exporter</h3><pre>helm repo add bitnami https://charts.bitnami.com/bitnami<br>helm install redis bitnami/redis \<br>  --set architecture=standalone \<br>  --set auth.enabled=false \<br>  --set metrics.enabled=true \<br>  --set metrics.extraArgs.check-keys=work-queue</pre><p>The metrics.enabled=true deploys a redis-exporter sidecar alongside Redis. The --check-keys work-queue argument tells the exporter to emit redis_key_size{key=&quot;work-queue&quot;} - the length of our Redis list. That&#39;s the metric we&#39;ll use to drive the HPA. The chart also sets up the correct Prometheus scrape annotations automatically.</p><h3>Deploy Prometheus and the Adapter</h3><p>The prometheus-community Helm charts get us running in two commands:</p><pre>helm repo add prometheus-community https://prometheus-community.github.io/helm-charts<br><br>helm install prometheus prometheus-community/prometheus \<br>  --set server.service.type=ClusterIP \<br>  --set alertmanager.enabled=false \<br>  --set kube-state-metrics.enabled=false \<br>  --set prometheus-node-exporter.enabled=false \<br>  --set prometheus-pushgateway.enabled=false</pre><p>This gives us a minimal Prometheus that auto-discovers pods annotated with prometheus.io/scrape: &quot;true&quot; - which the Bitnami Redis chart already configures.</p><p>Now deploy the prometheus-adapter with a custom external metrics rule:</p><pre>helm install prometheus-adapter prometheus-community/prometheus-adapter \<br>  --set prometheus.url=&quot;http://prometheus-server&quot; \<br>  --set prometheus.port=80 \<br>  --set-json &#39;rules.external=[{&quot;seriesQuery&quot;:&quot;{__name__=\&quot;redis_key_size\&quot;,key=\&quot;work-queue\&quot;}&quot;,&quot;metricsQuery&quot;:&quot;sum(&lt;&lt;.Series&gt;&gt;{&lt;&lt;.LabelMatchers&gt;&gt;})&quot;,&quot;name&quot;:{&quot;as&quot;:&quot;redis_queue_length&quot;},&quot;resources&quot;:{&quot;namespaced&quot;:false}}]&#39;</pre><p>This rule tells the adapter: take the redis_key_size metric where key=&quot;work-queue&quot;, expose it as an external metric called redis_queue_length, and don&#39;t filter by namespace (since the queue is a cluster-wide resource - there&#39;s only one Redis).</p><p>After a minute or so, verify the external metric is available:</p><pre>kubectl get --raw &quot;/apis/external.metrics.k8s.io/v1beta1/namespaces/default/redis_queue_length&quot; | jq .</pre><p>You should see a response with a value of 0 (since the queue is empty).</p><h3>The Worker and HPA</h3><p>The worker is deliberately simple — a shell script that pops items from the Redis list and “processes” them by sleeping for a second. In production this would be your actual consumer code.</p><p><strong>Important:</strong> start the deployment with replicas: 1, not zero. The HPA controller uses a ScaledToZero condition internally - it only scales from zero if it was the one that previously scaled the workload to zero. A deployment that starts at zero replicas with a fresh HPA will never scale up. Let the HPA handle the initial scale-down to zero on its own.</p><pre># worker.yaml<br>apiVersion: apps/v1<br>kind: Deployment<br>metadata:<br>  name: queue-worker<br>spec:<br>  replicas: 1  # start at 1 — the HPA will scale to zero once metrics are available<br>  selector:<br>    matchLabels:<br>      app: queue-worker<br>  template:<br>    metadata:<br>      labels:<br>        app: queue-worker<br>    spec:<br>      containers:<br>        - name: worker<br>          image: redis:7<br>          command:<br>            - /bin/sh<br>            - -c<br>            - |<br>              while true; do<br>                item=$(redis-cli -h redis-master BRPOP work-queue 30)<br>                if [ -n &quot;$item&quot; ]; then<br>                  echo &quot;Processing: $item&quot;<br>                  sleep 1  # simulate work<br>                fi<br>              done</pre><p>Now the HPA that scales this deployment between 0 and 10 replicas based on the queue length:</p><pre># hpa.yaml<br>apiVersion: autoscaling/v2<br>kind: HorizontalPodAutoscaler<br>metadata:<br>  name: queue-worker<br>spec:<br>  scaleTargetRef:<br>    apiVersion: apps/v1<br>    kind: Deployment<br>    name: queue-worker<br>  minReplicas: 0  # this is what HPAScaleToZero enables<br>  maxReplicas: 10<br>  metrics:<br>    - type: External<br>      external:<br>        metric:<br>          name: redis_queue_length<br>        target:<br>          type: Value<br>          value: &quot;5&quot;  # one pod per 5 queue items<br>  behavior:<br>    scaleDown:<br>      stabilizationWindowSeconds: 60<br>      policies:<br>        - type: Percent<br>          value: 100<br>          periodSeconds: 15</pre><p>The target.value: &quot;5&quot; means the HPA will try to maintain a ratio of one pod per five pending items. When the queue has 20 items, you get 4 pods. When it drops to zero, you get zero pods. The behavior.scaleDown section shortens the stabilization window from the default 5 minutes to 60 seconds - useful for demos and workloads where you want faster scale-to-zero.</p><h3>Seeing It in Action</h3><p>Apply the worker and HPA:</p><pre>kubectl apply -f worker.yaml -f hpa.yaml</pre><p>Open a watch in one terminal:</p><pre>kubectl get hpa queue-worker -w</pre><p>Since the queue is empty and the deployment starts at 1 replica, the HPA will first scale it down to zero. This initial scale-down is critical — it sets the internal ScaledToZero condition that enables future scale-from-zero behavior. After about a minute (our shortened stabilization window), you&#39;ll see replicas drop to 0.</p><p>Now push 20 items into the queue:</p><pre>kubectl exec redis-master-0 -c redis -- sh -c &#39;<br>  for i in $(seq 1 20); do<br>    redis-cli LPUSH work-queue &quot;job-$i&quot;<br>  done<br>&#39;</pre><p>Within 30–60 seconds (Prometheus scrape interval + adapter refresh + HPA sync period), you’ll see the HPA scale the deployment from 0 to 4 replicas. The workers will process the items — one per second per worker — and as the queue drains, the replica count drops. Once the queue is empty and the metric reads zero, the HPA scales the deployment back to zero replicas. Pods gone.</p><p>Here’s what the kubectl get hpa queue-worker -w output looks like over the full cycle:</p><pre>NAME           REFERENCE                 TARGETS   MINPODS   MAXPODS   REPLICAS<br>queue-worker   Deployment/queue-worker   0/5       0         10        1<br>queue-worker   Deployment/queue-worker   0/5       0         10        0<br>queue-worker   Deployment/queue-worker   20/5      0         10        0<br>queue-worker   Deployment/queue-worker   20/5      0         10        4<br>queue-worker   Deployment/queue-worker   8/5       0         10        4<br>queue-worker   Deployment/queue-worker   0/5       0         10        2<br>queue-worker   Deployment/queue-worker   0/5       0         10        0</pre><p>Push items again — the HPA scales from zero immediately, no manual intervention needed. The cycle repeats indefinitely.</p><h3>Cluster Autoscaler Integration</h3><p>When a deployment scales to zero, its pods vanish. If those pods were the only workload on a node, the node becomes empty. Cluster autoscaler (or any node lifecycle manager) will notice the idle node and remove it after its configurable cool-down period — typically 10 minutes.</p><p>This is where the real cost savings live. You’re not just saving pod-level resources; you’re saving entire node-hours. For a workload that processes a queue for two hours a day and sits idle for twenty-two, you go from paying for 24 node-hours to paying for roughly 2.5 (accounting for scale-up overhead).</p><p><strong>The cold-start chain matters.</strong> When an item hits the queue after a period of silence, here’s what happens in sequence:</p><ol><li>Prometheus scrapes the redis-exporter (scrape interval, typically 15s)</li><li>Prometheus-adapter picks up the new value (adapter refresh, typically 30s)</li><li>HPA sync loop fires (default 15s) and decides to scale up</li><li>Scheduler assigns the pod to a node — if no nodes are available, cluster autoscaler provisions one (60–120s for cloud providers)</li><li>Kubelet pulls the container image and starts the pod</li><li>Pod becomes Ready</li></ol><p>In a warm cluster (nodes already present), steps 1–5 take about 30–60 seconds total. When a new node must be provisioned, add 1–2 minutes. For workloads where a minute or two of latency is unacceptable, consider these mitigations:</p><ul><li><strong>Smaller container images</strong> reduce pull time. Distroless or scratch-based images start faster.</li><li><strong>Priority-based overprovisioning</strong>: deploy a low-priority “placeholder” pod that reserves a node. When real work arrives, the placeholder gets preempted and the worker starts immediately — no node provisioning needed.</li><li><strong>Shorter scrape intervals</strong> on Prometheus reduce the detection lag.</li></ul><p>For batch and queue workloads, the 30–60 second cold start is typically fine. You’re processing a backlog, not serving interactive requests.</p><h3>When to Use This vs. KEDA</h3><p><a href="https://keda.sh/">KEDA</a> is the most popular alternative for event-driven autoscaling in Kubernetes. It supports 60+ scalers (Redis, SQS, RabbitMQ, Kafka, Azure Queue, and many more), handles the metrics plumbing internally, and can scale to zero without any feature gate.</p><p><strong>Use native HPA scale-to-zero when:</strong></p><ul><li>You already have Prometheus and a metrics adapter deployed</li><li>You want fewer moving parts and no additional CRDs</li><li>Your scaling logic is simple (one metric, linear scaling)</li><li>You prefer to stay within the boundaries of native Kubernetes APIs</li></ul><p><strong>Use KEDA when:</strong></p><ul><li>You don’t have Prometheus or don’t want to maintain the adapter</li><li>You need advanced scaling logic (multiple triggers, complex cooldowns, cron-based scaling)</li><li>You want out-of-the-box support for dozens of event sources without manual adapter configuration</li><li>You need ScaledJobs (scaling Kubernetes Jobs rather than Deployments)</li></ul><p>There’s no shame in either choice. KEDA is excellent software. The native approach is simpler when your infrastructure already includes Prometheus — you’re just connecting pieces that already exist.</p><h3>Gotchas</h3><p><strong>You cannot use CPU or memory metrics to scale from zero.</strong> This is the most common mistake. When replica count is zero, there are no pods, so there’s no CPU or memory to measure. The HPA controller requires at least one non-resource metric (external or custom) to make scaling decisions at zero replicas. If you configure only resource metrics, the HPA will refuse to scale from zero.</p><p><strong>The deployment must not start at zero replicas.</strong> This is the subtlest gotcha and I haven’t seen it documented clearly anywhere. The HPA controller maintains a ScaledToZero condition internally. It only scales FROM zero if it was the one that previously scaled the workload TO zero. If you deploy with replicas: 0 and create a fresh HPA, the controller sees zero replicas but no ScaledToZero condition, and sets ScalingActive: False with reason ScalingDisabled. The fix is simple: start with replicas: 1 and let the HPA scale it down naturally. Once the HPA has performed that initial scale-to-zero, the ScaledToZero condition is set to True and all subsequent scale-from-zero operations work correctly.</p><p><strong>The stabilization window still applies at zero.</strong> By default, the HPA waits 5 minutes of sustained low metrics before scaling down. This applies to the transition from 1 to 0 as well. If you want faster scale-to-zero, configure the behavior.scaleDown section with a shorter stabilizationWindowSeconds.</p><p><strong>The feature gate is alpha — but don’t let that scare you.</strong> HPAScaleToZero has been available since Kubernetes 1.16 without breaking changes. It hasn&#39;t graduated to beta primarily due to KEP process inertia, not because of instability. The implementation is a small conditional in the HPA controller that allows minReplicas: 0 when the feature gate is enabled. It&#39;s been running in production clusters for years.</p><p><strong>PodDisruptionBudgets at zero replicas.</strong> A PDB with minAvailable: 1 on a deployment that&#39;s at zero replicas won&#39;t prevent the scale-to-zero - PDBs apply to voluntary eviction of running pods, and scaling down is a different path. However, consider what happens if you have a PDB and scale from zero to one: the single pod is protected by the PDB immediately upon becoming ready.</p><h3>Wrapping Up</h3><p>The combination of native HPA scale-to-zero, external metrics from Prometheus, and cluster autoscaler gives you genuine pay-for-what-you-use economics on idle workloads. No third-party controllers, no additional CRDs, no vendor lock-in. The feature gate has been stable for twenty releases. Enable it, point your HPA at a queue metric, set minReplicas: 0, and stop paying for compute that&#39;s doing nothing.</p><p>The KEP tracking this feature is <a href="https://github.com/kubernetes/enhancements/issues/2021">KEP-2021: HPA Scale to Zero</a> — if you want to see it graduate to beta and become the default, that’s the issue to watch and engage with.</p><p><em>Originally published at </em><a href="https://u11d.com/blog/kubernetes-hpa-scale-to-zero-without-keda/"><em>https://u11d.com</em></a><em> on May 6, 2026.</em></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=94c691d22387" width="1" height="1" alt=""><hr><p><a href="https://medium.com/u11d-tech-blog/kubernetes-hpa-scale-to-zero-without-keda-native-autoscaling-for-idle-workloads-94c691d22387">Kubernetes HPA Scale to Zero Without KEDA: Native Autoscaling for Idle Workloads</a> was originally published in <a href="https://medium.com/u11d-tech-blog">uninterrupted engineering</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
    </channel>
</rss>